From fdf44ea0b22abc8c7c81a4b48d26da3d14699296 Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 6 Mar 2026 12:28:45 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(matrix):=20add=20Expo=20React?= =?UTF-8?q?=20Native=20mobile=20client=20(Manalink)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full Matrix messaging client for iOS/Android with: - Matrix SDK integration with Zustand store and SecureStore credentials - Expo Router file-based navigation with auth guard - Room list, DMs, invites, and message timeline screens - Message input with replies, reactions, editing, and redaction - Image/file/voice message support with media upload - Room creation, settings, and member management - Global message search - Push notifications with badge count - Typing indicators and read receipts - NativeWind (Tailwind CSS) styling with dark theme Co-Authored-By: Claude Opus 4.6 --- apps/matrix/apps/mobile/.gitignore | 15 + apps/matrix/apps/mobile/app.json | 74 +++ apps/matrix/apps/mobile/app/(app)/_layout.tsx | 85 +++ apps/matrix/apps/mobile/app/(app)/dms.tsx | 98 ++++ apps/matrix/apps/mobile/app/(app)/index.tsx | 113 ++++ apps/matrix/apps/mobile/app/(app)/invites.tsx | 131 +++++ .../matrix/apps/mobile/app/(app)/settings.tsx | 236 +++++++++ .../matrix/apps/mobile/app/(auth)/_layout.tsx | 9 + apps/matrix/apps/mobile/app/(auth)/login.tsx | 231 +++++++++ apps/matrix/apps/mobile/app/+not-found.tsx | 13 + apps/matrix/apps/mobile/app/_layout.tsx | 71 +++ apps/matrix/apps/mobile/app/room/[id].tsx | 374 ++++++++++++++ apps/matrix/apps/mobile/app/room/new.tsx | 221 ++++++++ apps/matrix/apps/mobile/app/room/settings.tsx | 174 +++++++ apps/matrix/apps/mobile/app/search.tsx | 200 +++++++ apps/matrix/apps/mobile/babel.config.js | 7 + apps/matrix/apps/mobile/eas.json | 21 + apps/matrix/apps/mobile/eslint.config.js | 9 + apps/matrix/apps/mobile/expo-env.d.ts | 3 + apps/matrix/apps/mobile/global.css | 3 + apps/matrix/apps/mobile/metro.config.js | 23 + apps/matrix/apps/mobile/nativewind-env.d.ts | 1 + apps/matrix/apps/mobile/package.json | 63 +++ apps/matrix/apps/mobile/prettier.config.js | 8 + .../mobile/src/components/DateSeparator.tsx | 28 + .../mobile/src/components/ImageViewer.tsx | 72 +++ .../mobile/src/components/MessageBubble.tsx | 246 +++++++++ .../mobile/src/components/MessageInput.tsx | 157 ++++++ .../mobile/src/components/MessageText.tsx | 80 +++ .../mobile/src/components/RoomListItem.tsx | 106 ++++ .../mobile/src/components/SyncStatusBar.tsx | 26 + .../mobile/src/components/TypingIndicator.tsx | 22 + .../mobile/src/components/UnreadSeparator.tsx | 11 + .../src/components/UserProfileModal.tsx | 149 ++++++ .../mobile/src/components/VoiceMessage.tsx | 95 ++++ .../mobile/src/components/VoiceRecorder.tsx | 120 +++++ apps/matrix/apps/mobile/src/matrix/client.ts | 99 ++++ apps/matrix/apps/mobile/src/matrix/index.ts | 4 + apps/matrix/apps/mobile/src/matrix/media.ts | 41 ++ .../apps/mobile/src/matrix/polyfills.ts | 18 + apps/matrix/apps/mobile/src/matrix/store.ts | 489 ++++++++++++++++++ apps/matrix/apps/mobile/src/matrix/types.ts | 91 ++++ apps/matrix/apps/mobile/src/matrix/upload.ts | 63 +++ .../apps/mobile/src/notifications/index.ts | 107 ++++ apps/matrix/apps/mobile/tailwind.config.js | 21 + apps/matrix/apps/mobile/tsconfig.json | 18 + 46 files changed, 4246 insertions(+) create mode 100644 apps/matrix/apps/mobile/.gitignore create mode 100644 apps/matrix/apps/mobile/app.json create mode 100644 apps/matrix/apps/mobile/app/(app)/_layout.tsx create mode 100644 apps/matrix/apps/mobile/app/(app)/dms.tsx create mode 100644 apps/matrix/apps/mobile/app/(app)/index.tsx create mode 100644 apps/matrix/apps/mobile/app/(app)/invites.tsx create mode 100644 apps/matrix/apps/mobile/app/(app)/settings.tsx create mode 100644 apps/matrix/apps/mobile/app/(auth)/_layout.tsx create mode 100644 apps/matrix/apps/mobile/app/(auth)/login.tsx create mode 100644 apps/matrix/apps/mobile/app/+not-found.tsx create mode 100644 apps/matrix/apps/mobile/app/_layout.tsx create mode 100644 apps/matrix/apps/mobile/app/room/[id].tsx create mode 100644 apps/matrix/apps/mobile/app/room/new.tsx create mode 100644 apps/matrix/apps/mobile/app/room/settings.tsx create mode 100644 apps/matrix/apps/mobile/app/search.tsx create mode 100644 apps/matrix/apps/mobile/babel.config.js create mode 100644 apps/matrix/apps/mobile/eas.json create mode 100644 apps/matrix/apps/mobile/eslint.config.js create mode 100644 apps/matrix/apps/mobile/expo-env.d.ts create mode 100644 apps/matrix/apps/mobile/global.css create mode 100644 apps/matrix/apps/mobile/metro.config.js create mode 100644 apps/matrix/apps/mobile/nativewind-env.d.ts create mode 100644 apps/matrix/apps/mobile/package.json create mode 100644 apps/matrix/apps/mobile/prettier.config.js create mode 100644 apps/matrix/apps/mobile/src/components/DateSeparator.tsx create mode 100644 apps/matrix/apps/mobile/src/components/ImageViewer.tsx create mode 100644 apps/matrix/apps/mobile/src/components/MessageBubble.tsx create mode 100644 apps/matrix/apps/mobile/src/components/MessageInput.tsx create mode 100644 apps/matrix/apps/mobile/src/components/MessageText.tsx create mode 100644 apps/matrix/apps/mobile/src/components/RoomListItem.tsx create mode 100644 apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx create mode 100644 apps/matrix/apps/mobile/src/components/TypingIndicator.tsx create mode 100644 apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx create mode 100644 apps/matrix/apps/mobile/src/components/UserProfileModal.tsx create mode 100644 apps/matrix/apps/mobile/src/components/VoiceMessage.tsx create mode 100644 apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx create mode 100644 apps/matrix/apps/mobile/src/matrix/client.ts create mode 100644 apps/matrix/apps/mobile/src/matrix/index.ts create mode 100644 apps/matrix/apps/mobile/src/matrix/media.ts create mode 100644 apps/matrix/apps/mobile/src/matrix/polyfills.ts create mode 100644 apps/matrix/apps/mobile/src/matrix/store.ts create mode 100644 apps/matrix/apps/mobile/src/matrix/types.ts create mode 100644 apps/matrix/apps/mobile/src/matrix/upload.ts create mode 100644 apps/matrix/apps/mobile/src/notifications/index.ts create mode 100644 apps/matrix/apps/mobile/tailwind.config.js create mode 100644 apps/matrix/apps/mobile/tsconfig.json 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 ( + + + ); +} 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" + ] +}