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:
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)
}