commit d922690684b3ef221b21d362d1b2e1949319cdac
parent 07336a9284b182a23b3899d0b47350cc481e6430
Author: Silas Brack <silasbrack@gmail.com>
Date: Sat, 27 Dec 2025 21:32:59 +0100
Asdf
Diffstat:
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