Зачаток мобилки

This commit is contained in:
Халимов Рустам
2026-04-07 17:27:52 +03:00
parent 02043d4d97
commit abc491c042
32 changed files with 10377 additions and 1175 deletions

41
client-mobile/.gitignore vendored Normal file
View 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

View File

91
client-mobile/App.tsx Normal file
View 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
View 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"
}
}
]
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [],
};
};

View 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` и ассетов.

View 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
View 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
View 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);

View 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;

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

View 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);
}

View 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' или выше."

View 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 });
},
}));

View 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`;

View 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);
}
}
};

View 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);
}
},
};

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

View 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();
}
};

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

View 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 });
},
}));

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

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

View 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 }
});

View File

@@ -0,0 +1,6 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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