From 230f61cc3858e4eb170410e6869cd4aae2f0b968 Mon Sep 17 00:00:00 2001 From: Collecting Date: Mon, 12 Jan 2026 06:42:31 +0000 Subject: [PATCH] feat(multiplayer): More QoL Improvements Signed-off-by: Collecting --- src/citron/multiplayer/chat_room.cpp | 371 +++++++++++++++++++++++---- 1 file changed, 326 insertions(+), 45 deletions(-) diff --git a/src/citron/multiplayer/chat_room.cpp b/src/citron/multiplayer/chat_room.cpp index 8fea9f445..cfa0c0c41 100644 --- a/src/citron/multiplayer/chat_room.cpp +++ b/src/citron/multiplayer/chat_room.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -164,9 +165,15 @@ public: } QVariant data(int role) const override { + // If compact mode is on, we tell the model to return no text + if (role == Qt::DisplayRole && QStandardItem::data(Qt::UserRole + 7).toBool()) { + return QVariant(); + } + if (role != Qt::DisplayRole) { return QStandardItem::data(role); } + QString name; const QString nickname = data(NicknameRole).toString(); const QString username = data(UsernameRole).toString(); @@ -190,24 +197,53 @@ public: ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_unique()) { ui->setupUi(this); + // Setup the Emoji Button QToolButton* emoji_button = new QToolButton(this); emoji_button->setText(QStringLiteral("๐Ÿ˜€")); - emoji_button->setPopupMode(QToolButton::InstantPopup); + emoji_button->setToolButtonStyle(Qt::ToolButtonTextOnly); + emoji_button->setFixedSize(36, 30); emoji_button->setAutoRaise(true); - emoji_button->setFixedSize(30, 30); - // Hide the arrow indicator and remove padding to ensure the emoji is dead-center - emoji_button->setStyleSheet(QStringLiteral("QToolButton::menu-indicator { image: none; } QToolButton { padding: 0px; }")); + emoji_button->setPopupMode(QToolButton::InstantPopup); + emoji_button->setStyleSheet(QStringLiteral( + "QToolButton { padding: 0px; margin: 0px; }" + "QToolButton::menu-indicator { image: none; width: 0px; }" + )); - ui->horizontalLayout_3->insertWidget(1, emoji_button); + // Setup the Send Button + send_message = new QPushButton(QStringLiteral("โžค"), this); + send_message->setObjectName(QStringLiteral("send_message")); + send_message->setFixedSize(40, 30); + send_message->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + + // Rebuild Layout + ui->horizontalLayout_3->removeWidget(ui->chat_message); + ui->horizontalLayout_3->addWidget(ui->chat_message); // Index 0 + ui->horizontalLayout_3->addWidget(emoji_button); // Index 1 + ui->horizontalLayout_3->addWidget(send_message); // Index 2 + ui->horizontalLayout_3->setStretch(0, 1); + ui->horizontalLayout_3->setStretch(1, 0); + ui->horizontalLayout_3->setStretch(2, 0); + + connect(ui->chat_message, &QLineEdit::returnPressed, this, &ChatRoom::OnSendChat); + connect(send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat); QMenu* emoji_menu = new QMenu(this); QStringList emojis = { QStringLiteral("๐Ÿ˜€"), QStringLiteral("๐Ÿ˜‚"), QStringLiteral("๐Ÿคฃ"), QStringLiteral("๐Ÿ˜Š"), QStringLiteral("๐Ÿ˜Ž"), - QStringLiteral("๐Ÿค”"), QStringLiteral("๐Ÿคจ"), QStringLiteral("๐Ÿ˜ญ"), QStringLiteral("๐Ÿ˜ฎ"), QStringLiteral("๐Ÿ’€"), - QStringLiteral("๐Ÿ‘"), QStringLiteral("๐Ÿ‘Ž"), QStringLiteral("๐Ÿ”ฅ"), QStringLiteral("โœจ"), QStringLiteral("โค๏ธ"), - QStringLiteral("๐ŸŽ‰"), QStringLiteral("๐Ÿ’ฏ"), QStringLiteral("๐Ÿš€"), QStringLiteral("๐ŸŽฎ"), QStringLiteral("๐Ÿ•น๏ธ"), - QStringLiteral("๐Ÿ‘พ"), QStringLiteral("๐Ÿ„"), QStringLiteral("โญ๏ธ"), QStringLiteral("โš”๏ธ"), QStringLiteral("๐Ÿ›ก๏ธ") + QStringLiteral("๐Ÿค”"), QStringLiteral("๐Ÿคจ"), QStringLiteral("๐Ÿ™„"), QStringLiteral("๐Ÿฅบ"), QStringLiteral("๐Ÿ˜ญ"), + QStringLiteral("๐Ÿ˜ฎ"), QStringLiteral("๐Ÿฅณ"), QStringLiteral("๐Ÿ˜ด"), QStringLiteral("๐Ÿคก"), QStringLiteral("๐Ÿ’€"), + QStringLiteral("๐Ÿ‘€"), QStringLiteral("๐Ÿ’ค"), QStringLiteral("๐Ÿ‘‘"), QStringLiteral("๐Ÿ‘ป"), QStringLiteral("๐Ÿฅ€"), + QStringLiteral("๐Ÿ‘"), QStringLiteral("๐Ÿ‘Ž"), QStringLiteral("๐Ÿ‘"), QStringLiteral("๐Ÿ™Œ"), QStringLiteral("๐Ÿ™"), + QStringLiteral("๐Ÿค"), QStringLiteral("๐Ÿ’ช"), QStringLiteral("๐Ÿ‘‹"), QStringLiteral("๐Ÿ‘Š"), QStringLiteral("๐Ÿ‘Œ"), + QStringLiteral("๐ŸŽฎ"), QStringLiteral("๐Ÿ•น๏ธ"), QStringLiteral("๐Ÿ‘พ"), QStringLiteral("๐Ÿ’ป"), QStringLiteral("๐Ÿ“ฑ"), + QStringLiteral("๐Ÿ–ฑ๏ธ"), QStringLiteral("โŒจ๏ธ"), QStringLiteral("๐ŸŽง"), QStringLiteral("๐Ÿ“บ"), QStringLiteral("๐Ÿ”‹"), + QStringLiteral("๐Ÿ”ฅ"), QStringLiteral("โœจ"), QStringLiteral("โค๏ธ"), QStringLiteral("๐ŸŽ‰"), QStringLiteral("๐Ÿ’ฏ"), + QStringLiteral("๐Ÿš€"), QStringLiteral("๐Ÿ„"), QStringLiteral("โญ๏ธ"), QStringLiteral("โš”๏ธ"), QStringLiteral("๐Ÿ›ก๏ธ"), + QStringLiteral("๐Ÿ’Ž"), QStringLiteral("๐Ÿ’ก"), QStringLiteral("๐Ÿ’ฃ"), QStringLiteral("๐Ÿ“ข"), QStringLiteral("๐Ÿ””"), + QStringLiteral("โœ…"), QStringLiteral("โŒ"), QStringLiteral("โš ๏ธ"), QStringLiteral("๐Ÿšซ"), QStringLiteral("๐ŸŒˆ"), + QStringLiteral("๐ŸŒŠ"), QStringLiteral("โšก"), QStringLiteral("๐Ÿƒ"), QStringLiteral("๐Ÿฑ"), QStringLiteral("๐Ÿ‰"), + QStringLiteral("๐Ÿ‹"), QStringLiteral("๐Ÿ†"), QStringLiteral("๐Ÿง‚"), QStringLiteral("๐Ÿฟ"), QStringLiteral("๐Ÿซ ") }; // Create a container widget for the grid @@ -216,22 +252,22 @@ ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_uniquesetSpacing(2); grid_layout->setContentsMargins(5, 5, 5, 5); - const int max_columns = 5; + const int max_columns = 7; for (int i = 0; i < emojis.size(); ++i) { const QString emoji = emojis[i]; QToolButton* btn = new QToolButton(grid_container); btn->setText(emoji); - btn->setFixedSize(34, 30); + btn->setFixedSize(32, 30); btn->setAutoRaise(true); + btn->setStyleSheet(QStringLiteral("font-size: 16px;")); connect(btn, &QToolButton::clicked, [this, emoji, emoji_menu]() { ui->chat_message->insert(emoji); ui->chat_message->setFocus(); - emoji_menu->close(); // Close the menu after picking + emoji_menu->close(); }); - // Add to grid: row = i / columns, col = i % columns grid_layout->addWidget(btn, i / max_columns, i % max_columns); } @@ -265,9 +301,14 @@ ChatRoom::ChatRoom(QWidget* parent) : QWidget(parent), ui(std::make_uniquechat_history, &QTextEdit::customContextMenuRequested, this, &ChatRoom::OnChatContextMenu); connect(ui->chat_message, &QLineEdit::returnPressed, this, &ChatRoom::OnSendChat); + connect(send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat); connect(ui->chat_message, &QLineEdit::textChanged, this, &ChatRoom::OnChatTextChanged); - connect(ui->send_message, &QPushButton::clicked, this, &ChatRoom::OnSendChat); connect(ui->player_view, &QTreeView::doubleClicked, this, &ChatRoom::OnPlayerDoubleClicked); + ui->horizontalLayout_3->setStretch(0, 1); + ui->horizontalLayout_3->setStretch(1, 0); + ui->horizontalLayout_3->setStretch(2, 0); + send_message->setFixedSize(40, 30); + send_message->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); UpdateTheme(); } @@ -345,12 +386,12 @@ void ChatRoom::OnRoomUpdate(const Network::RoomInformation& info) { } void ChatRoom::Disable() { - ui->send_message->setDisabled(true); + if (send_message) send_message->setDisabled(true); ui->chat_message->setDisabled(true); } void ChatRoom::Enable() { - ui->send_message->setEnabled(true); + if (send_message) send_message->setEnabled(true); ui->chat_message->setEnabled(true); } @@ -386,6 +427,9 @@ void ChatRoom::OnChatReceive(const Network::ChatEntry& chat) { } AppendChatMessage(m.GetPlayerChatMessage(static_cast(player), show_timestamps, override_color)); + + // Trigger the 15-second border highlight for the person who just spoke + HighlightPlayer(chat.nickname); } } @@ -468,44 +512,139 @@ void ChatRoom::OnSendChat() { AppendChatMessage(m.GetPlayerChatMessage(static_cast(player), show_timestamps, override_color)); ui->chat_message->clear(); + + HighlightPlayer(nick); } } -void ChatRoom::UpdateIconDisplay() { - for (int row = 0; row < player_list->invisibleRootItem()->rowCount(); ++row) { - QStandardItem* item = player_list->invisibleRootItem()->child(row); - const std::string avatar_url = item->data(PlayerListItem::AvatarUrlRole).toString().toStdString(); +QColor ChatRoom::GetPlayerColor(const std::string& nickname, int index) const { + if (color_overrides.count(nickname)) { + return QColor(QString::fromStdString(color_overrides.at(nickname))); + } + const bool is_dark = QIcon::themeName().contains(QStringLiteral("dark")) || + QIcon::themeName().contains(QStringLiteral("midnight")); - QPixmap pixmap; + static constexpr std::array default_colors = { + "#0000FF", "#FF0000", "#8A2BE2", "#FF69B4", "#1E90FF", "#008000", "#00FF7F", "#B22222", + "#DAA520", "#FF4500", "#2E8B57", "#5F9EA0", "#D2691E", "#9ACD32", "#FF7F50", "#FFFF00"}; + static constexpr std::array dark_colors = { + "#559AD1", "#4EC9A8", "#D69D85", "#C6C923", "#B975B5", "#D81F1F", "#7EAE39", "#4F8733", + "#F7CD8A", "#6FCACF", "#CE4897", "#8A2BE2", "#D2691E", "#9ACD32", "#FF7F50", "#152ccd"}; + + return QColor(is_dark ? dark_colors[index % 16] : default_colors[index % 16]); +} + +void ChatRoom::UpdateIconDisplay() { + // 1. Determine canvas size based on mode + int canvas_w, canvas_h; + if (is_compact_mode) { + canvas_w = std::max(80, ui->player_view->viewport()->width() - 2); + canvas_h = 80; // Enough for avatar + name below + } else { + canvas_w = 54; // Just enough for 44px avatar + 4px border padding + canvas_h = 54; + } + + const QSize canvas_size(canvas_w, canvas_h); + ui->player_view->setIconSize(canvas_size); + + for (int row = 0; row < player_list->rowCount(); ++row) { + QStandardItem* item = player_list->item(row); + if (!item) continue; + + const QString nickname = item->data(PlayerListItem::NicknameRole).toString(); + const std::string nickname_std = nickname.toStdString(); + const std::string avatar_url = item->data(PlayerListItem::AvatarUrlRole).toString().toStdString(); + const QString game = item->data(PlayerListItem::GameNameRole).toString(); + const QString version = item->data(PlayerListItem::GameVersionRole).toString(); + + item->setData(is_compact_mode, Qt::UserRole + 7); + + QPixmap avatar_pixmap; if (icon_cache.count(avatar_url)) { - pixmap = icon_cache.at(avatar_url); + avatar_pixmap = icon_cache.at(avatar_url); } else { - pixmap = QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48); + avatar_pixmap = QIcon::fromTheme(QStringLiteral("no_avatar")).pixmap(48); } - QPixmap canvas = pixmap.copy(); + QPixmap canvas(canvas_size); + canvas.fill(Qt::transparent); QPainter painter(&canvas); painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::TextAntialiasing); + const int avatar_size = 44; + + // Center for Compact, Left-Align for Regular + int avatar_x = is_compact_mode ? (canvas.width() - avatar_size) / 2 : 5; + int avatar_y = is_compact_mode ? 4 : 5; + + // --- Draw Fading Border --- + float opacity = 0.0f; + if (highlight_states.count(nickname_std)) { + opacity = highlight_states[nickname_std].opacity; + } + + if (opacity > 0.0f) { + QColor border_color = GetPlayerColor(nickname_std, row); + border_color.setAlphaF(opacity); + painter.setPen(QPen(border_color, 4)); + painter.drawEllipse(avatar_x, avatar_y, avatar_size, avatar_size); + } else { + painter.setPen(QPen(QColor(255, 255, 255, 30), 1)); + painter.drawEllipse(avatar_x, avatar_y, avatar_size, avatar_size); + } + + // --- Draw Avatar --- + QPainterPath path; + path.addEllipse(avatar_x + 2, avatar_y + 2, 40, 40); + painter.setClipPath(path); + painter.drawPixmap(avatar_x + 2, avatar_y + 2, 40, 40, avatar_pixmap); + painter.setClipping(false); + + // --- Draw Status Dot --- QString dot_type = item->data(PlayerListItem::StatusDotRole).toString(); - QColor dot_color; - if (dot_type == QStringLiteral("๐ŸŸข")) dot_color = Qt::green; - else if (dot_type == QStringLiteral("๐ŸŸก")) dot_color = Qt::yellow; - else dot_color = Qt::gray; - - // Draw a small "outline" circle - painter.setBrush(QColor(30, 30, 30)); + QColor dot_color = (dot_type == QStringLiteral("๐ŸŸข")) ? Qt::green : + (dot_type == QStringLiteral("๐ŸŸก")) ? Qt::yellow : Qt::gray; painter.setPen(Qt::NoPen); - painter.drawEllipse(32, 32, 14, 14); - - // Draw the actual status dot + painter.setBrush(QColor(30, 30, 30)); + painter.drawEllipse(avatar_x + 30, avatar_y + 30, 12, 12); painter.setBrush(dot_color); - painter.drawEllipse(34, 34, 10, 10); + painter.drawEllipse(avatar_x + 32, avatar_y + 32, 8, 8); + + if (is_compact_mode) { + QFont font = painter.font(); + int point_size = 9; + font.setBold(true); + font.setPointSize(point_size); + painter.setFont(font); + + int text_width_limit = canvas.width() - 4; + while (painter.fontMetrics().horizontalAdvance(nickname) > text_width_limit && point_size > 6) { + point_size--; + font.setPointSize(point_size); + painter.setFont(font); + } + + QString elided_name = painter.fontMetrics().elidedText(nickname, Qt::ElideRight, text_width_limit); + QRect text_rect(0, avatar_y + avatar_size + 2, canvas.width(), 20); + + painter.setPen(QColor(0, 0, 0, 160)); + painter.drawText(text_rect.adjusted(1, 1, 1, 1), Qt::AlignCenter, elided_name); + painter.setPen(UISettings::IsDarkTheme() ? Qt::white : Qt::black); + painter.drawText(text_rect, Qt::AlignCenter, elided_name); + } painter.end(); - - // Set the final icon item->setData(canvas, Qt::DecorationRole); + + // Tooltip logic + QString display_game = version.isEmpty() ? game : QStringLiteral("%1 (%2)").arg(game, version); + item->setToolTip(tr("%1
%2").arg(nickname, display_game)); + + if (is_compact_mode) { + item->setText(QString()); + } } } @@ -582,12 +721,33 @@ void ChatRoom::OnChatTextChanged() { } void ChatRoom::PopupContextMenu(const QPoint& menu_location) { - QModelIndex item = ui->player_view->indexAt(menu_location); - if (!item.isValid()) return; - - std::string nickname = player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString(); QMenu context_menu; + // 1. Vertical Scrollbar Toggle + QAction* scroll_action = context_menu.addAction(tr("Hide Member Scrollbar")); + scroll_action->setCheckable(true); + scroll_action->setChecked(member_scrollbar_hidden); + connect(scroll_action, &QAction::triggered, [this](bool checked) { + member_scrollbar_hidden = checked; + ui->player_view->setVerticalScrollBarPolicy(checked ? Qt::ScrollBarAlwaysOff : Qt::ScrollBarAsNeeded); + + if (is_compact_mode) { + ui->player_view->setFixedWidth(checked ? 90 : 110); + UpdateIconDisplay(); + } + }); + context_menu.addSeparator(); + + QModelIndex item = ui->player_view->indexAt(menu_location); + if (!item.isValid()) { + // If clicking empty space, just show the scrollbar toggle and exit + context_menu.exec(ui->player_view->viewport()->mapToGlobal(menu_location)); + return; + } + + // 2. Player-specific options (Only shows if you click a name) + std::string nickname = player_list->item(item.row())->data(PlayerListItem::NicknameRole).toString().toStdString(); + QAction* color_action = context_menu.addAction(tr("Set Name Color")); connect(color_action, &QAction::triggered, [this, nickname] { QColor color = QColorDialog::getColor(Qt::white, this, tr("Select Color for %1").arg(QString::fromStdString(nickname))); @@ -643,15 +803,21 @@ void ChatRoom::UpdateTheme() { const QString accent_color = Theme::GetAccentColor(); if (UISettings::IsDarkTheme()) { style_sheet = QStringLiteral(R"( - QListView, QTextEdit, QLineEdit { background-color: #252525; color: #E0E0E0; border: 1px solid #4A4A4A; border-radius: 4px; } + QListView, QTextEdit { background-color: #252525; color: #E0E0E0; border: 1px solid #4A4A4A; border-radius: 4px; } QListView::item:selected { background-color: %1; } - QPushButton { background-color: #3E3E3E; color: #E0E0E0; border: 1px solid #5A5A5A; padding: 5px; border-radius: 4px; } + QLineEdit { background-color: #252525; color: #E0E0E0; border: 1px solid #4A4A4A; padding-left: 5px; border-radius: 4px; } + QPushButton { background-color: #3E3E3E; color: #E0E0E0; border: 1px solid #5A5A5A; padding: 2px; border-radius: 4px; } + QPushButton#send_message { padding: 0px; margin: 0px; min-width: 40px; max-width: 40px; } + QToolButton { padding: 0px; margin: 0px; font-size: 14px; border: none; } )").arg(accent_color); } else { style_sheet = QStringLiteral(R"( - QListView, QTextEdit, QLineEdit { background-color: #FFFFFF; color: #000000; border: 1px solid #CFCFCF; border-radius: 4px; } + QListView, QTextEdit { background-color: #FFFFFF; color: #000000; border: 1px solid #CFCFCF; border-radius: 4px; } QListView::item:selected { background-color: %1; } - QPushButton { background-color: #F0F0F0; color: #000000; border: 1px solid #BDBDBD; padding: 5px; border-radius: 4px; } + QLineEdit { background-color: #FFFFFF; color: #000000; border: 1px solid #CFCFCF; padding-left: 5px; border-radius: 4px; } + QPushButton { background-color: #F0F0F0; color: #000000; border: 1px solid #BDBDBD; padding: 2px; border-radius: 4px; } + QPushButton#send_message { padding: 0px; margin: 0px; min-width: 40px; max-width: 40px; } + QToolButton { padding: 0px; margin: 0px; font-size: 14px; border: none; } )").arg(accent_color); } this->setStyleSheet(style_sheet); @@ -660,9 +826,47 @@ void ChatRoom::UpdateTheme() { void ChatRoom::OnChatContextMenu(const QPoint& menu_location) { QMenu* context_menu = ui->chat_history->createStandardContextMenu(menu_location); context_menu->addSeparator(); + QAction* clear_action = context_menu->addAction(tr("Clear Chat History")); connect(clear_action, &QAction::triggered, this, &ChatRoom::Clear); + QAction* compact_action = context_menu->addAction(tr("Compact Member List")); + compact_action->setCheckable(true); + compact_action->setChecked(is_compact_mode); + connect(compact_action, &QAction::triggered, [this](bool checked) { + this->is_compact_mode = checked; + if (checked) { + int view_w = member_scrollbar_hidden ? 90 : 110; + ui->player_view->setFixedWidth(view_w); + ui->player_view->setIndentation(0); + ui->player_view->setHeaderHidden(true); + ui->player_view->setRootIsDecorated(false); + ui->player_view->header()->setSectionResizeMode(0, QHeaderView::Stretch); + ui->player_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + ui->player_view->setStyleSheet(QStringLiteral("QTreeView::item { padding: 0px; }")); + } else { + ui->player_view->setMinimumWidth(160); + ui->player_view->setMaximumWidth(1000); + ui->player_view->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + ui->player_view->setIndentation(20); + ui->player_view->setHeaderHidden(false); + ui->player_view->setRootIsDecorated(true); + ui->player_view->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + ui->player_view->header()->setStretchLastSection(false); + ui->player_view->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); + ui->player_view->setStyleSheet(QString()); + } + + UpdateIconDisplay(); + + // Refresh player list to restore text in regular mode + if (room_network) { + if (auto room = room_network->GetRoomMember().lock()) { + SetPlayerList(room->GetMemberInformation()); + } + } + }); + QAction* mute_action = context_menu->addAction(tr("Hide Future Messages")); mute_action->setCheckable(true); mute_action->setChecked(chat_muted); @@ -692,3 +896,80 @@ void ChatRoom::OnPlayerDoubleClicked(const QModelIndex& index) { ui->chat_message->setFocus(); } } + +void ChatRoom::HighlightPlayer(const std::string& nickname) { + auto& state = highlight_states[nickname]; + + // 1. Clean up existing animations/timers + // QPointer automatically becomes null if the animation was already deleted + if (state.animation) { + state.animation->stop(); + state.animation->deleteLater(); + } + + if (state.linger_timer) { + state.linger_timer->stop(); + state.linger_timer->deleteLater(); + state.linger_timer = nullptr; + } + + // 2. Create Fade-In Animation + auto* fadeIn = new QVariantAnimation(this); + state.animation = fadeIn; + fadeIn->setDuration(400); + fadeIn->setStartValue(state.opacity); + fadeIn->setEndValue(1.0f); + fadeIn->setEasingCurve(QEasingCurve::OutQuad); + + connect(fadeIn, &QVariantAnimation::valueChanged, [this, nickname](const QVariant& value) { + if (highlight_states.count(nickname)) { + highlight_states[nickname].opacity = value.toFloat(); + UpdateIconDisplay(); + } + }); + + connect(fadeIn, &QVariantAnimation::finished, [this, nickname]() { + if (!highlight_states.count(nickname)) return; + + auto& s1 = highlight_states[nickname]; + + // Cleanup the finished animation + if (s1.animation) s1.animation->deleteLater(); + + s1.linger_timer = new QTimer(this); + s1.linger_timer->setSingleShot(true); + + connect(s1.linger_timer, &QTimer::timeout, [this, nickname]() { + if (!highlight_states.count(nickname)) return; + auto& s2 = highlight_states[nickname]; + + auto* fadeOut = new QVariantAnimation(this); + s2.animation = fadeOut; + fadeOut->setDuration(400); + fadeOut->setStartValue(1.0f); + fadeOut->setEndValue(0.0f); + fadeOut->setEasingCurve(QEasingCurve::OutQuad); + + connect(fadeOut, &QVariantAnimation::valueChanged, [this, nickname](const QVariant& value) { + if (highlight_states.count(nickname)) { + highlight_states[nickname].opacity = value.toFloat(); + UpdateIconDisplay(); + } + }); + + connect(fadeOut, &QVariantAnimation::finished, [this, nickname]() { + if (highlight_states.count(nickname)) { + auto& final_state = highlight_states[nickname]; + if (final_state.animation) final_state.animation->deleteLater(); + highlight_states.erase(nickname); + } + UpdateIconDisplay(); + }); + + fadeOut->start(); + }); + s1.linger_timer->start(10000); + }); + + fadeIn->start(); +}