diff --git a/.gitignore b/.gitignore
index f8c6c2e..b9c680d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,7 +37,4 @@ yarn-error.*
*.tsbuildinfo
app-example
-
-# generated native folders
-/ios
-/android
+/google-services.json
\ No newline at end of file
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
index 34c77d8..6f03899 100644
--- a/.idea/caches/deviceStreaming.xml
+++ b/.idea/caches/deviceStreaming.xml
@@ -172,6 +172,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -184,6 +196,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -196,6 +220,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -669,6 +705,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -765,6 +813,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml
new file mode 100644
index 0000000..91f9558
--- /dev/null
+++ b/.idea/deviceManager.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.watchmanconfig b/.watchmanconfig
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/.watchmanconfig
@@ -0,0 +1 @@
+{}
diff --git a/README.md b/README.md
index 48dd63f..27970a5 100644
--- a/README.md
+++ b/README.md
@@ -1,50 +1,84 @@
-# Welcome to your Expo app 👋
+# Syncre Mobile
+
+Syncre is a secure, cross-platform mobile communication application built with React Native and Expo. It focuses on privacy and security through end-to-end encryption for all communications.
+
+## ✨ Features
+
+- **Secure Messaging:** End-to-end encrypted one-on-one conversations.
+- **User Authentication:** Secure user registration, login, and profile management.
+- **Friend System:** Users can search for others, send friend requests, and manage their friend list.
+- **Real-time Communication:** Utilizes WebSockets for instant message delivery.
+- **Push Notifications:** Stay updated with new messages and friend requests even when the app is closed.
+- **Cross-Platform:** Runs on iOS, Android, and Web from a single codebase.
+
+## 🚀 Technologies Used
+
+- **Framework:** [React Native](https://reactnative.dev/) with [Expo](https://expo.dev/)
+- **Routing:** [Expo Router](https://docs.expo.dev/router/introduction/) for file-based navigation.
+- **UI Components:** [NextUI](https://nextui.org/) for the user interface.
+- **State Management & Data:** React Context and custom hooks.
+- **Encryption:**
+ - `@stablelib/hkdf`
+ - `@stablelib/sha256`
+ - `@stablelib/xchacha20poly1305`
+ - `expo-crypto`
+ - `tweetnacl`
+- **Storage:** `AsyncStorage` and `Expo Secure Store` for persistent and secure data storage.
+- **Real-time:** WebSockets
+- **Linting:** ESLint
+- **Typing:** TypeScript
+
+## 🏁 Getting Started
+
+### Prerequisites
+
+- Node.js (LTS version recommended)
+- npm or pnpm
+- Expo Go app on your mobile device for development, or Android Studio/Xcode for emulators.
+
+### Installation
+
+1. **Clone the repository:**
+ ```bash
+ git clone
+ cd Mobile
+ ```
+
+2. **Install dependencies:**
+ ```bash
+ npm install
+ # or
+ pnpm install
+ ```
+
+## 📜 Available Scripts
+
+- **`npm start`**: Starts the development server with Expo.
+- **`npm run android`**: Runs the app on a connected Android device or emulator.
+- **`npm run ios`**: Runs the app on an iOS simulator or connected device.
+- **`npm run web`**: Runs the app in a web browser.
+- **`npm run lint`**: Lints the codebase using ESLint.
+- **`npm run build`**: Creates a production build for iOS using EAS Build and submits it.
+- **`npm run real-build`**: Creates release builds for both iOS and Android.
+
+## 📁 Project Structure
-This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
-
-## Get started
-
-1. Install dependencies
-
- ```bash
- npm install
- ```
-
-2. Start the app
-
- ```bash
- npx expo start
- ```
-
-In the output, you'll find options to open the app in a
-
-- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
-- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
-- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
-- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
-
-You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
-
-## Get a fresh project
-
-When you're ready, run:
-
-```bash
-npm run reset-project
+```
+.
+├── app/ # Expo Router routes (screens)
+│ ├── chat/ # Dynamic route for individual chats
+│ ├── _layout.tsx # Main layout
+│ ├── index.tsx # Login/entry screen
+│ └── ... # Other screens (profile, settings, etc.)
+├── assets/ # Static assets (images, fonts)
+├── components/ # Reusable UI components
+├── context/ # React context providers
+├── hooks/ # Custom React hooks
+├── services/ # Core services (API, Crypto, WebSocket, etc.)
+├── screens/ # (Potentially legacy) Screen components
+└── ... # Config files, etc.
```
-This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
-
-## Learn more
-
-To learn more about developing your project with Expo, look at the following resources:
-
-- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
-- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
-
-## Join the community
-
-Join our community of developers creating universal apps.
+## 📄 License
-- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
-- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
+This project is licensed under the [GNU GENERAL PUBLIC LICENSE](LICENSE).
diff --git a/android/.gitignore b/android/.gitignore
new file mode 100644
index 0000000..8a6be07
--- /dev/null
+++ b/android/.gitignore
@@ -0,0 +1,16 @@
+# OSX
+#
+.DS_Store
+
+# Android/IntelliJ
+#
+build/
+.idea
+.gradle
+local.properties
+*.iml
+*.hprof
+.cxx/
+
+# Bundle artifacts
+*.jsbundle
diff --git a/android/app/.gitignore b/android/app/.gitignore
new file mode 100644
index 0000000..515713c
--- /dev/null
+++ b/android/app/.gitignore
@@ -0,0 +1 @@
+google-services.json
\ No newline at end of file
diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..256e12a
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,184 @@
+apply plugin: "com.android.application"
+apply plugin: "org.jetbrains.kotlin.android"
+apply plugin: "com.facebook.react"
+
+def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
+
+/**
+ * This is the configuration block to customize your React Native Android app.
+ * By default you don't need to apply any configuration, just uncomment the lines you need.
+ */
+react {
+ entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
+ reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
+ hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
+ codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
+
+ enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
+ // Use Expo CLI to bundle the app, this ensures the Metro config
+ // works correctly with Expo projects.
+ cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
+ bundleCommand = "export:embed"
+
+ /* Folders */
+ // The root of your project, i.e. where "package.json" lives. Default is '../..'
+ // root = file("../../")
+ // The folder where the react-native NPM package is. Default is ../../node_modules/react-native
+ // reactNativeDir = file("../../node_modules/react-native")
+ // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
+ // codegenDir = file("../../node_modules/@react-native/codegen")
+
+ /* Variants */
+ // The list of variants to that are debuggable. For those we're going to
+ // skip the bundling of the JS bundle and the assets. By default is just 'debug'.
+ // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
+ // debuggableVariants = ["liteDebug", "prodDebug"]
+
+ /* Bundling */
+ // A list containing the node command and its flags. Default is just 'node'.
+ // nodeExecutableAndArgs = ["node"]
+
+ //
+ // The path to the CLI configuration file. Default is empty.
+ // bundleConfig = file(../rn-cli.config.js)
+ //
+ // The name of the generated asset file containing your JS bundle
+ // bundleAssetName = "MyApplication.android.bundle"
+ //
+ // The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
+ // entryFile = file("../js/MyApplication.android.js")
+ //
+ // A list of extra flags to pass to the 'bundle' commands.
+ // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
+ // extraPackagerArgs = []
+
+ /* Hermes Commands */
+ // The hermes compiler command to run. By default it is 'hermesc'
+ // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
+ //
+ // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
+ // hermesFlags = ["-O", "-output-source-map"]
+
+ /* Autolinking */
+ autolinkLibrariesWithApp()
+}
+
+/**
+ * Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
+ */
+def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
+
+/**
+ * The preferred build flavor of JavaScriptCore (JSC)
+ *
+ * For example, to use the international variant, you can use:
+ * `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
+ *
+ * The international variant includes ICU i18n library and necessary data
+ * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
+ * give correct results when using with locales other than en-US. Note that
+ * this variant is about 6MiB larger per architecture than default.
+ */
+def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
+
+android {
+ ndkVersion rootProject.ext.ndkVersion
+
+ buildToolsVersion rootProject.ext.buildToolsVersion
+ compileSdk rootProject.ext.compileSdkVersion
+
+ namespace 'com.devbeni.syncre'
+ defaultConfig {
+ applicationId 'com.devbeni.syncre'
+ minSdkVersion rootProject.ext.minSdkVersion
+ targetSdkVersion rootProject.ext.targetSdkVersion
+ versionCode 1
+ versionName "1.1.4"
+
+ buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
+ }
+ signingConfigs {
+ debug {
+ storeFile file('debug.keystore')
+ storePassword 'android'
+ keyAlias 'androiddebugkey'
+ keyPassword 'android'
+ }
+ }
+ buildTypes {
+ debug {
+ signingConfig signingConfigs.debug
+ }
+ release {
+ // Caution! In production, you need to generate your own keystore file.
+ // see https://reactnative.dev/docs/signed-apk-android.
+ signingConfig signingConfigs.debug
+ def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
+ shrinkResources enableShrinkResources.toBoolean()
+ minifyEnabled enableMinifyInReleaseBuilds
+ proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
+ def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
+ crunchPngs enablePngCrunchInRelease.toBoolean()
+ }
+ }
+ packagingOptions {
+ jniLibs {
+ def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
+ useLegacyPackaging enableLegacyPackaging.toBoolean()
+ }
+ }
+ androidResources {
+ ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
+ }
+}
+
+// Apply static values from `gradle.properties` to the `android.packagingOptions`
+// Accepts values in comma delimited lists, example:
+// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
+["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
+ // Split option: 'foo,bar' -> ['foo', 'bar']
+ def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
+ // Trim all elements in place.
+ for (i in 0.. 0) {
+ println "android.packagingOptions.$prop += $options ($options.length)"
+ // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
+ options.each {
+ android.packagingOptions[prop] += it
+ }
+ }
+}
+
+dependencies {
+ // The version of react-native is set by the React Native Gradle Plugin
+ implementation("com.facebook.react:react-android")
+
+ def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
+ def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
+ def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
+
+ if (isGifEnabled) {
+ // For animated gif support
+ implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
+ }
+
+ if (isWebpEnabled) {
+ // For webp support
+ implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
+ if (isWebpAnimatedEnabled) {
+ // Animated webp support
+ implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
+ }
+ }
+
+ if (hermesEnabled.toBoolean()) {
+ implementation("com.facebook.react:hermes-android")
+ } else {
+ implementation jscFlavor
+ }
+}
+
+apply plugin: 'com.google.gms.google-services'
\ No newline at end of file
diff --git a/android/app/debug.keystore b/android/app/debug.keystore
new file mode 100644
index 0000000..364e105
Binary files /dev/null and b/android/app/debug.keystore differ
diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro
new file mode 100644
index 0000000..551eb41
--- /dev/null
+++ b/android/app/proguard-rules.pro
@@ -0,0 +1,14 @@
+# Add project specific ProGuard rules here.
+# By default, the flags in this file are appended to flags specified
+# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
+# You can edit the include path and order by changing the proguardFiles
+# directive in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# react-native-reanimated
+-keep class com.swmansion.reanimated.** { *; }
+-keep class com.facebook.react.turbomodule.** { *; }
+
+# Add any project specific keep options here:
diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000..3ec2507
--- /dev/null
+++ b/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/android/app/src/debugOptimized/AndroidManifest.xml b/android/app/src/debugOptimized/AndroidManifest.xml
new file mode 100644
index 0000000..3ec2507
--- /dev/null
+++ b/android/app/src/debugOptimized/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..e0ace55
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/java/com/devbeni/syncre/MainActivity.kt b/android/app/src/main/java/com/devbeni/syncre/MainActivity.kt
new file mode 100644
index 0000000..2766aba
--- /dev/null
+++ b/android/app/src/main/java/com/devbeni/syncre/MainActivity.kt
@@ -0,0 +1,65 @@
+package com.devbeni.syncre
+import expo.modules.splashscreen.SplashScreenManager
+
+import android.os.Build
+import android.os.Bundle
+
+import com.facebook.react.ReactActivity
+import com.facebook.react.ReactActivityDelegate
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
+import com.facebook.react.defaults.DefaultReactActivityDelegate
+
+import expo.modules.ReactActivityDelegateWrapper
+
+class MainActivity : ReactActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ // Set the theme to AppTheme BEFORE onCreate to support
+ // coloring the background, status bar, and navigation bar.
+ // This is required for expo-splash-screen.
+ // setTheme(R.style.AppTheme);
+ // @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
+ SplashScreenManager.registerOnActivity(this)
+ // @generated end expo-splashscreen
+ super.onCreate(null)
+ }
+
+ /**
+ * Returns the name of the main component registered from JavaScript. This is used to schedule
+ * rendering of the component.
+ */
+ override fun getMainComponentName(): String = "main"
+
+ /**
+ * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
+ * which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
+ */
+ override fun createReactActivityDelegate(): ReactActivityDelegate {
+ return ReactActivityDelegateWrapper(
+ this,
+ BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
+ object : DefaultReactActivityDelegate(
+ this,
+ mainComponentName,
+ fabricEnabled
+ ){})
+ }
+
+ /**
+ * Align the back button behavior with Android S
+ * where moving root activities to background instead of finishing activities.
+ * @see onBackPressed
+ */
+ override fun invokeDefaultOnBackPressed() {
+ if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
+ if (!moveTaskToBack(false)) {
+ // For non-root activities, use the default implementation to finish them.
+ super.invokeDefaultOnBackPressed()
+ }
+ return
+ }
+
+ // Use the default back button implementation on Android S
+ // because it's doing more than [Activity.moveTaskToBack] in fact.
+ super.invokeDefaultOnBackPressed()
+ }
+}
diff --git a/android/app/src/main/java/com/devbeni/syncre/MainApplication.kt b/android/app/src/main/java/com/devbeni/syncre/MainApplication.kt
new file mode 100644
index 0000000..7ea5f5b
--- /dev/null
+++ b/android/app/src/main/java/com/devbeni/syncre/MainApplication.kt
@@ -0,0 +1,56 @@
+package com.devbeni.syncre
+
+import android.app.Application
+import android.content.res.Configuration
+
+import com.facebook.react.PackageList
+import com.facebook.react.ReactApplication
+import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
+import com.facebook.react.ReactNativeHost
+import com.facebook.react.ReactPackage
+import com.facebook.react.ReactHost
+import com.facebook.react.common.ReleaseLevel
+import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
+import com.facebook.react.defaults.DefaultReactNativeHost
+
+import expo.modules.ApplicationLifecycleDispatcher
+import expo.modules.ReactNativeHostWrapper
+
+class MainApplication : Application(), ReactApplication {
+
+ override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
+ this,
+ object : DefaultReactNativeHost(this) {
+ override fun getPackages(): List =
+ PackageList(this).packages.apply {
+ // Packages that cannot be autolinked yet can be added manually here, for example:
+ // add(MyReactNativePackage())
+ }
+
+ override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
+
+ override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
+
+ override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
+ }
+ )
+
+ override val reactHost: ReactHost
+ get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
+
+ override fun onCreate() {
+ super.onCreate()
+ DefaultNewArchitectureEntryPoint.releaseLevel = try {
+ ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
+ } catch (e: IllegalArgumentException) {
+ ReleaseLevel.STABLE
+ }
+ loadReactNative(this)
+ ApplicationLifecycleDispatcher.onApplicationCreate(this)
+ }
+
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
+ }
+}
diff --git a/android/app/src/main/res/drawable-hdpi/notification_icon.png b/android/app/src/main/res/drawable-hdpi/notification_icon.png
new file mode 100644
index 0000000..736f0c4
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/notification_icon.png differ
diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png
new file mode 100644
index 0000000..7a29eee
Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/notification_icon.png b/android/app/src/main/res/drawable-mdpi/notification_icon.png
new file mode 100644
index 0000000..9eda035
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/notification_icon.png differ
diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png
new file mode 100644
index 0000000..a352859
Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/notification_icon.png b/android/app/src/main/res/drawable-xhdpi/notification_icon.png
new file mode 100644
index 0000000..c663336
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/notification_icon.png differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png
new file mode 100644
index 0000000..5f676fb
Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/notification_icon.png b/android/app/src/main/res/drawable-xxhdpi/notification_icon.png
new file mode 100644
index 0000000..01548d3
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/notification_icon.png differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png
new file mode 100644
index 0000000..5072e0b
Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png b/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png
new file mode 100644
index 0000000..2c6473e
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png
new file mode 100644
index 0000000..15fb657
Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ
diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..883b2a0
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,6 @@
+
+
+ -
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml
new file mode 100644
index 0000000..5c25e72
--- /dev/null
+++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..3941bea
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..3941bea
--- /dev/null
+++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..ebd1d3a
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..85f5ddb
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..897002a
Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..d3cf578
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..5bb55be
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..7c182d2
Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..cdf9516
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..36d0314
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..5ab631c
Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..2cd0fdd
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..4dcbd39
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..7dc4e16
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..db56b85
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..ee2b288
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9a57411
Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..5429410
--- /dev/null
+++ b/android/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,3 @@
+
+ #000000
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..c4dcda5
--- /dev/null
+++ b/android/app/src/main/res/values/colors.xml
@@ -0,0 +1,7 @@
+
+ #000000
+ #000000
+ #023c69
+ #000000
+ #2C82FF
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..ebfc5fc
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,6 @@
+
+ syncre
+ automatic
+ contain
+ false
+
\ No newline at end of file
diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000..6ddce7e
--- /dev/null
+++ b/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,14 @@
+
+
+
+
\ No newline at end of file
diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml
new file mode 100644
index 0000000..5c0c8be
--- /dev/null
+++ b/android/app/src/main/res/xml/file_paths.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..e39704a
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,25 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ dependencies {
+ classpath 'com.google.gms:google-services:4.4.1'
+ classpath('com.android.tools.build:gradle')
+ classpath('com.facebook.react:react-native-gradle-plugin')
+ classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ maven { url 'https://www.jitpack.io' }
+ }
+}
+
+apply plugin: "expo-root-project"
+apply plugin: "com.facebook.react.rootproject"
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..dc6d348
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,66 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
+org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+org.gradle.parallel=true
+
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+
+# Enable AAPT2 PNG crunching
+android.enablePngCrunchInReleaseBuilds=true
+
+# Use this property to specify which architecture you want to build.
+# You can also override it from the CLI using
+# ./gradlew -PreactNativeArchitectures=x86_64
+reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
+
+# Use this property to enable support to the new architecture.
+# This will allow you to use TurboModules and the Fabric render in
+# your application. You should enable this flag either if you want
+# to write custom TurboModules/Fabric components OR use libraries that
+# are providing them.
+newArchEnabled=false
+
+# Use this property to enable or disable the Hermes JS engine.
+# If set to false, you will be using JSC instead.
+hermesEnabled=true
+expo.jsEngine=hermes
+
+# Use this property to enable edge-to-edge display support.
+# This allows your app to draw behind system bars for an immersive UI.
+# Note: Only works with ReactActivity and should not be used with custom Activity.
+edgeToEdgeEnabled=true
+
+# Enable GIF support in React Native images (~200 B increase)
+expo.gif.enabled=true
+# Enable webp support in React Native images (~85 KB increase)
+expo.webp.enabled=true
+# Enable animated webp support (~3.4 MB increase)
+# Disabled by default because iOS doesn't support animated webp
+expo.webp.animated=false
+
+# Enable network inspector
+EX_DEV_CLIENT_NETWORK_INSPECTOR=true
+
+# Use legacy packaging to compress native libraries in the resulting APK.
+expo.useLegacyPackaging=false
+
+# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
+# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
+expo.edgeToEdgeEnabled=true
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..1b33c55
Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..d4081da
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/android/gradlew
new file mode 100755
index 0000000..7f94d3d
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,251 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH="\\\"\\\""
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..5eed7ee
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..5e807d2
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,39 @@
+pluginManagement {
+ def reactNativeGradlePlugin = new File(
+ providers.exec {
+ workingDir(rootDir)
+ commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
+ }.standardOutput.asText.get().trim()
+ ).getParentFile().absolutePath
+ includeBuild(reactNativeGradlePlugin)
+
+ def expoPluginsPath = new File(
+ providers.exec {
+ workingDir(rootDir)
+ commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
+ }.standardOutput.asText.get().trim(),
+ "../android/expo-gradle-plugin"
+ ).absolutePath
+ includeBuild(expoPluginsPath)
+}
+
+plugins {
+ id("com.facebook.react.settings")
+ id("expo-autolinking-settings")
+}
+
+extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
+ if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
+ ex.autolinkLibrariesFromCommand()
+ } else {
+ ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
+ }
+}
+expoAutolinking.useExpoModules()
+
+rootProject.name = 'syncre'
+
+expoAutolinking.useExpoVersionCatalog()
+
+include ':app'
+includeBuild(expoAutolinking.reactNativeGradlePlugin)
diff --git a/app.json b/app.json
index 11d2280..b04dd66 100644
--- a/app.json
+++ b/app.json
@@ -2,12 +2,13 @@
"expo": {
"name": "syncre",
"slug": "syncre",
- "version": "1.0.1",
+ "version": "1.1.4",
"orientation": "portrait",
"icon": "./assets/logo.png",
"scheme": "syncre",
"userInterfaceStyle": "automatic",
- "newArchEnabled": true,
+ "newArchEnabled": false,
+ "jsEngine": "hermes",
"notification": {
"icon": "./assets/logo.png",
"color": "#2C82FF"
@@ -16,17 +17,35 @@
"supportsTablet": true,
"bundleIdentifier": "xyz.syncre.app",
"infoPlist": {
- "ITSAppUsesNonExemptEncryption": false
+ "ITSAppUsesNonExemptEncryption": false,
+ "NSLocationWhenInUseUsageDescription": "Syncre uses your approximate location to keep message timestamps accurate.",
+ "CFBundleURLTypes": [
+ {
+ "CFBundleURLSchemes": [
+ "syncre"
+ ]
+ }
+ ]
+ },
+ "entitlements": {
+ "com.apple.security.application-groups": [
+ "group.xyz.syncre.share"
+ ]
}
},
"android": {
"adaptiveIcon": {
- "backgroundColor": "#667eea",
+ "backgroundColor": "#000000",
"foregroundImage": "./assets/logo.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
- "package": "com.devbeni.syncre"
+ "package": "com.devbeni.syncre",
+ "googleServicesFile": "./google-services.json",
+ "permissions": [
+ "ACCESS_COARSE_LOCATION",
+ "ACCESS_FINE_LOCATION"
+ ]
},
"web": {
"output": "static",
@@ -40,19 +59,29 @@
"image": "./assets/logo.png",
"imageWidth": 200,
"resizeMode": "contain",
- "backgroundColor": "#667eea",
+ "backgroundColor": "#000000",
"dark": {
- "backgroundColor": "#03040A"
+ "backgroundColor": "#000000"
}
}
- ]
+ ],
+ "expo-web-browser",
+ [
+ "expo-notifications",
+ {
+ "icon": "./assets/logo.png",
+ "color": "#2C82FF"
+ }
+ ],
+ "expo-video"
],
"experiments": {
"typedRoutes": true,
- "reactCompiler": true
+ "reactCompiler": false
},
"extra": {
"router": {},
+ "maintenance": false,
"eas": {
"projectId": "03d65be8-49f1-404d-ac7a-aa795be2f915"
}
diff --git a/app/+not-found.tsx b/app/+not-found.tsx
index e69de29..26a1f80 100644
--- a/app/+not-found.tsx
+++ b/app/+not-found.tsx
@@ -0,0 +1,41 @@
+import { Link } from 'expo-router';
+import { View, Text, StyleSheet } from 'react-native';
+
+export default function NotFoundScreen() {
+ return (
+
+ Page Not Found
+ Sorry, this page doesn't exist.
+
+ Go to Home
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: '#03040A',
+ padding: 20,
+ },
+ title: {
+ fontSize: 24,
+ fontWeight: 'bold',
+ color: '#fff',
+ marginBottom: 10,
+ },
+ subtitle: {
+ fontSize: 16,
+ color: '#888',
+ marginBottom: 20,
+ textAlign: 'center',
+ },
+ link: {
+ color: '#2C82FF',
+ fontSize: 16,
+ textDecorationLine: 'underline',
+ },
+});
\ No newline at end of file
diff --git a/app/_layout.tsx b/app/_layout.tsx
index b4357a7..6d60e2c 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -1,17 +1,214 @@
-import { Stack } from 'expo-router';
+import { Stack, useRouter } from 'expo-router';
+import * as Linking from 'expo-linking';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
+import { useCallback, useEffect, useState } from 'react';
+import * as Notifications from 'expo-notifications';
+import { SafeAreaProvider } from 'react-native-safe-area-context';
+import { ApiService } from '../services/ApiService';
+import Constants from 'expo-constants';
+import { UpdateService } from '../services/UpdateService';
+import { IdentityService } from '../services/IdentityService';
+import { StorageService } from '../services/StorageService';
+import { CryptoService } from '../services/CryptoService';
+import { ShareIntentService } from '../services/ShareIntentService';
+import { palette } from '../theme/designSystem';
+
+const isMaintenanceEnabled = (): boolean => {
+ const raw = Constants.expoConfig?.extra?.maintenance;
+ return raw === true || raw === 'true';
+};
export default function RootLayout() {
+ const [maintenance, setMaintenance] = useState(false);
+ const router = useRouter();
+
+ const resolveInitialRoute = useCallback(async () => {
+ const maintenanceFlag = isMaintenanceEnabled();
+ if (maintenanceFlag) {
+ setMaintenance(true);
+ return { path: '/maintenance', allowChatNavigation: false };
+ }
+
+ try {
+ const apiStatus = await ApiService.get('/health');
+ if (!apiStatus.success) {
+ setMaintenance(true);
+ return { path: '/maintenance', allowChatNavigation: false };
+ }
+
+ const updateStatus = await UpdateService.checkForMandatoryUpdate();
+ if (updateStatus.requiresUpdate) {
+ setMaintenance(false);
+ return { path: '/update', allowChatNavigation: false };
+ }
+
+ const token = await StorageService.getAuthToken();
+ if (token) {
+ const needsIdentitySetup = await IdentityService.requiresBootstrap(token);
+ const hasLocalIdentity = Boolean(await CryptoService.getStoredIdentity());
+
+ if (needsIdentitySetup) {
+ setMaintenance(false);
+ return { path: '/identity?mode=setup', allowChatNavigation: false };
+ }
+
+ if (!hasLocalIdentity) {
+ setMaintenance(false);
+ return { path: '/identity?mode=unlock', allowChatNavigation: false };
+ }
+
+ setMaintenance(false);
+ return { path: '/home', allowChatNavigation: true };
+ }
+
+ setMaintenance(false);
+ return { path: '/', allowChatNavigation: false };
+ } catch {
+ setMaintenance(true);
+ return { path: '/maintenance', allowChatNavigation: false };
+ }
+ }, []);
+
+ const extractChatIdFromNotification = (response: Notifications.NotificationResponse | null) => {
+ const data = response?.notification?.request?.content?.data as any;
+ return data?.chatId || data?.chat_id || data?.chatID || null;
+ };
+
+ const extractWrapDateFromNotification = (response: Notifications.NotificationResponse | null) => {
+ const data = response?.notification?.request?.content?.data as any;
+ if (!data || data?.type !== 'daily_wrap') return null;
+ return data?.date || data?.day || null;
+ };
+
+ useEffect(() => {
+ let mounted = true;
+
+ const bootstrapNavigation = async () => {
+ const lastResponse = await Notifications.getLastNotificationResponseAsync().catch(() => null);
+ const pendingChatId = extractChatIdFromNotification(lastResponse);
+ const pendingWrapDate = extractWrapDateFromNotification(lastResponse);
+ const initialRoute = await resolveInitialRoute();
+ if (!mounted) {
+ return;
+ }
+
+ if (pendingWrapDate && initialRoute.allowChatNavigation) {
+ router.replace(`/wrap/${pendingWrapDate}` as any);
+ } else if (pendingChatId && initialRoute.allowChatNavigation) {
+ router.replace(`/chat/${pendingChatId}`);
+ } else {
+ router.replace(initialRoute.path as any);
+ }
+ };
+
+ bootstrapNavigation();
+
+ const responseSub = Notifications.addNotificationResponseReceivedListener((response) => {
+ const chatId = extractChatIdFromNotification(response);
+ const wrapDate = extractWrapDateFromNotification(response);
+ if (wrapDate) {
+ router.push(`/wrap/${wrapDate}` as any);
+ } else if (chatId) {
+ router.push(`/chat/${chatId}`);
+ }
+ });
+
+ return () => {
+ mounted = false;
+ responseSub.remove();
+ };
+ }, [resolveInitialRoute, router]);
+
+ useEffect(() => {
+ ShareIntentService.init();
+
+ const handleIncomingUrl = (url?: string | null) => {
+ if (!url) return false;
+ if (ShareIntentService.handleIncomingUrl(url)) {
+ router.replace('/share');
+ return true;
+ }
+ try {
+ const parsed = Linking.parse(url);
+ const path = (parsed?.path || '').replace(/^\//, '').toLowerCase();
+ if (path.startsWith('spotify/callback')) {
+ const params = parsed?.queryParams || {};
+ router.replace({
+ pathname: '/spotify/callback',
+ params,
+ } as any);
+ return true;
+ }
+ if (path.startsWith('reset')) {
+ const params = parsed?.queryParams || {};
+ router.replace({
+ pathname: '/reset',
+ params: {
+ email: (params.email as string) || '',
+ token: (params.token as string) || (params.t as string) || '',
+ code: (params.code as string) || (params.c as string) || '',
+ },
+ } as any);
+ return true;
+ }
+ } catch (err) {
+ console.warn('[linking] Failed to parse incoming url', err);
+ }
+ return false;
+ };
+
+ const unsubscribeShare = ShareIntentService.subscribe((payload) => {
+ if (payload) router.replace('/share');
+ });
+
+ const linkingSub = Linking.addEventListener('url', (event) => {
+ if (handleIncomingUrl(event.url)) return;
+ });
+
+ Linking.getInitialURL().then((url) => {
+ handleIncomingUrl(url);
+ }).catch(() => { });
+
+ return () => {
+ unsubscribeShare();
+ linkingSub.remove();
+ };
+ }, []);
+
return (
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/app/chat/[id].tsx b/app/chat/[id].tsx
index 16b8268..bd5ec27 100644
--- a/app/chat/[id].tsx
+++ b/app/chat/[id].tsx
@@ -1,11 +1,26 @@
-import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { ActivityIndicator, Animated, FlatList, KeyboardAvoidingView, LayoutAnimation, InteractionManager, Platform, RefreshControl, StatusBar, StyleSheet, Text, TextInput, UIManager, View, NativeSyntheticEvent, NativeScrollEvent, Pressable } from 'react-native';
+import React, {
+ ComponentProps,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react';
+import { ActivityIndicator, Alert, Animated, BackHandler, DeviceEventEmitter, FlatList, Keyboard, KeyboardAvoidingView, LayoutAnimation, InteractionManager, Modal, PanResponder, Platform, RefreshControl, ScrollView, StatusBar, StyleSheet, Text, TextInput, TouchableWithoutFeedback, UIManager, View, NativeSyntheticEvent, NativeScrollEvent, Pressable, Linking, Share, GestureResponderEvent, Dimensions, Easing } from 'react-native';
import { Stack, router, useLocalSearchParams } from 'expo-router';
+import { useFocusEffect, useNavigation, type NavigationProp, type ParamListBase } from '@react-navigation/native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
-
-import { TapGestureHandler } from 'react-native-gesture-handler';
-
+import { LinearGradient } from 'expo-linear-gradient';
+import { NativeBlur, BlurPresets } from '../../components/NativeBlur';
+import { GlassCard } from '../../components/GlassCard';
+import * as Haptics from 'expo-haptics';
+import * as DocumentPicker from 'expo-document-picker';
+import * as ImagePicker from 'expo-image-picker';
+import * as Clipboard from 'expo-clipboard';
+import { Image } from 'expo-image';
+import { Video, ResizeMode } from 'expo-av';
+import * as FileSystem from 'expo-file-system/legacy';
import { useAuth } from '../../hooks/useAuth';
import { ApiService } from '../../services/ApiService';
import { NotificationService } from '../../services/NotificationService';
@@ -14,6 +29,17 @@ import { CryptoService } from '../../services/CryptoService';
import { DeviceService } from '../../services/DeviceService';
import { WebSocketMessage, WebSocketService } from '../../services/WebSocketService';
import { UserCacheService } from '../../services/UserCacheService';
+import { TimezoneService } from '../../services/TimezoneService';
+import { ChatService, type UploadableAsset } from '../../services/ChatService';
+import { GroupMemberPicker } from '../../components/GroupMemberPicker';
+import { UserAvatar } from '../../components/UserAvatar';
+import { AppBackground } from '../../components/AppBackground';
+import { EphemeralOptions, type EphemeralDuration } from '../../components/EphemeralOptions';
+import { ScheduleMessageSheet } from '../../components/ScheduleMessageSheet';
+import { CreatePollSheet } from '../../components/CreatePollSheet';
+import { PollMessage, type PollData } from '../../components/PollMessage';
+import { font, palette, radii, spacing } from '../../theme/designSystem';
+import leo from 'leo-profanity';
if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
@@ -21,14 +47,76 @@ if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental
type MessageStatus = 'sending' | 'sent' | 'delivered' | 'seen';
+interface ReplyMetadata {
+ messageId: string;
+ senderId: string;
+ senderLabel?: string;
+ preview?: string;
+}
+
+interface MessageAttachment {
+ id: string;
+ name: string;
+ mimeType: string;
+ fileSize: number;
+ status: 'pending' | 'active' | 'expired';
+ isImage: boolean;
+ isVideo: boolean;
+ previewUrl?: string;
+ downloadUrl?: string;
+ publicViewUrl?: string;
+ publicDownloadUrl?: string;
+ localUri?: string;
+ uploadPending?: boolean;
+ uploadProgress?: number;
+ uploadError?: boolean;
+}
+
+interface SeenReceipt {
+ userId: string;
+ username?: string | null;
+ avatarUrl?: string | null;
+ seenAt?: string | null;
+}
+
interface Message {
id: string;
senderId: string;
receiverId: string;
+ senderName?: string | null;
+ senderAvatar?: string | null;
+ senderBadges?: string[];
content: string;
timestamp: string;
+ utcTimestamp?: string;
+ timezone?: string;
+ deliveredAt?: string;
+ seenAt?: string;
status?: MessageStatus;
isPlaceholder?: boolean;
+ replyTo?: ReplyMetadata;
+ attachments?: MessageAttachment[];
+ isDeleted?: boolean;
+ deletedByName?: string | null;
+ deletedAt?: string | null;
+ editedAt?: string | null;
+ isEdited?: boolean;
+ deletedLabel?: string | null;
+ seenBy?: SeenReceipt[];
+ reactions?: Array<{ reaction: string; count: number; userIds: string[] }>;
+ expiresAt?: string | null;
+ isEphemeral?: boolean;
+ poll?: PollData | null;
+ isPoll?: boolean;
+}
+
+interface ChatParticipant {
+ id: string;
+ username: string;
+ profile_picture?: string | null;
+ status?: string | null;
+ badges?: string[];
+ last_seen?: string | null;
}
type ChatListItem =
@@ -36,8 +124,363 @@ type ChatListItem =
| { kind: 'date'; id: string; label: string }
| { kind: 'typing'; id: string };
+type IoniconName = ComponentProps['name'];
+
+const MESSAGE_PAYLOAD_VERSION = 1;
+const API_ROOT = ApiService.baseUrl.replace(/\/v1\/?$/i, '');
+const ATTACHMENT_STATUS_MAP: Record = {
+ pending: 'pending',
+ active: 'active',
+ expired: 'expired',
+};
+const MAX_PENDING_ATTACHMENTS = 10;
+const MAX_ATTACHMENT_BYTES = 1024 * 1024 * 1024;
+const CHUNK_UPLOAD_THRESHOLD_BYTES = 99 * 1024 * 1024;
+const CHUNK_TARGET_BYTES = 80 * 1024 * 1024;
+
+const buildAbsoluteUrl = (pathValue?: string | null): string | undefined => {
+ if (!pathValue) {
+ return undefined;
+ }
+ if (/^https?:\/\//i.test(pathValue)) {
+ return pathValue;
+ }
+ const normalized = pathValue.startsWith('/') ? pathValue : `/${pathValue}`;
+ return `${API_ROOT}${normalized}`;
+};
+
+const formatBytes = (size: number): string => {
+ if (!Number.isFinite(size) || size <= 0) {
+ return '0 B';
+ }
+ const units = ['B', 'KB', 'MB', 'GB'];
+ const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
+ const value = size / 1024 ** index;
+ return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
+};
+
+const mapServerAttachment = (raw: any): MessageAttachment => {
+ const mime = typeof raw.mimeType === 'string' ? raw.mimeType.toLowerCase() : '';
+ const inferredImage = mime.startsWith('image/');
+ const inferredVideo = mime.startsWith('video/');
+ return {
+ id: String(raw.id),
+ name: raw.name || raw.fileName || 'Attachment',
+ mimeType: raw.mimeType || 'application/octet-stream',
+ fileSize: Number(raw.fileSize) || 0,
+ status: ATTACHMENT_STATUS_MAP[String(raw.status)] ?? 'active',
+ isImage: Boolean(raw.isImage ?? inferredImage),
+ isVideo: Boolean(raw.isVideo ?? inferredVideo),
+ previewUrl: buildAbsoluteUrl(raw.publicViewPath || raw.previewPath),
+ downloadUrl: buildAbsoluteUrl(raw.downloadPath || raw.publicDownloadPath),
+ publicViewUrl: buildAbsoluteUrl(raw.publicViewPath),
+ publicDownloadUrl: buildAbsoluteUrl(raw.publicDownloadPath),
+ uploadPending: false,
+ };
+};
+
+const mapServerAttachments = (raw?: any): MessageAttachment[] => {
+ if (!raw) {
+ return [];
+ }
+ if (Array.isArray(raw)) {
+ return raw.map((entry) => mapServerAttachment(entry));
+ }
+ return [];
+};
+
+const resolveAttachmentUri = (attachment: MessageAttachment): string | undefined =>
+ attachment.publicViewUrl ||
+ attachment.previewUrl ||
+ attachment.publicDownloadUrl ||
+ attachment.downloadUrl ||
+ attachment.localUri;
+
+const resolveFileIcon = (
+ attachment: MessageAttachment
+): { name: keyof typeof Ionicons.glyphMap; color: string; badgeColor: string } => {
+ const ext = (attachment.name || attachment.mimeType || '')
+ .split('.')
+ .pop()
+ ?.toLowerCase()
+ ?.trim();
+ const mime = (attachment.mimeType || '').toLowerCase();
+
+ const isAudio = mime.startsWith('audio/') || ['mp3', 'wav', 'aac', 'flac', 'ogg'].includes(ext || '');
+ const isPdf = mime.includes('pdf') || ext === 'pdf';
+ const isDoc = ['doc', 'docx', 'rtf', 'txt', 'md'].includes(ext || '');
+ const isSheet = ['xls', 'xlsx', 'csv', 'tsv', 'numbers'].includes(ext || '') || mime.includes('spreadsheet');
+ const isSlides = ['ppt', 'pptx', 'key'].includes(ext || '') || mime.includes('presentation');
+ const isArchive = ['zip', 'rar', '7z', 'tar', 'gz'].includes(ext || '') || mime.includes('zip');
+ const isCode = ['js', 'ts', 'tsx', 'json', 'py', 'rb', 'go', 'java', 'c', 'cpp', 'h', 'html', 'css'].includes(
+ ext || ''
+ );
+
+ if (isPdf) return { name: 'document-text-outline', color: '#ff6b6b', badgeColor: 'rgba(255,107,107,0.16)' };
+ if (isDoc) return { name: 'document-text-outline', color: '#69a8ff', badgeColor: 'rgba(105,168,255,0.16)' };
+ if (isSheet) return { name: 'grid-outline', color: '#4ade80', badgeColor: 'rgba(74,222,128,0.16)' };
+ if (isSlides) return { name: 'browsers-outline', color: '#f97316', badgeColor: 'rgba(249,115,22,0.16)' };
+ if (isArchive) return { name: 'archive-outline', color: '#fbbf24', badgeColor: 'rgba(251,191,36,0.16)' };
+ if (isAudio) return { name: 'musical-notes-outline', color: '#c084fc', badgeColor: 'rgba(192,132,252,0.16)' };
+ if (isCode) return { name: 'code-slash-outline', color: '#9ddcff', badgeColor: 'rgba(157,220,255,0.16)' };
+
+ return { name: 'document-attach-outline', color: '#e7ecff', badgeColor: 'rgba(231,236,255,0.12)' };
+};
+
+const mapServerReceipts = (raw?: any): SeenReceipt[] => {
+ if (!Array.isArray(raw)) {
+ return [];
+ }
+ return raw
+ .map((entry) => {
+ const identifier = entry?.userId ?? entry?.viewerId ?? entry?.id;
+ if (!identifier) {
+ return null;
+ }
+ return {
+ userId: String(identifier),
+ username: entry?.username ?? entry?.viewerUsername ?? entry?.viewerName ?? null,
+ avatarUrl: entry?.avatarUrl ?? entry?.viewerAvatar ?? entry?.avatar ?? null,
+ seenAt: entry?.seenAt ?? entry?.timestamp ?? null,
+ } as SeenReceipt;
+ })
+ .filter(Boolean) as SeenReceipt[];
+};
+
+const LINK_REGEX = /(https?:\/\/[^\s]+)/gi;
+const isImageUrl = (url: string) => /\.(png|jpe?g|gif|webp|bmp|tiff|heic|heif)$/i.test(url.split('?')[0]);
+const isVideoUrl = (url: string) => /\.(mp4|m4v|mov|avi|webm|mkv)$/i.test(url.split('?')[0]);
+
+const splitTextByLinks = (text: string): Array<{ type: 'text' | 'link'; value: string }> => {
+ if (!text || typeof text !== 'string') {
+ return [{ type: 'text', value: '' }];
+ }
+ const segments: Array<{ type: 'text' | 'link'; value: string }> = [];
+ let lastIndex = 0;
+ text.replace(LINK_REGEX, (match, _offset, index) => {
+ const startIndex = typeof index === 'number' ? index : text.indexOf(match, lastIndex);
+ if (startIndex > lastIndex) {
+ segments.push({ type: 'text', value: text.slice(lastIndex, startIndex) });
+ }
+ segments.push({ type: 'link', value: match });
+ lastIndex = startIndex + match.length;
+ return match;
+ });
+ if (lastIndex < text.length) {
+ segments.push({ type: 'text', value: text.slice(lastIndex) });
+ }
+ if (!segments.length) {
+ segments.push({ type: 'text', value: text });
+ }
+ return segments;
+};
+
+const findEmbeddableLink = (text: string): { type: 'image' | 'video'; url: string } | null => {
+ if (!text) {
+ return null;
+ }
+ const matches = text.match(LINK_REGEX);
+ if (!matches) {
+ return null;
+ }
+ const imageMatch = matches.find((url) => isImageUrl(url));
+ if (imageMatch) {
+ return { type: 'image', url: imageMatch };
+ }
+ const videoMatch = matches.find((url) => isVideoUrl(url));
+ if (videoMatch) {
+ return { type: 'video', url: videoMatch };
+ }
+ return null;
+};
+
+const clampFutureTimestamp = (value: string | null | undefined): string | null => {
+ if (!value) return null;
+ const parsed = Date.parse(value);
+ if (Number.isNaN(parsed)) {
+ return value;
+ }
+ const now = Date.now();
+ const maxFutureSkew = 5 * 60 * 1000;
+ if (parsed > now + maxFutureSkew) {
+ return new Date(now).toISOString();
+ }
+ return value;
+};
+
+const buildDeletedLabel = (username?: string | null) => {
+ if (!username) {
+ return 'Message deleted';
+ }
+ return `${username} deleted a message`;
+};
+
+const normalizePreviewText = (value: string): string => {
+ if (!value) {
+ return '';
+ }
+ const condensed = value.replace(/\s+/g, ' ').trim();
+ if (condensed.length > 140) {
+ return `${condensed.slice(0, 140)}...`;
+ }
+ return condensed;
+};
+
+const sanitizeReplyMetadata = (raw: any): ReplyMetadata | undefined => {
+ if (!raw) {
+ return undefined;
+ }
+ const replyId = raw.messageId ?? raw.id;
+ const replySenderId = raw.senderId ?? raw.sender_id;
+ if (!replyId || !replySenderId) {
+ return undefined;
+ }
+ const preview =
+ typeof raw.preview === 'string' && raw.preview.trim().length
+ ? raw.preview
+ : undefined;
+ const senderLabel =
+ typeof raw.senderLabel === 'string' && raw.senderLabel.trim().length
+ ? raw.senderLabel
+ : undefined;
+
+ return {
+ messageId: String(replyId),
+ senderId: String(replySenderId),
+ senderLabel,
+ preview,
+ };
+};
+
+const encodeMessagePayload = (text: string, replyTo?: ReplyMetadata | null): string => {
+ const payload: Record = {
+ v: MESSAGE_PAYLOAD_VERSION,
+ text,
+ };
+ if (replyTo) {
+ payload.replyTo = {
+ messageId: replyTo.messageId,
+ senderId: replyTo.senderId,
+ senderLabel: replyTo.senderLabel,
+ preview: replyTo.preview,
+ };
+ }
+ try {
+ return JSON.stringify(payload);
+ } catch {
+ return text;
+ }
+};
+
+const decodeMessagePayload = (raw: string): { text: string; replyTo?: ReplyMetadata } => {
+ if (typeof raw !== 'string') {
+ return { text: '' };
+ }
+ try {
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed.text === 'string') {
+ return {
+ text: parsed.text,
+ replyTo: sanitizeReplyMetadata(parsed.replyTo),
+ };
+ }
+ } catch {
+ }
+ return { text: raw };
+};
+
+const resolveContentText = (
+ decoded: { text: string },
+ preview?: string | null,
+ hasAttachments = false
+): string => {
+ if (decoded.text && decoded.text.trim().length) {
+ return decoded.text;
+ }
+ if (!hasAttachments) {
+ const trimmedPreview = typeof preview === 'string' ? preview.trim() : '';
+ if (trimmedPreview.length) {
+ return trimmedPreview;
+ }
+ }
+ return decoded.text || '';
+};
+
+const resolveDecryptFallbackText = (
+ preview: string | null | undefined,
+ attachments: MessageAttachment[]
+): string => {
+ if (attachments.length > 0) {
+ return '';
+ }
+ const trimmedPreview = typeof preview === 'string' ? preview.trim() : '';
+ return trimmedPreview;
+};
+
const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate());
+const normalizeTimestampValue = (value?: string): string | undefined => {
+ if (!value || typeof value !== 'string') {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ if (!trimmed) return undefined;
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(trimmed)) {
+ return `${trimmed.replace(' ', 'T')}Z`;
+ }
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(trimmed)) {
+ return `${trimmed}Z`;
+ }
+ return trimmed;
+};
+
+const pickFirstTimestamp = (source: Record, keys: string[]): string | undefined => {
+ for (const key of keys) {
+ const value = source?.[key];
+ const normalized = normalizeTimestampValue(value);
+ if (normalized) {
+ return normalized;
+ }
+ }
+ return undefined;
+};
+
+const resolveMessageTimestamps = (
+ source: Record,
+ fallbackTimezone: string
+): { local: string; utc: string; timezone: string } => {
+ const local =
+ pickFirstTimestamp(source, [
+ 'createdAtLocal',
+ 'created_at_local',
+ 'timestampLocal',
+ 'timestamp',
+ 'createdAt',
+ 'created_at',
+ ]) ?? new Date().toISOString();
+
+ const utc =
+ pickFirstTimestamp(source, [
+ 'createdAt',
+ 'created_at',
+ 'timestamp',
+ 'utcTimestamp',
+ 'createdAtUtc',
+ 'created_at_utc',
+ ]) ?? local;
+
+ const timezone = source?.timezone || fallbackTimezone;
+ const clampedLocal = clampFutureTimestamp(local) || local;
+ const clampedUtc = clampFutureTimestamp(utc) || utc;
+ return { local: clampedLocal, utc: clampedUtc, timezone };
+};
+
+const resolveDeliveryTimestamp = (source: Record): string | undefined =>
+ pickFirstTimestamp(source, ['deliveredAtLocal', 'delivered_at_local', 'deliveredAt', 'delivered_at']);
+
+const resolveSeenTimestamp = (source: Record): string | undefined =>
+ pickFirstTimestamp(source, ['seenAtLocal', 'seen_at_local', 'seenAt', 'seen_at']);
+
const parseDate = (value: string): Date => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
@@ -46,6 +489,9 @@ const parseDate = (value: string): Date => {
return date;
};
+const MONTH_LABELS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+const hasIntlSupport = typeof Intl !== 'undefined' && typeof Intl.DateTimeFormat === 'function';
+
const formatDateLabel = (date: Date): string => {
const today = startOfDay(new Date());
const target = startOfDay(date);
@@ -58,11 +504,18 @@ const formatDateLabel = (date: Date): string => {
return 'Yesterday';
}
+ const includeYear = today.getFullYear() !== date.getFullYear();
+ const fallback = `${MONTH_LABELS[date.getMonth()]} ${date.getDate()}${includeYear ? `, ${date.getFullYear()}` : ''}`;
+
+ if (!hasIntlSupport) {
+ return fallback;
+ }
+
const options: Intl.DateTimeFormatOptions = {
month: 'short',
day: 'numeric',
};
- if (today.getFullYear() !== date.getFullYear()) {
+ if (includeYear) {
options.year = 'numeric';
}
return new Intl.DateTimeFormat(undefined, options).format(date);
@@ -84,6 +537,14 @@ const formatStatusLabel = (status: MessageStatus): string => {
};
const formatTimestamp = (date: Date): string => {
+ if (!hasIntlSupport) {
+ const hours = date.getHours();
+ const minutes = date.getMinutes();
+ const paddedHours = String(hours).padStart(2, '0');
+ const paddedMinutes = String(minutes).padStart(2, '0');
+ return `${paddedHours}:${paddedMinutes}`;
+ }
+
const timeFormatter = new Intl.DateTimeFormat(undefined, {
hour: '2-digit',
minute: '2-digit',
@@ -92,12 +553,66 @@ const formatTimestamp = (date: Date): string => {
return timeFormatter.format(date);
};
+const formatLastSeenLabel = (lastSeen?: string | null) => {
+ if (!lastSeen) return 'Offline';
+ const parsed = Date.parse(lastSeen);
+ if (Number.isNaN(parsed)) return 'Offline';
+ const diffMs = Date.now() - parsed;
+ const diffMinutes = Math.floor(diffMs / 60000);
+ if (diffMinutes < 1) return 'Online';
+ if (diffMinutes < 3) return 'Idle';
+ if (diffMinutes < 60) return `${diffMinutes}m ago`;
+ const diffHours = Math.floor(diffMinutes / 60);
+ if (diffHours < 24) return `${diffHours}h ago`;
+ const diffDays = Math.floor(diffHours / 24);
+ if (diffDays === 1) return 'Yesterday';
+ return `${diffDays}d ago`;
+};
+
+const sortMessagesChronologically = (list: Message[]): Message[] =>
+ list
+ .slice()
+ .sort((a, b) => {
+ const timeA = parseDate(a.timestamp).getTime();
+ const timeB = parseDate(b.timestamp).getTime();
+ if (timeA !== timeB) {
+ return timeA - timeB;
+ }
+ // If timestamps are equal, sort by message ID (auto-increment ensures order)
+ const idA = parseInt(a.id, 10) || 0;
+ const idB = parseInt(b.id, 10) || 0;
+ return idA - idB;
+ });
+
+const MESSAGE_CHAR_LIMIT = 5000;
+const SWIPE_REPLY_THRESHOLD = 12;
+const SWIPE_CAPTURE_MIN_DISTANCE = 2;
+const SWIPE_MAX_DISTANCE = 70;
+const SWIPE_REPLY_FEEDBACK_OFFSET = 48;
+const SWIPE_REPLY_FEEDBACK_DURATION = 300;
+const SWIPE_REPLY_RETURN_DURATION = 420;
+const MIN_GROUP_MEMBERS = 3;
+const MAX_GROUP_MEMBERS = 10;
+const DEFAULT_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
+const huWords = [
+ "bazdmeg", "bazmeg", "geci", "fasz", "kurva", "picsa", "szar", "szopd", "kibaszott",
+ "buzi", "köcsög", "baszik", "kúr", "nemnormális", "balfasz", "fing", "ribanc",
+ "szopik", "baszod", "faszfej", "seggfej", "segg", "csicska", "pina"
+];
+
+leo.loadDictionary("en");
+leo.add(huWords);
+
+const shouldFilterMessage = (content: string | null | undefined): boolean => {
+ if (!content) return false;
+ return leo.check(content);
+};
const layoutNext = () => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
};
-const TypingIndicator: React.FC = () => {
+const TypingIndicator: React.FC<{ label?: string }> = ({ label }) => {
const dots = useRef([0, 1, 2].map(() => new Animated.Value(0))).current;
useEffect(() => {
@@ -145,6 +660,7 @@ const TypingIndicator: React.FC = () => {
/>
))}
+ {label ? {label} : null}
);
};
@@ -156,6 +672,24 @@ interface MessageBubbleProps {
isLastInGroup: boolean;
showStatus: boolean;
showTimestamp: boolean;
+ onReplyPress?: (reply: ReplyMetadata) => void;
+ onReplySwipe?: () => void;
+ onOpenThread?: (messageId: string) => void;
+ isHighlighted?: boolean;
+ replyCount?: number;
+ showSenderMetadata?: boolean;
+ onBubblePress?: () => void;
+ onBubbleDoubleTap?: (event: GestureResponderEvent) => void;
+ onBubbleLongPress?: (event: GestureResponderEvent) => void;
+ onAttachmentPress?: (attachment: MessageAttachment, attachments?: MessageAttachment[]) => void;
+ onLinkPress?: (url: string) => void;
+ onDownloadAttachment?: (attachment: MessageAttachment) => void;
+ isGroupChat: boolean;
+ directRecipient?: ChatParticipant | null;
+ seenOverride?: SeenReceipt[] | null;
+ onReact?: (message: Message, reaction: string) => void;
+ currentUserId?: string | null;
+ isFiltered?: boolean;
}
const MessageBubble: React.FC = ({
@@ -165,8 +699,218 @@ const MessageBubble: React.FC = ({
isLastInGroup,
showStatus,
showTimestamp,
+ onReplyPress,
+ onReplySwipe,
+ onOpenThread,
+ isHighlighted = false,
+ replyCount = 0,
+ showSenderMetadata = false,
+ onBubblePress,
+ onBubbleDoubleTap,
+ onBubbleLongPress,
+ onAttachmentPress,
+ onLinkPress,
+ onDownloadAttachment,
+ isGroupChat,
+ directRecipient,
+ seenOverride = null,
+ onReact,
+ currentUserId,
+ isFiltered = false,
}) => {
const fadeAnim = useRef(new Animated.Value(0)).current;
+ const swipeAnim = useRef(new Animated.Value(0)).current;
+ const swipePeakRef = useRef(0);
+ const replyTriggeredRef = useRef(false);
+ const lastTapRef = useRef(0);
+ const singleTapTimeoutRef = useRef | null>(null);
+ const mediaTapTimeoutRef = useRef | null>(null);
+ const mediaLastTapRef = useRef(0);
+ const attachmentLongPressRef = useRef(false);
+ const [isRevealed, setIsRevealed] = useState(false);
+ const isDeletedMessage = useMemo(() => Boolean(message.isDeleted), [message.isDeleted]);
+ const deletedLabel = useMemo(() => message.deletedLabel || 'Message deleted', [message.deletedLabel]);
+ const textSegments = useMemo(() => splitTextByLinks(message.content || ''), [message.content]);
+ const embeddableLink = useMemo(() => {
+ if (isDeletedMessage || message.isPlaceholder || message.attachments?.length) {
+ return null;
+ }
+ return findEmbeddableLink(message.content || '');
+ }, [isDeletedMessage, message.attachments, message.content, message.isPlaceholder]);
+ const [embedLoaded, setEmbedLoaded] = useState(false);
+ const [embedFailed, setEmbedFailed] = useState(false);
+ useEffect(() => {
+ setEmbedLoaded(false);
+ setEmbedFailed(false);
+ }, [embeddableLink?.url]);
+
+ const badgeKeys = useMemo(() => {
+ const list = Array.isArray(message.senderBadges) ? message.senderBadges : [];
+ const normalized = list
+ .map((entry) => (typeof entry === 'string' ? entry.toLowerCase() : null))
+ .filter(Boolean) as string[];
+ return Array.from(new Set(normalized));
+ }, [message.senderBadges]);
+
+ const forceResetSwipe = useCallback(() => {
+ swipeAnim.stopAnimation(() => {
+ Animated.spring(swipeAnim, {
+ toValue: 0,
+ tension: 130,
+ friction: 15,
+ useNativeDriver: true,
+ }).start();
+ });
+ }, [swipeAnim]);
+
+ const resetSwipe = useCallback(() => {
+ Animated.spring(swipeAnim, {
+ toValue: 0,
+ tension: 130,
+ friction: 15,
+ useNativeDriver: true,
+ }).start();
+ }, [swipeAnim]);
+
+ const triggerReplyFeedback = useCallback(() => {
+ const targetOffset = isMine ? -SWIPE_REPLY_FEEDBACK_OFFSET : SWIPE_REPLY_FEEDBACK_OFFSET;
+ swipeAnim.stopAnimation(() => {
+ Animated.sequence([
+ Animated.timing(swipeAnim, {
+ toValue: targetOffset,
+ duration: SWIPE_REPLY_FEEDBACK_DURATION,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ Animated.timing(swipeAnim, {
+ toValue: 0,
+ duration: SWIPE_REPLY_RETURN_DURATION,
+ easing: Easing.out(Easing.cubic),
+ useNativeDriver: true,
+ }),
+ ]).start();
+ });
+ }, [isMine, swipeAnim]);
+
+ const triggerReply = useCallback(() => {
+ if (replyTriggeredRef.current) {
+ return;
+ }
+ replyTriggeredRef.current = true;
+ onReplySwipe?.();
+ triggerReplyFeedback();
+ Haptics.selectionAsync().catch(() => null);
+ }, [onReplySwipe, triggerReplyFeedback]);
+
+ const shouldCaptureSwipe = useCallback(
+ (gesture: any) => {
+ const { dx, dy } = gesture;
+ const absHorizontal = Math.abs(dx);
+ const absVertical = Math.abs(dy);
+ if (absHorizontal < SWIPE_CAPTURE_MIN_DISTANCE) {
+ return false;
+ }
+ const isCorrectDirection = isMine ? dx < 0 : dx > 0;
+ if (!isCorrectDirection) {
+ return false;
+ }
+ return absHorizontal > absVertical * 0.28;
+ },
+ [isMine]
+ );
+ const panResponder = useMemo(
+ () =>
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => false,
+ onMoveShouldSetPanResponder: (_, gesture) => shouldCaptureSwipe(gesture),
+ onMoveShouldSetPanResponderCapture: (_, gesture) => shouldCaptureSwipe(gesture),
+ onPanResponderGrant: () => {
+ swipeAnim.stopAnimation();
+ swipePeakRef.current = 0;
+ replyTriggeredRef.current = false;
+ },
+ onPanResponderMove: (_, gesture) => {
+ let nextValue = 0;
+ if (!isMine && gesture.dx > 0) {
+ nextValue = Math.min(gesture.dx, SWIPE_MAX_DISTANCE);
+ } else if (isMine && gesture.dx < 0) {
+ nextValue = Math.max(gesture.dx, -SWIPE_MAX_DISTANCE);
+ }
+ swipeAnim.setValue(nextValue);
+ const magnitude = isMine ? -nextValue : nextValue;
+ if (magnitude > swipePeakRef.current) {
+ swipePeakRef.current = magnitude;
+ }
+ if (
+ magnitude >= SWIPE_REPLY_THRESHOLD &&
+ !replyTriggeredRef.current &&
+ onReplySwipe
+ ) {
+ triggerReply();
+ }
+ },
+ onPanResponderRelease: (_, gesture) => {
+ const magnitude =
+ swipePeakRef.current ||
+ (isMine ? Math.abs(Math.min(gesture.dx, 0)) : Math.abs(Math.max(gesture.dx, 0)));
+ const shouldTrigger = !replyTriggeredRef.current && magnitude > SWIPE_REPLY_THRESHOLD && !!onReplySwipe;
+ if (shouldTrigger) {
+ triggerReply();
+ } else {
+ resetSwipe();
+ }
+ swipePeakRef.current = 0;
+ replyTriggeredRef.current = false;
+ if (shouldTrigger) {
+ setTimeout(forceResetSwipe, SWIPE_REPLY_FEEDBACK_DURATION + SWIPE_REPLY_RETURN_DURATION + 60);
+ }
+ },
+ onPanResponderTerminate: () => {
+ swipePeakRef.current = 0;
+ replyTriggeredRef.current = false;
+ forceResetSwipe();
+ },
+ }),
+ [forceResetSwipe, isMine, onReplySwipe, resetSwipe, shouldCaptureSwipe, triggerReply, swipeAnim]
+ );
+
+ useEffect(
+ () => () => {
+ if (singleTapTimeoutRef.current) {
+ clearTimeout(singleTapTimeoutRef.current);
+ singleTapTimeoutRef.current = null;
+ }
+ if (mediaTapTimeoutRef.current) {
+ clearTimeout(mediaTapTimeoutRef.current);
+ mediaTapTimeoutRef.current = null;
+ }
+ },
+ []
+ );
+
+ const handleBubblePress = useCallback(
+ (event: GestureResponderEvent) => {
+ const now = Date.now();
+ if (now - lastTapRef.current < 250) {
+ if (singleTapTimeoutRef.current) {
+ clearTimeout(singleTapTimeoutRef.current);
+ singleTapTimeoutRef.current = null;
+ }
+ lastTapRef.current = 0;
+ onBubbleDoubleTap?.(event);
+ return;
+ }
+ lastTapRef.current = now;
+ if (singleTapTimeoutRef.current) {
+ clearTimeout(singleTapTimeoutRef.current);
+ }
+ singleTapTimeoutRef.current = setTimeout(() => {
+ singleTapTimeoutRef.current = null;
+ onBubblePress?.();
+ }, 220);
+ },
+ [onBubbleDoubleTap, onBubblePress]
+ );
useEffect(() => {
Animated.timing(fadeAnim, {
@@ -176,29 +920,179 @@ const MessageBubble: React.FC = ({
}).start();
}, [fadeAnim]);
+ const attachments = Array.isArray(message.attachments) ? message.attachments : [];
+ const allAttachmentsExpired =
+ !isDeletedMessage && attachments.length > 0 && attachments.every((attachment) => attachment.status === 'expired');
+ const showExpiredBubble = allAttachmentsExpired;
+ const hasAttachments = attachments.length > 0 && !isDeletedMessage && !showExpiredBubble;
+ const hasPreviewableMedia = hasAttachments
+ ? attachments.some(
+ (attachment) =>
+ (attachment.isImage || attachment.isVideo) &&
+ attachment.status !== 'expired' &&
+ (attachment.previewUrl || attachment.publicViewUrl || attachment.localUri)
+ )
+ : false;
+ const hasContent = Boolean((message.content || '').trim().length) && !isDeletedMessage && !showExpiredBubble;
+ const isMediaOnlyMessage =
+ hasPreviewableMedia && !hasContent && !message.replyTo && !message.isPlaceholder && !isDeletedMessage && !showExpiredBubble;
const containerStyle = [
styles.messageRow,
isMine ? styles.messageRowMine : styles.messageRowTheirs,
!isFirstInGroup && styles.messageRowStacked,
+ isLastInGroup ? styles.messageRowSpaced : styles.messageRowCompact,
+ message.replyTo && styles.messageRowWithReply,
+ isMediaOnlyMessage && styles.messageRowMedia,
];
+ const previewableImageAttachments = useMemo(
+ () =>
+ isDeletedMessage
+ ? []
+ :
+ attachments.filter(
+ (attachment) =>
+ attachment.isImage &&
+ attachment.status !== 'expired' &&
+ (attachment.previewUrl || attachment.publicViewUrl || attachment.localUri)
+ ),
+ [attachments, isDeletedMessage]
+ );
+ const previewableVideoAttachments = useMemo(
+ () =>
+ isDeletedMessage
+ ? []
+ :
+ attachments.filter(
+ (attachment) =>
+ attachment.isVideo &&
+ attachment.status !== 'expired' &&
+ (attachment.previewUrl || attachment.publicViewUrl || attachment.localUri)
+ ),
+ [attachments, isDeletedMessage]
+ );
+ const combinedPreviewable = useMemo(
+ () => [...previewableImageAttachments, ...previewableVideoAttachments],
+ [previewableImageAttachments, previewableVideoAttachments]
+ );
+ const previewableIds = useMemo(
+ () => new Set(combinedPreviewable.map((attachment) => attachment.id)),
+ [combinedPreviewable]
+ );
+ const fileAttachments = useMemo(
+ () =>
+ isDeletedMessage
+ ? []
+ : attachments.filter((attachment) => !previewableIds.has(attachment.id)),
+ [attachments, isDeletedMessage, previewableIds]
+ );
+ const isFileOnlyMessage =
+ fileAttachments.length > 0 &&
+ !hasPreviewableMedia &&
+ !hasContent &&
+ !message.replyTo &&
+ !message.isPlaceholder &&
+ !isDeletedMessage &&
+ !showExpiredBubble;
const bubbleStyle = [
styles.messageBubble,
- isMine ? styles.myBubble : styles.theirBubble,
- isMine
+ isMediaOnlyMessage && styles.messageBubbleMediaOnly,
+ isFileOnlyMessage && styles.messageBubbleFileOnly,
+ !isMediaOnlyMessage &&
+ !isFileOnlyMessage &&
+ (isMine ? styles.myBubble : styles.theirBubble),
+ !isMediaOnlyMessage &&
+ !isFileOnlyMessage &&
+ (isMine
? isLastInGroup
? styles.myBubbleLast
: styles.myBubbleStacked
: isLastInGroup
? styles.theirBubbleLast
- : styles.theirBubbleStacked,
+ : styles.theirBubbleStacked),
message.isPlaceholder && styles.placeholderBubble,
+ message.replyTo && styles.messageBubbleWithReply,
];
+ const handleAttachmentPress = useCallback(
+ (event: GestureResponderEvent, attachment: MessageAttachment, siblings?: MessageAttachment[]) => {
+ if (attachmentLongPressRef.current) {
+ attachmentLongPressRef.current = false;
+ return;
+ }
+ if (!attachment) {
+ return;
+ }
+ if (!attachment.isImage && !attachment.isVideo) {
+ onAttachmentPress?.(attachment, siblings);
+ return;
+ }
+ const now = Date.now();
+ if (now - mediaLastTapRef.current < 250) {
+ if (mediaTapTimeoutRef.current) {
+ clearTimeout(mediaTapTimeoutRef.current);
+ mediaTapTimeoutRef.current = null;
+ }
+ mediaLastTapRef.current = 0;
+ onBubbleDoubleTap?.(event);
+ return;
+ }
+ mediaLastTapRef.current = now;
+ if (mediaTapTimeoutRef.current) {
+ clearTimeout(mediaTapTimeoutRef.current);
+ }
+ mediaTapTimeoutRef.current = setTimeout(() => {
+ mediaTapTimeoutRef.current = null;
+ onAttachmentPress?.(attachment, siblings);
+ }, 220);
+ },
+ [onAttachmentPress, onBubbleDoubleTap]
+ );
+ const handleAttachmentLongPress = useCallback(
+ (event: GestureResponderEvent) => {
+ attachmentLongPressRef.current = true;
+ onBubbleLongPress?.(event);
+ },
+ [onBubbleLongPress]
+ );
+
+ const replyHintOpacity = useMemo(() => {
+ if (isMine) {
+ return swipeAnim.interpolate({
+ inputRange: [-SWIPE_REPLY_THRESHOLD, -10, 0],
+ outputRange: [1, 0, 0],
+ extrapolate: 'clamp',
+ });
+ }
+ return swipeAnim.interpolate({
+ inputRange: [0, 10, SWIPE_REPLY_THRESHOLD],
+ outputRange: [0, 0, 1],
+ extrapolate: 'clamp',
+ });
+ }, [isMine, swipeAnim]);
const statusText =
showStatus && message.status
? formatStatusLabel(message.status)
: null;
+ const overrideSeenReceipts = Array.isArray(seenOverride) ? seenOverride : [];
+ const activeSeenReceipts = overrideSeenReceipts;
+ const shouldShowSeenAvatars = activeSeenReceipts.length > 0;
+ const MAX_SEEN_AVATARS = 2;
+ const reactions =
+ isDeletedMessage || showExpiredBubble ? [] : Array.isArray(message.reactions) ? message.reactions : [];
+ const displayedSeenReceipts = (() => {
+ if (!shouldShowSeenAvatars) {
+ return [];
+ }
+ if (isGroupChat) {
+ return activeSeenReceipts.slice(-MAX_SEEN_AVATARS);
+ }
+ return activeSeenReceipts.slice(-1);
+ })();
+ const unseenReceiptCount =
+ shouldShowSeenAvatars && isGroupChat
+ ? Math.max(activeSeenReceipts.length - displayedSeenReceipts.length, 0)
+ : 0;
return (
<>
@@ -209,15 +1103,332 @@ const MessageBubble: React.FC = ({
)}
-
-
-
- {message.content}
-
-
- {statusText && (
- {statusText}
- )}
+
+
+
+
+ onBubbleLongPress?.(event)}
+ style={[
+ styles.messageContent,
+ isMine ? styles.messageContentMine : styles.messageContentTheirs,
+ message.replyTo && styles.messageContentWithReply,
+ isMediaOnlyMessage && styles.messageContentMediaOnly,
+ isFileOnlyMessage && styles.messageContentFileOnly,
+ ]}
+ delayLongPress={120}
+ >
+ {showSenderMetadata && !isMine ? (
+
+
+
+
+ {message.senderName || 'Member'}
+
+
+
+ ) : null}
+
+ {!isDeletedMessage && !showExpiredBubble && isFiltered && !isRevealed ? (
+ setIsRevealed(true)}
+ >
+
+
+ Content hidden
+ Tap to reveal
+
+
+ ) : isDeletedMessage || showExpiredBubble ? (
+
+ {isDeletedMessage ? deletedLabel : 'File or media expired'}
+
+ ) : (
+ <>
+ {message.replyTo && (
+ onReplyPress?.(message.replyTo as ReplyMetadata)}
+ style={[
+ styles.replyChip,
+ isMine ? styles.replyChipMine : styles.replyChipTheirs,
+ ]}
+ >
+
+
+
+ {message.replyTo.senderLabel}
+
+ {message.replyTo.preview ? (
+
+ {message.replyTo.preview}
+
+ ) : null}
+
+
+ )}
+ {(() => {
+ if (!hasAttachments) return null;
+ const showPreview = combinedPreviewable.length > 0;
+ const primaryItem = showPreview ? combinedPreviewable[0] : null;
+ const remainingPreviewItems = showPreview ? combinedPreviewable.slice(1) : [];
+
+ return (
+ <>
+ {primaryItem ? (
+ handleAttachmentPress(event, primaryItem, combinedPreviewable)}
+ onLongPress={handleAttachmentLongPress}
+ delayLongPress={120}
+ >
+ {primaryItem.isVideo ? (
+ resolveAttachmentUri(primaryItem) ? (
+
+ ) : (
+
+
+
+ )
+ ) : (
+
+ )}
+ {remainingPreviewItems.length ? (
+
+ +{remainingPreviewItems.length} more
+
+ ) : null}
+
+ ) : null}
+
+ {fileAttachments.length ? (
+
+ {fileAttachments.map((attachment) => {
+ const isExpired = attachment.status === 'expired';
+ const fileIcon = resolveFileIcon(attachment);
+ return (
+ [
+ styles.fileAttachmentCard,
+ isMine ? styles.fileAttachmentCardMine : styles.fileAttachmentCardTheirs,
+ isExpired && styles.fileAttachmentCardDisabled,
+ pressed && !isExpired && styles.fileAttachmentCardPressed,
+ ]}
+ disabled={isExpired}
+ onPress={() => onDownloadAttachment?.(attachment)}
+ >
+
+
+
+
+
+
+ {attachment.name}
+
+
+ {formatBytes(attachment.fileSize)}
+
+ {isExpired ? (
+
+ File unavailable
+
+ ) : null}
+
+ onDownloadAttachment?.(attachment)}
+ >
+
+ Download
+
+
+
+ );
+ })}
+
+ ) : null}
+ >
+ );
+ })()}
+ {message.content?.length ? (
+
+ {textSegments.map((segment, idx) =>
+ segment.type === 'link' ? (
+ onLinkPress?.(segment.value)}
+ >
+ {segment.value}
+
+ ) : (
+ {segment.value}
+ )
+ )}
+
+ ) : null}
+ {embeddableLink && !embedFailed ? (
+ onLinkPress?.(embeddableLink.url)}
+ >
+ {embeddableLink.type === 'image' ? (
+ setEmbedLoaded(true)}
+ onError={() => setEmbedFailed(true)}
+ />
+ ) : (
+
+ ) : null}
+ {reactions.length ? (
+
+ {reactions.map((entry) => {
+ const mine = currentUserId ? entry.userIds?.includes(currentUserId) : false;
+ return (
+ onReact?.(message, entry.reaction)}
+ >
+ {entry.reaction}
+ {entry.count > 1 ? (
+ {entry.count}
+ ) : null}
+
+ );
+ })}
+
+ ) : null}
+ {message.isEdited && !message.isPlaceholder && !isDeletedMessage ? (
+ Edited
+ ) : null}
+ {message.isEphemeral && !message.isPlaceholder && !isDeletedMessage ? (
+
+
+
+ ) : null}
+ >
+ )}
+
+ {statusText && !(message.status === 'seen' && shouldShowSeenAvatars) ? (
+ {statusText}
+ ) : null}
+ {shouldShowSeenAvatars && displayedSeenReceipts.length ? (
+ {
+ const names = activeSeenReceipts.map((r) => r.username || 'Member').join(', ');
+ Alert.alert('Seen by', names || 'No viewers', [{ text: 'OK' }]);
+ }}
+ >
+ {displayedSeenReceipts.map((receipt) => (
+
+ ))}
+ {isGroupChat && unseenReceiptCount > 0 ? (
+
+ +{unseenReceiptCount}
+
+ ) : null}
+
+ ) : null}
+ {replyCount > 0 && onOpenThread && !isDeletedMessage && (
+ onOpenThread(message.id)}
+ >
+
+
+
+ {replyCount} {replyCount === 1 ? 'Reply' : 'Replies'}
+
+
+
+ )}
+
>
);
@@ -228,11 +1439,14 @@ const ChatScreen: React.FC = () => {
const chatId = useMemo(() => (Array.isArray(id) ? id[0] : id), [id]);
const { user } = useAuth();
const insets = useSafeAreaInsets();
-
+ const navigation = useNavigation>();
const wsService = useMemo(() => WebSocketService.getInstance(), []);
const flatListRef = useRef>(null);
-
- const receiverNameRef = useRef('Loading…');
+ const listLayoutHeightRef = useRef(0);
+ const contentHeightRef = useRef(0);
+ const composerLimitWarningRef = useRef(false);
+ const initialScrollDoneRef = useRef(false);
+ const receiverNameRef = useRef('Loading...');
const otherUserIdRef = useRef(null);
const participantIdsRef = useRef([]);
const authTokenRef = useRef(null);
@@ -241,30 +1455,645 @@ const ChatScreen: React.FC = () => {
const topVisibleIdRef = useRef(null);
const pendingScrollIdRef = useRef(null);
const lastSeenMessageIdRef = useRef(null);
+ const seenMessageIdsRef = useRef>(new Set());
+ const markSeenInFlightRef = useRef(false);
+ const missingEnvelopeRef = useRef(false);
+ const missingHistoryPromptedRef = useRef(false);
+ const reencryptRequestedRef = useRef(false);
+ const pendingRefreshRef = useRef(false);
+ const identityRepairRef = useRef(false);
+ const requestReencrypt = useCallback(
+ (reason: string) => {
+ if (!chatId) {
+ return;
+ }
+ const payload: WebSocketMessage = {
+ type: 'request_reencrypt',
+ chatId,
+ reason,
+ targetDeviceId: deviceIdRef.current || undefined,
+ };
+ wsService.send(payload);
+ reencryptRequestedRef.current = true;
+ missingEnvelopeRef.current = false;
+ },
+ [chatId, wsService]
+ );
+
+ const promptIncompleteHistory = useCallback(() => {
+ if (!chatId || !missingEnvelopeRef.current || missingHistoryPromptedRef.current) {
+ return;
+ }
+ missingHistoryPromptedRef.current = true;
+ NotificationService.showAlert(
+ 'Chat history incomplete',
+ 'Some messages could not be decrypted. Request a re-encrypt to recover history?',
+ [
+ { text: 'Later', style: 'cancel', onPress: () => { } },
+ {
+ text: 'Request re-encrypt',
+ onPress: () => requestReencrypt('missing_history_prompt'),
+ },
+ ]
+ );
+ }, [chatId, requestReencrypt]);
+
+ const handleIdentityMissing = useCallback(() => {
+ if (identityRepairRef.current) {
+ return;
+ }
+ identityRepairRef.current = true;
+ NotificationService.show(
+ 'error',
+ 'Secure identity missing. Please set up your PIN to unlock messages.'
+ );
+ router.push('/identity?mode=setup');
+ }, []);
const typingStateRef = useRef<{ isTyping: boolean }>({ isTyping: false });
const typingTimeoutRef = useRef | null>(null);
- const remoteTypingTimeoutRef = useRef | null>(null);
+ const typingUsersRef = useRef