Bläddra i källkod

ChatList offline + announcement

Viktoriia 2 veckor sedan
förälder
incheckning
13605ee648
36 ändrade filer med 1734 tillägg och 310 borttagningar
  1. 10 0
      App.tsx
  2. 15 1
      Route.tsx
  3. 4 1
      babel.config.js
  4. 3 0
      package.json
  5. 0 1
      src/contexts/RegionContext.tsx
  6. 17 0
      src/database/index.ts
  7. 61 0
      src/database/speedService/index.ts
  8. 13 0
      src/modules/api/chat/chat-api.ts
  9. 1 1
      src/screens/InAppScreens/MessagesScreen/ChatScreen/index.tsx
  10. 0 1
      src/screens/InAppScreens/MessagesScreen/ChatScreen/styles.tsx
  11. 181 117
      src/screens/InAppScreens/MessagesScreen/Components/MoreModal.tsx
  12. 18 3
      src/screens/InAppScreens/MessagesScreen/Components/RenderMessageImage.tsx
  13. 28 11
      src/screens/InAppScreens/MessagesScreen/Components/SwipeableBlockedRow.tsx
  14. 50 41
      src/screens/InAppScreens/MessagesScreen/Components/SwipeableRow.tsx
  15. 5 3
      src/screens/InAppScreens/MessagesScreen/GroupChatScreen/index.tsx
  16. 0 1
      src/screens/InAppScreens/MessagesScreen/GroupSettingsScreen/index.tsx
  17. 108 114
      src/screens/InAppScreens/MessagesScreen/index.tsx
  18. 15 12
      src/screens/InAppScreens/MessagesScreen/types.ts
  19. 6 2
      src/types/api.ts
  20. 32 0
      src/watermelondb/backup.ts
  21. 46 0
      src/watermelondb/features/chat/data/blocked.repo.ts
  22. 104 0
      src/watermelondb/features/chat/data/chat.repo.ts
  23. 496 0
      src/watermelondb/features/chat/data/chat.sync.ts
  24. 190 0
      src/watermelondb/features/chat/data/importChatsFromServer.ts
  25. 15 0
      src/watermelondb/features/chat/hooks/useBlockedUsersLive.ts
  26. 19 0
      src/watermelondb/features/chat/hooks/useChatThread.ts
  27. 22 0
      src/watermelondb/features/chat/hooks/useChatsList.ts
  28. 66 0
      src/watermelondb/features/chat/networkSync.ts
  29. 21 0
      src/watermelondb/index.ts
  30. 5 0
      src/watermelondb/migrations.ts
  31. 14 0
      src/watermelondb/models/BlockedUser.ts
  32. 41 0
      src/watermelondb/models/Chat.ts
  33. 36 0
      src/watermelondb/models/Message.ts
  34. 5 0
      src/watermelondb/models/index.ts
  35. 84 0
      src/watermelondb/schema.ts
  36. 3 1
      tsconfig.json

+ 10 - 0
App.tsx

@@ -21,6 +21,10 @@ import { API } from 'src/types';
 import { LogBox } from 'react-native';
 import { storage, StoreType } from 'src/storage';
 import { setupGlobalErrorHandler } from 'src/utils/globalErrorHandler';
+import {
+  startAutoSyncListener,
+  stopAutoSyncListener
+} from 'src/watermelondb/features/chat/networkSync';
 
 const IOS_STORE_URL = 'https://apps.apple.com/app/id6502843543';
 const ANDROID_STORE_URL =
@@ -29,6 +33,7 @@ const ANDROID_STORE_URL =
 LogBox.ignoreLogs([/defaultProps will be removed/, /IMGElement/]);
 
 const userId = (storage.get('uid', StoreType.STRING) as string) ?? 'not_logged_in';
+const token = (storage.get('token', StoreType.STRING) as string) ?? null;
 
 const routingInstrumentation = Sentry.reactNavigationIntegration({
   enableTimeToInitialDisplay: true
@@ -131,6 +136,11 @@ const InnerApp = () => {
     checkLatestVersion();
   }, []);
 
+  useEffect(() => {
+    if (token) startAutoSyncListener(token);
+    return () => stopAutoSyncListener();
+  }, [token]);
+
   const handleUpdatePress = () => {
     const storeUrl = Platform.OS === 'ios' ? IOS_STORE_URL : ANDROID_STORE_URL;
     Linking.openURL(storeUrl).catch((err) => console.error('Failed to open store URL:', err));

+ 15 - 1
Route.tsx

@@ -58,7 +58,7 @@ import { openDatabases } from './src/db';
 
 import TabBarButton from './src/components/TabBarButton';
 import { ParamListBase, RouteProp, useIsFocused } from '@react-navigation/native';
-import setupDatabaseAndSync from 'src/database';
+import setupDatabaseAndSync, { initializeDatabase } from 'src/database';
 import { MenuDrawer } from 'src/components';
 import { API_URL, APP_VERSION } from 'src/constants';
 import {
@@ -111,6 +111,7 @@ import { SelectRegionScreen } from 'src/screens/OfflineMapsScreen/SelectRegionsS
 import EditCountryDataScreen from 'src/screens/InAppScreens/TravelsScreen/EditCountryDataScreen';
 import CreateSharedTripScreen from 'src/screens/InAppScreens/TravelsScreen/CreateSharedTrip';
 import EditNmDataScreen from 'src/screens/InAppScreens/TravelsScreen/EditNmDataScreen';
+import { testConnectionSpeed } from 'src/database/speedService';
 
 enableScreens();
 
@@ -213,6 +214,19 @@ const Route = () => {
       setDbLoaded(true);
       updateNotificationStatus();
       updateUnreadMessagesCount();
+      if (uid && token) {
+        try {
+          const speed = await testConnectionSpeed();
+          if (
+            (speed?.downloadSpeed && speed.downloadSpeed < 0.2) ||
+            (speed?.ping && speed.ping > 1500)
+          ) {
+            console.warn('Internet too slow for sync');
+            return;
+          }
+        } catch {}
+        await initializeDatabase(token);
+      }
     };
 
     prepareApp();

+ 4 - 1
babel.config.js

@@ -2,6 +2,9 @@ module.exports = function (api) {
   api.cache(true);
   return {
     presets: ['babel-preset-expo'],
-    plugins: ['react-native-worklets/plugin']
+    plugins: [
+      'react-native-worklets/plugin',
+      ['@babel/plugin-proposal-decorators', { legacy: true }]
+    ]
   };
 };

+ 3 - 0
package.json

@@ -24,6 +24,7 @@
   "dependencies": {
     "@expo/vector-icons": "^15.0.2",
     "@maplibre/maplibre-react-native": "^10.2.1",
+    "@nozbe/watermelondb": "^0.28.0",
     "@react-native-clipboard/clipboard": "^1.16.3",
     "@react-native-community/datetimepicker": "8.4.4",
     "@react-native-community/netinfo": "11.4.1",
@@ -65,6 +66,7 @@
     "expo-video": "~3.0.11",
     "formik": "^2.4.6",
     "moment": "^2.30.1",
+    "p-limit": "^7.2.0",
     "patch-package": "^8.0.0",
     "promise": "^8.3.0",
     "react": "19.1.0",
@@ -113,6 +115,7 @@
   },
   "devDependencies": {
     "@babel/core": "^7.25.2",
+    "@babel/plugin-proposal-decorators": "^7.28.0",
     "@react-native-community/cli": "latest",
     "@types/react": "~19.1.10",
     "babel-preset-expo": "^54.0.2",

+ 0 - 1
src/contexts/RegionContext.tsx

@@ -20,7 +20,6 @@ export const RegionProvider = ({ children }: { children: React.ReactNode }) => {
   const [dareRegions, setDareRegions] = useState<DareRegion[] | null>([]);
   const [slow, setSlow] = useState<SlowData[]>([]);
   const token = storage.get('token', StoreType.STRING) as string;
-  const { mutate: updateNM } = usePostSetNmRegionMutation();
   const { mutate: updateDARE } = usePostSetDareRegionMutation();
   const { mutate: updateSlow } = usePostSetSlowMutation();
   const { mutateAsync: mutateCountriesData } = fetchCountryUserData();

+ 17 - 0
src/database/index.ts

@@ -9,6 +9,9 @@ import { saveTriumphsData } from './triumphsService';
 import { saveSeriesRankingData } from './seriesRankingService';
 import { updateDarePlacesDb, updateNmRegionsDb } from 'src/db';
 import { cleanCache, deleteAvatarsDirectory } from './cacheService';
+import { database } from 'src/watermelondb';
+import { hasLocalBackup } from 'src/watermelondb/backup';
+import { importChatsFromServer } from 'src/watermelondb/features/chat/data/importChatsFromServer';
 
 const lastUpdateNmRegions =
   (storage.get('lastUpdateNmRegions', StoreType.STRING) as string) || '1990-01-01';
@@ -67,4 +70,18 @@ export const updateMasterRanking = async () => {
   await saveSeriesRankingData();
 };
 
+export async function initializeDatabase(token: string) {
+  const chats = await database.get('chats').query().fetch();
+  if (chats.length) {
+    console.log('🟢 Database already initialized');
+    return;
+  } else {
+    console.log('🔵 Importing chats from server...');
+    await importChatsFromServer(token);
+  }
+
+  // TO DO
+  // const hasBackup = await hasLocalBackup();
+}
+
 export default setupDatabaseAndSync;

+ 61 - 0
src/database/speedService/index.ts

@@ -0,0 +1,61 @@
+import { Platform } from 'react-native';
+import { API_URL, APP_VERSION } from 'src/constants';
+
+export async function testConnectionSpeed({ bytes = 2_000_000, timeout = 10_000 } = {}) {
+  const pingUrl = `${API_URL}/app/speed-ping`;
+  const downloadUrl = `${API_URL}/app/speed-bytes`;
+
+  const fetchWithTimeout = async (url: string, options = {}) => {
+    const controller = new AbortController();
+    const id = setTimeout(() => controller.abort(), timeout);
+    try {
+      const res = await fetch(url, {
+        ...options,
+        signal: controller.signal,
+        headers: {
+          Platform: Platform.OS,
+          'App-Version': APP_VERSION
+        }
+      });
+      clearTimeout(id);
+      return res;
+    } catch (e) {
+      clearTimeout(id);
+      throw e;
+    }
+  };
+
+  try {
+    const t0 = Date.now();
+    await fetchWithTimeout(pingUrl, { method: 'HEAD' });
+    const ping = Date.now() - t0;
+
+    const start = Date.now();
+    const form = new FormData();
+    form.append('bytes', String(bytes));
+
+    const res = await fetchWithTimeout(downloadUrl, {
+      method: 'POST',
+      body: form
+    });
+
+    const contentType = res.headers.get('content-type') || '';
+    if (!res.ok || !/application\/octet-stream/i.test(contentType)) {
+      const text = await res.text().catch(() => '');
+      console.warn('Unexpected response:', res.status, contentType, text);
+      throw new Error('speed-bytes did not return binary data');
+    }
+
+    const blob = await res.blob();
+    const end = Date.now();
+
+    const seconds = (end - start) / 1000;
+    const sizeMB = blob.size / (1024 * 1024);
+    const downloadSpeed = Number((sizeMB / seconds).toFixed(2));
+
+    return { ping, downloadSpeed };
+  } catch (error) {
+    console.error('Speed test failed:', error);
+    return null;
+  }
+}

+ 13 - 0
src/modules/api/chat/chat-api.ts

@@ -33,6 +33,9 @@ export interface PostGetChatsListReturn extends ResponseType {
     encrypted: 0 | 1;
     muted: 0 | 1;
     user_type: 'normal' | 'not_exist' | 'blocked';
+    can_send_message: 0 | 1;
+    is_admin: 0 | 1;
+    announcement: 0 | 1;
   }[];
 }
 
@@ -325,6 +328,16 @@ export const chatApi = {
       no_of_messages,
       previous_than_message_id
     }),
+  getChatWithAll: (token: string, uid: number) =>
+    request.postForm<PostGetChatWithReturn>(API.GET_CONVERSATION_WITH_ALL, {
+      token,
+      uid
+    }),
+  getGroupChatAll: (token: string, group_token: string) =>
+    request.postForm<PostGetGroupChatWithReturn>(API.GET_GROUP_CONVERSATION_ALL, {
+      token,
+      group_token
+    }),
   sendMessage: (data: PostSendMessage) => {
     const formData = new FormData();
     formData.append('token', data.token);

+ 1 - 1
src/screens/InAppScreens/MessagesScreen/ChatScreen/index.tsx

@@ -2096,7 +2096,7 @@ const ChatScreen = ({ route }: { route: any }) => {
               </View>
             )}
             shouldUpdateMessage={shouldUpdateMessage}
-            scrollToBottom={true}
+            isScrollToBottomEnabled={true}
             scrollToBottomComponent={renderScrollToBottom}
             scrollToBottomStyle={{ backgroundColor: 'transparent' }}
             parsePatterns={(linkStyle) => [

+ 0 - 1
src/screens/InAppScreens/MessagesScreen/ChatScreen/styles.tsx

@@ -109,7 +109,6 @@ export const styles = StyleSheet.create({
   scrollToBottom: {
     position: 'absolute',
     bottom: -20,
-    right: -20,
     backgroundColor: Colors.DARK_BLUE,
     borderRadius: 20,
     padding: 8

+ 181 - 117
src/screens/InAppScreens/MessagesScreen/Components/MoreModal.tsx

@@ -8,15 +8,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
 import { ChatProps, WarningProps } from '../types';
 import { useNavigation } from '@react-navigation/native';
 import { NAVIGATION_PAGES } from 'src/types';
-import {
-  usePostDeleteChatMutation,
-  usePostReportConversationMutation,
-  usePostSetBlockMutation,
-  usePostSetMuteMutation,
-  usePostSetMuteForGroupMutation,
-  usePostLeaveGroupMutation,
-  usePostRemoveGroupFromListMutation
-} from '@api/chat';
+import { usePostReportConversationMutation } from '@api/chat';
 import { useChatStore } from 'src/stores/chatStore';
 import TrashIcon from 'assets/icons/travels-screens/trash-solid.svg';
 import BanIcon from 'assets/icons/messages/ban.svg';
@@ -25,6 +17,61 @@ import { AvatarWithInitials } from 'src/components';
 import MegaphoneIcon from 'assets/icons/messages/megaphone.svg';
 import ExitIcon from 'assets/icons/messages/exit.svg';
 import GroupIcon from 'assets/icons/messages/group-chat.svg';
+import { database } from 'src/watermelondb';
+import { BlockedUser, Chat } from 'src/watermelondb/models';
+import { Q } from '@nozbe/watermelondb';
+import {
+  addDirtyAction,
+  syncChatsIncremental
+} from 'src/watermelondb/features/chat/data/chat.sync';
+
+async function findChatRecord(chatData: ChatProps | null): Promise<Chat | null> {
+  if (!chatData) return null;
+  const chatsCollection = database.get<Chat>('chats');
+
+  if (chatData.uid) {
+    const res = await chatsCollection.query(Q.where('chat_uid', chatData.uid)).fetch();
+    return res[0] ?? null;
+  }
+
+  if (chatData.groupToken) {
+    const res = await chatsCollection
+      .query(Q.where('group_chat_token', chatData.groupToken))
+      .fetch();
+    return res[0] ?? null;
+  }
+
+  return null;
+}
+
+async function upsertBlockedUserFromChat(chatData: ChatProps) {
+  if (!chatData.uid) return;
+
+  const blockedCollection = database.get<BlockedUser>('blocked_users');
+  const existing = await blockedCollection.query(Q.where('user_id', chatData.uid)).fetch();
+
+  const [firstName, ...rest] = (chatData.name ?? '').split(' ');
+  const lastName = rest.join(' ');
+
+  await database.write(async () => {
+    if (existing.length > 0) {
+      await existing[0].update((r) => {
+        r.firstName = firstName || r.firstName;
+        r.lastName = lastName || r.lastName;
+        r.avatar = chatData.avatar ?? r.avatar;
+        r.removed = false;
+      });
+    } else {
+      await blockedCollection.create((r) => {
+        r.userId = chatData.uid!;
+        r.firstName = firstName || '';
+        r.lastName = lastName || '';
+        r.avatar = chatData.avatar ?? null;
+        r.removed = false;
+      });
+    }
+  });
+}
 
 const MoreModal = () => {
   const insets = useSafeAreaInsets();
@@ -34,19 +81,11 @@ const MoreModal = () => {
   const [chatData, setChatData] = useState<
     | (ChatProps & {
         token: string;
-        refetch: () => void;
-        refetchBlocked: () => void;
       })
     | null
   >(null);
   const [name, setName] = useState<string | null>(null);
-  const { mutateAsync: muteUser } = usePostSetMuteMutation();
-  const { mutateAsync: blockUser } = usePostSetBlockMutation();
-  const { mutateAsync: deleteChat } = usePostDeleteChatMutation();
   const { mutateAsync: reportUser } = usePostReportConversationMutation();
-  const { mutateAsync: muteGroup } = usePostSetMuteForGroupMutation();
-  const { mutateAsync: leaveGroup } = usePostLeaveGroupMutation();
-  const { mutateAsync: removeGroupFromList } = usePostRemoveGroupFromListMutation();
 
   const [shouldOpenWarningModal, setShouldOpenWarningModal] = useState<WarningProps | null>(null);
 
@@ -54,8 +93,6 @@ const MoreModal = () => {
     payload:
       | (ChatProps & {
           token: string;
-          refetch: () => void;
-          refetchBlocked: () => void;
         })
       | null
   ) => {
@@ -72,52 +109,53 @@ const MoreModal = () => {
   const handleMute = async () => {
     if (!chatData) return;
 
-    chatData.uid
-      ? await muteUser(
-          {
-            token: chatData.token,
-            value: chatData.muted === 1 ? 0 : 1,
-            conversation_with_user: chatData.uid
-          },
-          {
-            onSuccess: () => {
-              setChatData({ ...chatData, muted: chatData.muted === 1 ? 0 : 1 });
-            }
-          }
-        )
-      : await muteGroup(
-          {
-            token: chatData.token,
-            value: chatData.muted === 1 ? 0 : 1,
-            group_token: chatData.groupToken as string
-          },
-          {
-            onSuccess: () => {
-              setChatData({ ...chatData, muted: chatData.muted === 1 ? 0 : 1 });
-            }
-          }
-        );
-
-    chatData.refetch();
+    const newMuted = chatData.muted === 1 ? 0 : 1;
+
+    const chatRec = await findChatRecord(chatData);
+    if (chatRec) {
+      await database.write(() =>
+        chatRec.update((r) => {
+          r.muted = newMuted;
+          addDirtyAction(r, { type: 'mute', value: newMuted });
+        })
+      );
+    }
+
+    setChatData((prev) => (prev ? { ...prev, muted: newMuted } : prev));
+
+    try {
+      await syncChatsIncremental(chatData.token);
+    } catch (e) {
+      console.warn('mute sync failed (will retry later):', e);
+    }
   };
 
   const handleBlock = async () => {
     if (!chatData) return;
 
     setShouldOpenWarningModal({
+      visible: true,
       title: 'Block user',
       buttonTitle: 'Block',
       message: `Are you sure you want to block ${name}?\nThis user will be blocked and you will not be able to send or receive messages from him/her.`,
       action: async () => {
-        chatData.uid &&
-          (await blockUser({
-            token: chatData.token,
-            value: 1,
-            conversation_with_user: chatData.uid
-          }));
+        const chatRec = await findChatRecord(chatData);
+        if (chatRec) {
+          await database.write(() =>
+            chatRec.update((r) => {
+              r.removed = true;
+              addDirtyAction(r, { type: 'block' });
+            })
+          );
+        }
 
-        chatData.refetch();
-        chatData.refetchBlocked();
+        await upsertBlockedUserFromChat(chatData);
+
+        try {
+          await syncChatsIncremental(chatData.token);
+        } catch (e) {
+          console.warn('block sync failed:', e);
+        }
       }
     });
 
@@ -131,6 +169,7 @@ const MoreModal = () => {
     if (!chatData) return;
 
     setShouldOpenWarningModal({
+      visible: true,
       title: `Report ${name}`,
       buttonTitle: 'Report',
       message: `Are you sure you want to report ${name}?\nIf you proceed, the chat history with ${name} will become visible to NomadMania admins for investigation.`,
@@ -153,16 +192,25 @@ const MoreModal = () => {
     if (!chatData) return;
 
     setShouldOpenWarningModal({
+      visible: true,
       title: 'Delete conversation',
       message: `Are you sure you want to delete conversation with ${name}?\nThis conversation will be deleted for both sides.`,
       action: async () => {
-        chatData.uid &&
-          (await deleteChat({
-            token: chatData.token,
-            conversation_with_user: chatData.uid
-          }));
+        const chatRec = await findChatRecord(chatData);
+        if (chatRec) {
+          await database.write(() =>
+            chatRec.update((r) => {
+              r.removed = true;
+              addDirtyAction(r, { type: 'delete' });
+            })
+          );
+        }
 
-        chatData.refetch();
+        try {
+          await syncChatsIncremental(chatData.token);
+        } catch (e) {
+          console.warn('delete sync failed:', e);
+        }
       }
     });
 
@@ -176,24 +224,26 @@ const MoreModal = () => {
     if (!chatData) return;
 
     setShouldOpenWarningModal({
+      visible: true,
       title: `Leave group ${name}`,
       buttonTitle: 'Leave',
       message: `Are you sure you want to leave ${name}?`,
       action: async () => {
-        chatData.groupToken &&
-          (await leaveGroup(
-            {
-              token: chatData.token,
-              group_token: chatData.groupToken
-            },
-            {
-              onSuccess: (res) => {
-                console.log(res);
-              }
-            }
-          ));
+        const chatRec = await findChatRecord(chatData);
+        if (chatRec) {
+          await database.write(() =>
+            chatRec.update((r) => {
+              r.removed = true;
+              addDirtyAction(r, { type: 'leave_group' });
+            })
+          );
+        }
 
-        chatData.refetch();
+        try {
+          await syncChatsIncremental(chatData.token);
+        } catch (e) {
+          console.warn('leaveGroup sync failed:', e);
+        }
       }
     });
 
@@ -207,16 +257,25 @@ const MoreModal = () => {
     if (!chatData) return;
 
     setShouldOpenWarningModal({
+      visible: true,
       title: `Delete ${name}`,
       message: `Are you sure you want to delete this group chat?\nThis action will remove the chat from your history, but it won't affect other participants.`,
       action: async () => {
-        chatData.groupToken &&
-          (await removeGroupFromList({
-            token: chatData.token,
-            group_token: chatData.groupToken
-          }));
+        const chatRec = await findChatRecord(chatData);
+        if (chatRec) {
+          await database.write(() =>
+            chatRec.update((r) => {
+              r.removed = true;
+              addDirtyAction(r, { type: 'delete' });
+            })
+          );
+        }
 
-        chatData.refetch();
+        try {
+          await syncChatsIncremental(chatData.token);
+        } catch (e) {
+          console.warn('deleteGroup sync failed:', e);
+        }
       }
     });
 
@@ -292,52 +351,57 @@ const MoreModal = () => {
             </View>
           )}
 
-          <View style={[styles.optionsContainer, { paddingVertical: 0, gap: 0 }]}>
-            {chatData?.groupToken && (
-              <TouchableOpacity
-                style={[styles.option, styles.dangerOption]}
-                onPress={handleLeaveGroup}
-              >
-                <Text style={[styles.optionText, styles.dangerText]}>Leave group chat</Text>
-                <ExitIcon fill={Colors.RED} width={16} />
-              </TouchableOpacity>
-            )}
-
-            {chatData?.userType === 'normal' && chatData?.uid && (
-              <>
+          {!chatData.announcement ? (
+            <View style={[styles.optionsContainer, { paddingVertical: 0, gap: 0 }]}>
+              {chatData?.groupToken && (
                 <TouchableOpacity
                   style={[styles.option, styles.dangerOption]}
-                  onPress={handleReport}
+                  onPress={handleLeaveGroup}
                 >
-                  <Text style={[styles.optionText, styles.dangerText]}>Report {name}</Text>
-                  <MegaphoneIcon fill={Colors.RED} />
+                  <Text style={[styles.optionText, styles.dangerText]}>Leave group chat</Text>
+                  <ExitIcon fill={Colors.RED} width={16} />
                 </TouchableOpacity>
-
+              )}
+
+              {chatData?.userType === 'normal' && chatData?.uid && (
+                <>
+                  <TouchableOpacity
+                    style={[styles.option, styles.dangerOption]}
+                    onPress={handleReport}
+                  >
+                    <Text style={[styles.optionText, styles.dangerText]}>Report {name}</Text>
+                    <MegaphoneIcon fill={Colors.RED} />
+                  </TouchableOpacity>
+
+                  <TouchableOpacity
+                    style={[styles.option, styles.dangerOption]}
+                    onPress={handleBlock}
+                  >
+                    <Text style={[styles.optionText, styles.dangerText]}>Block {name}</Text>
+                    <BanIcon fill={Colors.RED} />
+                  </TouchableOpacity>
+                </>
+              )}
+
+              {chatData?.uid ? (
                 <TouchableOpacity
                   style={[styles.option, styles.dangerOption]}
-                  onPress={handleBlock}
+                  onPress={handleDelete}
                 >
-                  <Text style={[styles.optionText, styles.dangerText]}>Block {name}</Text>
-                  <BanIcon fill={Colors.RED} />
+                  <Text style={[styles.optionText, styles.dangerText]}>Delete chat</Text>
+                  <TrashIcon fill={Colors.RED} width={18} height={18} />
                 </TouchableOpacity>
-              </>
-            )}
-
-            {chatData?.uid ? (
-              <TouchableOpacity style={[styles.option, styles.dangerOption]} onPress={handleDelete}>
-                <Text style={[styles.optionText, styles.dangerText]}>Delete chat</Text>
-                <TrashIcon fill={Colors.RED} width={18} height={18} />
-              </TouchableOpacity>
-            ) : (
-              <TouchableOpacity
-                style={[styles.option, styles.dangerOption]}
-                onPress={handleDeleteGroup}
-              >
-                <Text style={[styles.optionText, styles.dangerText]}>Delete group chat</Text>
-                <TrashIcon fill={Colors.RED} width={18} height={18} />
-              </TouchableOpacity>
-            )}
-          </View>
+              ) : (
+                <TouchableOpacity
+                  style={[styles.option, styles.dangerOption]}
+                  onPress={handleDeleteGroup}
+                >
+                  <Text style={[styles.optionText, styles.dangerText]}>Delete group chat</Text>
+                  <TrashIcon fill={Colors.RED} width={18} height={18} />
+                </TouchableOpacity>
+              )}
+            </View>
+          ) : null}
         </View>
       )}
     </ActionSheet>

+ 18 - 3
src/screens/InAppScreens/MessagesScreen/Components/RenderMessageImage.tsx

@@ -26,6 +26,10 @@ const RenderMessageImage = ({
   const [isCached, setIsCached] = useState(false);
   const [imageLoading, setImageLoading] = useState(true);
 
+  const [retryKey, setRetryKey] = useState(Date.now());
+  const [retryCount, setRetryCount] = useState(0);
+  const MAX_RETRIES = 10;
+
   useEffect(() => {
     const checkCache = async () => {
       try {
@@ -77,7 +81,7 @@ const RenderMessageImage = ({
       disabled={currentMessage.isSending}
     >
       <Image
-        key={currentMessage.image}
+        key={`${currentMessage.image}-${retryKey}`}
         source={{
           uri: isCached ? fileUri : currentMessage.image,
           headers: { Nmtoken: token, 'App-Version': APP_VERSION, Platform: Platform.OS }
@@ -86,8 +90,19 @@ const RenderMessageImage = ({
         style={styles.chatImage}
         resizeMode="cover"
         onLoadStart={() => setImageLoading(true)}
-        onLoad={() => setImageLoading(false)}
-        onError={() => setImageLoading(false)}
+        onLoad={() => {
+          setImageLoading(false);
+          setRetryCount(0);
+        }}
+        onLoadEnd={() => setImageLoading(false)}
+        onError={() => {
+          if (retryCount < MAX_RETRIES) {
+            setTimeout(() => {
+              setRetryKey(Date.now());
+              setRetryCount((c) => c + 1);
+            }, 1000);
+          }
+        }}
       />
 
       {(currentMessage.isSending || imageLoading) && (

+ 28 - 11
src/screens/InAppScreens/MessagesScreen/Components/SwipeableBlockedRow.tsx

@@ -5,25 +5,37 @@ import { Colors } from 'src/theme';
 import { getFontSize } from 'src/utils';
 
 import { Blocked } from '../types';
-import { usePostSetBlockMutation } from '@api/chat';
 import BanIcon from 'assets/icons/messages/ban.svg';
+import { database } from 'src/watermelondb';
+import { BlockedUser } from 'src/watermelondb/models';
+import { Q } from '@nozbe/watermelondb';
+import {
+  addDirtyAction,
+  syncChatsIncremental
+} from 'src/watermelondb/features/chat/data/chat.sync';
 
 interface AppleStyleSwipeableRowProps extends PropsWithChildren<unknown> {
   data: Blocked;
   token: string;
   onRowOpen: (ref: any) => void;
-  refetchBlocked: () => void;
+}
+
+async function findChatRecord(userId: number) {
+  const blocked = database.get<BlockedUser>('blocked_users');
+
+  const query = Q.where('user_id', userId);
+
+  const found = await blocked.query(query).fetch();
+  return found[0] ?? null;
 }
 
 const SwipeableBlockedRow: React.FC<AppleStyleSwipeableRowProps> = ({
   children,
   data,
   token,
-  onRowOpen,
-  refetchBlocked
+  onRowOpen
 }) => {
   const swipeableRow = useRef<Swipeable>(null);
-  const { mutateAsync: unBlockUser } = usePostSetBlockMutation();
 
   const close = () => {
     swipeableRow.current?.close();
@@ -42,12 +54,17 @@ const SwipeableBlockedRow: React.FC<AppleStyleSwipeableRowProps> = ({
 
     const pressHandler = async () => {
       close();
-      await unBlockUser({
-        token,
-        value: 0,
-        conversation_with_user: data.id
-      });
-      refetchBlocked();
+      const rec = await findChatRecord(data.userId);
+      if (!rec) return;
+
+      await database.write(() =>
+        rec.update((r) => {
+          r.removed = true;
+          addDirtyAction(r, { type: 'unblock' });
+        })
+      );
+
+      await syncChatsIncremental(token);
     };
 
     return (

+ 50 - 41
src/screens/InAppScreens/MessagesScreen/Components/SwipeableRow.tsx

@@ -10,38 +10,41 @@ import ArchiveIcon from 'assets/icons/messages/archive.svg';
 import DotsIcon from 'assets/icons/messages/dots.svg';
 import UnpinIcon from 'assets/icons/messages/unpin.svg';
 import { ChatProps } from '../types';
-import {
-  usePostSetArchiveForGroupMutation,
-  usePostSetArchiveMutation,
-  usePostSetPinForGroupMutation,
-  usePostSetPinMutation
-} from '@api/chat';
 import { useChatStore } from 'src/stores/chatStore';
+import {
+  addDirtyAction,
+  syncChatsIncremental
+} from 'src/watermelondb/features/chat/data/chat.sync';
+import { database } from 'src/watermelondb';
+import { Chat } from 'src/watermelondb/models';
+import { Q } from '@nozbe/watermelondb';
 
 interface AppleStyleSwipeableRowProps extends PropsWithChildren<unknown> {
   chat: ChatProps;
   token: string;
   onRowOpen: (ref: any) => void;
-  refetch: () => void;
-  refetchBlocked: () => void;
+}
+
+async function findChatRecord(chat: { uid?: number | null; groupToken?: string | null }) {
+  const chats = database.get<Chat>('chats');
+
+  const query = chat.groupToken
+    ? Q.where('group_chat_token', chat.groupToken)
+    : Q.where('chat_uid', chat.uid ?? 0);
+
+  const found = await chats.query(query).fetch();
+  return found[0] ?? null;
 }
 
 const SwipeableRow: React.FC<AppleStyleSwipeableRowProps> = ({
   children,
   chat,
   token,
-  onRowOpen,
-  refetch,
-  refetchBlocked
+  onRowOpen
 }) => {
   const swipeableRow = useRef<Swipeable>(null);
   const { setSelectedChat } = useChatStore();
 
-  const { mutateAsync: pinChat } = usePostSetPinMutation();
-  const { mutateAsync: archiveChat } = usePostSetArchiveMutation();
-  const { mutateAsync: pinGroupChat } = usePostSetPinForGroupMutation();
-  const { mutateAsync: archiveGroupChat } = usePostSetArchiveForGroupMutation();
-
   const close = () => {
     swipeableRow.current?.close();
   };
@@ -59,6 +62,19 @@ const SwipeableRow: React.FC<AppleStyleSwipeableRowProps> = ({
 
     const pressHandler = async () => {
       close();
+
+      const updateLocal = async (key: keyof Chat, value: number) => {
+        const chatRec = await findChatRecord(chat);
+        if (!chatRec) return;
+
+        await database.write(() =>
+          chatRec.update((r) => {
+            (r as any)[key] = value;
+            addDirtyAction(r, { type: 'archive', value });
+          })
+        );
+      };
+
       if (text === 'More') {
         setSelectedChat(chat);
         SheetManager.show('more-modal', {
@@ -70,23 +86,13 @@ const SwipeableRow: React.FC<AppleStyleSwipeableRowProps> = ({
             muted: chat.muted,
             token: token,
             userType: chat.userType,
-            refetch,
-            refetchBlocked
+            announcement: chat.announcement
           } as any
         });
       } else {
-        chat.uid
-          ? await archiveChat({
-              token,
-              value: chat.archive === 1 ? 0 : 1,
-              conversation_with_user: chat.uid
-            })
-          : await archiveGroupChat({
-              token,
-              value: chat.archive === 1 ? 0 : 1,
-              group_token: chat.groupToken as string
-            });
-        refetch();
+        const newArchive = chat.archive === 1 ? 0 : 1;
+        await updateLocal('archive', newArchive);
+        await syncChatsIncremental(token);
       }
     };
 
@@ -117,18 +123,21 @@ const SwipeableRow: React.FC<AppleStyleSwipeableRowProps> = ({
 
     const pressHandler = async () => {
       close();
-      chat.uid
-        ? await pinChat({
-            token,
-            value: chat.pin === 1 ? 0 : 1,
-            conversation_with_user: chat.uid
+
+      const updateLocal = async (key: keyof Chat, value: number) => {
+        const chatRec = await findChatRecord(chat);
+        if (!chatRec) return;
+
+        await database.write(() =>
+          chatRec.update((r) => {
+            (r as any)[key] = value;
+            addDirtyAction(r, { type: 'pin', value });
           })
-        : await pinGroupChat({
-            token,
-            value: chat.pin === 1 ? 0 : 1,
-            group_token: chat.groupToken as string
-          });
-      refetch();
+        );
+      };
+      const newPin = chat.pin === 1 ? 0 : 1;
+      await updateLocal('pin', newPin);
+      await syncChatsIncremental(token);
     };
 
     return (

+ 5 - 3
src/screens/InAppScreens/MessagesScreen/GroupChatScreen/index.tsx

@@ -112,12 +112,14 @@ const GroupChatScreen = ({ route }: { route: any }) => {
     group_token,
     name,
     avatar,
-    userType = 'normal'
+    userType = 'normal',
+    announcement
   }: {
     group_token: string;
     name: string;
     avatar: string | null;
     userType: 'normal' | 'not_exist' | 'blocked';
+    announcement: 0 | 1;
   } = route.params;
   const groupName =
     userType === 'blocked'
@@ -2183,7 +2185,7 @@ const GroupChatScreen = ({ route }: { route: any }) => {
                   ...([NAVIGATION_PAGES.GROUP_SETTINGS, { groupToken: group_token }] as never)
                 )
               }
-              disabled={userType !== 'normal'}
+              disabled={userType !== 'normal' || announcement === 1}
             >
               {groupAvatar && userType === 'normal' ? (
                 <Image
@@ -2386,7 +2388,7 @@ const GroupChatScreen = ({ route }: { route: any }) => {
               </View>
             )}
             shouldUpdateMessage={shouldUpdateMessage}
-            scrollToBottom={true}
+            isScrollToBottomEnabled={true}
             scrollToBottomComponent={renderScrollToBottom}
             scrollToBottomStyle={{ backgroundColor: 'transparent' }}
             parsePatterns={(linkStyle) => [

+ 0 - 1
src/screens/InAppScreens/MessagesScreen/GroupSettingsScreen/index.tsx

@@ -292,7 +292,6 @@ const GroupSettingScreen: FC<Props> = ({ navigation, route }) => {
                     itemVisiblePercentThreshold: 50,
                     minimumViewTime: 1000
                   }}
-                  estimatedItemSize={50}
                   data={
                     data.settings.member_count > 4
                       ? members.settings.slice(0, 4)

+ 108 - 114
src/screens/InAppScreens/MessagesScreen/index.tsx

@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useRef, useCallback } from 'react';
+import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
 import {
   View,
   Text,
@@ -43,6 +43,11 @@ import { useMessagesStore } from 'src/stores/unreadMessagesStore';
 import { useSafeAreaInsets } from 'react-native-safe-area-context';
 import GroupIcon from 'assets/icons/messages/group-chat.svg';
 import { usePushNotification } from 'src/contexts/PushNotificationContext';
+import { useChatsListLive } from 'src/watermelondb/features/chat/hooks/useChatsList';
+import { syncChatsIncremental } from 'src/watermelondb/features/chat/data/chat.sync';
+import NetInfo from '@react-native-community/netinfo';
+import { useBlockedUsersLive } from 'src/watermelondb/features/chat/hooks/useBlockedUsersLive';
+import { testConnectionSpeed } from 'src/database/speedService';
 
 const TypingIndicator = ({ name }: { name?: string }) => {
   const [dots, setDots] = useState('');
@@ -73,12 +78,8 @@ const MessagesScreen = () => {
   const insets = useSafeAreaInsets();
   const navigation = useNavigation<any>();
   const token = storage.get('token', StoreType.STRING) as string;
-  const [chats, setChats] = useState<Chat[]>([]);
   const [index, setIndex] = useState(0);
   const [cacheKey, setCacheKey] = useState(Date.now());
-  const { data: chatsData, refetch } = usePostGetChatsListQuery(token, index === 2 ? 1 : 0, true);
-  const { data: blockedData, refetch: refetchBlocked } = usePostGetBlockedQuery(token, true);
-  const [blocked, setBlocked] = useState<Blocked[]>([]);
   const updateUnreadMessagesCount = useMessagesStore((state) => state.updateUnreadMessagesCount);
   const { isSubscribed } = usePushNotification();
   const [isBannerVisible, setIsBannerVisible] = useState(false);
@@ -98,6 +99,9 @@ const MessagesScreen = () => {
     { [key: string]: boolean } | { [key: string]: { firstName: string; isTyping: boolean } }
   >({});
 
+  const chats = useChatsListLive({ archived: index === 2 ? 1 : 0 });
+  const blocked = useBlockedUsersLive();
+
   const appState = useRef(AppState.currentState);
 
   const socket = useRef<WebSocket | null>(null);
@@ -200,11 +204,11 @@ const MessagesScreen = () => {
     return () => clearInterval(pingInterval);
   }, []);
 
-  const handleWebSocketMessage = (data: any) => {
+  const handleWebSocketMessage = async (data: any) => {
     switch (data.action) {
       case 'new_message':
       case 'messages_read':
-        refetch();
+        await syncChatsIncremental(token);
         break;
       case 'is_typing':
         if (data.conversation_with) {
@@ -264,21 +268,11 @@ const MessagesScreen = () => {
     });
   });
 
-  useEffect(() => {
-    if (chatsData && chatsData.conversations) {
-      setChats(chatsData.conversations);
-    }
-  }, [chatsData]);
-
-  useEffect(() => {
-    if (blockedData && blockedData.blocked) {
-      setBlocked(blockedData.blocked);
-    }
-  }, [blockedData]);
-
   useFocusEffect(
     useCallback(() => {
-      refetch();
+      (async () => {
+        await syncChatsIncremental(token);
+      })();
       initializeSocket();
       updateUnreadMessagesCount();
 
@@ -291,113 +285,104 @@ const MessagesScreen = () => {
     }, [token])
   );
 
-  const filterChatsByTab = () => {
-    let filteredList = chats;
+  const getFilteredChats = (key: keyof typeof filteredChats) => {
+    if (key === 'blocked') return blocked;
 
-    if (index === 3) {
-      setFilteredChats((prev) => ({ ...prev, blocked }));
-      return;
-    }
+    let list = chats;
 
-    if (index === 1) {
-      filteredList = chats.filter((chat) => chat.unread_count > 0);
+    if (key === 'unread') {
+      list = list.filter((c) => c.unreadCount > 0);
     }
 
-    filteredList.sort((a, b) => {
-      if (b.pin - a.pin !== 0) {
-        return b.pin - a.pin;
-      }
-      if (b.pin_order - a.pin_order !== 0) {
-        return b.pin_order - a.pin_order;
-      }
+    if (key === 'archived') {
+      list = list.filter((c) => c.archive === 1);
+    }
 
+    return list.slice().sort((a, b) => {
+      if (b.pin - a.pin !== 0) return b.pin - a.pin;
+      if (b.pinOrder - a.pinOrder !== 0) return b.pinOrder - a.pinOrder;
       return new Date(b.updated).getTime() - new Date(a.updated).getTime();
     });
-    setFilteredChats((prev) => ({ ...prev, [routes[index].key]: filteredList }));
   };
 
-  useEffect(() => {
-    filterChatsByTab();
-  }, [chats, index, blocked]);
-
-  const searchFilter = (text: string) => {
-    if (text) {
-      const newData =
-        chats?.filter((item: Chat) => {
-          const itemData = item.short ? item.short.toLowerCase() : ''.toLowerCase();
-          const textData = text.toLowerCase();
-          return itemData.indexOf(textData) > -1;
-        }) ?? [];
-      setFilteredChats((prev) => ({ ...prev, [routes[index].key]: newData }));
-      setSearch(text);
-    } else {
-      filterChatsByTab();
-      setSearch(text);
-    }
-  };
+  const filteredChatsForCurrentTab = useMemo(() => {
+    return getFilteredChats(routes[index].key);
+  }, [index, chats]);
+
+  // const searchFilter = (text: string) => {
+  //   if (text) {
+  //     const newData =
+  //       chats?.filter((item: Chat) => {
+  //         const itemData = item.short ? item.short.toLowerCase() : ''.toLowerCase();
+  //         const textData = text.toLowerCase();
+  //         return itemData.indexOf(textData) > -1;
+  //       }) ?? [];
+  //     setFilteredChats((prev) => ({ ...prev, [routes[index].key]: newData }));
+  //     setSearch(text);
+  //   } else {
+  //     filterChatsByTab();
+  //     setSearch(text);
+  //   }
+  // };
 
   const renderChatItem = ({ item }: { item: Chat }) => {
     const name =
-      item.user_type === 'blocked'
+      item.userType === 'blocked'
         ? 'Account is blocked'
-        : item.user_type === 'not_exist'
+        : item.userType === 'not_exist'
           ? 'Account does not exist'
           : item.name;
 
     return (
       <SwipeableRow
         chat={{
-          uid: item.uid,
-          groupToken: item.group_chat_token,
+          uid: item.chatUid,
+          groupToken: item.groupChatToken,
           name: item.name,
           avatar: item.avatar,
           pin: item.pin,
           archive: item.archive,
           muted: item.muted,
-          userType: item.user_type ?? 'normal'
+          userType: item.userType ?? 'normal',
+          announcement: item.announcement
         }}
         token={token}
         onRowOpen={handleRowOpen}
-        refetch={refetch}
-        refetchBlocked={refetchBlocked}
       >
         <TouchableHighlight
           key={
-            item.uid
-              ? `${item.uid}-${typingUsers[item.uid]}`
-              : `${item.group_chat_token}-${typingUsers[item.group_chat_token ?? '']}`
+            item.chatUid
+              ? `${item.chatUid}-${typingUsers[item.chatUid]}`
+              : `${item.groupChatToken}-${typingUsers[item.groupChatToken ?? '']}`
           }
           activeOpacity={0.8}
           onPress={() => {
-            if (!item.uid) {
-              navigation.navigate(NAVIGATION_PAGES.GROUP_CHAT, {
-                group_token: item.group_chat_token,
-                name: item.name,
-                avatar: item.avatar,
-                userType: item.user_type
-              });
-            } else {
-              navigation.navigate(NAVIGATION_PAGES.CHAT, {
-                id: item.uid,
+            navigation.navigate(
+              item.groupChatToken ? NAVIGATION_PAGES.GROUP_CHAT : NAVIGATION_PAGES.CHAT,
+              {
+                id: item.chatUid,
+                group_token: item.groupChatToken,
                 name: item.name,
                 avatar: item.avatar,
-                userType: item.user_type
-              });
-            }
+                userType: item.userType ?? 'normal',
+                announcement: item.announcement
+              }
+            );
           }}
           underlayColor={Colors.FILL_LIGHT}
         >
           <View style={styles.chatItem}>
-            {item.avatar && (item.user_type === 'normal' || !item.user_type) ? (
+            {item.avatar && (item.userType === 'normal' || !item.userType) ? (
               <Image
                 source={{
-                  uri: item.group_chat_token
+                  uri: item.groupChatToken
                     ? `${API_HOST}${item.avatar}?cacheBust=${cacheKey}`
                     : `${API_HOST}${item.avatar}`
                 }}
                 style={styles.avatar}
+                onError={(e) => console.warn('Image error', e.nativeEvent)}
               />
-            ) : item.uid && (item.user_type === 'normal' || !item.user_type) ? (
+            ) : item.chatUid && (item.userType === 'normal' || !item.userType) ? (
               <AvatarWithInitials
                 text={
                   item.name
@@ -408,7 +393,7 @@ const MessagesScreen = () => {
                 flag={API_HOST + item?.flag}
                 size={54}
               />
-            ) : item.user_type === 'normal' || !item.user_type ? (
+            ) : item.userType === 'normal' || !item.userType ? (
               <GroupIcon fill={Colors.DARK_BLUE} width={54} height={54} />
             ) : (
               <BanIcon fill={Colors.RED} width={54} height={54} />
@@ -419,7 +404,7 @@ const MessagesScreen = () => {
                 <Text
                   style={[
                     styles.chatName,
-                    item.user_type === 'not_exist' || item.user_type === 'blocked'
+                    item.userType === 'not_exist' || item.userType === 'blocked'
                       ? { color: Colors.RED }
                       : {}
                   ]}
@@ -431,10 +416,9 @@ const MessagesScreen = () => {
                   {item.pin === 1 ? <PinIcon height={12} fill={Colors.DARK_BLUE} /> : null}
                   {item.muted === 1 ? <BellSlashIcon height={12} fill={Colors.DARK_BLUE} /> : null}
 
-                  {item.sent_by === +currentUserId && item.status === 3 ? (
+                  {item.sentBy === +currentUserId && item.status === 3 ? (
                     <ReadIcon fill={Colors.DARK_BLUE} />
-                  ) : item.sent_by === +currentUserId &&
-                    (item.status === 2 || item.status === 1) ? (
+                  ) : item.sentBy === +currentUserId && (item.status === 2 || item.status === 1) ? (
                     <UnreadIcon fill={Colors.LIGHT_GRAY} />
                   ) : null}
                   <Text style={styles.chatTime}>{formatDate(item.updated)}</Text>
@@ -442,24 +426,24 @@ const MessagesScreen = () => {
               </View>
 
               <View style={[styles.rowContainer, { flex: 1, gap: 6 }]}>
-                {item.uid && typingUsers[item.uid] ? (
+                {item.chatUid && typingUsers[item.chatUid] ? (
                   <TypingIndicator />
-                ) : item.group_chat_token &&
-                  typingUsers[item.group_chat_token] &&
-                  (typingUsers[item.group_chat_token] as any)?.firstName ? (
-                  <TypingIndicator name={(typingUsers[item.group_chat_token] as any).firstName} />
+                ) : item.groupChatToken &&
+                  typingUsers[item.groupChatToken] &&
+                  (typingUsers[item.groupChatToken] as any)?.firstName ? (
+                  <TypingIndicator name={(typingUsers[item.groupChatToken] as any).firstName} />
                 ) : (
                   <Text numberOfLines={2} style={styles.chatMessage}>
-                    {item.attachement_name && item.attachement_name.length
-                      ? item.attachement_name
+                    {item.attachementName && item.attachementName.length
+                      ? item.attachementName
                       : item.short}
                   </Text>
                 )}
 
-                {item.unread_count > 0 ? (
+                {item.unreadCount > 0 ? (
                   <View style={styles.unreadBadge}>
                     <Text style={styles.unreadText}>
-                      {item.unread_count > 99 ? '99+' : item.unread_count}
+                      {item.unreadCount > 99 ? '99+' : item.unreadCount}
                     </Text>
                   </View>
                 ) : null}
@@ -475,19 +459,18 @@ const MessagesScreen = () => {
     return (
       <SwipeableBlockedRow
         data={{
-          id: item.id,
-          first_name: item.first_name,
-          last_name: item.last_name,
+          userId: item.userId,
+          firstName: item.firstName,
+          lastName: item.lastName,
           avatar: item.avatar
         }}
         token={token}
         onRowOpen={handleRowOpen}
-        refetchBlocked={refetchBlocked}
       >
         <TouchableHighlight
           activeOpacity={0.8}
           onPress={() =>
-            navigation.navigate(NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: item.id })
+            navigation.navigate(NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: item.userId })
           }
           underlayColor={Colors.FILL_LIGHT}
         >
@@ -499,7 +482,7 @@ const MessagesScreen = () => {
               />
             ) : (
               <AvatarWithInitials
-                text={item.first_name[0] + item.last_name[0]}
+                text={item.firstName[0] + item.lastName[0]}
                 flag={API_HOST + item?.flag}
                 size={32}
                 fontSize={12}
@@ -508,7 +491,7 @@ const MessagesScreen = () => {
 
             <View style={{ flex: 1, gap: 6 }}>
               <View style={[styles.rowContainer, { alignItems: 'center' }]}>
-                <Text style={styles.chatName}>{item.first_name + ' ' + item.last_name}</Text>
+                <Text style={styles.chatName}>{item.firstName + ' ' + item.lastName}</Text>
 
                 <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
                   <BanIcon height={12} fill={Colors.RED} />
@@ -578,17 +561,19 @@ const MessagesScreen = () => {
         routes={routes}
         sceneStyles={{ paddingHorizontal: 0 }}
         maxTabHeight={50}
-        renderScene={({ route }: { route: { key: keyof typeof filteredChats } }) =>
-          route.key === 'blocked' ? (
+        renderScene={({ route }) => {
+          const data = route.key === routes[index].key ? filteredChatsForCurrentTab : [];
+
+          return route.key === 'blocked' ? (
             <FlashList
               viewabilityConfig={{
                 waitForInteraction: true,
                 itemVisiblePercentThreshold: 50,
                 minimumViewTime: 1000
               }}
-              data={filteredChats[route.key]}
+              data={blocked}
               renderItem={renderBlockedItem}
-              keyExtractor={(item, index) => `${item.id}-${index}`}
+              keyExtractor={(item, i) => `${item.userId}-${i}`}
             />
           ) : (
             <FlashList
@@ -597,13 +582,13 @@ const MessagesScreen = () => {
                 itemVisiblePercentThreshold: 50,
                 minimumViewTime: 1000
               }}
-              data={filteredChats[route.key]}
+              data={data as Chat[]}
               renderItem={renderChatItem}
-              keyExtractor={(item, index) => `${item.uid}-${index}`}
+              keyExtractor={(item, i) => `${item.chatUid}-${item.groupChatToken}-${i}`}
               extraData={typingUsers}
             />
-          )
-        }
+          );
+        }}
       />
 
       <SearchModal />
@@ -611,10 +596,19 @@ const MessagesScreen = () => {
       <WarningModal
         type={'delete'}
         buttonTitle={isWarningModalVisible?.buttonTitle ?? 'Delete'}
-        isVisible={!!isWarningModalVisible}
-        onClose={() => setIsWarningModalVisible(null)}
-        title={isWarningModalVisible?.title}
-        message={isWarningModalVisible?.message}
+        isVisible={!!isWarningModalVisible?.visible}
+        onClose={() =>
+          setIsWarningModalVisible({
+            ...isWarningModalVisible,
+            visible: false,
+            title: isWarningModalVisible?.title ?? '',
+            buttonTitle: isWarningModalVisible?.buttonTitle ?? 'Delete',
+            message: isWarningModalVisible?.message ?? '',
+            action: isWarningModalVisible?.action ?? (() => {})
+          })
+        }
+        title={isWarningModalVisible?.title ?? ''}
+        message={isWarningModalVisible?.message ?? ''}
         action={isWarningModalVisible?.action}
       />
     </View>

+ 15 - 12
src/screens/InAppScreens/MessagesScreen/types.ts

@@ -1,30 +1,31 @@
 import { IMessage } from 'react-native-gifted-chat';
 
 export type Chat = {
-  uid: number | null;
-  group_chat_token: string | null;
+  chatUid: number | null;
+  groupChatToken: string | null;
   name: string;
   avatar: string | null;
   short: string;
-  sent_by: number;
+  sentBy: number;
   updated: Date;
   status: 1 | 2 | 3 | 4;
-  unread_count: number;
-  last_message_id: number;
+  unreadCount: number;
+  lastMessageId: number;
   pin: 0 | 1;
-  pin_order: number;
+  pinOrder: number;
   archive: 0 | 1;
-  archive_order: number;
-  attachement_name: string;
+  archiveOrder: number;
+  attachementName: string;
   encrypted: 0 | 1;
   muted: 0 | 1;
-  user_type: 'normal' | 'not_exist' | 'blocked';
+  userType: 'normal' | 'not_exist' | 'blocked';
+  announcement: 0 | 1;
 };
 
 export type Blocked = {
-  id: number;
-  first_name: string;
-  last_name: string;
+  userId: number;
+  firstName: string;
+  lastName: string;
   avatar: string | null;
 };
 
@@ -37,6 +38,7 @@ export type ChatProps = {
   archive: 0 | 1;
   muted: 0 | 1;
   userType: 'normal' | 'not_exist' | 'blocked';
+  announcement: 0 | 1;
 };
 
 export interface Attachement {
@@ -126,6 +128,7 @@ export type Routes = {
 };
 
 export type WarningProps = {
+  visible: boolean;
   title: string;
   buttonTitle?: string;
   message: string;

+ 6 - 2
src/types/api.ts

@@ -221,7 +221,9 @@ export enum API_ENDPOINT {
   UPDATE_VISIT = 'update-visit',
   DELETE_VISIT = 'delete-visit',
   GET_SINGLE_REGION = 'get-single-region',
-  SET_NOT_VISITED = 'set-not-visited'
+  SET_NOT_VISITED = 'set-not-visited',
+  GET_GROUP_CONVERSATION_ALL = 'get-group-conversation-all',
+  GET_CONVERSATION_WITH_ALL = 'get-conversation-with-all'
 }
 
 export enum API {
@@ -416,7 +418,9 @@ export enum API {
   UPDATE_VISIT = `${API_ROUTE.QUICK_ENTER}/${API_ENDPOINT.UPDATE_VISIT}`,
   DELETE_VISIT = `${API_ROUTE.QUICK_ENTER}/${API_ENDPOINT.DELETE_VISIT}`,
   GET_SINGLE_REGION = `${API_ROUTE.QUICK_ENTER}/${API_ENDPOINT.GET_SINGLE_REGION}`,
-  SET_NOT_VISITED = `${API_ROUTE.QUICK_ENTER}/${API_ENDPOINT.SET_NOT_VISITED}`
+  SET_NOT_VISITED = `${API_ROUTE.QUICK_ENTER}/${API_ENDPOINT.SET_NOT_VISITED}`,
+  GET_GROUP_CONVERSATION_ALL = `${API_ROUTE.CHAT}/${API_ENDPOINT.GET_GROUP_CONVERSATION_ALL}`,
+  GET_CONVERSATION_WITH_ALL = `${API_ROUTE.CHAT}/${API_ENDPOINT.GET_CONVERSATION_WITH_ALL}`
 }
 
 export type BaseAxiosError = AxiosError;

+ 32 - 0
src/watermelondb/backup.ts

@@ -0,0 +1,32 @@
+import * as FileSystem from 'expo-file-system/legacy';
+
+const DB_PATH = `${FileSystem.documentDirectory}SQLite/WatermelonDB.db`;
+const BACKUP_PATH = `${FileSystem.documentDirectory}backups/backup.db`;
+
+export async function createLocalBackup() {
+  try {
+    await FileSystem.makeDirectoryAsync(`${FileSystem.documentDirectory}backups/`, {
+      intermediates: true
+    });
+    await FileSystem.copyAsync({ from: DB_PATH, to: BACKUP_PATH });
+  } catch (e) {
+    console.error('Backup failed', e);
+  }
+}
+
+export async function hasLocalBackup() {
+  const info = await FileSystem.getInfoAsync(BACKUP_PATH);
+  return info.exists;
+}
+
+export async function restoreFromBackup() {
+  try {
+    const info = await FileSystem.getInfoAsync(BACKUP_PATH);
+    if (!info.exists) return false;
+    await FileSystem.copyAsync({ from: BACKUP_PATH, to: DB_PATH });
+    return true;
+  } catch (e) {
+    console.error('Restore failed', e);
+    return false;
+  }
+}

+ 46 - 0
src/watermelondb/features/chat/data/blocked.repo.ts

@@ -0,0 +1,46 @@
+import { database } from 'src/watermelondb';
+import BlockedUser from 'src/watermelondb/models/BlockedUser';
+
+type Blocked = {
+  id: number;
+  first_name: string;
+  last_name: string;
+  avatar: string | null;
+};
+
+export async function upsertBlockedUsers(users: Blocked[]) {
+  if (!users?.length) return;
+  const collection = database.get<BlockedUser>('blocked_users');
+  const existingBlocked = await collection.query().fetch();
+
+  await database.write(async () => {
+    const batch: any[] = [];
+
+    for (const u of users) {
+      const existing = existingBlocked.find((ec) => ec.userId && ec.userId === u.id);
+
+      if (existing) {
+        batch.push(
+          existing.prepareUpdate((rec) => {
+            rec.firstName = u.first_name;
+            rec.lastName = u.last_name;
+            rec.avatar = u.avatar ?? null;
+            rec.removed = false;
+          })
+        );
+      } else {
+        batch.push(
+          collection.prepareCreate((rec) => {
+            rec.userId = u.id;
+            rec.firstName = u.first_name;
+            rec.lastName = u.last_name;
+            rec.avatar = u.avatar ?? null;
+            rec.removed = false;
+          })
+        );
+      }
+    }
+
+    await database.batch(batch);
+  });
+}

+ 104 - 0
src/watermelondb/features/chat/data/chat.repo.ts

@@ -0,0 +1,104 @@
+import { database } from 'src/watermelondb';
+import Chat from 'src/watermelondb/models/Chat';
+
+type ServerChat = {
+  uid: number | null;
+  group_chat_token: string | null;
+  name: string;
+  avatar: string | null;
+  short: string;
+  sent_by: number;
+  updated: Date | string;
+  status: 1 | 2 | 3 | 4;
+  unread_count: number;
+  last_message_id: number;
+  pin: 0 | 1;
+  pin_order: number;
+  archive: 0 | 1;
+  archive_order: number;
+  attachement_name: string;
+  encrypted: 0 | 1;
+  muted: 0 | 1;
+  user_type: 'normal' | 'not_exist' | 'blocked';
+  can_send_message: 0 | 1;
+  is_admin: 0 | 1;
+  announcement: 0 | 1;
+};
+
+export async function upsertChats(chats: ServerChat[]) {
+  if (!chats?.length) return;
+
+  const chatCollection = database.get<Chat>('chats');
+  const existingChats = await chatCollection.query().fetch();
+
+  await database.write(async () => {
+    const batch: any[] = [];
+
+    for (const c of chats) {
+      const existing = existingChats.find(
+        (ec) =>
+          (ec.chatUid && ec.chatUid === c.uid) ||
+          (ec.groupChatToken && ec.groupChatToken === c.group_chat_token)
+      );
+
+      if (existing) {
+        batch.push(
+          existing.prepareUpdate((rec) => {
+            rec.name = c.name;
+            rec.avatar = c.avatar ?? null;
+            rec.short = c.short;
+            rec.sentBy = c.sent_by;
+            rec.updated = new Date(c.updated).getTime();
+            rec.status = c.status;
+            rec.unreadCount = c.unread_count;
+            rec.lastMessageId = c.last_message_id;
+            rec.pin = c.pin;
+            rec.pinOrder = c.pin_order;
+            rec.archive = c.archive;
+            rec.archiveOrder = c.archive_order;
+            rec.attachmentName = c.attachement_name;
+            rec.encrypted = c.encrypted;
+            rec.muted = c.muted;
+            rec.userType = c.user_type ?? null;
+            rec.canSendMessages = c.can_send_message ?? 1;
+            rec.isAdmin = c.is_admin ?? 1;
+            rec.announcement = c.announcement ?? 0;
+            rec.removed = false;
+          })
+        );
+      } else {
+        batch.push(
+          chatCollection.prepareCreate((rec) => {
+            rec.chatUid = c.uid ?? null;
+            rec.groupChatToken = c.group_chat_token ?? null;
+
+            rec.name = c.name;
+            rec.avatar = c.avatar ?? null;
+            rec.short = c.short;
+            rec.sentBy = c.sent_by;
+            rec.updated = new Date(c.updated).getTime();
+            rec.status = c.status;
+            rec.unreadCount = c.unread_count;
+            rec.lastMessageId = c.last_message_id;
+            rec.pin = c.pin;
+            rec.pinOrder = c.pin_order;
+            rec.archive = c.archive;
+            rec.archiveOrder = c.archive_order;
+            rec.attachmentName = c.attachement_name;
+            rec.encrypted = c.encrypted;
+            rec.muted = c.muted;
+            rec.userType = c.user_type;
+
+            rec.canSendMessages = c.can_send_message ?? 1;
+            rec.isAdmin = c.is_admin ?? 1;
+            rec.announcement = c.announcement ?? 0;
+            rec.removed = false;
+          })
+        );
+      }
+    }
+
+    await database.batch(batch);
+    console.log('upsertChats complete');
+  });
+}

+ 496 - 0
src/watermelondb/features/chat/data/chat.sync.ts

@@ -0,0 +1,496 @@
+import { Q } from '@nozbe/watermelondb';
+import { database } from 'src/watermelondb';
+import Chat from 'src/watermelondb/models/Chat';
+import BlockedUser from 'src/watermelondb/models/BlockedUser';
+import { chatApi } from '@api/chat';
+import NetInfo from '@react-native-community/netinfo';
+import { testConnectionSpeed } from 'src/database/speedService';
+
+export type DirtyAction = {
+  type: 'pin' | 'archive' | 'mute' | 'block' | 'unblock' | 'delete' | 'leave_group';
+  value?: number | string | null;
+  ts: number;
+};
+
+export function addDirtyAction(
+  record: { isDirty: boolean; dirtyActions: string | null },
+  action: Omit<DirtyAction, 'ts'>
+) {
+  const list: DirtyAction[] = record.dirtyActions ? JSON.parse(record.dirtyActions) : [];
+  list.push({ ...action, ts: Date.now() });
+
+  record.isDirty = true;
+  record.dirtyActions = JSON.stringify(list);
+}
+
+async function retryWithBackoff<T>(fn: () => Promise<T>, tries = 3, delay = 300): Promise<T> {
+  let error: any = null;
+
+  for (let i = 0; i < tries; i++) {
+    try {
+      return await fn();
+    } catch (e) {
+      error = e;
+      await new Promise((r) => setTimeout(r, delay));
+      delay *= 2;
+    }
+  }
+
+  throw error;
+}
+
+export function compactDirtyActions(actions: DirtyAction[]): DirtyAction[] {
+  if (!actions?.length) return [];
+
+  actions = [...actions].sort((a, b) => a.ts - b.ts);
+
+  const result: DirtyAction[] = [];
+
+  for (const a of actions) {
+    const last = result[result.length - 1];
+
+    if (a.type === 'delete' || a.type === 'leave_group') {
+      return [a];
+    }
+
+    if (
+      (last?.type === 'block' && a.type === 'unblock') ||
+      (last?.type === 'unblock' && a.type === 'block')
+    ) {
+      result.pop();
+      continue;
+    }
+
+    if (['pin', 'archive', 'mute'].includes(a.type) && last?.type === a.type) {
+      last.value = a.value;
+      last.ts = a.ts;
+      continue;
+    }
+
+    result.push(a);
+  }
+
+  return result;
+}
+
+async function collectDirtyQueue() {
+  const chatCollection = database.get<Chat>('chats');
+  const blockedCollection = database.get<BlockedUser>('blocked_users');
+
+  const dirtyChats = await chatCollection.query(Q.where('is_dirty', true)).fetch();
+
+  const dirtyBlocked = await blockedCollection.query(Q.where('is_dirty', true)).fetch();
+
+  const queue: Array<{
+    source: 'chat' | 'blocked';
+    recordId: string;
+    chatUid: number | null;
+    groupToken: string | null;
+    action: DirtyAction;
+  }> = [];
+
+  for (const c of dirtyChats) {
+    const raw = (c as any)._raw;
+    const list: DirtyAction[] = raw.dirty_actions ? JSON.parse(raw.dirty_actions) : [];
+
+    const compacted = compactDirtyActions(list);
+
+    for (const a of compacted) {
+      queue.push({
+        source: 'chat',
+        recordId: c.id,
+        chatUid: c.chatUid ?? null,
+        groupToken: c.groupChatToken ?? null,
+        action: a
+      });
+    }
+  }
+
+  for (const b of dirtyBlocked) {
+    const raw = (b as any)._raw;
+    const list: DirtyAction[] = raw.dirty_actions ? JSON.parse(raw.dirty_actions) : [];
+
+    const compacted = compactDirtyActions(list);
+
+    for (const a of compacted) {
+      queue.push({
+        source: 'blocked',
+        recordId: b.id,
+        chatUid: b.userId ?? null,
+        groupToken: null,
+        action: a
+      });
+    }
+  }
+
+  queue.sort((a, b) => a.action.ts - b.action.ts);
+
+  return queue;
+}
+
+async function performAction(
+  token: string,
+  item: {
+    source: 'chat' | 'blocked';
+    recordId: string;
+    chatUid: number | null;
+    groupToken: string | null;
+    action: DirtyAction;
+  }
+): Promise<{ success: boolean; shouldDeleteLocal?: boolean }> {
+  const { chatUid, groupToken, action } = item;
+
+  return retryWithBackoff(async () => {
+    switch (action.type) {
+      case 'pin':
+        if (groupToken)
+          await chatApi.setPinForGroup({
+            token,
+            group_token: groupToken,
+            value: action.value as 0 | 1
+          });
+        else if (chatUid)
+          await chatApi.setPin({
+            token,
+            conversation_with_user: chatUid,
+            value: action.value as 0 | 1
+          });
+        return { success: true };
+
+      case 'archive':
+        if (groupToken)
+          await chatApi.setArchiveForGroup({
+            token,
+            group_token: groupToken,
+            value: action.value as 0 | 1
+          });
+        else if (chatUid)
+          await chatApi.setArchive({
+            token,
+            conversation_with_user: chatUid,
+            value: action.value as 0 | 1
+          });
+        return { success: true };
+
+      case 'mute':
+        if (groupToken)
+          await chatApi.setMuteForGroup({
+            token,
+            group_token: groupToken,
+            value: action.value as 0 | 1
+          });
+        else if (chatUid)
+          await chatApi.setMute({
+            token,
+            conversation_with_user: chatUid,
+            value: action.value as 0 | 1
+          });
+        return { success: true };
+
+      case 'block':
+        if (chatUid)
+          await chatApi.setBlock({
+            token,
+            conversation_with_user: chatUid,
+            value: 1
+          });
+        return { success: true, shouldDeleteLocal: true };
+
+      case 'unblock':
+        if (chatUid)
+          await chatApi.setBlock({
+            token,
+            conversation_with_user: chatUid,
+            value: 0
+          });
+        return { success: true, shouldDeleteLocal: true };
+
+      case 'delete':
+        if (groupToken) await chatApi.removeGroupFromList(token, groupToken);
+        else if (chatUid)
+          await chatApi.deleteChat({
+            token,
+            conversation_with_user: chatUid
+          });
+        return { success: true, shouldDeleteLocal: true };
+
+      case 'leave_group':
+        if (groupToken) await chatApi.leaveGroup(token, groupToken);
+        return { success: true, shouldDeleteLocal: true };
+
+      default:
+        console.warn('Unknown action type', action);
+        return { success: true };
+    }
+  });
+}
+
+async function pushLocalChanges(token: string) {
+  const queue = await collectDirtyQueue();
+
+  const CHUNK_SIZE = 3;
+  for (let i = 0; i < queue.length; i += CHUNK_SIZE) {
+    const chunk = queue.slice(i, i + CHUNK_SIZE);
+
+    await Promise.all(
+      chunk.map(async (item) => {
+        try {
+          const res = await performAction(token, item);
+
+          if (res.shouldDeleteLocal) {
+            const col =
+              item.source === 'chat'
+                ? database.get<Chat>('chats')
+                : database.get<BlockedUser>('blocked_users');
+
+            const rec = await col.find(item.recordId);
+            await database.write(() => rec.destroyPermanently());
+          } else {
+            const col: any =
+              item.source === 'chat'
+                ? database.get<Chat>('chats')
+                : database.get<BlockedUser>('blocked_users');
+            const rec = await col.find(item.recordId);
+            await database.write(() =>
+              rec.update((r: Chat | BlockedUser) => {
+                r.dirtyActions = null;
+                r.isDirty = false;
+              })
+            );
+          }
+        } catch (e) {
+          console.warn('pushLocalChanges: failed', e);
+        }
+      })
+    );
+  }
+}
+
+async function fetchList(token: string, archive: 0 | 1) {
+  return retryWithBackoff(async () => {
+    const { data } = await chatApi.getChatsList(token, archive);
+    return data?.conversations ?? [];
+  });
+}
+
+async function pullUpdates(token: string) {
+  const [active, archived] = await Promise.all([fetchList(token, 0), fetchList(token, 1)]);
+
+  const blockedRes = await retryWithBackoff(async () => {
+    const { data } = await chatApi.getBlocked(token);
+    return data ?? {};
+  });
+  const serverBlockedList: any[] = blockedRes.blocked ?? [];
+  const serverChats = [...active, ...archived];
+
+  const chatCollection = database.get<Chat>('chats');
+  const blockedCollection = database.get<BlockedUser>('blocked_users');
+
+  const localChats = await chatCollection.query().fetch();
+  const localBlocked = await blockedCollection.query().fetch();
+
+  const keyForServer = (s: any) =>
+    s.group_chat_token ? `g:${s.group_chat_token}` : `u:${s.uid ?? s.chat_uid}`;
+
+  const localKeyFor = (c: Chat) => (c.groupChatToken ? `g:${c.groupChatToken}` : `u:${c.chatUid}`);
+
+  const localMap = new Map<string, Chat>();
+  for (const c of localChats) {
+    const k = localKeyFor(c);
+    localMap.set(k, c);
+  }
+
+  const serverMap = new Map<string, any>();
+  for (const s of serverChats) {
+    const k = keyForServer(s);
+    serverMap.set(k, s);
+  }
+
+  const serverBlockedSet = new Set(serverBlockedList.map((id: any) => String(id)));
+
+  const toCreate: any[] = [];
+  const toUpdate: Array<{ local: Chat; server: any }> = [];
+  const toDeleteLocalKeys: string[] = [];
+
+  for (const [key, serverChat] of serverMap.entries()) {
+    const local = localMap.get(key);
+    if (!local) {
+      toCreate.push({ key, serverChat });
+    } else {
+      toUpdate.push({ local, server: serverChat });
+    }
+  }
+
+  for (const [localKey, local] of localMap.entries()) {
+    if (!serverMap.has(localKey)) {
+      toDeleteLocalKeys.push(localKey);
+    }
+  }
+
+  const blockedToDelete: BlockedUser[] = localBlocked.filter(
+    (b) => !serverBlockedSet.has(String(b.userId))
+  );
+
+  const createdLocalRecordKeysToCacheAvatar: string[] = [];
+
+  await database.write(async () => {
+    const batch: any[] = [];
+
+    for (const item of toCreate) {
+      const s = item.serverChat;
+      batch.push(
+        chatCollection.prepareCreate((r: any) => {
+          r.chatUid = s.uid ?? s.chat_uid ?? null;
+          r.groupChatToken = s.group_chat_token ?? null;
+
+          r.name = s.name ?? '';
+          r.avatar = s.avatar ?? null;
+          r.avatarLocal = s.avatar_local ?? null;
+          r.avatarEtag = s.avatar_etag ?? null;
+          r.avatarCheckedAt = s.avatar_checked_at ?? null;
+
+          r.short = s.short ?? '';
+          r.sentBy = s.sent_by ?? 0;
+          r.updated =
+            typeof s.updated === 'number'
+              ? s.updated
+              : s.updated
+                ? new Date(s.updated).getTime()
+                : Date.now();
+
+          r.status = s.status ?? 0;
+          r.unreadCount = s.unread_count ?? 0;
+          r.lastMessageId = s.last_message_id ?? 0;
+          r.pin = s.pin ?? 0;
+          r.pinOrder = s.pin_order ?? 0;
+          r.archive = s.archive ?? 0;
+          r.archiveOrder = s.archive_order ?? 0;
+          r.attachmentName = s.attachement_name ?? s.attachment_name ?? '';
+          r.encrypted = s.encrypted ?? 0;
+          r.muted = s.muted ?? 0;
+          r.userType = s.user_type ?? null;
+
+          r.canSendMessages = s.can_send_message ?? s.can_send_messages ?? 1;
+          r.isAdmin = s.is_admin ?? 0;
+          r.announcement = s.announcement ?? 0;
+
+          r.removed = false;
+          r.isDirty = false;
+          r.dirtyActions = null;
+        })
+      );
+
+      createdLocalRecordKeysToCacheAvatar.push(item.key);
+    }
+
+    for (const { local, server } of toUpdate) {
+      // if ((local as any).isDirty) {
+      //   // to do
+      //   const serverUpdated =
+      //     typeof server.updated === 'number'
+      //       ? server.updated
+      //       : server.updated
+      //         ? new Date(server.updated).getTime()
+      //         : null;
+      //   if (serverUpdated && serverUpdated > (local as any).updated) {
+      //     await local.update((r: any) => {
+      //       r.unreadCount = server.unread_count ?? r.unreadCount;
+      //       r.lastMessageId = server.last_message_id ?? r.lastMessageId;
+      //       r.updated = serverUpdated;
+      //       r.removed = false;
+      //     });
+      //   }
+      //   continue;
+      // }
+
+      await local.update((r: any) => {
+        r.name = server.name ?? r.name;
+        r.avatar = server.avatar ?? r.avatar;
+        r.short = server.short ?? r.short;
+        r.sentBy = server.sent_by ?? r.sentBy;
+        r.updated =
+          typeof server.updated === 'number'
+            ? server.updated
+            : server.updated
+              ? new Date(server.updated).getTime()
+              : r.updated;
+        r.status = server.status ?? r.status;
+        r.unreadCount = server.unread_count ?? r.unreadCount;
+        r.lastMessageId = server.last_message_id ?? r.lastMessageId;
+        r.pin = server.pin ?? r.pin;
+        r.pinOrder = server.pin_order ?? r.pinOrder;
+        r.archive = server.archive ?? r.archive;
+        r.archiveOrder = server.archive_order ?? r.archiveOrder;
+        r.attachmentName = server.attachement_name ?? server.attachment_name ?? r.attachmentName;
+        r.encrypted = server.encrypted ?? r.encrypted;
+        r.muted = server.muted ?? r.muted;
+        r.userType = server.user_type ?? r.userType;
+
+        r.canSendMessages =
+          server.can_send_message ?? server.can_send_messages ?? r.canSendMessages;
+        r.isAdmin = server.is_admin ?? r.isAdmin;
+        r.announcement = server.announcement ?? r.announcement;
+        r.removed = false;
+      });
+    }
+
+    for (const localKey of toDeleteLocalKeys) {
+      const local = localMap.get(localKey)!;
+      await local.destroyPermanently();
+    }
+
+    for (const b of blockedToDelete) {
+      await b.destroyPermanently();
+    }
+
+    if (batch.length) {
+      await database.batch(...batch);
+    }
+  });
+
+  await database.write(async () => {
+    const localBlockedMap = new Map<string, BlockedUser>();
+    const localBlockedNow = await blockedCollection.query().fetch();
+    for (const b of localBlockedNow) localBlockedMap.set(String(b.userId), b);
+
+    for (const serverId of serverBlockedList) {
+      const sId = String(serverId.id);
+      if (!localBlockedMap.has(sId)) {
+        await blockedCollection.create((r: any) => {
+          r.userId = Number(sId);
+          r.firstName = serverId.first_name;
+          r.lastName = serverId.last_name;
+          r.avatar = serverId.avatar ?? null;
+          r.removed = false;
+          r.isDirty = false;
+          r.dirtyActions = null;
+        });
+      } else {
+        const rec = localBlockedMap.get(sId)!;
+        await rec.update((r: any) => {
+          r.removed = false;
+          r.isDirty = false;
+          r.dirtyActions = null;
+        });
+      }
+    }
+  });
+}
+
+export async function syncChatsIncremental(token: string) {
+  const net = await NetInfo.fetch();
+  if (!net.isConnected) return;
+
+  try {
+    const speed = await testConnectionSpeed();
+    if ((speed?.downloadSpeed && speed.downloadSpeed < 0.2) || (speed?.ping && speed.ping > 1500)) {
+      console.warn('Internet too slow for sync');
+      return;
+    }
+  } catch {}
+
+  await pushLocalChanges(token);
+  console.log('push ended');
+  await pullUpdates(token);
+  console.log('pull ended');
+}

+ 190 - 0
src/watermelondb/features/chat/data/importChatsFromServer.ts

@@ -0,0 +1,190 @@
+import pLimit from 'p-limit';
+import { Q } from '@nozbe/watermelondb';
+import { database } from 'src/watermelondb';
+import Message from 'src/watermelondb/models/Message';
+import Chat from 'src/watermelondb/models/Chat';
+import { upsertChats } from './chat.repo';
+import { upsertBlockedUsers } from './blocked.repo';
+import { chatApi } from '@api/chat';
+import { createLocalBackup } from 'src/watermelondb/backup';
+
+const CONCURRENCY = 5;
+const BATCH_LIMIT = 500;
+
+async function fetchChatMessages(
+  token: string,
+  chat: { uid: number | null; group_chat_token: string | null }
+) {
+  try {
+    if (chat.group_chat_token) {
+      const { data } = await chatApi.getGroupChatAll(token, chat.group_chat_token);
+      return {
+        messages: data?.messages ?? [],
+        meta: {
+          groupToken: data?.groupToken
+        }
+      };
+    } else {
+      const { data } = await chatApi.getChatWithAll(token, chat.uid!);
+      return { messages: data?.messages ?? [], meta: undefined };
+    }
+  } catch (error) {
+    console.error(`fetchChatMessages error (${chat.uid ?? chat.group_chat_token}):`, error);
+    return { messages: [], meta: undefined };
+  }
+}
+
+async function importMessages({
+  messages,
+  chatInstance,
+  chatUid,
+  groupToken
+}: {
+  messages: any[];
+  chatInstance: Chat | null;
+  chatUid: number | null;
+  groupToken: string | null;
+}) {
+  if (!messages.length) return;
+
+  const messageCollection = database.get<Message>('messages');
+  const chatKey = groupToken ? `g:${groupToken}` : `u:${chatUid}`;
+
+  const chunks: any[][] = [];
+  for (let i = 0; i < messages.length; i += BATCH_LIMIT) {
+    chunks.push(messages.slice(i, i + BATCH_LIMIT));
+  }
+
+  for (const chunk of chunks) {
+    await database.write(async () => {
+      const batch = [];
+
+      for (const m of chunk) {
+        if (!m) continue;
+        const compositeId = `${chatKey}_${m.id}`;
+
+        const existing = await messageCollection
+          .query(Q.where('composite_id', compositeId))
+          .fetch();
+
+        if (existing.length > 0) {
+          const record = existing[0];
+          const updatedFields: any = {};
+
+          if (record.status !== String(m.status)) updatedFields.status = String(m.status);
+          if (record.edits !== m.edits) updatedFields.edits = m.edits ?? '';
+          if (record.reactions !== m.reactions) updatedFields.reactions = m.reactions ?? '';
+          if (record.text !== (m.text ?? '')) updatedFields.text = m.text ?? '';
+          if (record.deleted !== Boolean(m.deleted)) updatedFields.deleted = Boolean(m.deleted);
+          if (record.readAt !== (m.read_datetime ? new Date(m.read_datetime).getTime() : null))
+            updatedFields.readAt = m.read_datetime ? new Date(m.read_datetime).getTime() : null;
+
+          if (Object.keys(updatedFields).length > 0) {
+            batch.push(
+              record.prepareUpdate((rec: any) => {
+                Object.assign(rec, updatedFields);
+              })
+            );
+          }
+        } else {
+          batch.push(
+            messageCollection.prepareCreate((rec: any) => {
+              rec.compositeId = compositeId;
+              rec.messageId = String(m.id);
+              rec.chatUid = chatUid ?? null;
+
+              rec.senderId = m.sender;
+              rec.recipientId = m.recipient ?? 0;
+              rec.text = m.text ?? '';
+              rec.timestamp = new Date(m.sent_datetime).getTime();
+              rec.receivedAt = m.received_datetime ? new Date(m.received_datetime).getTime() : null;
+              rec.readAt = m.read_datetime ? new Date(m.read_datetime).getTime() : null;
+              rec.status = String(m.status);
+              rec.deleted = Boolean(m.deleted);
+
+              rec.reactions = m.reactions ?? '';
+              rec.edits = m.edits ?? '';
+              rec.attachments = JSON.stringify(m.attachement !== -1 ? m.attachement : null);
+              rec.replyTo = m.reply_to_id ?? null;
+              rec.encrypted = m.encrypted ?? 0;
+
+              rec.senderName = m.sender_name ?? null;
+              rec.senderAvatar = m.sender_avatar ?? null;
+              rec.poll = m.poll && m.poll !== -1 ? JSON.stringify(m.poll) : null;
+
+              if (chatInstance) rec.chat.set(chatInstance);
+            })
+          );
+        }
+      }
+
+      if (batch.length > 0) {
+        await database.batch(...batch);
+        console.log(`Synced ${batch.length} messages for ${groupToken ?? chatUid}`);
+      }
+    });
+  }
+}
+
+async function fetchChatsList(token: string, archive: 0 | 1) {
+  try {
+    const { data } = await chatApi.getChatsList(token, archive);
+    return data?.conversations ?? [];
+  } catch (e) {
+    console.error(`fetchChatsList(${archive}) failed`, e);
+    return [];
+  }
+}
+
+export async function importChatsFromServer(token: string) {
+  try {
+    const [active, archived] = await Promise.all([
+      fetchChatsList(token, 0),
+      fetchChatsList(token, 1)
+    ]);
+    const conversations = [...active, ...archived];
+
+    if (conversations.length > 0) await upsertChats(conversations);
+
+    const { data: blockedData } = await chatApi.getBlocked(token);
+    await upsertBlockedUsers(blockedData?.blocked ?? []);
+
+    // const chatCollection = database.get<Chat>('chats');
+    // const allChats = await chatCollection.query().fetch();
+    // const chatCache = new Map<string, Chat>();
+    // allChats.forEach((c: Chat) => {
+    //   if (c.groupChatToken) chatCache.set(`g:${c.groupChatToken}`, c);
+    //   if (c.chatUid) chatCache.set(`u:${c.chatUid}`, c);
+    // });
+
+    // const limit = pLimit(CONCURRENCY);
+    // const tasks = conversations.map((chat) =>
+    //   limit(async () => {
+    //     const { messages, meta } = await fetchChatMessages(token, {
+    //       uid: chat.uid,
+    //       group_chat_token: chat.group_chat_token
+    //     });
+
+    //     const key = chat.group_chat_token ? `g:${chat.group_chat_token}` : `u:${chat.uid ?? ''}`;
+    //     const chatInstance = chatCache.get(key) ?? null;
+
+    //     await importMessages({
+    //       messages,
+    //       chatInstance,
+    //       chatUid: chat.uid,
+    //       groupToken: chat.group_chat_token
+    //     });
+    //   })
+    // );
+
+    // console.log(`🚦 Running ${tasks.length} imports with concurrency=${CONCURRENCY}`);
+    // await Promise.all(tasks);
+
+    // console.log('💾 Creating local backup...');
+    // await createLocalBackup();
+
+    console.log('All chats imported successfully.');
+  } catch (err) {
+    console.error('importChatsFromServer failed at root level:', err);
+  }
+}

+ 15 - 0
src/watermelondb/features/chat/hooks/useBlockedUsersLive.ts

@@ -0,0 +1,15 @@
+import { useEffect, useState } from 'react';
+import { database } from 'src/watermelondb';
+import { Q } from '@nozbe/watermelondb';
+
+export function useBlockedUsersLive() {
+  const [users, setUsers] = useState<any[]>([]);
+
+  useEffect(() => {
+    const collection = database.get('blocked_users');
+    const subscription = collection.query(Q.where('removed', false)).observe().subscribe(setUsers);
+    return () => subscription.unsubscribe();
+  }, []);
+
+  return users;
+}

+ 19 - 0
src/watermelondb/features/chat/hooks/useChatThread.ts

@@ -0,0 +1,19 @@
+import { useEffect, useState } from 'react';
+import { database } from 'src/watermelondb';
+import { Q } from '@nozbe/watermelondb';
+import { Message } from 'src/watermelondb/models';
+
+export function useChatThreadLive(chatUid: number, limit = 50) {
+  const [messages, setMessages] = useState<Message[]>([]);
+  useEffect(() => {
+    const sub = database
+      .get<Message>('messages')
+      .query(Q.where('chat_uid', chatUid), Q.sortBy('timestamp', 'desc'), Q.take(limit))
+      .observe()
+      .subscribe(setMessages);
+
+    return () => sub.unsubscribe();
+  }, [chatUid, limit]);
+
+  return messages;
+}

+ 22 - 0
src/watermelondb/features/chat/hooks/useChatsList.ts

@@ -0,0 +1,22 @@
+import { useEffect, useState } from 'react';
+import { database } from 'src/watermelondb';
+import { Q } from '@nozbe/watermelondb';
+import { Chat } from 'src/watermelondb/models';
+
+export function useChatsListLive({ archived = 0 }: { archived?: number }) {
+  const [chats, setChats] = useState<Chat[]>([]);
+
+  useEffect(() => {
+    const chatsCollection = database.get<Chat>('chats');
+
+    let query = chatsCollection.query(Q.where('archive', archived), Q.where('removed', false));
+
+    const sub = query
+      .observeWithColumns(['pin', 'muted', 'unread_count', 'status', 'name'])
+      .subscribe(setChats);
+
+    return () => sub.unsubscribe();
+  }, [archived]);
+
+  return chats;
+}

+ 66 - 0
src/watermelondb/features/chat/networkSync.ts

@@ -0,0 +1,66 @@
+import NetInfo from '@react-native-community/netinfo';
+import { syncChatsIncremental } from './data/chat.sync';
+
+let listenerUnsubscribe: (() => void) | null = null;
+let isSyncing = false;
+let lastSyncTime = 0;
+let wasOffline = true;
+
+const SPEED_THRESHOLDS = {
+  minDownloadMBps: 0.3,
+  maxPingMs: 1000
+};
+
+export function startAutoSyncListener(token: string) {
+  if (listenerUnsubscribe) return;
+
+  listenerUnsubscribe = NetInfo.addEventListener(async (state) => {
+    const online =
+      state.isConnected === true &&
+      (state.isInternetReachable === true || state.isInternetReachable === null);
+
+    const justCameOnline = wasOffline && online === true;
+
+    if (!online) {
+      wasOffline = true;
+      return;
+    }
+
+    if (justCameOnline) {
+      wasOffline = false;
+      await triggerSync(token);
+      return;
+    }
+
+    const now = Date.now();
+    if (now - lastSyncTime < 3 * 60 * 1000) {
+      return;
+    }
+    console.log(lastSyncTime);
+
+    wasOffline = false;
+    await triggerSync(token);
+  });
+}
+
+async function triggerSync(token: string) {
+  if (isSyncing) return;
+  isSyncing = true;
+
+  try {
+    await syncChatsIncremental(token);
+
+    lastSyncTime = Date.now();
+  } catch (e) {
+    console.error('Auto-sync error:', e);
+  } finally {
+    isSyncing = false;
+  }
+}
+
+export function stopAutoSyncListener() {
+  if (listenerUnsubscribe) {
+    listenerUnsubscribe();
+    listenerUnsubscribe = null;
+  }
+}

+ 21 - 0
src/watermelondb/index.ts

@@ -0,0 +1,21 @@
+import { Database } from '@nozbe/watermelondb';
+import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite';
+import schema from './schema';
+import Chat from './models/Chat';
+import Message from './models/Message';
+import { BlockedUser } from './models';
+import { migrations } from './migrations';
+
+const adapter = new SQLiteAdapter({
+  schema,
+  dbName: 'WatermelonDB',
+  onSetUpError: (error) => {
+    console.error('Database setup error:', error);
+  },
+  migrations
+});
+
+export const database = new Database({
+  adapter,
+  modelClasses: [Chat, Message, BlockedUser]
+});

+ 5 - 0
src/watermelondb/migrations.ts

@@ -0,0 +1,5 @@
+import { schemaMigrations, addColumns, createTable } from '@nozbe/watermelondb/Schema/migrations';
+
+export const migrations = schemaMigrations({
+  migrations: []
+});

+ 14 - 0
src/watermelondb/models/BlockedUser.ts

@@ -0,0 +1,14 @@
+import { Model } from '@nozbe/watermelondb';
+import { field } from '@nozbe/watermelondb/decorators';
+
+export default class BlockedUser extends Model {
+  static table = 'blocked_users';
+
+  @field('user_id') userId!: number;
+  @field('first_name') firstName!: string;
+  @field('last_name') lastName!: string;
+  @field('avatar') avatar?: string | null;
+  @field('removed') removed?: boolean;
+  @field('is_dirty') isDirty!: boolean;
+  @field('dirty_actions') dirtyActions!: string | null;
+}

+ 41 - 0
src/watermelondb/models/Chat.ts

@@ -0,0 +1,41 @@
+import { Model } from '@nozbe/watermelondb';
+import { field, children, date } from '@nozbe/watermelondb/decorators';
+import Message from './Message';
+
+export default class Chat extends Model {
+  static table = 'chats';
+  static associations = {
+    messages: { type: 'has_many' as const, foreignKey: 'chat_uid' }
+  };
+
+  @field('chat_uid') chatUid?: number | null;
+  @field('group_chat_token') groupChatToken?: string | null;
+
+  @field('name') name!: string;
+  @field('avatar') avatar?: string | null;
+  @field('short') short!: string;
+  @field('sent_by') sentBy!: number;
+  @field('updated') updated!: number;
+  @field('status') status!: number;
+  @field('unread_count') unreadCount!: number;
+  @field('last_message_id') lastMessageId!: number;
+  @field('pin') pin!: number;
+  @field('pin_order') pinOrder!: number;
+  @field('archive') archive!: number; // 0|1
+  @field('archive_order') archiveOrder!: number;
+  @field('attachement_name') attachmentName!: string;
+  @field('encrypted') encrypted!: number; // 0|1
+  @field('muted') muted!: number; // 0|1
+  @field('user_type') userType?: string | null; // 'normal' | 'not_exist' | 'blocked'
+
+  @field('can_send_messages') canSendMessages?: number; // 0|1
+  @field('is_admin') isAdmin?: number; // 0|1
+  @field('announcement') announcement?: number; // 0|1
+
+  @field('removed') removed?: boolean;
+  @field('is_dirty') isDirty!: boolean;
+  @field('dirty_actions') dirtyActions!: string | null;
+  @date('deleted_at') deletedAt!: Date | null;
+
+  @children('messages') messages!: Message[];
+}

+ 36 - 0
src/watermelondb/models/Message.ts

@@ -0,0 +1,36 @@
+import { Model } from '@nozbe/watermelondb';
+import { field, relation } from '@nozbe/watermelondb/decorators';
+import Chat from './Chat';
+
+export default class Message extends Model {
+  static table = 'messages';
+  static associations = {
+    chats: { type: 'belongs_to' as const, key: 'chat_uid' }
+  };
+
+  @field('composite_id') compositeId!: string;
+
+  @field('message_id') messageId!: string;
+  @field('chat_uid') chatUid?: number | null;
+  @relation('chats', 'chat_uid') chat!: Chat;
+
+  @field('sender_id') senderId!: number;
+  @field('recipient_id') recipientId!: number;
+  @field('text') text?: string;
+
+  @field('timestamp') timestamp!: number;
+  @field('received_at') receivedAt?: number | null;
+  @field('read_at') readAt?: number | null;
+  @field('status') status!: string;
+  @field('deleted') deleted?: boolean;
+
+  @field('reactions') reactions?: string;
+  @field('edits') edits?: string;
+  @field('attachments') attachments?: string;
+  @field('reply_to') replyTo?: number | null;
+  @field('encrypted') encrypted?: number;
+
+  @field('sender_name') senderName?: string | null;
+  @field('sender_avatar') senderAvatar?: string | null;
+  @field('dirty_actions') dirtyActions!: string | null;
+}

+ 5 - 0
src/watermelondb/models/index.ts

@@ -0,0 +1,5 @@
+import BlockedUser from './BlockedUser';
+import Chat from './Chat';
+import Message from './Message';
+
+export { Chat, Message, BlockedUser };

+ 84 - 0
src/watermelondb/schema.ts

@@ -0,0 +1,84 @@
+import { appSchema, tableSchema } from '@nozbe/watermelondb';
+
+export default appSchema({
+  version: 1,
+  tables: [
+    tableSchema({
+      name: 'chats',
+      columns: [
+        { name: 'chat_uid', type: 'number', isOptional: true, isIndexed: true },
+        { name: 'group_chat_token', type: 'string', isOptional: true, isIndexed: true },
+
+        { name: 'name', type: 'string' },
+        { name: 'avatar', type: 'string', isOptional: true },
+        { name: 'avatar_local', type: 'string', isOptional: true },
+        { name: 'avatar_etag', type: 'string', isOptional: true },
+        { name: 'avatar_checked_at', type: 'number', isOptional: true },
+        { name: 'short', type: 'string' },
+        { name: 'sent_by', type: 'number' },
+        { name: 'updated', type: 'number', isIndexed: true },
+        { name: 'status', type: 'number' },
+        { name: 'unread_count', type: 'number' },
+        { name: 'last_message_id', type: 'number' },
+        { name: 'pin', type: 'number' },
+        { name: 'pin_order', type: 'number' },
+        { name: 'archive', type: 'number' },
+        { name: 'archive_order', type: 'number' },
+        { name: 'attachement_name', type: 'string' },
+        { name: 'encrypted', type: 'number' },
+        { name: 'muted', type: 'number' },
+        { name: 'user_type', type: 'string', isOptional: true },
+
+        { name: 'group_avatar', type: 'string', isOptional: true },
+        { name: 'can_send_messages', type: 'number', isOptional: true },
+        { name: 'is_admin', type: 'number', isOptional: true },
+        { name: 'announcement', type: 'number', isOptional: true },
+
+        { name: 'is_dirty', type: 'boolean', isOptional: true },
+        { name: 'dirty_actions', type: 'string', isOptional: true },
+        { name: 'removed', type: 'boolean', isOptional: true }
+      ]
+    }),
+    tableSchema({
+      name: 'messages',
+      columns: [
+        { name: 'composite_id', type: 'string', isIndexed: true },
+        { name: 'message_id', type: 'string' },
+
+        { name: 'chat_uid', type: 'number', isOptional: true, isIndexed: true },
+        { name: 'sender_id', type: 'number' },
+        { name: 'recipient_id', type: 'number' },
+        { name: 'text', type: 'string', isOptional: true },
+
+        { name: 'timestamp', type: 'number', isIndexed: true },
+        { name: 'received_at', type: 'number', isOptional: true },
+        { name: 'read_at', type: 'number', isOptional: true },
+        { name: 'status', type: 'string' },
+        { name: 'deleted', type: 'boolean', isOptional: true },
+
+        { name: 'reactions', type: 'string', isOptional: true },
+        { name: 'edits', type: 'string', isOptional: true },
+        { name: 'attachments', type: 'string', isOptional: true },
+        { name: 'reply_to', type: 'number', isOptional: true },
+        { name: 'encrypted', type: 'number', isOptional: true },
+
+        { name: 'sender_name', type: 'string', isOptional: true },
+        { name: 'sender_avatar', type: 'string', isOptional: true },
+        { name: 'is_dirty', type: 'boolean', isOptional: true },
+        { name: 'dirty_actions', type: 'string', isOptional: true }
+      ]
+    }),
+    tableSchema({
+      name: 'blocked_users',
+      columns: [
+        { name: 'user_id', type: 'number', isIndexed: true },
+        { name: 'first_name', type: 'string' },
+        { name: 'last_name', type: 'string' },
+        { name: 'avatar', type: 'string', isOptional: true },
+        { name: 'removed', type: 'boolean', isOptional: true },
+        { name: 'is_dirty', type: 'boolean', isOptional: true },
+        { name: 'dirty_actions', type: 'string', isOptional: true }
+      ]
+    })
+  ]
+});

+ 3 - 1
tsconfig.json

@@ -5,6 +5,8 @@
     "baseUrl": ".",
     "paths": {
       "@api/*": ["src/modules/api/*"]
-    }
+    },
+    "experimentalDecorators": true,
+    "emitDecoratorMetadata": true
   }
 }