Viktoriia před 2 týdny
rodič
revize
1574a8f875

+ 2 - 1
src/database/index.ts

@@ -12,7 +12,7 @@ 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';
-import { dedupeChats } from 'src/watermelondb/features/chat/data/chat.repo';
+import { backfillChatKeys, dedupeChats } from 'src/watermelondb/features/chat/data/chat.repo';
 
 const lastUpdateNmRegions =
   (storage.get('lastUpdateNmRegions', StoreType.STRING) as string) || '1990-01-01';
@@ -74,6 +74,7 @@ export const updateMasterRanking = async () => {
 export async function initializeDatabase(token: string) {
   const chats = await database.get('chats').query().fetch();
   if (chats.length) {
+    await backfillChatKeys();
     await dedupeChats();
     console.log('🟢 Database already initialized');
     return;

+ 1 - 13
src/modules/api/chat/queries/use-post-get-conversation-list.tsx

@@ -4,26 +4,14 @@ import { chatQueryKeys } from '../chat-query-keys';
 import { chatApi, type PostGetChatsListReturn } from '../chat-api';
 
 import type { BaseAxiosError } from '../../../../types';
-import { storage, StoreType } from 'src/storage';
 
 export const usePostGetChatsListQuery = (token: string, archive: 0 | 1, enabled: boolean) => {
   return useQuery<PostGetChatsListReturn, BaseAxiosError>({
     queryKey: chatQueryKeys.getChatsList(token, archive),
     queryFn: async () => {
       const response = await chatApi.getChatsList(token, archive);
-      storage.set('chats', JSON.stringify(response.data.conversations));
       return response.data;
     },
-    enabled,
-    initialData: () => {
-      try {
-        const storedChats = storage.get('chats', StoreType.STRING) as string;
-        return storedChats
-          ? ({ conversations: JSON.parse(storedChats), result: 'OK' } as PostGetChatsListReturn)
-          : undefined;
-      } catch {
-        return undefined;
-      }
-    }
+    enabled
   });
 };

+ 1 - 26
src/modules/api/chat/queries/use-post-get-conversation-with.tsx

@@ -2,10 +2,8 @@ import { useQuery } from '@tanstack/react-query';
 
 import { chatQueryKeys } from '../chat-query-keys';
 import { chatApi, type PostGetChatWithReturn } from '../chat-api';
-import NetInfo from '@react-native-community/netinfo';
 
 import type { BaseAxiosError } from '../../../../types';
-import { enforceStorageLimit, saveMessagesToStorage, storage, StoreType } from 'src/storage';
 
 export const usePostGetChatWithQuery = (
   token: string,
@@ -24,31 +22,8 @@ export const usePostGetChatWithQuery = (
         previous_than_message_id
       );
 
-      const netInfoState = await NetInfo.fetch();
-
-      if (!response.data.messages.length && previous_than_message_id === -1) {
-        storage.remove(`chat_${uid}`);
-        storage.remove(`chat_${uid}_updatedAt`);
-      } else if (previous_than_message_id === -1 && netInfoState.isConnected) {
-        saveMessagesToStorage(uid, response.data.messages);
-      }
-
-      enforceStorageLimit();
-
       return response.data;
     },
-    enabled,
-    initialData: () => {
-      try {
-        const storedMessages = storage.get(`chat_${uid}`, StoreType.STRING) as string;
-        let messages = storedMessages ? JSON.parse(storedMessages) : [];
-
-        return messages.length ? ({ messages, result: 'OK' } as PostGetChatWithReturn) : undefined;
-      } catch {
-        return undefined;
-      }
-    },
-    initialDataUpdatedAt:
-      (storage.get(`chat_${uid}_updatedAt`, StoreType.NUMBER) as number) || undefined
+    enabled
   });
 };

+ 1 - 23
src/modules/api/chat/queries/use-post-get-group-conversation.tsx

@@ -4,7 +4,6 @@ import { chatQueryKeys } from '../chat-query-keys';
 import { chatApi, type PostGetGroupChatWithReturn } from '../chat-api';
 
 import type { BaseAxiosError } from '../../../../types';
-import { enforceStorageLimit, saveMessagesToStorage, storage, StoreType } from 'src/storage';
 
 export const usePostGetGroupChatQuery = (
   token: string,
@@ -28,29 +27,8 @@ export const usePostGetGroupChatQuery = (
         previous_than_message_id
       );
 
-      if (!response.data.messages.length && previous_than_message_id === -1) {
-        storage.remove(`chat_${group_token}`);
-        storage.remove(`chat_${group_token}_updatedAt`);
-      } else if (previous_than_message_id === -1) {
-        saveMessagesToStorage(group_token, response.data);
-      }
-
-      enforceStorageLimit();
       return response.data;
     },
-    enabled,
-    initialData: () => {
-      try {
-        const storedMessages = storage.get(`chat_${group_token}`, StoreType.STRING) as string;
-
-        return storedMessages
-          ? (JSON.parse(storedMessages) as PostGetGroupChatWithReturn)
-          : undefined;
-      } catch {
-        return undefined;
-      }
-    },
-    initialDataUpdatedAt:
-      (storage.get(`chat_${group_token}_updatedAt`, StoreType.NUMBER) as number) || undefined
+    enabled
   });
 };

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 406 - 447
src/screens/InAppScreens/MessagesScreen/ChatScreen/index.tsx


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

@@ -56,7 +56,9 @@ export const styles = StyleSheet.create({
     borderColor: Colors.LIGHT_GRAY,
     paddingHorizontal: 10,
     fontSize: 16,
-    marginBottom: 5
+    marginBottom: 5,
+    maxHeight: 120,
+    minHeight: 34
   },
   container: {
     flex: 1,

+ 76 - 62
src/screens/InAppScreens/MessagesScreen/Components/ReactionsListModal.tsx

@@ -1,10 +1,18 @@
 import React, { useState } from 'react';
 import { View, Text, TouchableOpacity, FlatList, StyleSheet } from 'react-native';
 import ActionSheet, { SheetManager } from 'react-native-actions-sheet';
-import { usePostUnreactToGroupMessageMutation, usePostUnreactToMessageMutation } from '@api/chat';
 import { getFontSize } from 'src/utils';
 import { Colors } from 'src/theme';
-import { CustomMessage } from '../types';
+import { CustomMessage, Reaction } from '../types';
+import { findMsgRecord } from '../ChatScreen';
+import {
+  addMessageDirtyAction,
+  triggerMessagePush
+} from 'src/watermelondb/features/chat/data/message.sync';
+import { database } from 'src/watermelondb';
+import { useNavigation } from '@react-navigation/native';
+import { NAVIGATION_PAGES } from 'src/types';
+import { findGroupMsgRecord } from '../GroupChatScreen';
 
 const ReactionsListModal = () => {
   const [reactionsData, setReactionsData] = useState<{
@@ -19,8 +27,7 @@ const ReactionsListModal = () => {
     groupToken?: string;
   } | null>(null);
 
-  const { mutateAsync: unreactToMessage } = usePostUnreactToMessageMutation();
-  const { mutateAsync: unreactToGroupMessage } = usePostUnreactToGroupMessageMutation();
+  const navigation = useNavigation();
 
   const handleSheetOpen = (
     payload: {
@@ -38,66 +45,64 @@ const ReactionsListModal = () => {
     setReactionsData(payload);
   };
 
-  const handleUnreact = () => {
+  const handleUnreact = async () => {
     if (reactionsData) {
       if (reactionsData.isGroup) {
-        unreactToGroupMessage(
-          {
-            token: reactionsData.token,
-            message_id: reactionsData.messageId,
-            group_token: reactionsData.groupToken as string
-          },
-          {
-            onSuccess: () => {
-              SheetManager.hide('reactions-list-modal');
-              reactionsData.setMessages((prevMessages: any) =>
-                prevMessages?.map((msg: any) => {
-                  if (msg._id === reactionsData.messageId) {
-                    return {
-                      ...msg,
-                      reactions: Array.isArray(msg.reactions)
-                        ? msg.reactions.filter((r: any) => r.uid !== reactionsData.currentUserId)
-                        : []
-                    };
-                  }
-                  return msg;
-                })
-              );
-              reactionsData.sendWebSocketMessage('unreact', {
-                _id: reactionsData.messageId
-              } as unknown as CustomMessage);
-            }
-          }
+        const existingMsg = await findGroupMsgRecord(
+          reactionsData.messageId,
+          reactionsData.groupToken as string
         );
+
+        if (existingMsg) {
+          const messageReactions = existingMsg.reactions ? JSON.parse(existingMsg.reactions) : null;
+          const updatedReactions: Reaction[] = Array.isArray(messageReactions)
+            ? messageReactions?.filter((r: Reaction) => r.uid !== reactionsData.currentUserId)
+            : [];
+
+          await database.write(() =>
+            existingMsg.update((r) => {
+              r.reactions = JSON.stringify(updatedReactions);
+              addMessageDirtyAction(r, {
+                type: 'unreaction'
+              });
+            })
+          );
+        }
+
+        reactionsData.sendWebSocketMessage('unreact', {
+          _id: reactionsData.messageId
+        } as unknown as CustomMessage);
+
+        SheetManager.hide('reactions-list-modal');
+        await triggerMessagePush(reactionsData.token);
       } else {
-        unreactToMessage(
-          {
-            token: reactionsData.token,
-            message_id: reactionsData.messageId,
-            conversation_with_user: reactionsData.conversation_with_user
-          },
-          {
-            onSuccess: () => {
-              SheetManager.hide('reactions-list-modal');
-              reactionsData.setMessages((prevMessages: any) =>
-                prevMessages?.map((msg: any) => {
-                  if (msg._id === reactionsData.messageId) {
-                    return {
-                      ...msg,
-                      reactions: Array.isArray(msg.reactions)
-                        ? msg.reactions.filter((r: any) => r.uid !== reactionsData.currentUserId)
-                        : []
-                    };
-                  }
-                  return msg;
-                })
-              );
-              reactionsData.sendWebSocketMessage('unreact', {
-                _id: reactionsData.messageId
-              } as unknown as CustomMessage);
-            }
-          }
+        const existingMsg = await findMsgRecord(
+          reactionsData.messageId,
+          reactionsData.conversation_with_user
         );
+
+        if (existingMsg) {
+          const messageReactions = existingMsg.reactions ? JSON.parse(existingMsg.reactions) : null;
+          const updatedReactions: Reaction[] = Array.isArray(messageReactions)
+            ? messageReactions?.filter((r: Reaction) => r.uid !== reactionsData.currentUserId)
+            : [];
+
+          await database.write(() =>
+            existingMsg.update((r) => {
+              r.reactions = JSON.stringify(updatedReactions);
+              addMessageDirtyAction(r, {
+                type: 'unreaction'
+              });
+            })
+          );
+        }
+
+        reactionsData.sendWebSocketMessage('unreact', {
+          _id: reactionsData.messageId
+        } as unknown as CustomMessage);
+
+        SheetManager.hide('reactions-list-modal');
+        await triggerMessagePush(reactionsData.token);
       }
     }
   };
@@ -117,14 +122,23 @@ const ReactionsListModal = () => {
       <View style={styles.container}>
         <FlatList
           data={reactionsData?.users || []}
-          keyExtractor={(item) => item.uid.toString()}
+          keyExtractor={(item) => item.uid?.toString()}
           renderItem={({ item }) => {
             const isUserReacted = item.uid === reactionsData?.currentUserId;
             return (
               <TouchableOpacity
                 style={styles.userItem}
-                onPress={handleUnreact}
-                disabled={!isUserReacted}
+                onPress={() => {
+                  if (isUserReacted) {
+                    handleUnreact();
+                  } else {
+                    SheetManager.hide('reactions-list-modal');
+
+                    navigation.navigate(
+                      ...([NAVIGATION_PAGES.PUBLIC_PROFILE_VIEW, { userId: item.uid }] as never)
+                    );
+                  }
+                }}
               >
                 <View style={{ gap: 2 }}>
                   <Text style={styles.userName}>{item.name}</Text>

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 386 - 458
src/screens/InAppScreens/MessagesScreen/GroupChatScreen/index.tsx


+ 187 - 87
src/screens/InAppScreens/MessagesScreen/index.tsx

@@ -28,7 +28,6 @@ import { SheetManager } from 'react-native-actions-sheet';
 import MoreModal from './Components/MoreModal';
 import SearchIcon from 'assets/icons/search.svg';
 import { storage, StoreType } from 'src/storage';
-import { usePostGetBlockedQuery, usePostGetChatsListQuery } from '@api/chat';
 import { Blocked, Chat } from './types';
 
 import PinIcon from 'assets/icons/messages/pin.svg';
@@ -45,9 +44,41 @@ 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';
+import { useMessageSearch } from 'src/watermelondb/features/chat/hooks/useMessagesSearch';
+
+export function highlightText(text: string, query: string, highlightStyle: any) {
+  if (!query.trim()) return <Text>{text}</Text>;
+
+  const lowerText = text.toLowerCase();
+  const lowerQuery = query.toLowerCase();
+
+  const parts: React.ReactNode[] = [];
+  let lastIndex = 0;
+
+  let index = lowerText.indexOf(lowerQuery);
+
+  while (index !== -1) {
+    if (index > lastIndex) {
+      parts.push(text.slice(lastIndex, index));
+    }
+
+    parts.push(
+      <Text key={index} style={highlightStyle}>
+        {text.slice(index, index + query.length)}
+      </Text>
+    );
+
+    lastIndex = index + query.length;
+    index = lowerText.indexOf(lowerQuery, lastIndex);
+  }
+
+  if (lastIndex < text.length) {
+    parts.push(text.slice(lastIndex));
+  }
+
+  return <Text>{parts}</Text>;
+}
 
 const TypingIndicator = ({ name }: { name?: string }) => {
   const [dots, setDots] = useState('');
@@ -106,6 +137,16 @@ const MessagesScreen = () => {
 
   const socket = useRef<WebSocket | null>(null);
 
+  const [debouncedSearch, setDebouncedSearch] = useState('');
+
+  useEffect(() => {
+    const t = setTimeout(() => {
+      setDebouncedSearch(search);
+    }, 300);
+
+    return () => clearTimeout(t);
+  }, [search]);
+
   const initializeSocket = () => {
     if (socket.current) {
       socket.current.close();
@@ -251,22 +292,24 @@ const MessagesScreen = () => {
     openRowRef.current = ref;
   };
 
-  useFocusEffect(() => {
-    const isKeyboardVisible = Keyboard.isVisible();
-    if (isKeyboardVisible) {
-      Keyboard.dismiss();
-    }
-    navigation.getParent()?.setOptions({
-      tabBarStyle: {
-        display: 'flex',
-        ...Platform.select({
-          android: {
-            // height: 58
-          }
-        })
+  useFocusEffect(
+    useCallback(() => {
+      const isKeyboardVisible = Keyboard.isVisible();
+      if (isKeyboardVisible) {
+        Keyboard.dismiss();
       }
-    });
-  });
+      navigation.getParent()?.setOptions({
+        tabBarStyle: {
+          display: 'flex',
+          ...Platform.select({
+            android: {
+              // height: 58
+            }
+          })
+        }
+      });
+    }, [])
+  );
 
   useFocusEffect(
     useCallback(() => {
@@ -309,91 +352,143 @@ const MessagesScreen = () => {
     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 }) => {
+  type ChatSearchResult =
+    | {
+        type: 'chat';
+        chat: Chat;
+      }
+    | {
+        type: 'message';
+        chat: Chat;
+        messageId: number;
+        messageText: string;
+        messageTime: number;
+      };
+
+  const messageSearchResults = useMessageSearch(debouncedSearch);
+
+  const searchedChats: ChatSearchResult[] = useMemo(() => {
+    if (!debouncedSearch.trim()) {
+      return filteredChatsForCurrentTab.map((chat) => ({
+        type: 'chat',
+        chat
+      }));
+    }
+
+    const q = debouncedSearch.toLowerCase();
+    const results: ChatSearchResult[] = [];
+
+    for (const chat of filteredChatsForCurrentTab) {
+      if (chat.name?.toLowerCase().includes(q) || chat.short?.toLowerCase().includes(q)) {
+        results.push({
+          type: 'chat',
+          chat
+        });
+      }
+    }
+
+    for (const m of messageSearchResults) {
+      const chat = filteredChatsForCurrentTab.find((c) => {
+        const key = c.groupChatToken ? `g:${c.groupChatToken}` : `u:${c.chatUid}`;
+        return key === m.chatKey;
+      });
+
+      if (!chat) continue;
+
+      results.push({
+        type: 'message',
+        chat,
+        messageId: m.messageId,
+        messageText: m.text,
+        messageTime: m.sentAt
+      });
+    }
+
+    return results;
+  }, [filteredChatsForCurrentTab, debouncedSearch, messageSearchResults]);
+
+  const searchFilter = (text: string) => {
+    if (text) {
+      setSearch(text);
+    } else {
+      setSearch(text);
+    }
+  };
+
+  const renderChatItem = ({ item }: { item: ChatSearchResult }) => {
     const name =
-      item.userType === 'blocked'
+      item.chat.userType === 'blocked'
         ? 'Account is blocked'
-        : item.userType === 'not_exist'
+        : item.chat.userType === 'not_exist'
           ? 'Account does not exist'
-          : item.name;
+          : item.chat.name;
+
+    const previewText = item.type === 'message' ? item.messageText : item.chat.short;
+
+    const previewTime = item.type === 'message' ? item.messageTime : item.chat.updated;
 
     return (
       <SwipeableRow
         chat={{
-          uid: item.chatUid,
-          groupToken: item.groupChatToken,
-          name: item.name,
-          avatar: item.avatar,
-          pin: item.pin,
-          archive: item.archive,
-          muted: item.muted,
-          userType: item.userType ?? 'normal',
-          announcement: item.announcement
+          uid: item.chat.chatUid,
+          groupToken: item.chat.groupChatToken,
+          name: item.chat.name,
+          avatar: item.chat.avatar,
+          pin: item.chat.pin,
+          archive: item.chat.archive,
+          muted: item.chat.muted,
+          userType: item.chat.userType ?? 'normal',
+          announcement: item.chat.announcement
         }}
         token={token}
         onRowOpen={handleRowOpen}
       >
         <TouchableHighlight
           key={
-            item.chatUid
-              ? `${item.chatUid}-${typingUsers[item.chatUid]}`
-              : `${item.groupChatToken}-${typingUsers[item.groupChatToken ?? '']}`
+            item.chat.chatUid
+              ? `${item.chat.chatUid}-${typingUsers[item.chat.chatUid]}`
+              : `${item.chat.groupChatToken}-${typingUsers[item.chat.groupChatToken ?? '']}`
           }
           activeOpacity={0.8}
           onPress={() => {
             navigation.navigate(
-              item.groupChatToken ? NAVIGATION_PAGES.GROUP_CHAT : NAVIGATION_PAGES.CHAT,
+              item.chat.groupChatToken ? NAVIGATION_PAGES.GROUP_CHAT : NAVIGATION_PAGES.CHAT,
               {
-                id: item.chatUid,
-                group_token: item.groupChatToken,
-                name: item.name,
-                avatar: item.avatar,
-                userType: item.userType ?? 'normal',
-                announcement: item.announcement
+                id: item.chat.chatUid,
+                group_token: item.chat.groupChatToken,
+                name: item.chat.name,
+                avatar: item.chat.avatar,
+                userType: item.chat.userType ?? 'normal',
+                announcement: item.chat.announcement,
+                scrollToMessageId: item?.messageId ?? null
               }
             );
           }}
           underlayColor={Colors.FILL_LIGHT}
         >
           <View style={styles.chatItem}>
-            {item.avatar && (item.userType === 'normal' || !item.userType) ? (
+            {item.chat.avatar && (item.chat.userType === 'normal' || !item.chat.userType) ? (
               <Image
                 source={{
-                  uri: item.groupChatToken
-                    ? `${API_HOST}${item.avatar}?cacheBust=${cacheKey}`
-                    : `${API_HOST}${item.avatar}`
+                  uri: item.chat.groupChatToken
+                    ? `${API_HOST}${item.chat.avatar}?cacheBust=${cacheKey}`
+                    : `${API_HOST}${item.chat.avatar}`
                 }}
                 style={styles.avatar}
                 onError={(e) => console.warn('Image error', e.nativeEvent)}
               />
-            ) : item.chatUid && (item.userType === 'normal' || !item.userType) ? (
+            ) : item.chat.chatUid && (item.chat.userType === 'normal' || !item.chat.userType) ? (
               <AvatarWithInitials
                 text={
-                  item.name
+                  item.chat.name
                     ?.split(/ (.+)/)
                     .map((n) => n[0])
                     .join('') ?? ''
                 }
-                flag={API_HOST + item?.flag}
+                flag={API_HOST + item.chat?.flag}
                 size={54}
               />
-            ) : item.userType === 'normal' || !item.userType ? (
+            ) : item.chat.userType === 'normal' || !item.chat.userType ? (
               <GroupIcon fill={Colors.DARK_BLUE} width={54} height={54} />
             ) : (
               <BanIcon fill={Colors.RED} width={54} height={54} />
@@ -404,46 +499,51 @@ const MessagesScreen = () => {
                 <Text
                   style={[
                     styles.chatName,
-                    item.userType === 'not_exist' || item.userType === 'blocked'
+                    item.chat.userType === 'not_exist' || item.chat.userType === 'blocked'
                       ? { color: Colors.RED }
                       : {}
                   ]}
                 >
-                  {name}
+                  {highlightText(name, debouncedSearch, { backgroundColor: '#FFE58A' })}
                 </Text>
 
                 <View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
-                  {item.pin === 1 ? <PinIcon height={12} fill={Colors.DARK_BLUE} /> : null}
-                  {item.muted === 1 ? <BellSlashIcon height={12} fill={Colors.DARK_BLUE} /> : null}
+                  {item.chat.pin === 1 ? <PinIcon height={12} fill={Colors.DARK_BLUE} /> : null}
+                  {item.chat.muted === 1 ? (
+                    <BellSlashIcon height={12} fill={Colors.DARK_BLUE} />
+                  ) : null}
 
-                  {item.sentBy === +currentUserId && item.status === 3 ? (
+                  {item.chat.sentBy === +currentUserId && item.chat.status === 3 ? (
                     <ReadIcon fill={Colors.DARK_BLUE} />
-                  ) : item.sentBy === +currentUserId && (item.status === 2 || item.status === 1) ? (
+                  ) : item.chat.sentBy === +currentUserId &&
+                    (item.chat.status === 2 || item.chat.status === 1) ? (
                     <UnreadIcon fill={Colors.LIGHT_GRAY} />
                   ) : null}
-                  <Text style={styles.chatTime}>{formatDate(item.updated)}</Text>
+                  <Text style={styles.chatTime}>{formatDate(previewTime)}</Text>
                 </View>
               </View>
 
               <View style={[styles.rowContainer, { flex: 1, gap: 6 }]}>
-                {item.chatUid && typingUsers[item.chatUid] ? (
+                {item.chat.chatUid && typingUsers[item.chat.chatUid] ? (
                   <TypingIndicator />
-                ) : item.groupChatToken &&
-                  typingUsers[item.groupChatToken] &&
-                  (typingUsers[item.groupChatToken] as any)?.firstName ? (
-                  <TypingIndicator name={(typingUsers[item.groupChatToken] as any).firstName} />
+                ) : item.chat.groupChatToken &&
+                  typingUsers[item.chat.groupChatToken] &&
+                  (typingUsers[item.chat.groupChatToken] as any)?.firstName ? (
+                  <TypingIndicator
+                    name={(typingUsers[item.chat.groupChatToken] as any).firstName}
+                  />
                 ) : (
                   <Text numberOfLines={2} style={styles.chatMessage}>
-                    {item.attachementName && item.attachementName.length
-                      ? item.attachementName
-                      : item.short}
+                    {item.chat.attachementName && item.chat.attachementName.length
+                      ? item.chat.attachementName
+                      : highlightText(previewText, debouncedSearch, { backgroundColor: '#FFE58A' })}
                   </Text>
                 )}
 
-                {item.unreadCount > 0 ? (
+                {item.chat.unreadCount > 0 ? (
                   <View style={styles.unreadBadge}>
                     <Text style={styles.unreadText}>
-                      {item.unreadCount > 99 ? '99+' : item.unreadCount}
+                      {item.chat.unreadCount > 99 ? '99+' : item.chat.unreadCount}
                     </Text>
                   </View>
                 ) : null}
@@ -544,7 +644,7 @@ const MessagesScreen = () => {
         </View>
       )}
 
-      {/* <View style={[{ paddingHorizontal: '4%' }]}>
+      <View style={[{ paddingHorizontal: '4%' }]}>
         <Input
           inputMode={'search'}
           placeholder={'Search'}
@@ -553,7 +653,7 @@ const MessagesScreen = () => {
           icon={<SearchIcon fill={'#C8C8C8'} width={14} height={14} />}
           height={38}
         />
-      </View> */}
+      </View>
 
       <HorizontalTabView
         index={index}
@@ -562,7 +662,7 @@ const MessagesScreen = () => {
         sceneStyles={{ paddingHorizontal: 0 }}
         maxTabHeight={50}
         renderScene={({ route }) => {
-          const data = route.key === routes[index].key ? filteredChatsForCurrentTab : [];
+          const data = route.key === routes[index].key ? searchedChats : [];
 
           return route.key === 'blocked' ? (
             <FlashList
@@ -582,9 +682,9 @@ const MessagesScreen = () => {
                 itemVisiblePercentThreshold: 50,
                 minimumViewTime: 1000
               }}
-              data={data as Chat[]}
+              data={data}
               renderItem={renderChatItem}
-              keyExtractor={(item, i) => `${item.chatUid}-${item.groupChatToken}-${i}`}
+              keyExtractor={(item, i) => `${item.chat.chatUid}-${item.chat.groupChatToken}-${i}`}
               extraData={typingUsers}
             />
           );

+ 31 - 9
src/watermelondb/features/chat/data/chat.repo.ts

@@ -53,6 +53,7 @@ export async function upsertChats(chats: ServerChat[]) {
       if (existingNow.length) {
         batch.push(
           existingNow[0].prepareUpdate((rec) => {
+            rec.chatKey = makeChatKey({ chatUid: c.uid, groupChatToken: c.group_chat_token });
             rec.name = c.name;
             rec.avatar = c.avatar ?? null;
             rec.short = c.short;
@@ -78,6 +79,7 @@ export async function upsertChats(chats: ServerChat[]) {
       } else {
         batch.push(
           chatCollection.prepareCreate((rec) => {
+            rec.chatKey = makeChatKey({ chatUid: c.uid, groupChatToken: c.group_chat_token });
             rec.chatUid = c.uid ?? null;
             rec.groupChatToken = c.group_chat_token ?? null;
 
@@ -112,6 +114,32 @@ export async function upsertChats(chats: ServerChat[]) {
   });
 }
 
+export function makeChatKey(input: { chatUid?: number | null; groupChatToken?: string | null }) {
+  if (input.chatUid) return `u:${input.chatUid}`;
+  if (input.groupChatToken) return `g:${input.groupChatToken}`;
+  throw new Error('Chat must have uid or group token');
+}
+
+export async function backfillChatKeys() {
+  const col = database.get<Chat>('chats');
+  const chats = await col.query().fetch();
+
+  await database.write(async () => {
+    for (const c of chats) {
+      if (!c.chatKey) {
+        const key = makeChatKey({
+          chatUid: c.chatUid,
+          groupChatToken: c.groupChatToken
+        });
+
+        await c.update((r) => {
+          r.chatKey = key;
+        });
+      }
+    }
+  });
+}
+
 export async function dedupeChats() {
   const chatCollection = database.get<Chat>('chats');
   const chats = await chatCollection.query().fetch();
@@ -119,16 +147,10 @@ export async function dedupeChats() {
   const map = new Map<string, Chat[]>();
 
   for (const c of chats) {
-    const key = c.chatUid
-      ? `dm:${c.chatUid}`
-      : c.groupChatToken
-        ? `group:${c.groupChatToken}`
-        : null;
-
-    if (!key) continue;
+    if (!c.chatKey) continue;
 
-    if (!map.has(key)) map.set(key, []);
-    map.get(key)!.push(c);
+    if (!map.has(c.chatKey)) map.set(c.chatKey, []);
+    map.get(c.chatKey)!.push(c);
   }
 
   await database.write(async () => {

+ 84 - 0
src/watermelondb/features/chat/data/createOptimisticMessage.ts

@@ -0,0 +1,84 @@
+import moment from 'moment';
+import { database } from 'src/watermelondb';
+import Message from 'src/watermelondb/models/Message';
+import { addMessageDirtyAction, makeChatKey } from './message.sync';
+
+type CreateOptimisticParams = {
+  chatUid?: number;
+  groupToken?: string;
+  currentUserId: number;
+  text?: string;
+  uiAttachment?: any | null;
+  sendAttachment?: any | null;
+  replyMessage?: any | null;
+};
+
+export async function createOptimisticMessage({
+  chatUid,
+  groupToken,
+  currentUserId,
+  text = '',
+  uiAttachment = null,
+  sendAttachment = null,
+  replyMessage = null
+}: CreateOptimisticParams) {
+  const tempId = Date.now() * -1;
+  const chatKey = makeChatKey({ chatUid, groupChatToken: groupToken });
+  const compositeId = `${chatKey}:${tempId}`;
+
+  const col = database.get<Message>('messages');
+
+  await database.write(async () => {
+    const batch: any[] = [];
+
+    batch.push(
+      col.prepareCreate((r) => {
+        r.chatKey = chatKey;
+        r.isGroup = chatUid ? false : true;
+
+        r.messageId = tempId;
+        r.compositeId = compositeId;
+
+        r.sentAt = moment().utc().format('YYYY-MM-DD HH:mm:ss');
+        r.receivedAt = null;
+        r.readAt = null;
+
+        r.senderId = currentUserId;
+        r.recipientId = chatUid!;
+
+        r.text = text;
+        r.status = 1;
+        r.isSending = true;
+
+        r.reactions = '{}';
+        r.edits = '{}';
+
+        r.attachment = uiAttachment ? JSON.stringify(uiAttachment) : null;
+        r.encrypted = 0;
+
+        if (replyMessage) {
+          r.replyToId = replyMessage.id;
+          r.replyTo = JSON.stringify(replyMessage);
+        } else {
+          r.replyToId = -1;
+          r.replyTo = null;
+        }
+
+        addMessageDirtyAction(r, {
+          type: 'send',
+          value: {
+            text,
+            currentUid: currentUserId,
+            attachment: sendAttachment ?? -1,
+            reply_to_id: replyMessage ? replyMessage.id : -1,
+            replyMessage
+          }
+        });
+      })
+    );
+
+    await database.batch(batch);
+  });
+
+  return tempId;
+}

+ 45 - 139
src/watermelondb/features/chat/data/importChatsFromServer.ts

@@ -3,127 +3,51 @@ 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 { makeChatKey, upsertChats } from './chat.repo';
 import { upsertBlockedUsers } from './blocked.repo';
 import { chatApi } from '@api/chat';
 import { createLocalBackup } from 'src/watermelondb/backup';
+import { normalizeServerMessage } from './message.sync';
 
 const CONCURRENCY = 5;
 const BATCH_LIMIT = 500;
 
-async function fetchChatMessages(
+export async function importAllMessagesForChat(
   token: string,
-  chat: { uid: number | null; group_chat_token: string | null }
+  params: { chatUid?: number; groupToken?: string }
 ) {
-  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 };
+  const isGroup = Boolean(params.groupToken);
+  const chatKey = makeChatKey(params);
+
+  const res = isGroup
+    ? await chatApi.getGroupChatAll(token, params.groupToken!)
+    : await chatApi.getChatWithAll(token, params.chatUid!);
+
+  const col = database.get<Message>('messages');
+
+  await database.write(async () => {
+    const batch: any[] = [];
+    const existing = await col.query(Q.where('chat_key', chatKey)).fetch();
+
+    for (const m of existing) {
+      await m.destroyPermanently();
     }
-  } 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 s of res?.data?.messages ?? []) {
+      const data = normalizeServerMessage(s, chatKey, isGroup);
+      batch.push(
+        col.prepareCreate((m) => {
+          Object.assign(m, data);
+          m.dirtyActions = null;
+          m.isDirty = false;
+        })
+      );
+    }
 
-  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}`);
-      }
-    });
-  }
+    if (batch.length) {
+      await database.batch(...batch);
+    }
+  });
 }
 
 async function fetchChatsList(token: string, archive: 0 | 1) {
@@ -149,36 +73,18 @@ export async function importChatsFromServer(token: string) {
     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);
+    const limit = pLimit(CONCURRENCY);
+    const tasks = conversations.map((chat) =>
+      limit(async () => {
+        await importAllMessagesForChat(token, {
+          chatUid: chat.uid ?? undefined,
+          groupToken: chat.group_chat_token ?? undefined
+        });
+      })
+    );
+
+    console.log(`Running ${tasks.length} imports with concurrency=${CONCURRENCY}`);
+    await Promise.all(tasks);
 
     // console.log('💾 Creating local backup...');
     // await createLocalBackup();

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

@@ -0,0 +1,548 @@
+import { Q } from '@nozbe/watermelondb';
+import { database } from 'src/watermelondb';
+import Message from 'src/watermelondb/models/Message';
+import { chatApi } from '@api/chat';
+import NetInfo from '@react-native-community/netinfo';
+import { testConnectionSpeed } from 'src/database/speedService';
+
+export function makeChatKey(params: { chatUid?: number | null; groupChatToken?: string | null }) {
+  if (params.chatUid) return `u:${params.chatUid}`;
+  if (params.groupChatToken) return `g:${params.groupChatToken}`;
+  throw new Error('Invalid chat identity');
+}
+
+function now() {
+  return Date.now();
+}
+
+export type MessageDirtyAction =
+  | {
+      type: 'send';
+      value: {
+        text: string;
+        currentUid: number;
+        attachment?: any;
+        reply_to_id?: number;
+        replyMessage?: any;
+      };
+      ts: number;
+    }
+  | { type: 'edit'; value: { text: string }; ts: number }
+  | { type: 'delete'; ts: number }
+  | { type: 'read'; value: { messagesIds: number[] }; ts: number }
+  | { type: 'reaction'; value: string; ts: number }
+  | { type: 'unreaction'; value: string; ts: number };
+
+export function addMessageDirtyAction(msg: Message, action: Omit<MessageDirtyAction, 'ts'>) {
+  const list: MessageDirtyAction[] = msg.dirtyActions ? JSON.parse(msg.dirtyActions) : [];
+
+  list.push({ ...action, ts: now() });
+
+  msg.isDirty = true;
+  msg.dirtyActions = JSON.stringify(list);
+}
+
+export function compactMessageActions(actions: MessageDirtyAction[]): MessageDirtyAction[] {
+  if (!actions.length) return [];
+
+  const sorted = [...actions].sort((a, b) => a.ts - b.ts);
+  const res: MessageDirtyAction[] = [];
+
+  for (const a of sorted) {
+    const last = res[res.length - 1];
+
+    if (a.type === 'delete') {
+      return [a];
+    }
+
+    if (a.type === 'edit' && last?.type === 'edit') {
+      last.value = a.value;
+      last.ts = a.ts;
+      continue;
+    }
+
+    if (
+      (last?.type === 'reaction' && a.type === 'unreaction') ||
+      (last?.type === 'unreaction' && a.type === 'reaction')
+    ) {
+      res.pop();
+      continue;
+    }
+
+    if (a.type === 'reaction' && last?.type === 'reaction') {
+      last.value = a.value;
+      last.ts = a.ts;
+      continue;
+    }
+
+    if (a.type === 'read' && last?.type === 'read') {
+      last.ts = a.ts;
+      continue;
+    }
+
+    res.push(a);
+  }
+
+  return res;
+}
+
+async function performMessageAction(token: string, msg: Message, action: MessageDirtyAction) {
+  const isGroup = Boolean(msg.isGroup);
+  const chatKey = msg.chatKey;
+  if (action.type !== 'send' && msg.messageId == null) {
+    throw new Error('Message has no server id yet');
+  }
+
+  switch (action.type) {
+    case 'send': {
+      if (isGroup) {
+        const res = await chatApi.sendGroupMessage({
+          token,
+          to_group_token: chatKey.slice(2),
+          text: action.value.text,
+          attachment: action.value.attachment,
+          reply_to_id: action.value.reply_to_id
+        });
+
+        return {
+          newId: res.data.message_id,
+          attachment: res.data.attachment,
+          wsEvent: {
+            action: 'new_message',
+            payload: {
+              message: {
+                _id: res.data.message_id,
+                text: action.value.text,
+                replyMessage: action.value.replyMessage,
+                attachment: res.data.attachment ? res.data.attachment : -1
+              }
+            }
+          }
+        };
+      } else {
+        const res = await chatApi.sendMessage({
+          token,
+          to_uid: Number(chatKey.slice(2)),
+          text: action.value.text,
+          attachment: action.value.attachment,
+          reply_to_id: action.value.reply_to_id
+        });
+
+        return {
+          newId: res.data.message_id,
+          attachment: res.data.attachment,
+          wsEvent: {
+            action: 'new_message',
+            payload: {
+              message: {
+                _id: res.data.message_id,
+                text: action.value.text,
+                replyMessage: action.value.replyMessage,
+                attachment: res.data.attachment ? res.data.attachment : -1
+              }
+            }
+          }
+        };
+      }
+    }
+
+    case 'edit': {
+      if (isGroup) {
+        await chatApi.editGroupMessage({
+          token,
+          group_token: chatKey.slice(2),
+          message_id: msg.messageId!,
+          text: action.value.text
+        });
+      } else {
+        await chatApi.editMessage({
+          token,
+          to_uid: Number(chatKey.slice(2)),
+          message_id: msg.messageId!,
+          text: action.value.text
+        });
+      }
+      return {};
+    }
+
+    case 'delete': {
+      if (isGroup) {
+        await chatApi.deleteGroupMessage({
+          token,
+          group_token: chatKey.slice(2),
+          message_id: msg.messageId!
+        });
+      } else {
+        await chatApi.deleteMessage({
+          token,
+          conversation_with_user: Number(chatKey.slice(2)),
+          message_id: msg.messageId!
+        });
+      }
+      return { deleted: true };
+    }
+
+    case 'reaction': {
+      if (isGroup) {
+        await chatApi.reactToGroupMessage({
+          token,
+          group_token: chatKey.slice(2),
+          message_id: msg.messageId!,
+          reaction: action.value
+        });
+      } else {
+        await chatApi.reactToMessage({
+          token,
+          conversation_with_user: Number(chatKey.slice(2)),
+          message_id: msg.messageId!,
+          reaction: action.value
+        });
+      }
+      return {};
+    }
+
+    case 'unreaction': {
+      if (isGroup) {
+        await chatApi.unreactToGroupMessage({
+          token,
+          group_token: chatKey.slice(2),
+          message_id: msg.messageId!
+        });
+      } else {
+        await chatApi.unreactToMessage({
+          token,
+          conversation_with_user: Number(chatKey.slice(2)),
+          message_id: msg.messageId!
+        });
+      }
+      return {};
+    }
+
+    case 'read': {
+      if (isGroup) {
+        await chatApi.groupMessagesRead({
+          token,
+          group_token: chatKey.slice(2),
+          messages_id: action.value.messagesIds
+        });
+      } else {
+        await chatApi.messagesRead({
+          token,
+          from_user: Number(chatKey.slice(2)),
+          messages_id: action.value.messagesIds
+        });
+      }
+      return {};
+    }
+  }
+}
+export async function upsertMessagesIntoDB({
+  chatUid,
+  groupToken,
+  apiMessages,
+  avatar = null,
+  name = ''
+}: {
+  chatUid?: number;
+  groupToken?: string;
+  apiMessages: any[];
+  avatar?: string | null;
+  name?: string | null;
+}) {
+  if (!apiMessages?.length) return;
+  const chatKey = makeChatKey({ chatUid, groupChatToken: groupToken });
+  const isGroup = Boolean(groupToken);
+
+  const col = database.get<Message>('messages');
+
+  await database.write(async () => {
+    const batch: any[] = [];
+
+    for (const msg of apiMessages) {
+      const compositeId = `${chatKey}:${msg.id}`;
+
+      const existing = await col
+        .query(Q.where('chat_key', chatKey), Q.where('message_id', msg.id))
+        .fetch();
+
+      if (existing.length) {
+        const record = existing[0];
+        const hasDirty = Boolean(msg.dirtyActions);
+
+        batch.push(
+          record.prepareUpdate((r) => {
+            r.messageId = msg.id;
+            r.sentAt = msg.sent_datetime;
+            r.receivedAt = msg.received_datetime ?? r.receivedAt;
+            r.readAt = msg.read_datetime ?? r.readAt;
+
+            if (!hasDirty) {
+              r.text = msg.text;
+            }
+
+            if (msg.attachement && msg.attachement !== -1) {
+              const prev = r.attachment ? JSON.parse(r.attachment) : {};
+              r.attachment = JSON.stringify({
+                ...msg.attachement,
+                local_uri: prev?.local_uri ?? null
+              });
+            } else {
+              r.attachment = null;
+            }
+
+            r.status = msg.status;
+            r.isSending = false;
+            r.replyToId = msg.reply_to_id;
+            r.replyTo = msg.reply_to ? JSON.stringify(msg.reply_to) : null;
+
+            if (avatar && groupToken) {
+              r.senderAvatar = avatar;
+            } else {
+              r.senderAvatar = msg.sender_avatar ?? null;
+            }
+            if (name && groupToken) {
+              r.senderName = name;
+            } else {
+              r.senderName = msg.sender_name ?? '';
+            }
+
+            r.reactions = msg.reactions ?? '{}';
+            r.edits = msg.edits ?? '{}';
+            (r as any)._raw._status = 'synced';
+            (r as any)._raw._changed = '';
+          })
+        );
+      } else {
+        batch.push(
+          col.prepareCreate((r) => {
+            r.chatKey = chatKey;
+            r.isGroup = isGroup;
+
+            r.messageId = msg.id;
+            r.compositeId = compositeId;
+
+            r.sentAt = msg.sent_datetime;
+            r.receivedAt = msg.received_datetime ?? null;
+            r.readAt = msg.read_datetime ?? null;
+
+            r.senderId = msg.sender;
+            r.recipientId = msg.recipient;
+
+            r.text = msg.text;
+            r.status = msg.status;
+            r.isSending = false;
+
+            r.reactions = msg.reactions ?? '{}';
+            r.edits = msg.edits ?? '{}';
+
+            r.attachment =
+              msg.attachement && msg.attachement !== -1 ? JSON.stringify(msg.attachement) : null;
+
+            r.encrypted = msg.encrypted ?? 0;
+            r.replyToId = msg.reply_to_id ?? -1;
+            r.replyTo = msg.reply_to ? JSON.stringify(msg.reply_to) : null;
+
+            if (avatar && groupToken) {
+              r.senderAvatar = avatar;
+            } else {
+              r.senderAvatar = msg.sender_avatar ?? null;
+            }
+            if (name && groupToken) {
+              r.senderName = name;
+            } else {
+              r.senderName = msg.sender_name ?? '';
+            }
+
+            r.isDirty = false;
+            r.dirtyActions = null;
+
+            (r as any)._raw._status = 'synced';
+            (r as any)._raw._changed = '';
+          })
+        );
+      }
+    }
+
+    if (batch.length) {
+      await database.batch(batch);
+    }
+  });
+}
+
+export async function reconcileChatRange(
+  chatKey: string,
+  serverMessages: any[],
+  isLatest: boolean
+) {
+  if (!serverMessages.length) return;
+
+  const col = database.get<Message>('messages');
+
+  if (serverMessages.length === 1 && serverMessages[0].status === 4 && isLatest) {
+    const keepCompositeId = `${chatKey}:${serverMessages[0].id}`;
+
+    const local = await col.query(Q.where('chat_key', chatKey)).fetch();
+
+    await database.write(async () => {
+      for (const msg of local) {
+        if (msg.compositeId !== keepCompositeId) {
+          await msg.destroyPermanently();
+        }
+      }
+    });
+
+    return;
+  }
+
+  const serverIds = new Set(serverMessages.map((m) => m.id));
+
+  const minId = Math.min(...serverMessages.map((m) => m.id));
+  const maxId = Math.max(...serverMessages.map((m) => m.id));
+
+  const local = await col
+    .query(Q.where('chat_key', chatKey), Q.where('message_id', Q.between(minId, maxId)))
+    .fetch();
+
+  await database.write(async () => {
+    for (const msg of local) {
+      if (msg.messageId && msg.messageId > 0 && !serverIds.has(msg.messageId) && !msg.isDirty) {
+        await msg.destroyPermanently();
+      }
+    }
+  });
+}
+
+export type OutgoingWsEvent = {
+  action: string;
+  payload: Record<string, any>;
+};
+
+export async function pushMessageChanges(
+  token: string,
+  onWsEvent?: (event: OutgoingWsEvent) => void
+) {
+  const col = database.get<Message>('messages');
+
+  const dirty = await col.query(Q.where('is_dirty', true)).fetch();
+  if (!dirty.length) return;
+
+  for (const msg of dirty) {
+    const raw = (msg as any)._raw;
+    const actions: MessageDirtyAction[] = msg.dirtyActions ? JSON.parse(msg.dirtyActions) : [];
+
+    const compacted = compactMessageActions(actions);
+
+    for (const a of compacted) {
+      const res = await performMessageAction(token, msg, a);
+
+      await database.write(async () => {
+        if (res?.newId) {
+          const duplicates = await col
+            .query(Q.where('chat_key', msg.chatKey), Q.where('message_id', res.newId))
+            .fetch();
+
+          for (const d of duplicates) {
+            if (d.id !== msg.id) {
+              await d.destroyPermanently();
+            }
+          }
+        }
+
+        msg.update((m) => {
+          if (res?.newId) {
+            m.messageId = res.newId;
+            m.compositeId = `${msg.chatKey}:${res.newId}`;
+            m.status = 1;
+            m.isSending = false;
+          }
+
+          if (res?.attachment) {
+            const prev = m.attachment ? JSON.parse(m.attachment) : {};
+            m.attachment = JSON.stringify({
+              ...res.attachment,
+              local_uri: prev?.local_uri ?? null
+            });
+
+            // if (res.attachment.filetype === 'nomadmania/location') {
+            //   const locationUri = a?.value?.attachment?.uri;
+            //   // await FileSystem.deleteAsync(locationUri);
+            // }
+          }
+          if (res?.wsEvent && onWsEvent) {
+            onWsEvent(res.wsEvent);
+          }
+
+          m.dirtyActions = null;
+          m.isDirty = false;
+
+          (m as any)._raw._status = 'synced';
+          (m as any)._raw._changed = '';
+        });
+      });
+    }
+  }
+}
+
+let pushInFlight = false;
+let needsAnotherRun = false;
+
+export async function triggerMessagePush(
+  token: string,
+  onWsEvent?: (event: OutgoingWsEvent) => void
+) {
+  if (pushInFlight) {
+    needsAnotherRun = true;
+    return;
+  }
+
+  pushInFlight = true;
+
+  try {
+    do {
+      needsAnotherRun = false;
+      await pushMessageChanges(token, onWsEvent);
+    } while (needsAnotherRun);
+  } finally {
+    pushInFlight = false;
+  }
+}
+
+export function normalizeServerMessage(s: any, chatKey: string, isGroup: boolean) {
+  return {
+    messageId: s.id,
+    compositeId: `${chatKey}:${s.id}`,
+    chatKey,
+    isGroup,
+    senderId: s.sender,
+    recipientId: s.recipient,
+    text: s.text,
+    sentAt: s.sent_datetime,
+    receivedAt: s.received_datetime,
+    readAt: s.read_datetime,
+    status: s.status,
+    reactions: s.reactions,
+    edits: s.edits,
+    attachment: s.attachement !== -1 ? JSON.stringify(s.attachement) : null,
+    replyToId: s.reply_to_id ?? null,
+    replyTo: s.reply_to_id && s.reply_to_id !== -1 ? JSON.stringify(s.reply_to) : null,
+    encrypted: s.encrypted,
+    senderName: s.sender_name ?? null,
+    senderAvatar: s.sender_avatar ?? null,
+    isSending: false
+  };
+}
+
+export async function syncMessagesIncremental(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 pushMessageChanges(token);
+}

+ 84 - 9
src/watermelondb/features/chat/hooks/useChatThread.ts

@@ -1,19 +1,94 @@
 import { useEffect, useState } from 'react';
-import { database } from 'src/watermelondb';
 import { Q } from '@nozbe/watermelondb';
-import { Message } from 'src/watermelondb/models';
+import { database } from 'src/watermelondb';
+import Message from 'src/watermelondb/models/Message';
+import { makeChatKey } from '../data/message.sync';
 
-export function useChatThreadLive(chatUid: number, limit = 50) {
+export function useMessagesLive(params: {
+  chatUid?: number;
+  groupChatToken?: string;
+  limit: number;
+  aroundMessageId?: number;
+}) {
   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))
+    const chatKey = makeChatKey({ chatUid: params.chatUid, groupChatToken: params.groupChatToken });
+    const col = database.get<Message>('messages');
+    let unsubBefore: any;
+    let unsubAfter: any;
+
+    if (!params.aroundMessageId) {
+      const sub = col
+        .query(Q.where('chat_key', chatKey), Q.sortBy('sent_at', Q.desc), Q.take(params.limit))
+        .observeWithColumns([
+          'text',
+          'status',
+          'reactions',
+          'edits',
+          'reply_to_id',
+          'attachment',
+          'is_sending'
+        ])
+        .subscribe(setMessages);
+
+      return () => sub.unsubscribe();
+    }
+    const targetId = params.aroundMessageId;
+    const beforeLimit = 50;
+    const afterLimit = 50;
+
+    let before: Message[] = [];
+    let after: Message[] = [];
+
+    const emit = () => {
+      const merged = [...before, ...after];
+
+      const map = new Map<number, Message>();
+      merged.forEach((m) => {
+        if (m.messageId != null) {
+          map.set(m.messageId, m);
+        }
+      });
+
+      const sorted = Array.from(map.values()).sort(
+        (a, b) => new Date(b.sentAt).getTime() - new Date(a.sentAt).getTime()
+      );
+
+      setMessages(sorted);
+    };
+
+    unsubBefore = col
+      .query(
+        Q.where('chat_key', chatKey),
+        Q.where('message_id', Q.lte(targetId)),
+        Q.sortBy('sent_at', Q.desc),
+        Q.take(beforeLimit)
+      )
+      .observe()
+      .subscribe((rows) => {
+        before = rows;
+        emit();
+      });
+
+    unsubAfter = col
+      .query(
+        Q.where('chat_key', chatKey),
+        Q.where('message_id', Q.gt(targetId)),
+        Q.sortBy('sent_at', Q.asc),
+        Q.take(afterLimit)
+      )
       .observe()
-      .subscribe(setMessages);
+      .subscribe((rows) => {
+        after = rows;
+        emit();
+      });
 
-    return () => sub.unsubscribe();
-  }, [chatUid, limit]);
+    return () => {
+      unsubBefore?.unsubscribe();
+      unsubAfter?.unsubscribe();
+    };
+  }, [params.chatUid, params.groupChatToken, params.aroundMessageId]);
 
   return messages;
 }

+ 49 - 0
src/watermelondb/features/chat/hooks/useMessagesSearch.ts

@@ -0,0 +1,49 @@
+import { Q } from '@nozbe/watermelondb';
+import { useEffect, useState } from 'react';
+import { database } from 'src/watermelondb';
+import Message from 'src/watermelondb/models/Message';
+
+export function useMessageSearch(search: string) {
+  const [results, setResults] = useState<
+    {
+      chatKey: string;
+      messageId: number;
+      text: string;
+      sentAt: number;
+    }[]
+  >([]);
+
+  useEffect(() => {
+    let cancelled = false;
+
+    async function run() {
+      if (!search.trim()) {
+        setResults([]);
+        return;
+      }
+
+      const rows = await database
+        .get<Message>('messages')
+        .query(Q.where('text', Q.like(`%${search}%`)), Q.sortBy('sent_at', Q.desc), Q.take(100))
+        .fetch();
+
+      if (cancelled) return;
+
+      setResults(
+        rows.map((m) => ({
+          chatKey: m.chatKey,
+          messageId: m.messageId!,
+          text: m.text ?? '',
+          sentAt: m.sentAt as any
+        }))
+      );
+    }
+
+    run();
+    return () => {
+      cancelled = true;
+    };
+  }, [search]);
+
+  return results;
+}

+ 26 - 2
src/watermelondb/migrations.ts

@@ -1,5 +1,29 @@
-import { schemaMigrations, addColumns, createTable } from '@nozbe/watermelondb/Schema/migrations';
+import { schemaMigrations } from '@nozbe/watermelondb/Schema/migrations';
 
 export const migrations = schemaMigrations({
-  migrations: []
+  migrations: [
+    {
+      toVersion: 3,
+      steps: [
+        {
+          type: 'add_columns',
+          table: 'messages',
+          columns: [
+            { name: 'chat_key', type: 'string', isOptional: true },
+            { name: 'is_group', type: 'boolean' },
+            { name: 'reply_to_id', type: 'number', isOptional: true },
+            { name: 'reply_to', type: 'string', isOptional: true },
+            { name: 'attachment', type: 'string', isOptional: true },
+            { name: 'is_sending', type: 'boolean', isOptional: true },
+            { name: 'sent_at', type: 'string' }
+          ]
+        },
+        {
+          type: 'add_columns',
+          table: 'chats',
+          columns: [{ name: 'chat_key', type: 'string', isOptional: true }]
+        }
+      ]
+    }
+  ]
 });

+ 10 - 7
src/watermelondb/models/Chat.ts

@@ -4,10 +4,13 @@ import Message from './Message';
 
 export default class Chat extends Model {
   static table = 'chats';
+
   static associations = {
-    messages: { type: 'has_many' as const, foreignKey: 'chat_uid' }
+    messages: { type: 'has_many' as const, foreignKey: 'chat_key' }
   };
 
+  @field('chat_key') chatKey!: string | null;
+
   @field('chat_uid') chatUid?: number | null;
   @field('group_chat_token') groupChatToken?: string | null;
 
@@ -21,16 +24,16 @@ export default class Chat extends Model {
   @field('last_message_id') lastMessageId!: number;
   @field('pin') pin!: number;
   @field('pin_order') pinOrder!: number;
-  @field('archive') archive!: number; // 0|1
+  @field('archive') archive!: number;
   @field('archive_order') archiveOrder!: number;
   @field('attachement_name') attachmentName!: string;
-  @field('encrypted') encrypted!: number; // 0|1
-  @field('muted') muted!: number; // 0|1
+  @field('encrypted') encrypted!: number;
+  @field('muted') muted!: number;
   @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('can_send_messages') canSendMessages?: number;
+  @field('is_admin') isAdmin?: number;
+  @field('announcement') announcement?: number;
 
   @field('removed') removed?: boolean;
   @field('is_dirty') isDirty!: boolean;

+ 18 - 14
src/watermelondb/models/Message.ts

@@ -4,33 +4,37 @@ import Chat from './Chat';
 
 export default class Message extends Model {
   static table = 'messages';
+
   static associations = {
-    chats: { type: 'belongs_to' as const, key: 'chat_uid' }
+    chats: { type: 'belongs_to' as const, key: 'chat_key' }
   };
 
-  @field('composite_id') compositeId!: string;
+  @field('chat_key') chatKey!: string;
 
-  @field('message_id') messageId!: string;
-  @field('chat_uid') chatUid?: number | null;
-  @relation('chats', 'chat_uid') chat!: Chat;
+  @field('composite_id') compositeId!: string;
+  @field('message_id') messageId!: number;
+  @field('is_group') isGroup!: boolean;
+  @relation('chats', 'chat_key') 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('sent_at') sentAt!: string;
+  @field('received_at') receivedAt?: string | null;
+  @field('read_at') readAt?: string | null;
+  @field('status') status!: number;
 
-  @field('reactions') reactions?: string;
-  @field('edits') edits?: string;
-  @field('attachments') attachments?: string;
-  @field('reply_to') replyTo?: number | null;
+  @field('reactions') reactions?: string | null;
+  @field('edits') edits?: string | null;
+  @field('attachment') attachment?: string | null;
+  @field('reply_to_id') replyToId?: number | null;
+  @field('reply_to') replyTo?: string | null;
   @field('encrypted') encrypted?: number;
 
   @field('sender_name') senderName?: string | null;
   @field('sender_avatar') senderAvatar?: string | null;
+  @field('is_dirty') isDirty!: boolean;
   @field('dirty_actions') dirtyActions!: string | null;
+  @field('is_sending') isSending?: boolean;
 }

+ 11 - 8
src/watermelondb/schema.ts

@@ -1,11 +1,12 @@
 import { appSchema, tableSchema } from '@nozbe/watermelondb';
 
 export default appSchema({
-  version: 1,
+  version: 3,
   tables: [
     tableSchema({
       name: 'chats',
       columns: [
+        { name: 'chat_key', type: 'string', isOptional: true },
         { name: 'chat_uid', type: 'number', isOptional: true, isIndexed: true },
         { name: 'group_chat_token', type: 'string', isOptional: true, isIndexed: true },
 
@@ -42,24 +43,26 @@ export default appSchema({
     tableSchema({
       name: 'messages',
       columns: [
+        { name: 'chat_key', type: 'string', isOptional: true },
+        { name: 'is_group', type: 'boolean' },
+        { name: 'reply_to_id', type: 'number', isOptional: true },
+        { name: 'is_sending', type: 'boolean', isOptional: true },
         { name: 'composite_id', type: 'string', isIndexed: true },
-        { name: 'message_id', type: 'string' },
+        { name: 'message_id', type: 'number', isOptional: true },
 
-        { 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: 'sent_at', type: 'string' },
         { 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: 'status', type: 'number' },
 
         { 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: 'attachment', type: 'string', isOptional: true },
+        { name: 'reply_to', type: 'string', isOptional: true },
         { name: 'encrypted', type: 'number', isOptional: true },
 
         { name: 'sender_name', type: 'string', isOptional: true },

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů