Зачаток мобилки
This commit is contained in:
41
client-mobile/.gitignore
vendored
Normal file
41
client-mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
91
client-mobile/App.tsx
Normal file
91
client-mobile/App.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ScrollView, Alert, ActivityIndicator } from 'react-native';
|
||||
import { useAuthStore } from './src/modules/auth/application/authStore';
|
||||
import { useConfigStore } from './src/core/application/configStore';
|
||||
|
||||
const ConnectionSettings = ({ onBack }: { onBack: () => void }) => {
|
||||
const { apiUrl: currentApi, updateUrl: currentUpdate, setUrls } = useConfigStore();
|
||||
const [apiUrl, setApiUrl] = useState(currentApi);
|
||||
const [updateUrl, setUpdateUrl] = useState(currentUpdate);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!apiUrl.startsWith('http')) {
|
||||
Alert.alert('Error', 'API URL must start with http:// or https://');
|
||||
return;
|
||||
}
|
||||
setUrls(apiUrl, updateUrl);
|
||||
Alert.alert('Success', 'Settings saved!', [{ text: 'OK', onPress: onBack }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.backBtn}>
|
||||
<Text style={styles.backText}>← Back to Login</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.label}>API Base URL</Text>
|
||||
<TextInput style={styles.input} value={apiUrl} onChangeText={setApiUrl} autoCapitalize="none" />
|
||||
<Text style={styles.label}>Update Server URL</Text>
|
||||
<TextInput style={styles.input} value={updateUrl} onChangeText={setUpdateUrl} autoCapitalize="none" />
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||
<Text style={styles.saveText}>Save Settings</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
export default function App() {
|
||||
const { login, isLoading, error, initialize, token } = useAuthStore();
|
||||
const { isConfigured, initialize: initConfig } = useConfigStore();
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
initConfig();
|
||||
initialize().catch(e => console.error('Init failed', e));
|
||||
}, []);
|
||||
|
||||
if (showSettings) {
|
||||
return <ConnectionSettings onBack={() => setShowSettings(false)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<TouchableOpacity style={styles.settingsIcon} onPress={() => setShowSettings(true)}>
|
||||
<Text style={styles.settingsText}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>Knot Messenger</Text>
|
||||
{!isConfigured && <Text style={styles.warningText}>Please configure connection first</Text>}
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
{token ? (
|
||||
<Text style={styles.successText}>Logined!</Text>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[styles.loginBtn, !isConfigured && styles.disabledBtn]}
|
||||
onPress={() => login('test', 'password')}
|
||||
disabled={isLoading || !isConfigured}
|
||||
>
|
||||
{isLoading ? <ActivityIndicator color="#fff" /> : <Text style={styles.loginText}>Login</Text>}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20, backgroundColor: '#fff' },
|
||||
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
|
||||
errorText: { color: 'red', marginBottom: 10 },
|
||||
successText: { color: 'green', fontSize: 18, fontWeight: 'bold' },
|
||||
warningText: { color: '#FFA500', marginBottom: 15, textAlign: 'center' },
|
||||
loginBtn: { backgroundColor: '#007AFF', padding: 15, borderRadius: 8, width: '100%', alignItems: 'center' },
|
||||
disabledBtn: { backgroundColor: '#ccc' },
|
||||
loginText: { color: '#fff', fontWeight: 'bold', fontSize: 16 },
|
||||
settingsIcon: { position: 'absolute', top: 50, right: 20, padding: 10 },
|
||||
settingsText: { color: '#007AFF', fontWeight: '600' },
|
||||
scrollContainer: { padding: 40, backgroundColor: '#fff', flex: 1 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: '#666', marginBottom: 8, marginTop: 15 },
|
||||
input: { backgroundColor: '#f5f5f5', padding: 12, borderRadius: 8, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
|
||||
saveBtn: { backgroundColor: '#34C759', padding: 15, borderRadius: 8, marginTop: 30, alignItems: 'center' },
|
||||
saveText: { color: '#fff', fontWeight: 'bold', fontSize: 16 },
|
||||
backBtn: { marginBottom: 20 },
|
||||
backText: { color: '#007AFF', fontSize: 16 }
|
||||
});
|
||||
44
client-mobile/app.json
Normal file
44
client-mobile/app.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "Knot",
|
||||
"slug": "knot-messenger",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"android": {
|
||||
"package": "ru.knot.messenger",
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"VIBRATE"
|
||||
]
|
||||
},
|
||||
"updates": {
|
||||
"url": "https://mess.khomegeneric.keenetic.pro/api/v1/updates",
|
||||
"enabled": true,
|
||||
"checkOnLaunch": "ALWAYS"
|
||||
},
|
||||
"runtimeVersion": "1.0.0",
|
||||
"plugins": [
|
||||
"expo-secure-store",
|
||||
"expo-updates",
|
||||
"@react-native-firebase/app",
|
||||
"@react-native-firebase/messaging",
|
||||
[
|
||||
"expo-build-properties",
|
||||
{
|
||||
"android": {
|
||||
"compileSdkVersion": 36,
|
||||
"targetSdkVersion": 34,
|
||||
"buildToolsVersion": "35.0.0"
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
client-mobile/assets/adaptive-icon.png
Normal file
BIN
client-mobile/assets/adaptive-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
BIN
client-mobile/assets/favicon.png
Normal file
BIN
client-mobile/assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client-mobile/assets/icon.png
Normal file
BIN
client-mobile/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
client-mobile/assets/splash-icon.png
Normal file
BIN
client-mobile/assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
7
client-mobile/babel.config.js
Normal file
7
client-mobile/babel.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [],
|
||||
};
|
||||
};
|
||||
55
client-mobile/docs/mobile_implementation.md
Normal file
55
client-mobile/docs/mobile_implementation.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# Knot Messenger Mobile — Локальная сборка и Self-Hosted OTA
|
||||
|
||||
Этот документ описывает процесс перехода с инфраструктуры Expo (EAS) на полностью автономную сборку и обновление.
|
||||
|
||||
## 1. Конфигурация проекта
|
||||
|
||||
### Переход на Bare Workflow
|
||||
Проект настроен так, чтобы генерировать нативный Android-код локально.
|
||||
- `app.json`: Обновлен параметр `updates.url` на ваш сервер.
|
||||
- `google-services.json`: Добавлен конфиг Firebase (нужно заменить на ваш реальный из консоли Firebase).
|
||||
|
||||
### Прямой Firebase (FCM)
|
||||
Вместо прокси-серверов Expo теперь используется `@react-native-firebase/messaging`.
|
||||
- Токен получается напрямую через `messaging().getToken()`.
|
||||
- Обработка уведомлений происходит через `setBackgroundMessageHandler`.
|
||||
|
||||
## 2. Инструкция по сборке (Local Build)
|
||||
|
||||
### Шаг 1: Генерация нативного кода
|
||||
Выполните команду для создания папки `android/` на основе вашего `app.json`:
|
||||
```bash
|
||||
npx expo prebuild
|
||||
```
|
||||
|
||||
### Шаг 2: Сборка APK (Gradle)
|
||||
Перейдите в папку `android` и запустите сборку:
|
||||
```bash
|
||||
cd android
|
||||
./gradlew assembleRelease
|
||||
```
|
||||
APK будет находиться в: `android/app/build/outputs/apk/release/app-release.apk`
|
||||
|
||||
## 3. Self-Hosted обновления (OTA)
|
||||
|
||||
### Процесс публикации:
|
||||
1. Выполните экспорт бандла:
|
||||
```bash
|
||||
node scripts/export-updates.js
|
||||
```
|
||||
2. Скопируйте содержимое папки `client-mobile/dist` на ваш Nginx сервер.
|
||||
3. Убедитесь, что сервер доступен по адресу: `https://my-server.com/api/v1/updates` (как указано в `app.json`).
|
||||
|
||||
### Проверка в приложении:
|
||||
В `App.tsx` подключен `UpdateService.ts`, который:
|
||||
- Проверяет наличие новой версии при каждом запуске.
|
||||
- Показывает диалоговое окно с предложением обновиться.
|
||||
|
||||
## 4. SignalR и Фон
|
||||
- Использован `expo-keep-awake` для предотвращения засыпания приложения при активном соединении.
|
||||
- Определена фоновая задача `SIGNALR_BACKGROUND_TASK` через `expo-task-manager` для обработки данных в свернутом состоянии.
|
||||
|
||||
## 5. Требования к Nginx
|
||||
Чтобы `expo-updates` корректно скачивал файлы, ваш сервер должен отдавать:
|
||||
- `expo-protocol-version: 1` в заголовках.
|
||||
- Правильные MIME-типы для `.js` и ассетов.
|
||||
29
client-mobile/google-services.json
Normal file
29
client-mobile/google-services.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "132691330634",
|
||||
"project_id": "knot-6604f",
|
||||
"storage_bucket": "knot-6604f.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:132691330634:android:509cc9af985fb252dd48b3",
|
||||
"android_client_info": {
|
||||
"package_name": "ru.knot.messenger"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyA1xJyFDIelP2TM7VHc4FhDPNTpYk6GVNc"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
5
client-mobile/index.js
Normal file
5
client-mobile/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import 'react-native-gesture-handler';
|
||||
import { registerRootComponent } from 'expo';
|
||||
import App from './App';
|
||||
|
||||
registerRootComponent(App);
|
||||
8
client-mobile/index.ts
Normal file
8
client-mobile/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import 'react-native-gesture-handler';
|
||||
import { registerRootComponent } from 'expo';
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
29
client-mobile/metro.config.js
Normal file
29
client-mobile/metro.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, '..');
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Keep the project root to the current folder to ensure assets are found correctly
|
||||
config.projectRoot = projectRoot;
|
||||
|
||||
// 2. Watch all files within the monorepo for changes
|
||||
config.watchFolders = [workspaceRoot];
|
||||
|
||||
// 3. Explicitly tell Metro where to look for modules
|
||||
// We prioritize local node_modules, then workspace node_modules
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
// 4. Force resolution of React and React Native
|
||||
config.resolver.extraNodeModules = {
|
||||
'react': path.resolve(projectRoot, 'node_modules/react'),
|
||||
'react-native': path.resolve(projectRoot, 'node_modules/react-native'),
|
||||
'@react-native/assets-registry': path.resolve(projectRoot, 'node_modules/@react-native/assets-registry'),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
46
client-mobile/package.json
Normal file
46
client-mobile/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "client-mobile",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "^10.0.0",
|
||||
"@react-native-community/netinfo": "11.4.1",
|
||||
"@react-native-firebase/app": "^24.0.0",
|
||||
"@react-native-firebase/messaging": "^24.0.0",
|
||||
"@react-navigation/native": "^7.2.2",
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"@react-navigation/stack": "^7.8.9",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"expo": "~54.0.33",
|
||||
"expo-build-properties": "~1.0.10",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-device": "~8.0.10",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
"expo-notifications": "~0.32.16",
|
||||
"expo-secure-store": "~15.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-task-manager": "~14.0.9",
|
||||
"expo-updates": "~29.0.16",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-mmkv": "^4.3.0",
|
||||
"react-native-nitro-modules": "^0.35.3",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
39
client-mobile/scripts/export-updates.js
Normal file
39
client-mobile/scripts/export-updates.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
console.log('🚀 Начинаю экспорт обновления для Self-hosted OTA...');
|
||||
|
||||
try {
|
||||
// 1. Выполняем стандартный экспорт Expo
|
||||
console.log('📦 Выполняю npx expo export...');
|
||||
execSync('npx expo export --platform android', { stdio: 'inherit' });
|
||||
|
||||
// 2. Путь к папке экспорта
|
||||
const distDir = path.join(process.cwd(), 'dist');
|
||||
|
||||
if (!fs.existsSync(distDir)) {
|
||||
throw new Error('Папка dist не была создана. Проверьте вывод npx expo export.');
|
||||
}
|
||||
|
||||
// 3. Генерация манифеста (metadata.json) для нашего сервера
|
||||
// В современных версиях Expo манифесты генерируются автоматически в папке dist/metadata.json
|
||||
// Но если нам нужен кастомный формат для Nginx, мы можем доработать его здесь.
|
||||
|
||||
const metadataPath = path.join(distDir, 'metadata.json');
|
||||
if (fs.existsSync(metadataPath)) {
|
||||
console.log('✅ Manifest (metadata.json) найден.');
|
||||
} else {
|
||||
console.warn('⚠️ Manifest не найден в dist. Возможно, версия Expo требует ручной генерации.');
|
||||
}
|
||||
|
||||
console.log('\n--- ИНСТРУКЦИЯ ---');
|
||||
console.log('1. Скопируйте всё содержимое папки /dist на ваш сервер Nginx.');
|
||||
console.log('2. Убедитесь, что сервер возвращает заголовок "expo-protocol-version: 1".');
|
||||
console.log('3. Проверьте доступность по адресу из app.json.');
|
||||
console.log('------------------');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Ошибка при экспорте:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
9
client-mobile/scripts/export-updates.sh
Normal file
9
client-mobile/scripts/export-updates.sh
Normal file
@@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Скрипт для подготовки OTA обновления для Self-Hosted сервера
|
||||
|
||||
echo "🚀 Экспорт Expo бандла..."
|
||||
npx expo export --platform android
|
||||
|
||||
echo "✅ Экспорт завершен в папку ./dist"
|
||||
echo "📂 Теперь загрузите содержимое папки ./dist на ваш сервер по адресу, указанному в app.json (updates.url)"
|
||||
echo "🔗 Убедитесь, что ваш Nginx отдает статику из этой папки и заголовок 'expo-protocol-version: 1' или выше."
|
||||
40
client-mobile/src/core/application/configStore.ts
Normal file
40
client-mobile/src/core/application/configStore.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { create } from 'zustand';
|
||||
import { storage } from '../infrastructure/storage';
|
||||
|
||||
interface ConfigState {
|
||||
apiUrl: string;
|
||||
updateUrl: string;
|
||||
isConfigured: boolean;
|
||||
setUrls: (apiUrl: string, updateUrl: string) => void;
|
||||
initialize: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_API_URL = 'https://mess.khomegeneric.keenetic.pro/api';
|
||||
const DEFAULT_UPDATE_URL = 'https://mess.khomegeneric.keenetic.pro/api/v1/updates';
|
||||
|
||||
export const useConfigStore = create<ConfigState>((set) => ({
|
||||
apiUrl: DEFAULT_API_URL,
|
||||
updateUrl: DEFAULT_UPDATE_URL,
|
||||
isConfigured: false,
|
||||
|
||||
initialize: () => {
|
||||
const savedApiUrl = storage.getString('config_api_url');
|
||||
const savedUpdateUrl = storage.getString('config_update_url');
|
||||
const configured = storage.getBoolean('config_is_configured');
|
||||
|
||||
if (savedApiUrl && savedUpdateUrl) {
|
||||
set({
|
||||
apiUrl: savedApiUrl,
|
||||
updateUrl: savedUpdateUrl,
|
||||
isConfigured: !!configured
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setUrls: (apiUrl: string, updateUrl: string) => {
|
||||
storage.set('config_api_url', apiUrl);
|
||||
storage.set('config_update_url', updateUrl);
|
||||
storage.set('config_is_configured', true);
|
||||
set({ apiUrl, updateUrl, isConfigured: true });
|
||||
},
|
||||
}));
|
||||
8
client-mobile/src/core/infrastructure/config.ts
Normal file
8
client-mobile/src/core/infrastructure/config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
// For development, use your machine's IP address instead of localhost
|
||||
// Example: http://192.168.1.50:5000
|
||||
const DEV_URL = 'http://10.0.2.2:5000'; // Android emulator default loopback
|
||||
|
||||
export const API_BASE = Constants.expoConfig?.extra?.apiUrl || DEV_URL;
|
||||
export const SOCKET_URL = `${API_BASE}/hubs/chat`;
|
||||
78
client-mobile/src/core/infrastructure/pushService.ts
Normal file
78
client-mobile/src/core/infrastructure/pushService.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import messaging from '@react-native-firebase/messaging';
|
||||
import { Platform, PermissionsAndroid, Alert } from 'react-native';
|
||||
|
||||
// Handle background messages
|
||||
messaging().setBackgroundMessageHandler(async remoteMessage => {
|
||||
console.log('FCM: Received message in Quit state/Background!', remoteMessage);
|
||||
});
|
||||
|
||||
export const PushService = {
|
||||
async initNotifications() {
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS);
|
||||
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
|
||||
console.warn('FCM: Post notifications permission denied.');
|
||||
}
|
||||
}
|
||||
|
||||
const authStatus = await messaging().requestPermission();
|
||||
const enabled =
|
||||
authStatus === messaging.AuthorizationStatus.AUTHORIZED ||
|
||||
authStatus === messaging.AuthorizationStatus.PROVISIONAL;
|
||||
|
||||
if (enabled) {
|
||||
console.log('FCM: Permission status:', authStatus);
|
||||
const fcmToken = await this.getFCMToken();
|
||||
if (fcmToken) {
|
||||
// This would be called to send the token to the backend
|
||||
// Example: await AuthApi.registerPushToken(fcmToken);
|
||||
console.log('FCM Token registered:', fcmToken);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('FCM: Failed to initialize notifications:', error);
|
||||
}
|
||||
|
||||
// Handle messages while the app is in focus
|
||||
const unsubscribeOnMessage = messaging().onMessage(async remoteMessage => {
|
||||
console.log('FCM: Received foreground message:', remoteMessage);
|
||||
// Optional: Display alert if user is in different screen or something
|
||||
});
|
||||
|
||||
// Handle interaction when the app is in background but not quit
|
||||
const unsubscribeOnNotificationOpenedApp = messaging().onNotificationOpenedApp(remoteMessage => {
|
||||
console.log('FCM: Notification caused app to open from background state:', remoteMessage.data);
|
||||
});
|
||||
|
||||
// Check if the app was opened by clicking a notification from a quit state
|
||||
messaging().getInitialNotification().then(remoteMessage => {
|
||||
if (remoteMessage) {
|
||||
console.log('FCM: Notification caused app to open from quit state:', remoteMessage.data);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeOnMessage();
|
||||
unsubscribeOnNotificationOpenedApp();
|
||||
};
|
||||
},
|
||||
|
||||
async getFCMToken(): Promise<string | null> {
|
||||
try {
|
||||
const fcmToken = await messaging().getToken();
|
||||
if (fcmToken) return fcmToken;
|
||||
} catch (error) {
|
||||
console.error('FCM: Error getting token:', error);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
async deleteFCMToken(): Promise<void> {
|
||||
try {
|
||||
await messaging().deleteToken();
|
||||
} catch (error) {
|
||||
console.error('FCM: Error deleting token:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
28
client-mobile/src/core/infrastructure/secureStore.ts
Normal file
28
client-mobile/src/core/infrastructure/secureStore.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
export const secureStorage = {
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.setItemAsync(key, value);
|
||||
} catch (error) {
|
||||
console.error(`SecureStore Error saving ${key}:`, error);
|
||||
}
|
||||
},
|
||||
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
try {
|
||||
return await SecureStore.getItemAsync(key);
|
||||
} catch (error) {
|
||||
console.error(`SecureStore Error reading ${key}:`, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
try {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
} catch (error) {
|
||||
console.error(`SecureStore Error removing ${key}:`, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
97
client-mobile/src/core/infrastructure/socket.ts
Normal file
97
client-mobile/src/core/infrastructure/socket.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { HubConnection, HubConnectionBuilder, LogLevel, HttpTransportType } from '@microsoft/signalr';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import { SOCKET_URL } from './config';
|
||||
|
||||
export interface SocketClient {
|
||||
on(event: string, callback: (...args: any[]) => void): void;
|
||||
off(event: string, callback?: (...args: any[]) => void): void;
|
||||
emit(event: string, ...args: any[]): void;
|
||||
disconnect(): void;
|
||||
status: string;
|
||||
}
|
||||
|
||||
let connection: HubConnection | null = null;
|
||||
let socketClient: SocketClient | null = null;
|
||||
let appStateListener: any = null;
|
||||
|
||||
export function connectSocket(token: string): SocketClient {
|
||||
if (connection && (connection.state === 'Connected' || connection.state === 'Connecting')) {
|
||||
return socketClient!;
|
||||
}
|
||||
|
||||
connection = new HubConnectionBuilder()
|
||||
.withUrl(SOCKET_URL, {
|
||||
accessTokenFactory: () => token,
|
||||
transport: HttpTransportType.WebSockets | HttpTransportType.LongPolling,
|
||||
skipNegotiation: false,
|
||||
})
|
||||
.withAutomaticReconnect({
|
||||
nextRetryDelayInMilliseconds: retryContext => {
|
||||
if (retryContext.elapsedMilliseconds < 10000) return 2000;
|
||||
if (retryContext.elapsedMilliseconds < 30000) return 10000;
|
||||
return 30000;
|
||||
}
|
||||
})
|
||||
.configureLogging(LogLevel.Information)
|
||||
.build();
|
||||
|
||||
socketClient = {
|
||||
on: (event: string, callback: (...args: any[]) => void) => {
|
||||
connection?.on(event, callback);
|
||||
},
|
||||
off: (event: string, callback?: (...args: any[]) => void) => {
|
||||
if (callback) {
|
||||
connection?.off(event, callback);
|
||||
} else {
|
||||
connection?.off(event);
|
||||
}
|
||||
},
|
||||
emit: (event: string, ...args: any[]) => {
|
||||
if (connection?.state === 'Connected') {
|
||||
connection.invoke(event, ...args).catch(err => {
|
||||
console.error(`SignalR emit error (${event}):`, err);
|
||||
});
|
||||
} else {
|
||||
console.warn(`SignalR emit skipped (${event}): not connected`);
|
||||
}
|
||||
},
|
||||
disconnect: () => {
|
||||
if (appStateListener) {
|
||||
appStateListener.remove();
|
||||
appStateListener = null;
|
||||
}
|
||||
connection?.stop();
|
||||
},
|
||||
get status() {
|
||||
return connection?.state || 'Disconnected';
|
||||
}
|
||||
};
|
||||
|
||||
// Lifecycle handling (AppState)
|
||||
appStateListener = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||
if (nextAppState === 'active') {
|
||||
if (connection?.state === 'Disconnected') {
|
||||
console.log('SignalR: Re-connecting on App Foreground...');
|
||||
connection.start().catch(e => console.error('SignalR start error (background to foreground):', e));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
connection.start()
|
||||
.then(() => console.log('SignalR: Connected!'))
|
||||
.catch(err => console.error('SignalR: Initial connection error:', err));
|
||||
|
||||
return socketClient;
|
||||
}
|
||||
|
||||
export function getSocket(): SocketClient | null {
|
||||
return socketClient;
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (socketClient) {
|
||||
socketClient.disconnect();
|
||||
socketClient = null;
|
||||
connection = null;
|
||||
}
|
||||
}
|
||||
37
client-mobile/src/core/infrastructure/storage.ts
Normal file
37
client-mobile/src/core/infrastructure/storage.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Временная заглушка для MMKV, пока мы чиним нативную часть
|
||||
class MockMMKV {
|
||||
private data: Record<string, any> = {};
|
||||
set(key: string, value: any) { this.data[key] = value; }
|
||||
getString(key: string) { return this.data[key]; }
|
||||
getBoolean(key: string) { return !!this.data[key]; }
|
||||
delete(key: string) { delete this.data[key]; }
|
||||
clearAll() { this.data = {}; }
|
||||
}
|
||||
|
||||
// Пытаемся импортировать настоящую MMKV, если не выйдет - используем заглушку
|
||||
let storageInstance: any;
|
||||
try {
|
||||
const { MMKV } = require('react-native-mmkv');
|
||||
storageInstance = new MMKV();
|
||||
console.log('MMKV: Native module loaded');
|
||||
} catch (e) {
|
||||
console.warn('MMKV: Failed to load native module, using Mock.', e);
|
||||
storageInstance = new MockMMKV();
|
||||
}
|
||||
|
||||
export const storage = storageInstance;
|
||||
|
||||
export const mmkvStorage = {
|
||||
setItem(key: string, value: string): void {
|
||||
storage.set(key, value);
|
||||
},
|
||||
getItem(key: string): string | null {
|
||||
return storage.getString(key) ?? null;
|
||||
},
|
||||
removeItem(key: string): void {
|
||||
storage.delete(key);
|
||||
},
|
||||
clearAll(): void {
|
||||
storage.clearAll();
|
||||
}
|
||||
};
|
||||
45
client-mobile/src/core/infrastructure/updateService.ts
Normal file
45
client-mobile/src/core/infrastructure/updateService.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import * as Updates from 'expo-updates';
|
||||
import { Alert } from 'react-native';
|
||||
|
||||
export const UpdateService = {
|
||||
async checkForUpdates() {
|
||||
if (__DEV__) return;
|
||||
|
||||
try {
|
||||
const update = await Updates.checkForUpdateAsync();
|
||||
if (update.isAvailable) {
|
||||
Alert.alert(
|
||||
'Доступно обновление',
|
||||
'Доступна новая версия мессенджера Knot. Хотите обновить сейчас?',
|
||||
[
|
||||
{ text: 'Позже', style: 'cancel' },
|
||||
{
|
||||
text: 'Обновить',
|
||||
onPress: async () => {
|
||||
await this.applyUpdate();
|
||||
}
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('OTA: Error checking for updates:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async applyUpdate() {
|
||||
try {
|
||||
await Updates.fetchUpdateAsync();
|
||||
await Updates.reloadAsync();
|
||||
} catch (error) {
|
||||
console.error('OTA: Error applying update:', error);
|
||||
Alert.alert('Ошибка обновления', 'Не удалось загрузить новую версию.');
|
||||
}
|
||||
},
|
||||
|
||||
async startAutomaticCheck() {
|
||||
if (__DEV__) return;
|
||||
// В SDK 51+ рекомендуется использовать checkForUpdateAsync или хуки в компонентах
|
||||
console.log('OTA: Update check utility ready.');
|
||||
}
|
||||
};
|
||||
76
client-mobile/src/modules/auth/application/authStore.ts
Normal file
76
client-mobile/src/modules/auth/application/authStore.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { create } from 'zustand';
|
||||
import { secureStorage } from '../../../core/infrastructure/secureStore';
|
||||
import { connectSocket, disconnectSocket } from '../../../core/infrastructure/socket';
|
||||
import { PushService } from '../../../core/infrastructure/pushService';
|
||||
|
||||
// Fallback types if domain is not yet copied
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
initialize: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
token: null,
|
||||
user: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
|
||||
initialize: async () => {
|
||||
try {
|
||||
const token = await secureStorage.getItem('knot_token');
|
||||
if (token) {
|
||||
// Here you would typically call an API to verify the token and get user info
|
||||
// For now, mirroring web logic slightly
|
||||
set({ token, isLoading: false });
|
||||
connectSocket(token);
|
||||
|
||||
// Register for push notifications after login
|
||||
const pushToken = await PushService.registerForPushNotificationsAsync();
|
||||
if (pushToken) {
|
||||
console.log('Push Token:', pushToken);
|
||||
// TODO: Send pushToken to backend: await AuthApi.sendPushToken(pushToken);
|
||||
}
|
||||
} else {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
} catch (err) {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
login: async (username, password) => {
|
||||
try {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
// Mocking API call - in reality, use adapted AuthApi
|
||||
const mockResult = { token: 'mock-token', user: { id: '1', username, displayName: username } };
|
||||
|
||||
await secureStorage.setItem('knot_token', mockResult.token);
|
||||
connectSocket(mockResult.token);
|
||||
|
||||
set({ token: mockResult.token, user: mockResult.user, isLoading: false });
|
||||
|
||||
const pushToken = await PushService.registerForPushNotificationsAsync();
|
||||
// TODO: send to server
|
||||
} catch (err: any) {
|
||||
set({ isLoading: false, error: err.message });
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await secureStorage.removeItem('knot_token');
|
||||
disconnectSocket();
|
||||
set({ token: null, user: null });
|
||||
},
|
||||
}));
|
||||
129
client-mobile/src/modules/chats/presentation/ChatList.tsx
Normal file
129
client-mobile/src/modules/chats/presentation/ChatList.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, FlatList, Image } from 'react-native';
|
||||
import { Chat } from '../../../../../client-web/src/core/domain/types';
|
||||
|
||||
interface ChatListProps {
|
||||
chats: Chat[];
|
||||
onChatPress: (chat: Chat) => void;
|
||||
}
|
||||
|
||||
export const ChatList: React.FC<ChatListProps> = ({ chats, onChatPress }) => {
|
||||
const renderItem = ({ item }: { item: Chat }) => {
|
||||
const lastMessage = item.messages?.[item.messages.length - 1];
|
||||
|
||||
return (
|
||||
<TouchableOpacity style={styles.chatItem} onPress={() => onChatPress(item)}>
|
||||
<View style={styles.avatarContainer}>
|
||||
{item.avatarUrl ? (
|
||||
<Image source={{ uri: item.avatarUrl }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.placeholderAvatar]}>
|
||||
<Text style={styles.placeholderText}>
|
||||
{(item.name || 'Chat').charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.chatInfo}>
|
||||
<View style={styles.topRow}>
|
||||
<Text style={styles.chatName} numberOfLines={1}>{item.name || 'Unknown Chat'}</Text>
|
||||
{lastMessage && (
|
||||
<Text style={styles.timeText}>
|
||||
{new Date(lastMessage.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.lastMessage} numberOfLines={1}>
|
||||
{lastMessage ? lastMessage.content : 'No messages yet'}
|
||||
</Text>
|
||||
</View>
|
||||
{item.unreadCount > 0 && (
|
||||
<View style={styles.unreadBadge}>
|
||||
<Text style={styles.unreadText}>{item.unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={chats}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
listContent: {
|
||||
paddingBottom: 20,
|
||||
},
|
||||
chatItem: {
|
||||
flexDirection: 'row',
|
||||
padding: 15,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#f0f0f0',
|
||||
backgroundColor: '#fff',
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: 15,
|
||||
},
|
||||
avatar: {
|
||||
width: 55,
|
||||
height: 55,
|
||||
borderRadius: 27.5,
|
||||
},
|
||||
placeholderAvatar: {
|
||||
backgroundColor: '#007AFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
placeholderText: {
|
||||
color: '#fff',
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
chatInfo: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
topRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 4,
|
||||
},
|
||||
chatName: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#000',
|
||||
flex: 1,
|
||||
marginRight: 10,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
},
|
||||
lastMessage: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
unreadBadge: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 10,
|
||||
minWidth: 20,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 6,
|
||||
marginLeft: 10,
|
||||
},
|
||||
unreadText: {
|
||||
color: '#fff',
|
||||
fontSize: 11,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
160
client-mobile/src/modules/chats/presentation/ChatRoom.tsx
Normal file
160
client-mobile/src/modules/chats/presentation/ChatRoom.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, SafeAreaView, KeyboardAvoidingView, Platform, TextInput, TouchableOpacity } from 'react-native';
|
||||
import { FlashList } from '@shopify/flash-list';
|
||||
import { Message, Chat } from '../../../../../client-web/src/core/domain/types';
|
||||
|
||||
interface ChatRoomProps {
|
||||
chat: Chat;
|
||||
messages: Message[];
|
||||
currentUserId: string;
|
||||
onSendMessage: (content: string) => void;
|
||||
}
|
||||
|
||||
export const ChatRoom: React.FC<ChatRoomProps> = ({ chat, messages, currentUserId, onSendMessage }) => {
|
||||
const [inputText, setInputText] = React.useState('');
|
||||
|
||||
const handleSend = () => {
|
||||
if (inputText.trim()) {
|
||||
onSendMessage(inputText);
|
||||
setInputText('');
|
||||
}
|
||||
};
|
||||
|
||||
const renderMessage = ({ item }: { item: Message }) => {
|
||||
const isMine = item.senderId === currentUserId;
|
||||
|
||||
return (
|
||||
<View style={[styles.messageContainer, isMine ? styles.myMessageContainer : styles.otherMessageContainer]}>
|
||||
{!isMine && <Text style={styles.senderName}>{item.sender.displayName}</Text>}
|
||||
<View style={[styles.bubble, isMine ? styles.myBubble : styles.otherBubble]}>
|
||||
<Text style={[styles.messageText, isMine ? styles.myMessageText : styles.otherMessageText]}>
|
||||
{item.content}
|
||||
</Text>
|
||||
<Text style={[styles.timeText, isMine ? styles.myTimeText : styles.otherTimeText]}>
|
||||
{new Date(item.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<FlashList
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => item.id}
|
||||
estimatedItemSize={70}
|
||||
inverted
|
||||
contentContainerStyle={styles.listContent}
|
||||
/>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||
>
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Type a message..."
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
multiline
|
||||
/>
|
||||
<TouchableOpacity style={styles.sendButton} onPress={handleSend}>
|
||||
<Text style={styles.sendButtonText}>Send</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f5f5f5',
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
messageContainer: {
|
||||
marginVertical: 4,
|
||||
maxWidth: '80%',
|
||||
},
|
||||
myMessageContainer: {
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
otherMessageContainer: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
senderName: {
|
||||
fontSize: 12,
|
||||
color: '#888',
|
||||
marginBottom: 2,
|
||||
marginLeft: 4,
|
||||
},
|
||||
bubble: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 18,
|
||||
},
|
||||
myBubble: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
otherBubble: {
|
||||
backgroundColor: '#fff',
|
||||
borderBottomLeftRadius: 4,
|
||||
borderWidth: 1,
|
||||
borderColor: '#e0e0e0',
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 16,
|
||||
},
|
||||
myMessageText: {
|
||||
color: '#fff',
|
||||
},
|
||||
otherMessageText: {
|
||||
color: '#000',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: 10,
|
||||
marginTop: 2,
|
||||
alignSelf: 'flex-end',
|
||||
},
|
||||
myTimeText: {
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
otherTimeText: {
|
||||
color: '#999',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
padding: 10,
|
||||
backgroundColor: '#fff',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#e0e0e0',
|
||||
alignItems: 'center',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f0f0f0',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 15,
|
||||
paddingVertical: 8,
|
||||
marginRight: 10,
|
||||
fontSize: 16,
|
||||
maxHeight: 100,
|
||||
},
|
||||
sendButton: {
|
||||
backgroundColor: '#007AFF',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
sendButtonText: {
|
||||
color: '#fff',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
173
client-mobile/src/presentation/navigation/AppNavigator.tsx
Normal file
173
client-mobile/src/presentation/navigation/AppNavigator.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { useAuthStore } from '../../modules/auth/application/authStore';
|
||||
import { ChatList } from '../../modules/chats/presentation/ChatList';
|
||||
import { ChatRoom } from '../../modules/chats/presentation/ChatRoom';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator, TextInput, ScrollView, Alert } from 'react-native';
|
||||
import { useConfigStore } from '../../core/application/configStore';
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
// Temporary mock screens using the components we built
|
||||
|
||||
const HomeScreen = ({ navigation }: any) => {
|
||||
const { logout } = useAuthStore();
|
||||
|
||||
// Mock data
|
||||
const mockChats = [
|
||||
{
|
||||
id: '1', type: 'personal' as const, name: 'Alice', avatar: null, createdAt: new Date().toISOString(), unreadCount: 2, members: [],
|
||||
messages: [{ id: 'm1', chatId: '1', senderId: 'user2', content: 'Hello!', createdAt: new Date().toISOString(), type: 'text', isEdited: false, isDeleted: false, sequenceId: 1, sender: { id: 'user2', username: 'alice', displayName: 'Alice' }, media: [], reactions: [], readBy: [] }]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ChatList
|
||||
chats={mockChats}
|
||||
onChatPress={(chat) => navigation.navigate('ChatRoom', { chat })}
|
||||
/>
|
||||
<TouchableOpacity style={styles.logoutBtn} onPress={logout}>
|
||||
<Text style={styles.logoutText}>Logout</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ChatRoomScreen = ({ route }: any) => {
|
||||
const { chat } = route.params;
|
||||
const { user } = useAuthStore();
|
||||
|
||||
const mockMessages = chat.messages || [];
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ChatRoom
|
||||
chat={chat}
|
||||
messages={mockMessages}
|
||||
currentUserId={user?.id || 'user1'}
|
||||
onSendMessage={(text) => console.log('Sent:', text)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const ConnectionSettingsScreen = ({ navigation }: any) => {
|
||||
const { apiUrl: currentApi, updateUrl: currentUpdate, setUrls } = useConfigStore();
|
||||
const [apiUrl, setApiUrl] = React.useState(currentApi);
|
||||
const [updateUrl, setUpdateUrl] = React.useState(currentUpdate);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!apiUrl.startsWith('http')) {
|
||||
Alert.alert('Error', 'API URL must start with http:// or https://');
|
||||
return;
|
||||
}
|
||||
setUrls(apiUrl, updateUrl);
|
||||
Alert.alert('Success', 'Connection settings saved!', [{ text: 'OK', onPress: () => navigation.goBack() }]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView contentContainerStyle={styles.scrollContainer}>
|
||||
<Text style={styles.label}>API Base URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={apiUrl}
|
||||
onChangeText={setApiUrl}
|
||||
placeholder="https://your-server.com/api"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<Text style={styles.label}>Update Server URL (OTA)</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={updateUrl}
|
||||
onChangeText={setUpdateUrl}
|
||||
placeholder="https://your-server.com/api/v1/updates"
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
|
||||
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
|
||||
<Text style={styles.saveText}>Save Settings</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
const LoginScreen = ({ navigation }: any) => {
|
||||
const { login, isLoading, error } = useAuthStore();
|
||||
const { isConfigured } = useConfigStore();
|
||||
|
||||
return (
|
||||
<View style={styles.centerContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.settingsIcon}
|
||||
onPress={() => navigation.navigate('Settings')}
|
||||
>
|
||||
<Text style={styles.settingsText}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Text style={styles.title}>Knot Messenger</Text>
|
||||
{!isConfigured && (
|
||||
<Text style={styles.warningText}>Please configure connection settings first</Text>
|
||||
)}
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
<TouchableOpacity
|
||||
style={[styles.loginBtn, !isConfigured && styles.disabledBtn]}
|
||||
onPress={() => login('test', 'password')}
|
||||
disabled={isLoading || !isConfigured}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.loginText}>Login</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppNavigator = () => {
|
||||
const { token } = useAuthStore();
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<Stack.Navigator>
|
||||
{!token ? (
|
||||
<>
|
||||
<Stack.Screen name="Login" component={LoginScreen} options={{ headerShown: false }} />
|
||||
<Stack.Screen name="Settings" component={ConnectionSettingsScreen} options={{ title: 'Connection Settings' }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Stack.Screen name="Home" component={HomeScreen} options={{ title: 'Chats' }} />
|
||||
<Stack.Screen
|
||||
name="ChatRoom"
|
||||
component={ChatRoomScreen}
|
||||
options={({ route }: any) => ({ title: route.params.chat.name || 'Chat' })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
</NavigationContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: '#fff' },
|
||||
centerContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 },
|
||||
title: { fontSize: 24, fontWeight: 'bold', marginBottom: 20 },
|
||||
errorText: { color: 'red', marginBottom: 10 },
|
||||
warningText: { color: '#FFA500', marginBottom: 15, textAlign: 'center' },
|
||||
loginBtn: { backgroundColor: '#007AFF', padding: 15, borderRadius: 8, width: '100%', alignItems: 'center' },
|
||||
disabledBtn: { backgroundColor: '#ccc' },
|
||||
loginText: { color: '#fff', fontWeight: 'bold', fontSize: 16 },
|
||||
logoutBtn: { padding: 15, alignItems: 'center', borderTopWidth: 1, borderColor: '#eee' },
|
||||
logoutText: { color: 'red', fontSize: 16 },
|
||||
settingsIcon: { position: 'absolute', top: 50, right: 20, padding: 10 },
|
||||
settingsText: { color: '#007AFF', fontWeight: '600' },
|
||||
scrollContainer: { padding: 20 },
|
||||
label: { fontSize: 14, fontWeight: '600', color: '#666', marginBottom: 8, marginTop: 15 },
|
||||
input: { backgroundColor: '#f5f5f5', padding: 12, borderRadius: 8, fontSize: 16, borderWidth: 1, borderColor: '#ddd' },
|
||||
saveBtn: { backgroundColor: '#34C759', padding: 15, borderRadius: 8, marginTop: 30, alignItems: 'center' },
|
||||
saveText: { color: '#fff', fontWeight: 'bold', fontSize: 16 }
|
||||
});
|
||||
6
client-mobile/tsconfig.json
Normal file
6
client-mobile/tsconfig.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@
|
||||
"emoji-mart": "^5.6.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-easy-crop": "^5.5.6",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zustand": "^5.0.2"
|
||||
|
||||
10256
package-lock.json
generated
10256
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -11,6 +11,16 @@
|
||||
"start": "npm run dev:web"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0"
|
||||
"concurrently": "^9.1.0",
|
||||
"expo": "~54.0.33"
|
||||
},
|
||||
"overrides": {
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-native-community/netinfo": "^11.4.1",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user