mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
✨ 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:
parent
216a868127
commit
fdf44ea0b2
46 changed files with 4246 additions and 0 deletions
15
apps/matrix/apps/mobile/.gitignore
vendored
Normal file
15
apps/matrix/apps/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
build/
|
||||
ios/
|
||||
android/
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
.env
|
||||
.env.local
|
||||
74
apps/matrix/apps/mobile/app.json
Normal file
74
apps/matrix/apps/mobile/app.json
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
apps/matrix/apps/mobile/app/(app)/_layout.tsx
Normal file
85
apps/matrix/apps/mobile/app/(app)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
apps/matrix/apps/mobile/app/(app)/dms.tsx
Normal file
98
apps/matrix/apps/mobile/app/(app)/dms.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
apps/matrix/apps/mobile/app/(app)/index.tsx
Normal file
113
apps/matrix/apps/mobile/app/(app)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
apps/matrix/apps/mobile/app/(app)/invites.tsx
Normal file
131
apps/matrix/apps/mobile/app/(app)/invites.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
236
apps/matrix/apps/mobile/app/(app)/settings.tsx
Normal file
236
apps/matrix/apps/mobile/app/(app)/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
apps/matrix/apps/mobile/app/(auth)/_layout.tsx
Normal file
9
apps/matrix/apps/mobile/app/(auth)/_layout.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function AuthLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="login" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
231
apps/matrix/apps/mobile/app/(auth)/login.tsx
Normal file
231
apps/matrix/apps/mobile/app/(auth)/login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
apps/matrix/apps/mobile/app/+not-found.tsx
Normal file
13
apps/matrix/apps/mobile/app/+not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
apps/matrix/apps/mobile/app/_layout.tsx
Normal file
71
apps/matrix/apps/mobile/app/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
374
apps/matrix/apps/mobile/app/room/[id].tsx
Normal file
374
apps/matrix/apps/mobile/app/room/[id].tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
221
apps/matrix/apps/mobile/app/room/new.tsx
Normal file
221
apps/matrix/apps/mobile/app/room/new.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
apps/matrix/apps/mobile/app/room/settings.tsx
Normal file
174
apps/matrix/apps/mobile/app/room/settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
200
apps/matrix/apps/mobile/app/search.tsx
Normal file
200
apps/matrix/apps/mobile/app/search.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
apps/matrix/apps/mobile/babel.config.js
Normal file
7
apps/matrix/apps/mobile/babel.config.js
Normal 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'],
|
||||
};
|
||||
};
|
||||
21
apps/matrix/apps/mobile/eas.json
Normal file
21
apps/matrix/apps/mobile/eas.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
9
apps/matrix/apps/mobile/eslint.config.js
Normal file
9
apps/matrix/apps/mobile/eslint.config.js
Normal 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
3
apps/matrix/apps/mobile/expo-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/// <reference types="expo/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be in your git ignore
|
||||
3
apps/matrix/apps/mobile/global.css
Normal file
3
apps/matrix/apps/mobile/global.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
23
apps/matrix/apps/mobile/metro.config.js
Normal file
23
apps/matrix/apps/mobile/metro.config.js
Normal 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' });
|
||||
1
apps/matrix/apps/mobile/nativewind-env.d.ts
vendored
Normal file
1
apps/matrix/apps/mobile/nativewind-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="nativewind/types" />
|
||||
63
apps/matrix/apps/mobile/package.json
Normal file
63
apps/matrix/apps/mobile/package.json
Normal 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
|
||||
}
|
||||
8
apps/matrix/apps/mobile/prettier.config.js
Normal file
8
apps/matrix/apps/mobile/prettier.config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
trailingComma: 'all',
|
||||
printWidth: 100,
|
||||
useTabs: true,
|
||||
plugins: ['prettier-plugin-tailwindcss'],
|
||||
};
|
||||
28
apps/matrix/apps/mobile/src/components/DateSeparator.tsx
Normal file
28
apps/matrix/apps/mobile/src/components/DateSeparator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/matrix/apps/mobile/src/components/ImageViewer.tsx
Normal file
72
apps/matrix/apps/mobile/src/components/ImageViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
apps/matrix/apps/mobile/src/components/MessageBubble.tsx
Normal file
246
apps/matrix/apps/mobile/src/components/MessageBubble.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
apps/matrix/apps/mobile/src/components/MessageInput.tsx
Normal file
157
apps/matrix/apps/mobile/src/components/MessageInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
80
apps/matrix/apps/mobile/src/components/MessageText.tsx
Normal file
80
apps/matrix/apps/mobile/src/components/MessageText.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
apps/matrix/apps/mobile/src/components/RoomListItem.tsx
Normal file
106
apps/matrix/apps/mobile/src/components/RoomListItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx
Normal file
26
apps/matrix/apps/mobile/src/components/SyncStatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
apps/matrix/apps/mobile/src/components/TypingIndicator.tsx
Normal file
22
apps/matrix/apps/mobile/src/components/TypingIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx
Normal file
11
apps/matrix/apps/mobile/src/components/UnreadSeparator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
apps/matrix/apps/mobile/src/components/UserProfileModal.tsx
Normal file
149
apps/matrix/apps/mobile/src/components/UserProfileModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
apps/matrix/apps/mobile/src/components/VoiceMessage.tsx
Normal file
95
apps/matrix/apps/mobile/src/components/VoiceMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx
Normal file
120
apps/matrix/apps/mobile/src/components/VoiceRecorder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
99
apps/matrix/apps/mobile/src/matrix/client.ts
Normal file
99
apps/matrix/apps/mobile/src/matrix/client.ts
Normal 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}`;
|
||||
}
|
||||
4
apps/matrix/apps/mobile/src/matrix/index.ts
Normal file
4
apps/matrix/apps/mobile/src/matrix/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './types';
|
||||
export * from './client';
|
||||
export * from './media';
|
||||
export { useMatrixStore } from './store';
|
||||
41
apps/matrix/apps/mobile/src/matrix/media.ts
Normal file
41
apps/matrix/apps/mobile/src/matrix/media.ts
Normal 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}`;
|
||||
}
|
||||
18
apps/matrix/apps/mobile/src/matrix/polyfills.ts
Normal file
18
apps/matrix/apps/mobile/src/matrix/polyfills.ts
Normal 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 {};
|
||||
489
apps/matrix/apps/mobile/src/matrix/store.ts
Normal file
489
apps/matrix/apps/mobile/src/matrix/store.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
91
apps/matrix/apps/mobile/src/matrix/types.ts
Normal file
91
apps/matrix/apps/mobile/src/matrix/types.ts
Normal 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;
|
||||
}
|
||||
63
apps/matrix/apps/mobile/src/matrix/upload.ts
Normal file
63
apps/matrix/apps/mobile/src/matrix/upload.ts
Normal 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';
|
||||
}
|
||||
107
apps/matrix/apps/mobile/src/notifications/index.ts
Normal file
107
apps/matrix/apps/mobile/src/notifications/index.ts
Normal 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();
|
||||
}
|
||||
21
apps/matrix/apps/mobile/tailwind.config.js
Normal file
21
apps/matrix/apps/mobile/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
18
apps/matrix/apps/mobile/tsconfig.json
Normal file
18
apps/matrix/apps/mobile/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue