qt-chat-app

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

commit 2ae0c6e07e0ea9ffd660c332d772cf8c9e7f3789
parent 5cc5ac6f1da2ce992b2f96ae2728332bfe5229e5
Author: Silas Brack <silasbrack@gmail.com>
Date:   Fri, 26 Dec 2025 16:57:55 +0100

Add realtime SSE subscriptions on chats

Diffstat:
MCMakeLists.txt | 2+-
AINSTALL.md | 104+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mderivation_demo.nix | 43+++++++++++++++++++++++++++++++++++++++++--
Mflake.lock | 12+++++++-----
Mflake.nix | 44++++++++++++++++++++++++++++++++++++++------
Mmain.cpp | 4++--
Mnetworkmanager.cpp | 43++++++++++++++++++++++++-------------------
Mnetworkmanager.h | 4++++
Mqml/models/MessageListModel.qml | 50+++++++++++++++++++++++++++++---------------------
Mqml/views/MessageThreadView.qml | 13+------------
10 files changed, 251 insertions(+), 68 deletions(-)

diff --git a/CMakeLists.txt b/CMakeLists.txt @@ -1,4 +1,4 @@ -cmake_minimum_required(VERSION 3.1.0) +cmake_minimum_required(VERSION 3.5.0) project(demo VERSION 1.0.0 LANGUAGES CXX) diff --git a/INSTALL.md b/INSTALL.md @@ -0,0 +1,104 @@ +# Installation Guide + +## Prerequisites + +Make sure you have Nix installed with flake support enabled. If not: + +```bash +# Install Nix (if not already installed) +curl -L https://nixos.org/nix/install | sh + +# Enable flakes (add to ~/.config/nix/nix.conf or /etc/nix/nix.conf) +experimental-features = nix-command flakes +``` + +## Building the Application + +### Option 1: Build and Run Directly + +```bash +# Build the application +nix build + +# The app will be in ./result/Applications/telegram-clone.app/ +# Run it directly +./result/Applications/telegram-clone.app/Contents/MacOS/demo +``` + +### Option 2: Install to User Profile + +```bash +# Install to your Nix profile +nix profile install . + +# The app will be available in ~/.nix-profile/Applications/ +``` + +### Option 3: Install to /Applications + +```bash +# Build the app +nix build + +# Copy to your Applications folder +cp -r ./result/Applications/telegram-clone.app /Applications/ + +# Or create a symlink +ln -s $(pwd)/result/Applications/telegram-clone.app /Applications/telegram-clone.app +``` + +### Option 4: Create a DMG for Distribution + +```bash +# Build the app first +nix build + +# Create a DMG (requires macOS tools) +hdiutil create -volname "Telegram Clone" \ + -srcfolder ./result/Applications/telegram-clone.app \ + -ov -format UDZO \ + telegram-clone.dmg +``` + +## Development + +Enter the development environment with all dependencies: + +```bash +nix develop +``` + +This gives you access to cmake, Qt5, and all build tools needed for development. + +## Running from Source + +```bash +# Enter dev environment +nix develop + +# Build manually +mkdir build && cd build +cmake .. +make + +# Run +./demo +``` + +## Uninstall + +```bash +# If installed via nix profile +nix profile remove telegram-clone + +# If copied to /Applications +rm -rf /Applications/telegram-clone.app +``` + +## Cross-Platform Notes + +This flake supports both: +- Intel Macs (x86_64-darwin) +- Apple Silicon Macs (aarch64-darwin) + +Nix will automatically build for your current architecture. diff --git a/derivation_demo.nix b/derivation_demo.nix @@ -5,13 +5,52 @@ , wrapQtAppsHook }: stdenv.mkDerivation rec { - pname = ""; - version = ""; + pname = "telegram-clone"; + version = "1.0.0"; src = ./.; + nativeBuildInputs = [ cmake wrapQtAppsHook ]; + buildInputs = [ qt5.qtbase qt5.qtdeclarative + qt5.qtquickcontrols2 ]; + + # macOS-specific: Create .app bundle + postInstall = lib.optionalString stdenv.isDarwin '' + mkdir -p $out/Applications/${pname}.app/Contents/{MacOS,Resources} + mv $out/bin/demo $out/Applications/${pname}.app/Contents/MacOS/ + + # Create Info.plist + cat > $out/Applications/${pname}.app/Contents/Info.plist << EOF + <?xml version="1.0" encoding="UTF-8"?> + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> + <plist version="1.0"> + <dict> + <key>CFBundleExecutable</key> + <string>demo</string> + <key>CFBundleIdentifier</key> + <string>com.example.telegram-clone</string> + <key>CFBundleName</key> + <string>${pname}</string> + <key>CFBundleVersion</key> + <string>${version}</string> + <key>CFBundlePackageType</key> + <string>APPL</string> + </dict> + </plist> + EOF + + # Remove empty bin directory + rmdir $out/bin || true + ''; + + meta = with lib; { + description = "Telegram clone Qt application"; + license = licenses.mit; + platforms = platforms.darwin; + maintainers = [ ]; + }; } diff --git a/flake.lock b/flake.lock @@ -2,16 +2,18 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1668650906, - "narHash": "sha256-JuiYfDO23O8oxUUOmhQflmOoJovyC5G4RjcYQMQjrRE=", + "lastModified": 1735564410, + "narHash": "sha256-HB/FA0+1gpSs8+/boEavrGJH+Eq08/R2wWNph1sM1Dg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3a86856a13c88c8c64ea32082a851fefc79aa700", + "rev": "1e7a8f391f1a490460760065fa0630b5520f9cf8", "type": "github" }, "original": { - "id": "nixpkgs", - "type": "indirect" + "owner": "NixOS", + "ref": "nixpkgs-24.05-darwin", + "repo": "nixpkgs", + "type": "github" } }, "root": { diff --git a/flake.nix b/flake.nix @@ -1,11 +1,43 @@ { - description = "A very basic flake"; + description = "Telegram clone Qt application"; - outputs = { self, nixpkgs }: let - pkgs = nixpkgs.legacyPackages.x86_64-darwin; - in { + inputs = { + # Pin to a version compatible with macOS 13 + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-24.05-darwin"; + }; - packages.x86_64-darwin.default = pkgs.libsForQt5.callPackage ./derivation_demo.nix {}; + outputs = { self, nixpkgs }: + let + # Support both Intel and Apple Silicon Macs + systems = [ "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + in + { + packages = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = pkgs.libsForQt5.callPackage ./derivation_demo.nix { }; + } + ); - }; + # Development shell with Qt tools + devShells = forAllSystems (system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + default = pkgs.mkShell { + buildInputs = with pkgs; [ + cmake + libsForQt5.qt5.qtbase + libsForQt5.qt5.qtdeclarative + libsForQt5.qt5.qtquickcontrols2 + libsForQt5.wrapQtAppsHook + ]; + }; + } + ); + }; } diff --git a/main.cpp b/main.cpp @@ -1,12 +1,12 @@ //https://riptutorial.com/qml/example/14475/creating-a-qtquick-window-from-cplusplus -#include <QGuiApplication> +#include <QApplication> #include <QQmlApplicationEngine> #include <QQmlContext> #include "networkmanager.h" int main(int argc, char *argv[]) { - QGuiApplication app(argc, argv); + QApplication app(argc, argv); QQmlApplicationEngine engine; diff --git a/networkmanager.cpp b/networkmanager.cpp @@ -13,9 +13,14 @@ NetworkManager::NetworkManager(QObject *parent) , m_currentSseChat(-1) , m_reconnectTimer(new QTimer(this)) , m_reconnectAttempts(0) + , m_trayIcon(new QSystemTrayIcon(this)) { m_reconnectTimer->setSingleShot(true); connect(m_reconnectTimer, &QTimer::timeout, this, &NetworkManager::attemptReconnect); + + // Initialize system tray icon + m_trayIcon->setIcon(QIcon::fromTheme("mail-message-new")); + m_trayIcon->show(); } NetworkManager::~NetworkManager() @@ -224,8 +229,6 @@ void NetworkManager::postMessage(int chatId, const QString& content) // Connect to the finished signal connect(reply, &QNetworkReply::finished, this, &NetworkManager::onPostMessageFinished); - - qDebug() << "Sending POST request to create message for chat:" << chatId; } void NetworkManager::onPostMessageFinished() @@ -246,7 +249,6 @@ void NetworkManager::onPostMessageFinished() // Get HTTP status code int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - qDebug() << "Create message HTTP status code:" << statusCode; // Check if status code is success (200-299) if (statusCode < 200 || statusCode >= 300) { @@ -259,7 +261,6 @@ void NetworkManager::onPostMessageFinished() // Read response data QByteArray responseData = reply->readAll(); - qDebug() << "Message created response:" << responseData; // Parse JSON response QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); @@ -275,7 +276,6 @@ void NetworkManager::onPostMessageFinished() QJsonObject jsonObj = jsonDoc.object(); QVariantMap messageData = jsonObj.toVariantMap(); - qDebug() << "Message created successfully:" << messageData; emit messageCreated(messageData); // Clean up @@ -305,8 +305,6 @@ void NetworkManager::fetchMessages(int chatId, const QString& cursor) // Connect to the finished signal connect(reply, &QNetworkReply::finished, this, &NetworkManager::onFetchMessagesFinished); - - qDebug() << "Fetching messages from server with URL:" << url.toString(); } void NetworkManager::onFetchMessagesFinished() @@ -327,7 +325,6 @@ void NetworkManager::onFetchMessagesFinished() // Get HTTP status code int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - qDebug() << "Fetch messages HTTP status code:" << statusCode; // Check if status code is success (200-299) if (statusCode < 200 || statusCode >= 300) { @@ -340,7 +337,6 @@ void NetworkManager::onFetchMessagesFinished() // Read response data QByteArray responseData = reply->readAll(); - qDebug() << "Messages response data:" << responseData; // Parse JSON response QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); @@ -395,7 +391,6 @@ void NetworkManager::onFetchMessagesFinished() return; } - qDebug() << "Fetched" << messagesList.size() << "messages successfully, next cursor:" << nextCursor; emit messagesFetched(messagesList, nextCursor); // Clean up @@ -439,7 +434,7 @@ void NetworkManager::subscribeToChat(int chatId) QOverload<QNetworkReply::NetworkError>::of(&QNetworkReply::errorOccurred), this, &NetworkManager::onSseError); - qDebug() << "SSE: Subscribed to chat" << chatId << "URL:" << urlStr; + // qDebug() << "SSE: Subscribed to chat" << chatId << "URL:" << urlStr; emit sseConnected(chatId); resetReconnectBackoff(); } @@ -464,7 +459,7 @@ void NetworkManager::unsubscribeFromChat() m_recentMessageIds.clear(); m_reconnectTimer->stop(); - qDebug() << "SSE: Unsubscribed from chat" << previousChat; + // qDebug() << "SSE: Unsubscribed from chat" << previousChat; emit sseDisconnected(previousChat, "User initiated"); } @@ -523,7 +518,7 @@ void NetworkManager::parseSSEEvent(const QString& eventData) QString data = dataLines.join("\n"); if (data.isEmpty()) return; - qDebug() << "SSE: Received event, type:" << eventType << "data:" << data; + // qDebug() << "SSE: Received event, type:" << eventType << "data:" << data; // Parse JSON data QJsonDocument jsonDoc = QJsonDocument::fromJson(data.toUtf8()); @@ -552,7 +547,7 @@ void NetworkManager::parseSSEEvent(const QString& eventData) // Check for duplicates if (isDuplicateMessage(messageId)) { - qDebug() << "SSE: Ignoring duplicate message" << messageId; + // qDebug() << "SSE: Ignoring duplicate message" << messageId; return; } @@ -567,8 +562,18 @@ void NetworkManager::parseSSEEvent(const QString& eventData) // Cache message ID addToRecentMessages(messageId); + // Show system tray notification + QString content = messageData.value("content", "New message").toString(); + QString notificationText = content.length() > 100 + ? content.left(100) + "..." + : content; + m_trayIcon->showMessage("New Message", + notificationText, + QSystemTrayIcon::Information, + 5000); + // Emit to QML layer - qDebug() << "SSE: New message received:" << messageId; + // qDebug() << "SSE: New message received:" << messageId; emit messageReceived(messageData); } @@ -598,7 +603,7 @@ void NetworkManager::onSseFinished() int chatId = m_currentSseChat; QString reason = "Connection closed by server"; - qDebug() << "SSE: Connection finished for chat" << chatId; + // qDebug() << "SSE: Connection finished for chat" << chatId; // Cleanup m_sseReply->deleteLater(); @@ -630,8 +635,8 @@ void NetworkManager::attemptReconnect() if (m_currentSseChat < 0) return; int chatId = m_currentSseChat; - qDebug() << "SSE: Attempting reconnect to chat" << chatId - << "attempt" << m_reconnectAttempts; + // qDebug() << "SSE: Attempting reconnect to chat" << chatId + // << "attempt" << m_reconnectAttempts; subscribeToChat(chatId); } @@ -642,7 +647,7 @@ void NetworkManager::scheduleReconnect() int delay = qMin(1000 * (1 << m_reconnectAttempts), 30000); m_reconnectAttempts++; - qDebug() << "SSE: Scheduling reconnect in" << delay << "ms"; + // qDebug() << "SSE: Scheduling reconnect in" << delay << "ms"; m_reconnectTimer->start(delay); } diff --git a/networkmanager.h b/networkmanager.h @@ -7,6 +7,7 @@ #include <QVariantMap> #include <QTimer> #include <QSet> +#include <QSystemTrayIcon> class NetworkManager : public QObject { @@ -67,6 +68,9 @@ private: QSet<int> m_recentMessageIds; QTimer* m_reconnectTimer; int m_reconnectAttempts; + + // System tray + QSystemTrayIcon* m_trayIcon; }; #endif // NETWORKMANAGER_H diff --git a/qml/models/MessageListModel.qml b/qml/models/MessageListModel.qml @@ -17,7 +17,7 @@ Item { } // Transform server response to message object format - model.insert(0, { + model.append({ id: messageId || 0, senderId: serverData.sender_id || 0, text: serverData.content || "", @@ -31,19 +31,33 @@ Item { // Clear existing messages on initial load if (isInitialLoad) { model.clear() - } - // Load messages from server (insert at beginning to maintain bottom-to-top order) - for (var i = messages.length - 1; i >= 0; i--) { - var message = messages[i] - model.append({ - id: message.id, - senderId: message.sender_id || 0, - text: message.content || "", - timestamp: message.sent_at || "", - isOutgoing: false, // TODO: determine based on current user - isRead: true - }) + // 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] + model.append({ + id: message.id, + senderId: message.sender_id || 0, + text: message.content || "", + timestamp: message.sent_at || "", + isOutgoing: false, // TODO: determine based on current user + isRead: true + }) + } + } 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] + model.insert(0, { + id: message.id, + senderId: message.sender_id || 0, + text: message.content || "", + timestamp: message.sent_at || "", + isOutgoing: false, // TODO: determine based on current user + isRead: true + }) + } } // Update cursor for next page @@ -69,12 +83,10 @@ Item { if (messages.length > 0) { var firstMessageChatId = messages[0].chat_id if (firstMessageChatId !== root.chatId) { - console.log("Ignoring messages for chat", firstMessageChatId, "(this is chat", root.chatId + ")") return } } - console.log("Received", messages.length, "messages for chat", root.chatId, "cursor:", cursor) var isInitial = (model.count === 0) root.loadMessages(messages, cursor, isInitial) } @@ -84,14 +96,11 @@ Item { // Only process if message belongs to this chat var messageChatId = messageData.chat_id if (messageChatId !== root.chatId) { - console.log("SSE: Ignoring message for chat", messageChatId) return } - console.log("SSE: Adding real-time message to chat", root.chatId) - - // Transform and add to model (prepend for newest-first) - model.insert(0, { + // Transform and add to model (append for newest at bottom) + model.append({ id: messageData.id || 0, senderId: messageData.sender_id || 0, text: messageData.content || "", @@ -121,7 +130,6 @@ Item { onChatIdChanged: { if (chatId >= 0) { // Reset and fetch messages when chat changes - console.log("Fetching messages for chat:", chatId) model.clear() currentCursor = "" hasMore = true diff --git a/qml/views/MessageThreadView.qml b/qml/views/MessageThreadView.qml @@ -21,7 +21,6 @@ Item { Connections { target: NetworkManager function onMessageCreated(messageData) { - console.log("Message created successfully:", JSON.stringify(messageData)) messageListModel.addMessage(messageData) root.isSending = false } @@ -77,7 +76,6 @@ Item { anchors.fill: parent anchors.margins: TelegramStyle.smallMargin spacing: 8 - verticalLayoutDirection: ListView.BottomToTop clip: true model: messageListModel.model @@ -85,7 +83,6 @@ Item { // Detect when user scrolls to top (to load older messages) onAtYBeginningChanged: { if (atYBeginning && model.count > 0 && messageListModel.hasMore && !messageListModel.isLoadingMore) { - console.log("Reached top, loading more messages...") messageListModel.loadMore() } } @@ -97,7 +94,7 @@ Item { // Auto-scroll to bottom on load Component.onCompleted: { - positionViewAtBeginning() + positionViewAtEnd() } // Loading indicator at top (for older messages) @@ -105,13 +102,6 @@ Item { width: messageList.width height: messageListModel.isLoadingMore ? 60 : 0 visible: messageListModel.isLoadingMore - - Text { - anchors.centerIn: parent - text: "Loading more messages..." - color: TelegramStyle.textSecondary - font.pixelSize: TelegramStyle.fontSizeSmall - } } } } @@ -122,7 +112,6 @@ Item { enabled: !root.isSending && root.chatId >= 0 onSendMessage: { if (text.trim().length > 0 && root.chatId >= 0) { - console.log("Sending message:", text, "to chat:", chatId) root.isSending = true NetworkManager.postMessage(root.chatId, text) }