qt-chat-app

Unnamed repository; edit this file 'description' to name the repository.
Log | Files | Refs

commit d922690684b3ef221b21d362d1b2e1949319cdac
parent 07336a9284b182a23b3899d0b47350cc481e6430
Author: Silas Brack <silasbrack@gmail.com>
Date:   Sat, 27 Dec 2025 21:32:59 +0100

Asdf

Diffstat:
Mmain.qml | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mnetworkmanager.cpp | 71++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mnetworkmanager.h | 1+
Mqml.qrc | 1+
Mqml/components/MessageBubble.qml | 44+++++++++++++++++++++++++-------------------
Mqml/models/ChatListModel.qml | 23++++++++++++++++++++++-
Aqml/models/MessageCache.qml | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mqml/models/MessageListModel.qml | 220++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mqml/views/MessageThreadView.qml | 1+
9 files changed, 467 insertions(+), 107 deletions(-)

diff --git a/main.qml b/main.qml @@ -10,9 +10,138 @@ Window { minimumHeight: 600 title: "Beegram" + // Global message cache - inline implementation + QtObject { + id: globalMessageCache + + // Map of chatId -> array of messages + property var messagesByChat: ({}) + + function getMessages(chatId) { + if (!messagesByChat) { + messagesByChat = {} + } + if (!messagesByChat[chatId]) { + messagesByChat[chatId] = [] + } + return messagesByChat[chatId] + } + + function addMessage(chatId, messageData) { + if (!messagesByChat[chatId]) { + messagesByChat[chatId] = [] + } + + var messages = messagesByChat[chatId] + for (var i = 0; i < messages.length; i++) { + if (messages[i].id === messageData.id) { + return false + } + } + + messages.push(messageData) + messagesByChat[chatId] = messages + messageAdded(chatId, messageData) + return true + } + + function setMessages(chatId, messagesArray) { + messagesByChat[chatId] = messagesArray.slice() + } + + function prependMessages(chatId, messagesArray) { + if (!messagesByChat[chatId]) { + messagesByChat[chatId] = [] + } + messagesByChat[chatId] = messagesArray.concat(messagesByChat[chatId]) + } + + function clearMessages(chatId) { + messagesByChat[chatId] = [] + } + + signal messageAdded(int chatId, var messageData) + signal messagesFetchedForChat(int chatId, string cursor) + } + + // Listen for SSE messages and route to cache + Connections { + target: NetworkManager + function onMessageReceived(messageData) { + var chatId = messageData.chat_id + if (chatId === undefined || chatId < 0) { + return + } + + globalMessageCache.addMessage(chatId, messageData) + } + + // Handle fetched messages globally to prevent duplication + function onMessagesFetched(messages, cursor) { + if (messages.length === 0) { + return + } + + // Determine which chat these messages belong to + var chatId = messages[0].chat_id + + // Filter and transform messages for this chat only + var transformedMessages = [] + for (var i = 0; i < messages.length; i++) { + if (messages[i].chat_id !== chatId) { + continue + } + + transformedMessages.push({ + id: messages[i].id, + sender_id: messages[i].sender_id, + content: messages[i].content, + sent_at: messages[i].sent_at, + chat_id: messages[i].chat_id, + isRead: true + }) + } + + // Check if this is initial load or pagination + var existingMessages = globalMessageCache.getMessages(chatId) + if (existingMessages.length === 0) { + // Initial load: reverse and set + transformedMessages.reverse() + globalMessageCache.setMessages(chatId, transformedMessages) + } else { + // Pagination: check for duplicates before prepending + var newMessages = [] + for (var i = 0; i < transformedMessages.length; i++) { + var isDuplicate = false + for (var j = 0; j < existingMessages.length; j++) { + if (transformedMessages[i].id === existingMessages[j].id) { + isDuplicate = true + break + } + } + if (!isDuplicate) { + newMessages.push(transformedMessages[i]) + } + } + + if (newMessages.length > 0) { + newMessages.reverse() + globalMessageCache.prependMessages(chatId, newMessages) + } + } + + // Signal that messages were updated + globalMessageCache.messagesFetchedForChat(chatId, cursor) + } + } + + // Signal for when fetched messages are processed + signal messagesFetchedForChat(int chatId, string cursor) + // Simple stack-based navigation using Loader property var navigationStack: [] property var stackView: stackViewItem // Expose for child views + property var messageCache: globalMessageCache // Expose globally Component.onCompleted: { // Check for saved credentials on startup diff --git a/networkmanager.cpp b/networkmanager.cpp @@ -280,31 +280,21 @@ void NetworkManager::onPostChatParticipationFinished() void NetworkManager::fetchChats(const QString& cursor) { - // Create the GET request with properly encoded URL QUrl url("http://localhost:4000/api/records/v1/user_chats"); QUrlQuery query; // query.addQueryItem("filter[user_id][$eq]", "EbBlvV9iR_-_UumVnu2JIw=="); query.addQueryItem("limit", "15"); query.addQueryItem("order", "-created_at,-id"); - - // Add cursor parameter if provided if (!cursor.isEmpty()) { query.addQueryItem("cursor", cursor); } - url.setQuery(query); QNetworkRequest request(url); - - // Add Authorization header if token exists if (!m_authToken.isEmpty()) { request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8()); } - - // Make the GET request QNetworkReply* reply = m_manager->get(request); - - // Connect to the finished signal connect(reply, &QNetworkReply::finished, this, &NetworkManager::onFetchChatsFinished); } @@ -483,7 +473,7 @@ void NetworkManager::fetchMessages(int chatId, const QString& cursor) QUrlQuery query; query.addQueryItem("limit", "20"); query.addQueryItem("order", "-sent_at,-id"); - query.addQueryItem("chat_id", QString::number(chatId)); + query.addQueryItem("filter[chat_id][$eq]", QString::number(chatId)); // Add cursor parameter if provided if (!cursor.isEmpty()) { @@ -642,6 +632,61 @@ void NetworkManager::subscribeToChat(int chatId) resetReconnectBackoff(); } +void NetworkManager::subscribeToChats(const QVariantList& chatIds) +{ + if (chatIds.isEmpty()) return; + + // Unsubscribe from previous subscription if any + unsubscribeFromChat(); + + // Build regex pattern: (id1|id2|id3|...) + QStringList idStrings; + for (const QVariant& chatIdVariant : chatIds) { + int chatId = chatIdVariant.toInt(); + if (chatId > 0) { + idStrings.append(QString::number(chatId)); + } + } + + if (idStrings.isEmpty()) return; + + QString chatIdList = idStrings.join(","); + + // Build SSE URL with comma-separated chat IDs + QString urlStr = QString( + "http://localhost:4000/api/records/v1/message/subscribe/*" + "?chat_id=%1" + ).arg(chatIdList); + + QUrl url(urlStr); + QNetworkRequest request(url); + + // SSE requires Accept header + request.setRawHeader("Accept", "text/event-stream"); + request.setRawHeader("Cache-Control", "no-cache"); + + // Add Authorization header if token exists + if (!m_authToken.isEmpty()) { + request.setRawHeader("Authorization", QString("Bearer %1").arg(m_authToken).toUtf8()); + } + + // Create persistent GET request + m_sseReply = m_manager->get(request); + m_currentSseChat = -1; // Set to -1 to indicate multi-chat subscription + + // Connect signals + connect(m_sseReply, &QNetworkReply::readyRead, + this, &NetworkManager::onSseReadyRead); + connect(m_sseReply, &QNetworkReply::finished, + this, &NetworkManager::onSseFinished); + connect(m_sseReply, + QOverload<QNetworkReply::NetworkError>::of(&QNetworkReply::errorOccurred), + this, &NetworkManager::onSseError); + + emit sseConnected(-1); // -1 indicates multi-chat subscription + resetReconnectBackoff(); +} + void NetworkManager::unsubscribeFromChat() { if (!m_sseReply) return; @@ -750,9 +795,9 @@ void NetworkManager::parseSSEEvent(const QString& eventData) return; } - // Verify chat_id matches subscription + // Verify chat_id matches subscription (only if single chat subscription) int messageChatId = messageData.value("chat_id", -1).toInt(); - if (messageChatId != m_currentSseChat) { + if (m_currentSseChat >= 0 && messageChatId != m_currentSseChat) { qWarning() << "SSE: Message for wrong chat" << messageChatId << "expected" << m_currentSseChat; return; diff --git a/networkmanager.h b/networkmanager.h @@ -38,6 +38,7 @@ public: // SSE methods Q_INVOKABLE void subscribeToChat(int chatId); + Q_INVOKABLE void subscribeToChats(const QVariantList& chatIds); Q_INVOKABLE void unsubscribeFromChat(); signals: diff --git a/qml.qrc b/qml.qrc @@ -21,6 +21,7 @@ <!-- Models --> <file>qml/models/ChatListModel.qml</file> <file>qml/models/MessageListModel.qml</file> + <file>qml/models/MessageCache.qml</file> <file>qml/models/qmldir</file> <!-- Styles --> diff --git a/qml/components/MessageBubble.qml b/qml/components/MessageBubble.qml @@ -4,27 +4,32 @@ import "../styles" Item { id: root - height: bubbleColumn.height + 10 width: parent.width + height: childrenRect.height property var messageData: null property bool isOutgoing: messageData ? messageData.isOutgoing : false - RowLayout { - anchors.fill: parent - spacing: 8 + Item { + id: contentContainer + width: parent.width + height: childrenRect.height - // Left spacer for outgoing messages - Item { - Layout.fillWidth: !isOutgoing - visible: !isOutgoing - } + RowLayout { + width: parent.width + spacing: 8 + + // Left spacer for outgoing messages + Item { + Layout.fillWidth: !isOutgoing + visible: !isOutgoing + } - // Message content - ColumnLayout { - id: bubbleColumn - spacing: 4 - Layout.maximumWidth: root.width * 0.7 + // Message content + ColumnLayout { + id: bubbleColumn + spacing: 4 + Layout.maximumWidth: contentContainer.width * 0.7 // Attachment preview (if available) Loader { @@ -51,7 +56,7 @@ Item { wrapMode: Text.WordWrap font.pixelSize: TelegramStyle.fontSizeNormal color: TelegramStyle.textPrimary - width: Math.min(implicitWidth, root.width * 0.6) + width: Math.min(implicitWidth, contentContainer.width * 0.6) } } @@ -64,10 +69,11 @@ Item { } } - // Right spacer for incoming messages - Item { - Layout.fillWidth: isOutgoing - visible: isOutgoing + // Right spacer for incoming messages + Item { + Layout.fillWidth: isOutgoing + visible: isOutgoing + } } } diff --git a/qml/models/ChatListModel.qml b/qml/models/ChatListModel.qml @@ -10,7 +10,6 @@ Item { function addChat(serverData) { // Transform server response to chat object format - console.log("asdf", serverData.id); model.append({ id: serverData.id || 123, title: serverData.title || serverData.name || "Unknown", @@ -20,6 +19,9 @@ Item { avatar: serverData.avatar || "", isOnline: serverData.isOnline || false }) + + // Re-subscribe to include the new chat + subscribeToAllChats() } function loadChats(chats, cursor, isInitialLoad) { @@ -56,12 +58,31 @@ Item { NetworkManager.fetchChats(currentCursor) } + function subscribeToAllChats() { + // Collect all chat IDs from the model + var chatIds = [] + for (var i = 0; i < model.count; i++) { + var chatId = model.get(i).id + if (chatId > 0) { + chatIds.push(chatId) + } + } + + // Subscribe to all chats if we have any + if (chatIds.length > 0) { + NetworkManager.subscribeToChats(chatIds) + } + } + // Listen for fetched chats from server Connections { target: NetworkManager function onChatsFetched(chats, cursor) { var isInitial = (model.count === 0) root.loadChats(chats, cursor, isInitial) + + // Subscribe to all chats after loading + root.subscribeToAllChats() } } diff --git a/qml/models/MessageCache.qml b/qml/models/MessageCache.qml @@ -0,0 +1,84 @@ +import QtQuick 2.5 + +// Singleton-like persistent message cache +QtObject { + id: root + + // Map of chatId -> array of messages + property var messagesByChat: ({}) + + Component.onCompleted: { + console.log("MessageCache: Initialized") + } + + // Get messages for a specific chat + function getMessages(chatId) { + if (!messagesByChat) { + messagesByChat = {} + } + if (!messagesByChat[chatId]) { + messagesByChat[chatId] = [] + } + return messagesByChat[chatId] + } + + // Add a message to a specific chat + function addMessage(chatId, messageData) { + if (!messagesByChat[chatId]) { + messagesByChat[chatId] = [] + } + + // Check for duplicates + var messages = messagesByChat[chatId] + for (var i = 0; i < messages.length; i++) { + if (messages[i].id === messageData.id) { + console.log("MessageCache: Ignoring duplicate message", messageData.id) + return false + } + } + + console.log("MessageCache: Adding message", messageData.id, "to chat", chatId) + messages.push(messageData) + messagesByChat[chatId] = messages + return true + } + + // Set all messages for a chat (from fetch) + function setMessages(chatId, messagesArray) { + console.log("MessageCache: Setting", messagesArray.length, "messages for chat", chatId) + messagesByChat[chatId] = messagesArray.slice() // Create a copy + } + + // Prepend older messages (for pagination) + function prependMessages(chatId, messagesArray) { + if (!messagesByChat[chatId]) { + messagesByChat[chatId] = [] + } + console.log("MessageCache: Prepending", messagesArray.length, "messages to chat", chatId) + messagesByChat[chatId] = messagesArray.concat(messagesByChat[chatId]) + } + + // Clear messages for a chat + function clearMessages(chatId) { + console.log("MessageCache: Clearing messages for chat", chatId) + messagesByChat[chatId] = [] + } + + // Listen for SSE messages + Connections { + target: NetworkManager + function onMessageReceived(messageData) { + var chatId = messageData.chat_id + if (chatId === undefined || chatId < 0) { + console.warn("MessageCache: Received message without valid chat_id") + return + } + + root.addMessage(chatId, messageData) + root.messageAdded(chatId, messageData) + } + } + + // Signal when a message is added (so views can refresh) + signal messageAdded(int chatId, var messageData) +} diff --git a/qml/models/MessageListModel.qml b/qml/models/MessageListModel.qml @@ -8,39 +8,96 @@ Item { property string currentCursor: "" property bool hasMore: true property bool isLoadingMore: false + property var messageCache: null // Injected from parent + + function syncFromCache() { + if (chatId < 0 || !messageCache) return + + var cachedMessages = messageCache.getMessages(chatId) + model.clear() + + for (var i = 0; i < cachedMessages.length; i++) { + var msg = cachedMessages[i] + var senderId = msg.sender_id || "" + model.append({ + id: msg.id, + senderId: senderId, + text: msg.content, + timestamp: msg.sent_at, + isOutgoing: (senderId === NetworkManager.currentUserId), + isRead: msg.isRead !== undefined ? msg.isRead : true + }) + } + } function loadMessages(messages, cursor, isInitialLoad) { - // Clear existing messages on initial load - if (isInitialLoad) { - model.clear() - - // Initial load: DB returns newest first, so reverse to get oldest first - for (var i = messages.length - 1; i >= 0; i--) { - var message = messages[i] - var senderId = message.sender_id || "" - model.append({ - id: message.id, - senderId: senderId, - text: message.content, - timestamp: message.sent_at, - isOutgoing: (senderId === NetworkManager.currentUserId), + if (chatId < 0) return + + if (messageCache) { + // Use cache if available - FILTER messages to only include this chat + var transformedMessages = [] + for (var i = 0; i < messages.length; i++) { + // Only include messages that actually belong to this chat + if (messages[i].chat_id !== chatId) { + continue + } + + transformedMessages.push({ + id: messages[i].id, + sender_id: messages[i].sender_id, + content: messages[i].content, + sent_at: messages[i].sent_at, + chat_id: messages[i].chat_id, isRead: true }) } + + if (isInitialLoad) { + transformedMessages.reverse() + messageCache.setMessages(chatId, transformedMessages) + } else { + transformedMessages.reverse() + messageCache.prependMessages(chatId, transformedMessages) + } + + syncFromCache() } else { - // Loading more (older messages): DB returns newest first in this batch - // We want to insert them at the top, oldest of this batch first - for (var i = 0; i < messages.length; i++) { - var message = messages[i] - var senderId = message.sender_id || "" - model.insert(0, { - id: message.id, - senderId: senderId, - text: message.content || "", - timestamp: message.sent_at || "", - isOutgoing: (senderId === NetworkManager.currentUserId), - isRead: true - }) + // Fallback: load directly into model (old behavior) + if (isInitialLoad) { + model.clear() + for (var i = messages.length - 1; i >= 0; i--) { + var message = messages[i] + // Filter to only this chat + if (message.chat_id !== chatId) { + continue + } + var senderId = message.sender_id || "" + model.append({ + id: message.id, + senderId: senderId, + text: message.content, + timestamp: message.sent_at, + isOutgoing: (senderId === NetworkManager.currentUserId), + isRead: true + }) + } + } else { + for (var i = 0; i < messages.length; i++) { + var message = messages[i] + // Filter to only this chat + if (message.chat_id !== chatId) { + continue + } + var senderId = message.sender_id || "" + model.insert(0, { + id: message.id, + senderId: senderId, + text: message.content || "", + timestamp: message.sent_at || "", + isOutgoing: (senderId === NetworkManager.currentUserId), + isRead: true + }) + } } } @@ -59,31 +116,56 @@ Item { NetworkManager.fetchMessages(chatId, currentCursor) } - // Listen for fetched messages from server + // Listen for when the global cache receives fetched messages Connections { - target: NetworkManager - function onMessagesFetched(messages, cursor) { - // Only process messages if they belong to this chat - if (messages.length > 0) { - var firstMessageChatId = messages[0].chat_id - if (firstMessageChatId !== root.chatId) { - return - } + target: messageCache !== null ? messageCache : null + function onMessagesFetchedForChat(chatId, cursor) { + if (!messageCache) return + + // Only process if this is for our chat + if (chatId !== root.chatId) { + return } - var isInitial = (model.count === 0) - root.loadMessages(messages, cursor, isInitial) + // Sync from cache + syncFromCache() + + // Update pagination state + currentCursor = cursor || "" + hasMore = (cursor !== undefined && cursor !== null && cursor !== "") + isLoadingMore = false } + } - // SSE real-time message handler + // Listen for cache updates (SSE messages) + Connections { + target: messageCache !== null ? messageCache : null + function onMessageAdded(chatId, messageData) { + if (!messageCache) return + + // Only update if this is our chat + if (chatId !== root.chatId) { + return + } + + // Refresh from cache + root.syncFromCache() + } + } + + // Fallback: Listen for SSE messages directly if no cache + Connections { + target: messageCache === null ? NetworkManager : null function onMessageReceived(messageData) { + if (messageCache !== null) return + // Only process if message belongs to this chat var messageChatId = messageData.chat_id - if (messageChatId !== root.chatId) { + if (messageChatId !== root.chatId || root.chatId < 0) { return } - // Transform and add to model (append for newest at bottom) + // Transform and add to model var senderId = messageData.sender_id || "" model.append({ id: messageData.id || 0, @@ -94,47 +176,37 @@ Item { isRead: false }) } - - function onSseConnected(chatId) { - if (chatId === root.chatId) { - console.log("SSE: Connected to chat", chatId) - } - } - - function onSseDisconnected(chatId, reason) { - if (chatId === root.chatId) { - console.log("SSE: Disconnected from chat", chatId, "reason:", reason) - } - } - - function onSseError(error) { - console.error("SSE: Error:", error) - } } onChatIdChanged: { if (chatId >= 0) { - // Reset and fetch messages when chat changes - model.clear() + var hasCachedMessages = false + if (messageCache) { + // Check if we have cached messages first + var cachedMessages = messageCache.getMessages(chatId) + if (cachedMessages.length > 0) { + syncFromCache() + hasCachedMessages = true + } + } else { + // No cache, clear the model + model.clear() + } + + // Reset pagination state currentCursor = "" hasMore = true - isLoadingMore = true - // Fetch initial messages - NetworkManager.fetchMessages(chatId, "") - - // Subscribe to real-time updates - NetworkManager.subscribeToChat(chatId) - } else { - // Unsubscribe when no chat is active - NetworkManager.unsubscribeFromChat() - } - } + // Only fetch from server if we don't have cached messages + if (!hasCachedMessages) { + isLoadingMore = true + NetworkManager.fetchMessages(chatId, "") + } else { + isLoadingMore = false + } - Component.onDestruction: { - // Ensure we unsubscribe when model is destroyed - if (chatId >= 0) { - NetworkManager.unsubscribeFromChat() + // Note: No need to subscribe individually - we're already subscribed + // to all chats globally via ChatListModel.subscribeToAllChats() } } } diff --git a/qml/views/MessageThreadView.qml b/qml/views/MessageThreadView.qml @@ -15,6 +15,7 @@ Item { MessageListModel { id: messageListModel chatId: root.chatId + messageCache: typeof mainWindow !== 'undefined' ? mainWindow.messageCache : null } // Handle network responses