feat(matrix): add Expo React Native mobile client (Manalink)

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 <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-06 12:28:45 +01:00
parent 216a868127
commit fdf44ea0b2
46 changed files with 4246 additions and 0 deletions

15
apps/matrix/apps/mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,15 @@
node_modules/
.expo/
dist/
build/
ios/
android/
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
web-build/
.env
.env.local

View file

@ -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": ""
}
}
}
}

View file

@ -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 (
<View
className="absolute -top-1 -right-2 bg-destructive rounded-full min-w-4 h-4 items-center justify-center px-0.5"
style={{ zIndex: 1 }}
>
<Text className="text-white text-xs font-bold leading-none">
{count > 9 ? '9+' : count}
</Text>
</View>
);
}
export default function AppLayout() {
const invites = useMatrixStore((s) => s.invites);
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarStyle: {
backgroundColor: BG,
borderTopColor: BORDER,
height: 58,
paddingBottom: 8,
},
tabBarActiveTintColor: ACTIVE,
tabBarInactiveTintColor: INACTIVE,
tabBarLabelStyle: { fontSize: 11 },
}}
>
<Tabs.Screen
name="index"
options={{
title: 'Chats',
tabBarIcon: ({ focused }) => (
<ChatCircle size={SIZE} weight={focused ? 'fill' : 'regular'} color={focused ? ACTIVE : INACTIVE} />
),
}}
/>
<Tabs.Screen
name="dms"
options={{
title: 'DMs',
tabBarIcon: ({ focused }) => (
<User size={SIZE} weight={focused ? 'fill' : 'regular'} color={focused ? ACTIVE : INACTIVE} />
),
}}
/>
<Tabs.Screen
name="invites"
options={{
title: 'Invites',
tabBarIcon: ({ focused }) => (
<View>
<Bell size={SIZE} weight={focused ? 'fill' : 'regular'} color={focused ? ACTIVE : INACTIVE} />
<InviteBadge count={invites.length} />
</View>
),
}}
/>
<Tabs.Screen
name="settings"
options={{
title: 'Settings',
tabBarIcon: ({ focused }) => (
<GearSix size={SIZE} weight={focused ? 'fill' : 'regular'} color={focused ? ACTIVE : INACTIVE} />
),
}}
/>
</Tabs>
);
}

View file

@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<SyncStatusBar syncState={syncState} />
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Text className="text-foreground text-2xl font-bold">Direct Messages</Text>
<Pressable
onPress={() => router.push('/room/new')}
className={({ pressed }) =>
`w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
}
>
<Plus size={18} color="#fff" weight="bold" />
</Pressable>
</View>
{(dmRooms.length > 0 || search.length > 0) && (
<View className="flex-row items-center bg-surface border border-border rounded-xl mx-4 mb-3 px-3 gap-2">
<MagnifyingGlass size={16} color="#6b7280" />
<TextInput
className="flex-1 py-2.5 text-foreground text-sm"
value={search}
onChangeText={setSearch}
placeholder="Search people..."
placeholderTextColor="#6b7280"
/>
</View>
)}
{!isReady && syncState === 'STOPPED' ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#7c6bff" />
</View>
) : (
<FlatList
data={dmRooms}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<RoomListItem room={item} onPress={() => handleRoomPress(item.id)} />
)}
contentContainerClassName="pb-4"
ListHeaderComponent={
dmInvites.length > 0 ? (
<View className="px-4 py-2 bg-primary/10 border-b border-border">
<Text className="text-primary text-sm font-medium">
{dmInvites.length} pending invite{dmInvites.length !== 1 ? 's' : ''}
</Text>
</View>
) : null
}
ListEmptyComponent={
<View className="items-center justify-center py-20">
<Text className="text-muted-foreground text-base">
{search ? 'No people found' : 'No direct messages'}
</Text>
{!search && (
<Text className="text-muted-foreground text-sm mt-1">
Tap + to start a conversation
</Text>
)}
</View>
}
/>
)}
</SafeAreaView>
);
}

View file

@ -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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<SyncStatusBar syncState={syncState} />
{/* Header */}
<View className="flex-row items-center justify-between px-4 pt-3 pb-2">
<Text className="text-foreground text-2xl font-bold">Chats</Text>
<View className="flex-row gap-2">
<Pressable
onPress={() => router.push('/search')}
className={({ pressed }) =>
`w-9 h-9 bg-surface border border-border rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
}
>
<Compass size={18} color="#7c6bff" />
</Pressable>
<Pressable
onPress={() => router.push('/room/new')}
className={({ pressed }) =>
`w-9 h-9 bg-primary rounded-full items-center justify-center ${pressed ? 'opacity-70' : ''}`
}
>
<Plus size={18} color="#fff" weight="bold" />
</Pressable>
</View>
</View>
{/* Search */}
{(groupRooms.length > 0 || search.length > 0) && (
<View className="flex-row items-center bg-surface border border-border rounded-xl mx-4 mb-3 px-3 gap-2">
<MagnifyingGlass size={16} color="#6b7280" />
<TextInput
className="flex-1 py-2.5 text-foreground text-sm"
value={search}
onChangeText={setSearch}
placeholder="Search rooms..."
placeholderTextColor="#6b7280"
/>
</View>
)}
{/* Loading state */}
{!isReady && syncState === 'STOPPED' ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#7c6bff" />
<Text className="text-muted-foreground text-sm mt-3">Connecting...</Text>
</View>
) : (
<FlatList
data={groupRooms}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<RoomListItem room={item} onPress={() => handleRoomPress(item.id)} />
)}
contentContainerClassName="pb-4"
ListHeaderComponent={
invites.length > 0 ? (
<View className="px-4 py-2 bg-primary/10 border-b border-border">
<Text className="text-primary text-sm font-medium">
{invites.length} pending invite{invites.length !== 1 ? 's' : ''}
</Text>
</View>
) : null
}
ListEmptyComponent={
<View className="items-center justify-center py-20">
<Text className="text-muted-foreground text-base">
{search ? 'No rooms found' : 'No group chats yet'}
</Text>
{!search && (
<Text className="text-muted-foreground text-sm mt-1">
Tap + to create or join a room
</Text>
)}
</View>
}
/>
)}
</SafeAreaView>
);
}

View file

@ -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 (
<View className="mx-4 mb-3 bg-surface border border-border rounded-2xl overflow-hidden">
<View className="flex-row items-center gap-3 p-4">
{/* Avatar */}
<View className="w-12 h-12 rounded-full bg-background border border-border overflow-hidden items-center justify-center">
{room.avatar ? (
<Image source={{ uri: room.avatar }} style={{ width: 48, height: 48 }} contentFit="cover" />
) : (
<Text className="text-foreground text-lg font-semibold">
{(room.name ?? '?')[0].toUpperCase()}
</Text>
)}
</View>
{/* Info */}
<View className="flex-1">
<Text className="text-foreground font-semibold text-base" numberOfLines={1}>
{room.name}
</Text>
{room.topic && (
<Text className="text-muted-foreground text-xs mt-0.5" numberOfLines={1}>
{room.topic}
</Text>
)}
{room.inviter && (
<Text className="text-muted-foreground text-xs mt-0.5">
Invited by {room.inviter}
</Text>
)}
<View className="flex-row items-center gap-1 mt-1">
<Text className="text-muted-foreground text-xs">
{room.isDirect ? 'Direct message' : `${room.memberCount} member${room.memberCount !== 1 ? 's' : ''}`}
</Text>
{room.isEncrypted && (
<Text className="text-green-500 text-xs">· 🔒 Encrypted</Text>
)}
</View>
</View>
</View>
{/* Actions */}
<View className="flex-row border-t border-border">
<Pressable
onPress={onDecline}
className={({ pressed }) =>
`flex-1 py-3 items-center border-r border-border ${pressed ? 'bg-surface' : ''}`
}
>
<Text className="text-destructive font-medium text-sm">Decline</Text>
</Pressable>
<Pressable
onPress={onAccept}
className={({ pressed }) => `flex-1 py-3 items-center ${pressed ? 'bg-primary/80' : 'bg-primary'}`}
>
<Text className="text-white font-semibold text-sm">Accept</Text>
</Pressable>
</View>
</View>
);
}
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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="px-4 pt-3 pb-2 flex-row items-center justify-between">
<Text className="text-foreground text-2xl font-bold">Invites</Text>
{invites.length > 0 && (
<View className="bg-primary rounded-full min-w-6 h-6 items-center justify-center px-1.5">
<Text className="text-white text-xs font-bold">{invites.length}</Text>
</View>
)}
</View>
{!isReady ? (
<View className="flex-1 items-center justify-center">
<ActivityIndicator color="#7c6bff" />
</View>
) : (
<FlatList
data={invites}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<InviteCard
room={item}
onAccept={() => handleAccept(item.id)}
onDecline={() => handleDecline(item.id, item.name)}
/>
)}
contentContainerClassName="pt-2 pb-6"
ListEmptyComponent={
<View className="items-center justify-center py-24">
<Text className="text-4xl mb-3"></Text>
<Text className="text-foreground text-base font-medium">No pending invites</Text>
<Text className="text-muted-foreground text-sm mt-1">
Room invites will appear here
</Text>
</View>
}
/>
)}
</SafeAreaView>
);
}

View file

@ -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 (
<View className="w-20 h-20 rounded-full bg-surface border-2 border-border overflow-hidden items-center justify-center">
{avatarUrl ? (
<Image source={{ uri: avatarUrl }} style={{ width: 80, height: 80 }} contentFit="cover" />
) : (
<Text className="text-foreground text-3xl font-semibold">{initial}</Text>
)}
</View>
);
}
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 (
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="px-4 py-3">
<Text className="text-foreground text-2xl font-bold">Settings</Text>
</View>
<ScrollView className="flex-1" contentContainerClassName="p-4 gap-4">
{/* Profile card */}
<View className="bg-surface rounded-2xl border border-border p-4 items-center gap-3">
{/* Avatar */}
<Pressable
onPress={handleChangeAvatar}
disabled={uploadingAvatar}
className="relative"
>
<ProfileAvatar
displayName={profileInfo.displayName}
avatarUrl={profileInfo.avatarUrl}
/>
<View className="absolute bottom-0 right-0 w-6 h-6 bg-primary rounded-full items-center justify-center border-2 border-background">
{uploadingAvatar ? (
<ActivityIndicator size={10} color="#fff" />
) : (
<PencilSimple size={12} color="#fff" weight="bold" />
)}
</View>
</Pressable>
{/* Display name */}
<View className="items-center">
<View className="flex-row items-center gap-2">
<Text className="text-foreground text-lg font-semibold">
{profileInfo.displayName}
</Text>
<Pressable onPress={handleEditName}>
<PencilSimple size={16} color="#7c6bff" />
</Pressable>
</View>
<Text className="text-muted-foreground text-sm mt-0.5" numberOfLines={1}>
{userId}
</Text>
</View>
</View>
{/* Connection info */}
<View className="bg-surface rounded-2xl overflow-hidden border border-border">
<View className="px-4 py-3 border-b border-border">
<Text className="text-muted-foreground text-xs uppercase tracking-wider">Connection</Text>
</View>
<View className="p-4 gap-3">
<View>
<Text className="text-muted-foreground text-xs">Homeserver</Text>
<Text className="text-foreground text-sm mt-0.5" numberOfLines={1}>{homeserver || '—'}</Text>
</View>
<View className="flex-row items-center justify-between">
<Text className="text-muted-foreground text-xs">Sync status</Text>
<View className="flex-row items-center gap-1.5">
<View className={`w-2 h-2 rounded-full ${syncState === 'SYNCING' || syncState === 'PREPARED' ? 'bg-green-500' : syncState === 'ERROR' ? 'bg-destructive' : 'bg-yellow-500'}`} />
<Text className="text-foreground text-sm capitalize">{syncState.toLowerCase()}</Text>
</View>
</View>
</View>
</View>
{/* About */}
<View className="bg-surface rounded-2xl overflow-hidden border border-border">
<View className="px-4 py-3 border-b border-border">
<Text className="text-muted-foreground text-xs uppercase tracking-wider">About</Text>
</View>
<View className="p-4 gap-2">
<View className="flex-row justify-between">
<Text className="text-muted-foreground text-sm">App</Text>
<Text className="text-foreground text-sm">Manalink</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-muted-foreground text-sm">Version</Text>
<Text className="text-foreground text-sm">1.0.0</Text>
</View>
<View className="flex-row justify-between">
<Text className="text-muted-foreground text-sm">Protocol</Text>
<Text className="text-foreground text-sm">Matrix</Text>
</View>
</View>
</View>
{/* Sign out */}
<Pressable
onPress={handleLogout}
className={({ pressed }) =>
`bg-destructive/10 border border-destructive/30 rounded-2xl p-4 items-center ${pressed ? 'opacity-60' : ''}`
}
>
<Text className="text-destructive font-semibold">Sign out</Text>
</Pressable>
</ScrollView>
{/* Edit display name modal */}
<Modal visible={editingName} transparent animationType="fade" onRequestClose={() => setEditingName(false)}>
<View className="flex-1 bg-black/60 items-center justify-center p-6">
<View className="bg-surface border border-border rounded-2xl p-5 w-full gap-4">
<View className="flex-row items-center justify-between">
<Text className="text-foreground text-base font-semibold">Display name</Text>
<Pressable onPress={() => setEditingName(false)}>
<X size={20} color="#6b7280" />
</Pressable>
</View>
<TextInput
className="bg-background border border-border rounded-xl px-4 py-3 text-foreground"
value={newDisplayName}
onChangeText={setNewDisplayName}
placeholder="Your display name"
placeholderTextColor="#6b7280"
autoFocus
/>
<Pressable
onPress={handleSaveName}
disabled={savingName || !newDisplayName.trim()}
className={({ pressed }) =>
`bg-primary rounded-xl py-3 items-center ${pressed || savingName || !newDisplayName.trim() ? 'opacity-60' : ''}`
}
>
{savingName ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-white font-semibold">Save</Text>
)}
</Pressable>
</View>
</View>
</Modal>
</SafeAreaView>
);
}

View file

@ -0,0 +1,9 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="login" />
</Stack>
);
}

View file

@ -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<string | null>(null);
const [loading, setLoading] = useState(false);
const [ssoLoading, setSsoLoading] = useState(false);
const [checkingServer, setCheckingServer] = useState(false);
const [serverOk, setServerOk] = useState<boolean | null>(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 (
<SafeAreaView className="flex-1 bg-background">
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView
contentContainerClassName="flex-grow justify-center p-6"
keyboardShouldPersistTaps="handled"
>
{/* Logo */}
<View className="items-center mb-10">
<View className="w-20 h-20 bg-primary rounded-3xl items-center justify-center mb-4">
<Text className="text-white text-4xl"></Text>
</View>
<Text className="text-foreground text-4xl font-bold tracking-tight">Manalink</Text>
<Text className="text-muted-foreground text-sm mt-1">Secure Matrix messaging</Text>
</View>
<View className="gap-4">
{/* Homeserver */}
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Homeserver
</Text>
<View className="flex-row items-center gap-2">
<TextInput
className="flex-1 bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={homeserver}
onChangeText={(v) => { setHomeserver(v); setServerOk(null); }}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder="matrix.example.com"
placeholderTextColor="#6b7280"
onBlur={handleCheckServer}
/>
{checkingServer && <ActivityIndicator size="small" color="#7c6bff" />}
{serverOk === true && <Text className="text-green-500 text-lg"></Text>}
{serverOk === false && <Text className="text-destructive text-lg"></Text>}
</View>
</View>
{/* Username */}
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Username
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={username}
onChangeText={setUsername}
autoCapitalize="none"
autoCorrect={false}
placeholder="@user:matrix.org or just user"
placeholderTextColor="#6b7280"
/>
</View>
{/* Password */}
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Password
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={password}
onChangeText={setPassword}
secureTextEntry
placeholder="••••••••"
placeholderTextColor="#6b7280"
onSubmitEditing={handleLogin}
returnKeyType="go"
/>
</View>
{error && <Text className="text-destructive text-sm text-center">{error}</Text>}
{/* Password login */}
<Pressable
onPress={handleLogin}
disabled={loading || ssoLoading}
className={({ pressed }) =>
`bg-primary rounded-xl py-4 items-center mt-1 ${pressed || loading ? 'opacity-70' : ''}`
}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-white font-semibold text-base">Sign in</Text>
)}
</Pressable>
{/* Divider */}
<View className="flex-row items-center gap-3">
<View className="flex-1 h-px bg-border" />
<Text className="text-muted-foreground text-xs">or</Text>
<View className="flex-1 h-px bg-border" />
</View>
{/* SSO */}
<Pressable
onPress={handleSSO}
disabled={loading || ssoLoading}
className={({ pressed }) =>
`bg-surface border border-border rounded-xl py-4 items-center ${
pressed || ssoLoading ? 'opacity-70' : ''
}`
}
>
{ssoLoading ? (
<ActivityIndicator color="#7c6bff" />
) : (
<Text className="text-foreground font-medium text-base">
Sign in with SSO
</Text>
)}
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View file

@ -0,0 +1,13 @@
import { View, Text } from 'react-native';
import { Link } from 'expo-router';
export default function NotFound() {
return (
<View className="flex-1 bg-background items-center justify-center p-6">
<Text className="text-foreground text-xl font-semibold mb-2">Screen not found</Text>
<Link href="/(app)" className="text-primary mt-4">
Go home
</Link>
</View>
);
}

View file

@ -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 (
<GestureHandlerRootView style={{ flex: 1 }}>
<StatusBar style="light" />
<AuthGuard>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="(auth)" />
<Stack.Screen name="(app)" />
<Stack.Screen
name="room/[id]"
options={{ headerShown: false, animation: 'slide_from_right' }}
/>
<Stack.Screen
name="room/new"
options={{ headerShown: false, animation: 'slide_from_bottom', presentation: 'modal' }}
/>
<Stack.Screen
name="room/settings"
options={{ headerShown: false, animation: 'slide_from_right' }}
/>
<Stack.Screen
name="search"
options={{ headerShown: false, animation: 'slide_from_bottom', presentation: 'modal' }}
/>
<Stack.Screen name="+not-found" />
</Stack>
</AuthGuard>
</GestureHandlerRootView>
);
}

View file

@ -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 (
<>
<Pressable
onPress={() => setShowProfile(true)}
className={({ pressed }) => `flex-row items-center gap-3 px-4 py-3 ${pressed ? 'bg-surface/60' : ''}`}
>
<View className="w-10 h-10 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{member.avatarUrl ? (
<Image source={{ uri: member.avatarUrl }} style={{ width: 40, height: 40 }} contentFit="cover" />
) : (
<Text className="text-foreground font-semibold">
{member.displayName[0]?.toUpperCase() ?? '?'}
</Text>
)}
</View>
<View className="flex-1">
<Text className="text-foreground text-sm font-medium">{member.displayName}</Text>
<Text className="text-muted-foreground text-xs" numberOfLines={1}>{member.userId}</Text>
</View>
{member.powerLevel >= 100 && (
<View className="bg-primary/20 rounded-full px-2 py-0.5">
<Text className="text-primary text-xs">Admin</Text>
</View>
)}
{member.powerLevel >= 50 && member.powerLevel < 100 && (
<View className="bg-surface border border-border rounded-full px-2 py-0.5">
<Text className="text-muted-foreground text-xs">Mod</Text>
</View>
)}
</Pressable>
<UserProfileModal
userId={showProfile ? member.userId : null}
onClose={() => { setShowProfile(false); onClose(); }}
/>
</>
);
}
export default function RoomScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const router = useRouter();
const listRef = useRef<FlatList<ListItem>>(null);
const [loadingMore, setLoadingMore] = useState(false);
const [uploading, setUploading] = useState(false);
const [showVoiceRecorder, setShowVoiceRecorder] = useState(false);
const [replyTo, setReplyTo] = useState<SimpleMessage | null>(null);
const [editingMessage, setEditingMessage] = useState<SimpleMessage | null>(null);
const [showMembers, setShowMembers] = useState(false);
const [viewingImage, setViewingImage] = useState<string | null>(null);
const [profileUserId, setProfileUserId] = useState<string | null>(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 <DateSeparator timestamp={item.timestamp} />;
if (item.type === 'unread') return <UnreadSeparator />;
const msgIndex = messages.indexOf(item.data);
return (
<MessageBubble
message={item.data}
prevMessage={messages[msgIndex - 1] ?? null}
onReply={(msg) => { setEditingMessage(null); setReplyTo(msg); }}
onEdit={handleEdit}
onReact={sendReaction}
onDelete={redactMessage}
onImagePress={setViewingImage}
onAvatarPress={setProfileUserId}
/>
);
};
return (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable onPress={() => router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<View className="flex-1">
<View className="flex-row items-center gap-1.5">
<Text className="text-foreground font-semibold text-base" numberOfLines={1}>
{room?.name ?? id}
</Text>
{room?.isEncrypted && <Lock size={12} color="#22c55e" weight="fill" />}
</View>
{room?.topic ? (
<Text className="text-muted-foreground text-xs" numberOfLines={1}>{room.topic}</Text>
) : room?.memberCount != null ? (
<Text className="text-muted-foreground text-xs">
{room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
</Text>
) : null}
</View>
<Pressable onPress={handleRoomOptions} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<DotsThreeVertical size={22} color="#6b7280" />
</Pressable>
</View>
{(loadingMore || uploading) && (
<View className="flex-row items-center justify-center gap-2 py-1.5 bg-primary/10">
<ActivityIndicator size="small" color="#7c6bff" />
<Text className="text-primary text-xs">{uploading ? 'Uploading...' : 'Loading...'}</Text>
</View>
)}
<FlatList
ref={listRef}
data={listItems}
keyExtractor={(item) => 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={
<View className="items-center justify-center py-20">
<Text className="text-muted-foreground text-sm">No messages yet</Text>
</View>
}
/>
{typingUsers.length > 0 && <TypingIndicator users={typingUsers} />}
{showVoiceRecorder ? (
<VoiceRecorder
onSend={async (uri, durationMs) => {
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)}
/>
) : (
<MessageInput
onSend={handleSend}
onEdit={handleEditSave}
onTyping={sendTyping}
onAttach={handleAttach}
onVoiceRecord={() => setShowVoiceRecorder(true)}
replyTo={replyTo}
onCancelReply={() => setReplyTo(null)}
editingMessage={editingMessage}
onCancelEdit={() => setEditingMessage(null)}
/>
)}
{/* Members modal */}
<Modal visible={showMembers} animationType="slide" presentationStyle="pageSheet" onRequestClose={() => setShowMembers(false)}>
<SafeAreaView className="flex-1 bg-background" edges={['top']}>
<View className="flex-row items-center justify-between px-4 py-3 border-b border-border">
<Text className="text-foreground text-lg font-semibold">
Members{room?.memberCount != null ? ` (${room.memberCount})` : ''}
</Text>
<Pressable onPress={() => setShowMembers(false)} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<X size={22} color="#6b7280" />
</Pressable>
</View>
<ScrollView contentContainerClassName="py-2">
{roomMembers.length === 0 ? (
<View className="items-center py-10"><ActivityIndicator color="#7c6bff" /></View>
) : (
roomMembers.map((member) => (
<MemberRow key={member.userId} member={member} onClose={() => setShowMembers(false)} />
))
)}
</ScrollView>
</SafeAreaView>
</Modal>
<ImageViewer uri={viewingImage} onClose={() => setViewingImage(null)} />
<UserProfileModal userId={profileUserId} onClose={() => setProfileUserId(null)} />
</SafeAreaView>
);
}

View file

@ -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<Mode>('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<string | null>(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 (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable
onPress={() => router.back()}
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
>
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="text-foreground text-lg font-semibold">New conversation</Text>
</View>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
className="flex-1"
>
<ScrollView
contentContainerClassName="p-4 gap-5"
keyboardShouldPersistTaps="handled"
>
{/* Mode toggle */}
<View className="flex-row bg-surface rounded-2xl p-1 border border-border">
{(['dm', 'room'] as Mode[]).map((m) => (
<Pressable
key={m}
onPress={() => {
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' ? (
<ChatCircle size={16} color={mode === m ? '#fff' : '#6b7280'} />
) : (
<Users size={16} color={mode === m ? '#fff' : '#6b7280'} />
)}
<Text
className={`text-sm font-medium ${mode === m ? 'text-white' : 'text-muted-foreground'}`}
>
{m === 'dm' ? 'Direct message' : 'Group room'}
</Text>
</Pressable>
))}
</View>
{/* DM form */}
{mode === 'dm' && (
<View className="gap-4">
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
User ID
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={dmTarget}
onChangeText={setDmTarget}
autoCapitalize="none"
autoCorrect={false}
placeholder="@user:matrix.org"
placeholderTextColor="#6b7280"
/>
</View>
</View>
)}
{/* Room form */}
{mode === 'room' && (
<View className="gap-4">
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Room name
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={name}
onChangeText={setName}
placeholder="My room"
placeholderTextColor="#6b7280"
/>
</View>
<View>
<Text className="text-muted-foreground text-xs mb-1 uppercase tracking-wider">
Topic (optional)
</Text>
<TextInput
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
value={topic}
onChangeText={setTopic}
placeholder="What this room is about"
placeholderTextColor="#6b7280"
/>
</View>
<View className="flex-row items-center justify-between bg-surface border border-border rounded-xl px-4 py-3">
<View>
<Text className="text-foreground text-sm">Private room</Text>
<Text className="text-muted-foreground text-xs mt-0.5">
Only invited members can join
</Text>
</View>
<Switch
value={isPrivate}
onValueChange={setIsPrivate}
trackColor={{ true: '#7c6bff', false: '#2a2a2a' }}
/>
</View>
</View>
)}
{/* Error */}
{error && <Text className="text-destructive text-sm text-center">{error}</Text>}
{/* Create button */}
<Pressable
onPress={handleCreate}
disabled={loading}
className={({ pressed }) =>
`bg-primary rounded-xl py-4 items-center ${pressed || loading ? 'opacity-70' : ''}`
}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text className="text-white font-semibold text-base">
{mode === 'dm' ? 'Start conversation' : 'Create room'}
</Text>
)}
</Pressable>
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}

View file

@ -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<string | null>(room?.avatar ?? null);
const [saving, setSaving] = useState(false);
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [newAvatarMxc, setNewAvatarMxc] = useState<string | null>(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 (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable onPress={() => router.back()} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="flex-1 text-foreground font-semibold text-base">Room Settings</Text>
<Pressable
onPress={handleSave}
disabled={!hasChanges || saving}
className={({ pressed }) =>
`px-4 py-1.5 rounded-full ${hasChanges && !saving ? 'bg-primary' : 'bg-surface border border-border'} ${pressed ? 'opacity-60' : ''}`
}
>
{saving ? (
<ActivityIndicator size={14} color="#fff" />
) : (
<Text className={`text-sm font-semibold ${hasChanges ? 'text-white' : 'text-muted-foreground'}`}>
Save
</Text>
)}
</Pressable>
</View>
<ScrollView contentContainerClassName="px-4 py-6 gap-8">
{/* Avatar */}
<View className="items-center gap-3">
<Pressable onPress={handlePickAvatar} disabled={uploadingAvatar}>
<View className="w-24 h-24 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{uploadingAvatar ? (
<ActivityIndicator color="#7c6bff" />
) : avatarUri ? (
<Image source={{ uri: avatarUri }} style={{ width: 96, height: 96 }} contentFit="cover" />
) : (
<Text className="text-foreground text-3xl font-bold">
{room?.name?.[0]?.toUpperCase() ?? '#'}
</Text>
)}
</View>
<View className="absolute bottom-0 right-0 w-8 h-8 rounded-full bg-primary items-center justify-center border-2 border-background">
<Camera size={14} color="#fff" weight="fill" />
</View>
</Pressable>
<Text className="text-muted-foreground text-xs">Tap to change room avatar</Text>
</View>
{/* Name */}
<View className="gap-2">
<Text className="text-foreground text-sm font-semibold">Room name</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Room name"
placeholderTextColor="#6b7280"
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
maxLength={255}
/>
</View>
{/* Topic */}
<View className="gap-2">
<Text className="text-foreground text-sm font-semibold">Topic</Text>
<TextInput
value={topic}
onChangeText={setTopic}
placeholder="Describe this room…"
placeholderTextColor="#6b7280"
className="bg-surface border border-border rounded-xl px-4 py-3 text-foreground"
multiline
numberOfLines={3}
textAlignVertical="top"
style={{ minHeight: 80 }}
maxLength={1000}
/>
</View>
{/* Room ID info */}
<View className="gap-2">
<Text className="text-foreground text-sm font-semibold">Room ID</Text>
<View className="bg-surface border border-border rounded-xl px-4 py-3">
<Text className="text-muted-foreground text-sm font-mono" selectable>{id}</Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
}

View file

@ -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<PublicRoom[]>([]);
const [loading, setLoading] = useState(false);
const [joiningId, setJoiningId] = useState<string | null>(null);
const [nextBatch, setNextBatch] = useState<string | undefined>();
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 (
<View className="flex-row items-start gap-3 px-4 py-3 border-b border-border">
{/* Avatar */}
<View className="w-11 h-11 rounded-xl bg-surface border border-border overflow-hidden items-center justify-center shrink-0">
{item.avatar_url ? (
<Image source={{ uri: item.avatar_url }} style={{ width: 44, height: 44 }} contentFit="cover" />
) : (
<Text className="text-foreground text-lg font-semibold">{initial}</Text>
)}
</View>
{/* Info */}
<View className="flex-1">
<View className="flex-row items-center gap-1.5 flex-wrap">
<Text className="text-foreground text-sm font-semibold" numberOfLines={1}>
{name}
</Text>
{item.join_rule === 'public' ? null : (
<Lock size={11} color="#6b7280" />
)}
</View>
{item.topic && (
<Text className="text-muted-foreground text-xs mt-0.5" numberOfLines={2}>
{item.topic}
</Text>
)}
<View className="flex-row items-center gap-1 mt-1">
<Users size={11} color="#6b7280" />
<Text className="text-muted-foreground text-xs">{item.num_joined_members}</Text>
</View>
</View>
{/* Join button */}
<Pressable
onPress={() => handleJoin(item)}
disabled={isJoining}
className={({ pressed }) =>
`bg-primary rounded-lg px-3 py-1.5 shrink-0 ${pressed || isJoining ? 'opacity-60' : ''}`
}
>
{isJoining ? (
<ActivityIndicator size={14} color="#fff" />
) : (
<Text className="text-white text-xs font-semibold">Join</Text>
)}
</Pressable>
</View>
);
};
return (
<SafeAreaView className="flex-1 bg-background" edges={['top', 'bottom']}>
{/* Header */}
<View className="flex-row items-center gap-3 px-4 py-3 border-b border-border">
<Pressable
onPress={() => router.back()}
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
>
<ArrowLeft size={22} color="#7c6bff" />
</Pressable>
<Text className="text-foreground text-lg font-semibold">Explore rooms</Text>
</View>
{/* Search bar */}
<View className="flex-row items-center gap-2 px-4 py-3 border-b border-border">
<MagnifyingGlass size={18} color="#6b7280" />
<TextInput
className="flex-1 text-foreground text-base"
value={query}
onChangeText={handleSearch}
placeholder="Search public rooms..."
placeholderTextColor="#6b7280"
autoFocus
autoCapitalize="none"
autoCorrect={false}
/>
{loading && <ActivityIndicator size="small" color="#7c6bff" />}
</View>
<FlatList
data={results}
keyExtractor={(item) => item.room_id}
renderItem={renderRoom}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.2}
ListEmptyComponent={
!loading ? (
<View className="items-center justify-center py-20">
<Text className="text-muted-foreground text-base">
{query.length > 0 ? 'No rooms found' : 'Search for public rooms'}
</Text>
</View>
) : null
}
ListFooterComponent={
hasMore && !loading ? (
<Pressable onPress={handleLoadMore} className="py-4 items-center">
<Text className="text-primary text-sm">Load more</Text>
</Pressable>
) : null
}
/>
</SafeAreaView>
);
}

View file

@ -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'],
};
};

View file

@ -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": {}
}
}

View file

@ -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/**'],
},
]);

3
apps/matrix/apps/mobile/expo-env.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference types="expo/types" />
// NOTE: This file should not be edited and should be in your git ignore

View file

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -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' });

View file

@ -0,0 +1 @@
/// <reference types="nativewind/types" />

View file

@ -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
}

View file

@ -0,0 +1,8 @@
module.exports = {
semi: true,
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
useTabs: true,
plugins: ['prettier-plugin-tailwindcss'],
};

View file

@ -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 (
<View className="flex-row items-center gap-3 mx-4 my-4">
<View className="flex-1 h-px bg-border" />
<Text className="text-muted-foreground text-xs">{formatDate(timestamp)}</Text>
<View className="flex-1 h-px bg-border" />
</View>
);
}

View file

@ -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 (
<Modal visible={!!uri} transparent animationType="fade" onRequestClose={onClose}>
<StatusBar hidden />
<View className="flex-1 bg-black">
{/* Controls */}
<View className="absolute top-0 left-0 right-0 z-10 flex-row justify-between p-4 pt-12">
<Pressable
onPress={onClose}
className={({ pressed }) =>
`w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed ? 'opacity-60' : ''}`
}
>
<X size={20} color="#fff" />
</Pressable>
<Pressable
onPress={handleSave}
disabled={saving}
className={({ pressed }) =>
`w-10 h-10 rounded-full bg-black/50 items-center justify-center ${pressed || saving ? 'opacity-60' : ''}`
}
>
<DownloadSimple size={20} color="#fff" />
</Pressable>
</View>
{/* Image */}
{uri && (
<Pressable onPress={onClose} className="flex-1 items-center justify-center">
<Image
source={{ uri }}
style={{ width: SCREEN_W, height: SCREEN_H }}
contentFit="contain"
/>
</Pressable>
)}
</View>
</Modal>
);
}

View file

@ -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 = (
<View
style={{ width: size, height: size, borderRadius: size / 2 }}
className="bg-surface border border-border overflow-hidden items-center justify-center"
>
{url ? (
<Image source={{ uri: url }} style={{ width: size, height: size }} contentFit="cover" />
) : (
<Text style={{ fontSize: size * 0.42 }} className="text-foreground font-semibold">
{name[0]?.toUpperCase() ?? '?'}
</Text>
)}
</View>
);
if (!onPress) return inner;
return (
<Pressable onPress={onPress} className={({ pressed }) => `${pressed ? 'opacity-60' : ''}`}>
{inner}
</Pressable>
);
}
const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢'];
function SwipeReplyAction({ progress }: { progress: Animated.SharedValue<number> }) {
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 (
<View className="justify-center items-center w-16">
<Animated.View style={style} className="w-9 h-9 rounded-full bg-primary/20 items-center justify-center">
<ArrowBendUpLeft size={18} color="#7c6bff" />
</Animated.View>
</View>
);
}
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 (
<View className={`flex-row ${isOwn ? 'justify-end' : 'justify-start'} mb-1 px-3`}>
<View className="bg-surface border border-border rounded-2xl px-3 py-2">
<Text className="text-muted-foreground text-sm italic">Message deleted</Text>
</View>
</View>
);
}
const renderLeftActions = isOwn
? undefined
: (progress: Animated.SharedValue<number>) => <SwipeReplyAction progress={progress} />;
const renderRightActions = isOwn
? (progress: Animated.SharedValue<number>) => <SwipeReplyAction progress={progress} />
: undefined;
return (
<Swipeable
renderLeftActions={renderLeftActions}
renderRightActions={renderRightActions}
onSwipeableOpen={(direction) => {
if ((direction === 'left' && !isOwn) || (direction === 'right' && isOwn)) {
onReply?.(message);
}
}}
friction={2}
overshootFriction={8}
>
<View className={`flex-row items-end gap-2 ${isOwn ? 'justify-end' : 'justify-start'} ${isGrouped ? 'mb-0.5' : 'mb-3'} px-3`}>
{/* Left avatar */}
{!isOwn && (
<View style={{ width: 28 }} className="mb-0.5">
{showAvatar && (
<AvatarCircle
name={message.senderName}
url={message.senderAvatar}
size={28}
onPress={onAvatarPress ? () => onAvatarPress(message.sender) : undefined}
/>
)}
</View>
)}
<View className={`max-w-[75%] ${isOwn ? 'items-end' : 'items-start'}`}>
{showSenderName && (
<Pressable onPress={onAvatarPress ? () => onAvatarPress(message.sender) : undefined}>
<Text className="text-primary text-xs mb-1 ml-1 font-medium">{message.senderName}</Text>
</Pressable>
)}
<Pressable
onLongPress={handleLongPress}
delayLongPress={400}
className={`rounded-2xl overflow-hidden ${
isOwn ? 'bg-primary rounded-br-sm' : 'bg-surface border border-border rounded-bl-sm'
}`}
>
{/* Reply preview */}
{message.replyTo && (
<View
className={`mx-2 mt-2 mb-1 px-2 py-1.5 rounded-xl border-l-2 ${
isOwn ? 'bg-white/10 border-white/40' : 'bg-primary/8 border-primary/40'
}`}
>
<Text className={`text-xs font-semibold mb-0.5 ${isOwn ? 'text-white/80' : 'text-primary'}`} numberOfLines={1}>
{message.replyToSenderName ?? 'Unknown'}
</Text>
<Text className={`text-xs ${isOwn ? 'text-white/60' : 'text-muted-foreground'}`} numberOfLines={2}>
{message.replyToBody ?? '…'}
</Text>
</View>
)}
{message.type === 'm.image' && message.media?.thumbnailUrl && (
<Pressable onPress={() => onImagePress?.(message.media!.thumbnailUrl!)}>
<Image
source={{ uri: message.media.thumbnailUrl }}
style={{ width: 220, height: 165 }}
contentFit="cover"
/>
</Pressable>
)}
{message.type === 'm.file' && (
<View className="flex-row items-center gap-2 px-3 py-2">
<Text className="text-2xl">📎</Text>
<Text className={`text-sm flex-1 ${isOwn ? 'text-white' : 'text-foreground'}`} numberOfLines={1}>
{message.media?.filename ?? message.body}
</Text>
</View>
)}
{message.type === 'm.audio' && message.media?.downloadUrl && (
<VoiceMessage
uri={message.media.downloadUrl}
duration={message.media.duration}
isOwn={isOwn}
/>
)}
{(message.type === 'm.text' || message.type === 'm.notice' || message.type === 'm.emote') && (
<MessageText
body={message.type === 'm.emote' ? `* ${message.senderName} ${message.body}` : message.body}
isOwn={isOwn}
/>
)}
</Pressable>
{/* Reactions */}
{message.reactions && message.reactions.length > 0 && (
<View className="flex-row flex-wrap gap-1 mt-1 mx-1">
{message.reactions.map((r) => (
<Pressable
key={r.key}
onPress={() => 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'
}`}
>
<Text className="text-xs">{r.key}</Text>
{r.count > 1 && (
<Text className={`text-xs ${r.includesMe ? 'text-primary' : 'text-muted-foreground'}`}>
{r.count}
</Text>
)}
</Pressable>
))}
</View>
)}
<Text className="text-muted-foreground text-xs mt-0.5 mx-1">
{formatTime(message.timestamp)}
{message.edited && ' · edited'}
</Text>
</View>
</View>
</Swipeable>
);
}

View file

@ -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<void>;
onEdit?: (eventId: string, newBody: string) => Promise<void>;
onTyping: (typing: boolean) => Promise<void>;
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<ReturnType<typeof setTimeout> | 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 (
<View className="border-t border-border bg-background">
{/* Context banner: Reply or Edit */}
{(replyTo || isEditing) && (
<View className="flex-row items-center gap-2 px-3 pt-2 pb-1">
<View className={`w-0.5 self-stretch rounded-full ${isEditing ? 'bg-yellow-500' : 'bg-primary'}`} />
<View className="flex-1">
<Text className={`text-xs font-medium ${isEditing ? 'text-yellow-500' : 'text-primary'}`}>
{isEditing ? 'Editing message' : `Reply to ${replyTo!.senderName}`}
</Text>
<Text className="text-muted-foreground text-xs" numberOfLines={1}>
{isEditing ? editingMessage!.body : replyTo!.body}
</Text>
</View>
<Pressable
onPress={isEditing ? onCancelEdit : onCancelReply}
className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}
>
<X size={16} color="#6b7280" />
</Pressable>
</View>
)}
{/* Input row */}
<View className="flex-row items-end gap-2 px-3 py-2">
{onAttach && !isEditing && (
<Pressable
onPress={onAttach}
className={({ pressed }) =>
`w-10 h-10 items-center justify-center rounded-full ${pressed ? 'opacity-50' : ''}`
}
>
<Paperclip size={20} color="#6b7280" />
</Pressable>
)}
<TextInput
className="flex-1 bg-surface border border-border rounded-2xl px-4 py-3 text-foreground max-h-32"
value={text}
onChangeText={handleChangeText}
placeholder={
isEditing
? 'Edit message...'
: replyTo
? `Reply to ${replyTo.senderName}...`
: 'Message...'
}
placeholderTextColor="#6b7280"
multiline
textAlignVertical="center"
/>
{showMic ? (
<Pressable
onPress={onVoiceRecord}
className={({ pressed }) =>
`w-10 h-10 rounded-full items-center justify-center bg-surface border border-border ${pressed ? 'opacity-60' : ''}`
}
>
<Microphone size={20} color="#7c6bff" />
</Pressable>
) : (
<Pressable
onPress={handleSubmit}
disabled={!canSend}
className={({ pressed }) =>
`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 ? (
<PencilSimple size={16} weight="bold" color={canSend ? '#fff' : '#6b7280'} />
) : (
<ArrowUp size={18} weight="bold" color={canSend ? '#fff' : '#6b7280'} />
)}
</Pressable>
)}
</View>
</View>
);
}

View file

@ -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 (
<Text
selectable
className={`text-sm leading-5 px-3 py-2 ${isOwn ? 'text-white' : 'text-foreground'} ${className ?? ''}`}
>
{segments.map((seg, i) => {
if (seg.type === 'url') {
return (
<Text
key={i}
style={{ color: isOwn ? 'rgba(200,190,255,1)' : '#7c6bff', textDecorationLine: 'underline' }}
onPress={() => Linking.openURL(seg.text).catch(() => {})}
>
{seg.text}
</Text>
);
}
if (seg.type === 'mention') {
return (
<Text
key={i}
style={{ color: isOwn ? 'rgba(200,255,200,1)' : '#22c55e', fontWeight: '600' }}
>
{seg.text}
</Text>
);
}
return (
<Text key={i} style={baseColor ? { color: baseColor } : undefined}>
{seg.text}
</Text>
);
})}
</Text>
);
}

View file

@ -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 (
<View
className={`absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-background ${
presence === 'online' ? 'bg-green-500' : 'bg-yellow-500'
}`}
/>
);
}
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 (
<Pressable
onPress={onPress}
className={({ pressed }) =>
`flex-row items-center px-4 py-3 gap-3 ${pressed ? 'bg-surface/60' : ''}`
}
>
{/* Avatar */}
<View className="relative">
<View className="w-12 h-12 rounded-full bg-surface border border-border overflow-hidden items-center justify-center">
{room.avatar ? (
<Image
source={{ uri: room.avatar }}
style={{ width: 48, height: 48 }}
contentFit="cover"
transition={200}
/>
) : (
<Text className="text-foreground text-lg font-semibold">{initial}</Text>
)}
</View>
{room.isDirect && <PresenceDot presence={room.presence} />}
</View>
{/* Content */}
<View className="flex-1 min-w-0">
<View className="flex-row items-baseline justify-between">
<Text
className={`text-base flex-1 mr-2 ${hasUnread || hasHighlight ? 'text-foreground font-semibold' : 'text-foreground'}`}
numberOfLines={1}
>
{displayName}
</Text>
<Text className="text-muted-foreground text-xs shrink-0">
{formatTime(room.lastMessageTime)}
</Text>
</View>
<View className="flex-row items-center justify-between mt-0.5">
<Text
className={`text-sm flex-1 mr-2 ${hasUnread ? 'text-foreground' : 'text-muted-foreground'}`}
numberOfLines={1}
>
{room.lastMessage
? (room.lastMessageSender && !room.isDirect
? `${room.lastMessageSender.split(':')[0].slice(1)}: `
: '') + room.lastMessage
: room.isEncrypted
? '🔒 Encrypted'
: 'No messages'}
</Text>
{/* Badge */}
{(hasUnread || hasHighlight) && (
<View
className={`min-w-5 h-5 rounded-full items-center justify-center px-1 ${
hasHighlight ? 'bg-primary' : 'bg-muted'
}`}
>
<Text className="text-white text-xs font-bold leading-none">
{hasHighlight ? room.highlightCount : room.unreadCount > 99 ? '99+' : room.unreadCount}
</Text>
</View>
)}
</View>
</View>
</Pressable>
);
}

View file

@ -0,0 +1,26 @@
import { View, Text } from 'react-native';
import type { SyncState } from '~/src/matrix/types';
interface Props {
syncState: SyncState;
}
const statusConfig: Record<string, { label: string; color: string } | null> = {
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 (
<View className={`${config.color} px-4 py-1 items-center`}>
<Text className="text-white text-xs font-medium">{config.label}</Text>
</View>
);
}

View file

@ -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 (
<View className="px-4 py-1">
<Text className="text-muted-foreground text-xs italic">{label}</Text>
</View>
);
}

View file

@ -0,0 +1,11 @@
import { View, Text } from 'react-native';
export default function UnreadSeparator() {
return (
<View className="flex-row items-center gap-3 px-4 py-2 my-1">
<View className="flex-1 h-px bg-destructive/40" />
<Text className="text-destructive text-xs font-semibold">New messages</Text>
<View className="flex-1 h-px bg-destructive/40" />
</View>
);
}

View file

@ -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<UserProfile | null>(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 (
<Modal
visible={!!userId}
transparent
animationType="fade"
onRequestClose={onClose}
>
<Pressable className="flex-1 bg-black/60" onPress={onClose}>
<SafeAreaView className="flex-1 justify-end" edges={['bottom']}>
<Pressable onPress={(e) => e.stopPropagation()}>
<View className="bg-background rounded-t-3xl overflow-hidden border-t border-border">
{/* Handle */}
<View className="items-center pt-3 pb-1">
<View className="w-10 h-1 bg-border rounded-full" />
</View>
{/* Close */}
<View className="absolute top-3 right-4 z-10">
<Pressable onPress={onClose} className={({ pressed }) => `p-1 ${pressed ? 'opacity-50' : ''}`}>
<X size={20} color="#6b7280" />
</Pressable>
</View>
<ScrollView contentContainerClassName="px-6 pt-4 pb-8 items-center gap-4">
{loading ? (
<ActivityIndicator color="#7c6bff" className="py-10" />
) : profile ? (
<>
{/* Avatar */}
<View className="w-24 h-24 rounded-full bg-surface border-2 border-border overflow-hidden items-center justify-center">
{profile.avatarUrl ? (
<Image source={{ uri: profile.avatarUrl }} style={{ width: 96, height: 96 }} contentFit="cover" />
) : (
<Text className="text-foreground text-4xl font-semibold">{initial}</Text>
)}
</View>
{/* Name */}
<View className="items-center gap-1">
<Text className="text-foreground text-xl font-bold">{profile.displayName}</Text>
<Text className="text-muted-foreground text-sm" selectable>{profile.userId}</Text>
</View>
{/* Actions */}
{profile.userId !== credentials?.userId && (
<Pressable
onPress={handleStartDM}
className={({ pressed }) =>
`flex-row items-center gap-2 bg-primary rounded-2xl px-6 py-3 ${pressed ? 'opacity-70' : ''}`
}
>
<ChatCircle size={18} color="#fff" weight="fill" />
<Text className="text-white font-semibold">
{existingDM ? 'Open conversation' : 'Send message'}
</Text>
</Pressable>
)}
</>
) : null}
</ScrollView>
</View>
</Pressable>
</SafeAreaView>
</Pressable>
</Modal>
);
}

View file

@ -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<Audio.Sound | null>(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 (
<View className="flex-row items-center gap-3 px-3 py-2.5 min-w-[160px]">
<Pressable
onPress={handleToggle}
className={({ pressed }) => `w-8 h-8 rounded-full items-center justify-center ${pressed ? 'opacity-60' : ''} ${isOwn ? 'bg-white/20' : 'bg-primary/10'}`}
>
{loading ? (
<ActivityIndicator size={14} color={iconColor} />
) : playing ? (
<Pause size={14} color={iconColor} weight="fill" />
) : (
<Play size={14} color={iconColor} weight="fill" />
)}
</Pressable>
{/* Waveform / progress bar */}
<View className="flex-1 h-1 rounded-full overflow-hidden" style={{ backgroundColor: barColor }}>
<View style={{ width: `${progress * 100}%`, backgroundColor: fillColor }} className="h-full rounded-full" />
</View>
<Text className={`text-xs tabular-nums ${isOwn ? 'text-white/70' : 'text-muted-foreground'}`}>
{formatDuration(playing || position > 0 ? position : totalDuration)}
</Text>
</View>
);
}

View file

@ -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<void>;
onCancel: () => void;
}
export default function VoiceRecorder({ onSend, onCancel }: Props) {
const recordingRef = useRef<Audio.Recording | null>(null);
const [duration, setDuration] = useState(0);
const [sending, setSending] = useState(false);
const pulseAnim = useRef(new Animated.Value(1)).current;
const timerRef = useRef<ReturnType<typeof setInterval> | 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 (
<View className="flex-row items-center gap-4 px-4 py-3 bg-background border-t border-border">
{/* Discard */}
<Pressable
onPress={handleDiscard}
className={({ pressed }) => `w-10 h-10 rounded-full bg-destructive/10 items-center justify-center ${pressed ? 'opacity-60' : ''}`}
>
<Trash size={18} color="#ef4444" />
</Pressable>
{/* Recording indicator */}
<View className="flex-1 flex-row items-center gap-3">
<Animated.View
style={{ transform: [{ scale: pulseAnim }] }}
className="w-3 h-3 rounded-full bg-destructive"
/>
<Text className="text-foreground font-mono text-sm">{formatDuration(duration)}</Text>
<Text className="text-muted-foreground text-xs">Recording...</Text>
</View>
{/* Send */}
<Pressable
onPress={handleSend}
disabled={sending || duration < 1}
className={({ pressed }) =>
`w-10 h-10 rounded-full items-center justify-center ${duration >= 1 ? 'bg-primary' : 'bg-surface border border-border'} ${pressed || sending ? 'opacity-60' : ''}`
}
>
<PaperPlaneRight size={18} color={duration >= 1 ? '#fff' : '#6b7280'} weight="fill" />
</Pressable>
</View>
);
}

View file

@ -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<LoginResult> {
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<LoginResult> {
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<string | null> {
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}`;
}

View file

@ -0,0 +1,4 @@
export * from './types';
export * from './client';
export * from './media';
export { useMatrixStore } from './store';

View file

@ -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}`;
}

View file

@ -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 {};

View file

@ -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<void>;
restoreSession: () => Promise<boolean>;
selectRoom: (roomId: string) => void;
loadRoomMembers: (roomId: string) => void;
sendMessage: (body: string, replyToEventId?: string) => Promise<void>;
sendReaction: (eventId: string, key: string) => Promise<void>;
redactMessage: (eventId: string) => Promise<void>;
sendTyping: (typing: boolean) => Promise<void>;
sendImage: (fileUri: string, filename: string, mimetype: string, width?: number, height?: number) => Promise<void>;
sendFile: (fileUri: string, filename: string, mimetype: string) => Promise<void>;
editMessage: (eventId: string, newBody: string) => Promise<void>;
sendVoice: (fileUri: string, durationMs: number) => Promise<void>;
acceptInvite: (roomId: string) => Promise<void>;
declineInvite: (roomId: string) => Promise<void>;
leaveRoom: (roomId: string) => Promise<void>;
logout: () => Promise<void>;
}
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<MatrixState>((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,
});
},
}));

View file

@ -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;
}

View file

@ -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<UploadResult> {
// 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<string, string> = {
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';
}

View file

@ -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<boolean> {
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<string | null> {
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<void> {
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<void> {
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<void> {
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();
}

View file

@ -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: [],
};

View file

@ -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"
]
}