[UserList] Show users in every section they belong to

The sectioned list used one row per user with a priority rule
(ignored > buddy > online), so an online buddy only appeared under
"Buddies" and never in the "Online" list. Sections are now pure
membership views: a user gets one row per section they belong to, so an
online buddy appears under both "Online" and "Buddies".

- Track rows per (section, user) in sectionUsers instead of reparenting
  a single row; the name->primary-row map is kept for external lookups.
- Rebuild, presence and buddy/ignore mutations create/drop rows per
  section instead of moving a single row between sections.
- Dropping one membership no longer removes the user from the other
  sections.
This commit is contained in:
Lukas Brübach 2026-08-15 13:24:48 +02:00
parent 29452fcead
commit bb94c34c7e
2 changed files with 141 additions and 101 deletions

View file

@ -756,11 +756,13 @@ void UserListWidget::bind(UserListManager *mgr)
} }
} else { } else {
// ── Sectioned mode: one tree, every source feeds its own section. ───── // ── Sectioned mode: one tree, every source feeds its own section. ─────
// Presence drives the "online" section and the online state of buddies // Sections are pure membership views: the "Online" section holds every
// and ignored users. Buddy/ignore mutations move users between sections // currently online user, the "Buddy"/"Ignore" sections hold those
// (priority: ignored > buddy > online). // lists. A user can therefore appear in several sections at once (an
connect(manager, &UserListManager::userJoinedOnline, this, // online buddy gets one row in each).
[this](const ServerInfo_User &user) { handleOnlineChange(user, true); }); connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) {
handleOnlineChange(user);
});
connect(manager, &UserListManager::userLeftOnline, this, connect(manager, &UserListManager::userLeftOnline, this,
[this](const QString &name) { handleOnlineChangeLeft(name); }); [this](const QString &name) { handleOnlineChangeLeft(name); });
connect(manager, &UserListManager::addedToBuddyList, this, connect(manager, &UserListManager::addedToBuddyList, this,
@ -1113,6 +1115,7 @@ void UserListWidget::rebuild()
{ {
userTree->clear(); userTree->clear();
users.clear(); users.clear();
sectionUsers.clear();
cardArtParamsMap.clear(); cardArtParamsMap.clear();
onlineCount = 0; onlineCount = 0;
@ -1125,9 +1128,9 @@ void UserListWidget::rebuild()
} }
if (sectioned) { if (sectioned) {
// Feed the sections in priority order (online first, then buddy, then // Every source feeds its own section; users that belong to several
// ignore): users present in several sources end up in the highest // sources (an online buddy) get one row per section because
// priority section because processUserInfo() reparents existing items. // ensureSectionMembership() creates the row when it is missing.
beginBulkLoad(); beginBulkLoad();
const auto &onlineUsers = manager->getAllUsersList(); const auto &onlineUsers = manager->getAllUsersList();
for (auto it = onlineUsers.cbegin(); it != onlineUsers.cend(); ++it) { for (auto it = onlineUsers.cbegin(); it != onlineUsers.cend(); ++it) {
@ -1135,9 +1138,6 @@ void UserListWidget::rebuild()
} }
const auto &buddyUsers = manager->getBuddyList(); const auto &buddyUsers = manager->getBuddyList();
for (auto it = buddyUsers.cbegin(); it != buddyUsers.cend(); ++it) { for (auto it = buddyUsers.cbegin(); it != buddyUsers.cend(); ++it) {
if (manager->isUserIgnored(it.key())) {
continue; // handled by the ignore pass below
}
processUserInfo(QStringLiteral("buddy"), it.value(), manager->getOnlineUser(it.key()) != nullptr); processUserInfo(QStringLiteral("buddy"), it.value(), manager->getOnlineUser(it.key()) != nullptr);
} }
const auto &ignoreUsers = manager->getIgnoreList(); const auto &ignoreUsers = manager->getIgnoreList();
@ -1223,34 +1223,7 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online)
void UserListWidget::processUserInfo(const QString &sectionId, const ServerInfo_User &user, bool online) void UserListWidget::processUserInfo(const QString &sectionId, const ServerInfo_User &user, bool online)
{ {
const QString userName = QString::fromStdString(user.name()); ensureSectionMembership(sectionId, user, online);
updateCardArtParams(user, userName);
QTreeWidgetItem *sectionItem = sectionItems.value(sectionId);
if (!sectionItem) {
return;
}
UserListTWI *item = users.value(userName);
if (item) {
item->setUserInfo(user);
if (item->parent() != sectionItem) {
sectionItem->addChild(item); // reparent to the section it now belongs to
}
} else {
item = new UserListTWI(user);
users.insert(userName, item);
sectionItem->addChild(item);
if (online) {
++onlineCount;
}
updateCount();
if (!bulkLoading && isItemNearViewport(item)) {
avatarProvider->requestAvatar(userName);
}
}
item->setOnline(online);
if (!bulkLoading) { if (!bulkLoading) {
sortItems(); sortItems();
applyFilter(); applyFilter();
@ -1260,6 +1233,21 @@ void UserListWidget::processUserInfo(const QString &sectionId, const ServerInfo_
bool UserListWidget::deleteUser(const QString &userName) bool UserListWidget::deleteUser(const QString &userName)
{ {
if (sectioned) {
// The user may own several rows (one per section); drop them all.
bool removed = false;
const QStringList sections = sectionUsers.keys(); // snapshot: maps mutate
for (const QString &sectionId : sections) {
removed = dropSectionMembership(sectionId, userName) || removed;
}
if (removed && !bulkLoading) {
sortItems();
applyFilter();
userTree->viewport()->update();
}
return removed;
}
UserListTWI *twi = users.value(userName); UserListTWI *twi = users.value(userName);
if (!twi) { if (!twi) {
return false; return false;
@ -1282,6 +1270,19 @@ bool UserListWidget::deleteUser(const QString &userName)
void UserListWidget::setUserOnline(const QString &userName, bool online) void UserListWidget::setUserOnline(const QString &userName, bool online)
{ {
if (sectioned) {
// The rows in the "Online" section are created/removed by the presence
// handlers; this only keeps the presence flag of the surviving rows
// (e.g. a buddy row after the user went offline) in sync.
for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend(); ++it) {
UserListTWI *item = it.value().value(userName);
if (item) {
item->setOnline(online);
}
}
return;
}
UserListTWI *twi = users.value(userName); UserListTWI *twi = users.value(userName);
if (!twi) { if (!twi) {
return; return;
@ -1579,91 +1580,121 @@ void UserListWidget::setSectionExpanded(const QString &sectionId, bool expanded)
userTree->viewport()->update(); userTree->viewport()->update();
} }
void UserListWidget::handleOnlineChange(const ServerInfo_User &user, bool online) void UserListWidget::handleOnlineChange(const ServerInfo_User &user)
{ {
// A user came online: they get a row in the "Online" section, plus (if
// applicable) a row in the buddy/ignore sections, which flip to online.
const QString name = QString::fromStdString(user.name()); const QString name = QString::fromStdString(user.name());
UserListTWI *item = users.value(name); ensureSectionMembership(QStringLiteral("online"), user, true);
if (item) { if (manager->isUserBuddy(name)) {
item->setUserInfo(user); ensureSectionMembership(QStringLiteral("buddy"), user, true);
setUserOnline(name, online);
return;
} }
// A brand-new user lands in the highest-priority section they belong to.
QString sectionId = QStringLiteral("online");
if (manager->isUserIgnored(name)) { if (manager->isUserIgnored(name)) {
sectionId = QStringLiteral("ignore"); ensureSectionMembership(QStringLiteral("ignore"), user, true);
} else if (manager->isUserBuddy(name)) {
sectionId = QStringLiteral("buddy");
} }
processUserInfo(sectionId, user, online); finishSectionedMutation();
} }
void UserListWidget::handleOnlineChangeLeft(const QString &userName) void UserListWidget::handleOnlineChangeLeft(const QString &userName)
{ {
if (!users.contains(userName)) { // The user is no longer online: their "Online" row disappears. Buddies and
return; // ignored users keep their own section's row, marked offline; a plain user
} // has no rows left.
// Buddies and ignored users persist as offline. Pain room members leave dropSectionMembership(QStringLiteral("online"), userName);
// the list entirely.
if (manager->isUserBuddy(userName) || manager->isUserIgnored(userName)) { if (manager->isUserBuddy(userName) || manager->isUserIgnored(userName)) {
setUserOnline(userName, false); setUserOnline(userName, false);
} else {
deleteUser(userName);
} }
finishSectionedMutation();
} }
void UserListWidget::handleListAdd(const QString &sectionId, const ServerInfo_User &user) void UserListWidget::handleListAdd(const QString &sectionId, const ServerInfo_User &user)
{ {
const QString name = QString::fromStdString(user.name()); const QString name = QString::fromStdString(user.name());
// Ignored status outranks buddy status in the single list. const bool online = manager->getOnlineUser(name) != nullptr;
if (sectionId == QLatin1String("buddy") && manager->isUserIgnored(name)) { ensureSectionMembership(sectionId, user, online);
processUserInfo(QStringLiteral("ignore"), user, manager->getOnlineUser(name) != nullptr); if (online) {
return; // The user belongs to the "Online" section as well; make sure the row
// exists even if the join event raced ahead of the list mutation.
ensureSectionMembership(QStringLiteral("online"), user, true);
} }
processUserInfo(sectionId, user, manager->getOnlineUser(name) != nullptr); finishSectionedMutation();
} }
void UserListWidget::handleListRemove(const QString &sectionId, const QString &userName) void UserListWidget::handleListRemove(const QString &sectionId, const QString &userName)
{ {
// A user leaving one list may still belong elsewhere: // Only the row of the removed section disappears: an online user keeps
// ignored > buddy > online. // their "Online" row, and other list memberships keep theirs.
if (sectionId == QLatin1String("buddy")) { dropSectionMembership(sectionId, userName);
if (manager->isUserIgnored(userName)) { finishSectionedMutation();
moveToSection(QStringLiteral("ignore"), userName);
return;
}
if (manager->getOnlineUser(userName)) {
moveToSection(QStringLiteral("online"), userName);
return;
}
deleteUser(userName);
return;
}
if (sectionId == QLatin1String("ignore")) {
if (manager->isUserBuddy(userName)) {
moveToSection(QStringLiteral("buddy"), userName);
return;
}
if (manager->getOnlineUser(userName)) {
moveToSection(QStringLiteral("online"), userName);
return;
}
deleteUser(userName);
}
} }
void UserListWidget::moveToSection(const QString &sectionId, const QString &userName) UserListTWI *UserListWidget::ensureSectionMembership(const QString &sectionId, const ServerInfo_User &user, bool online)
{ {
UserListTWI *item = users.value(userName); const QString userName = QString::fromStdString(user.name());
QTreeWidgetItem *target = sectionItems.value(sectionId);
if (!item || !target || item->parent() == target) { updateCardArtParams(user, userName);
QTreeWidgetItem *divider = sectionItems.value(sectionId);
if (!divider) {
return nullptr;
}
QMap<QString, UserListTWI *> &sectionMap = sectionUsers[sectionId];
UserListTWI *item = sectionMap.value(userName);
if (!item) {
item = new UserListTWI(user);
sectionMap.insert(userName, item);
divider->addChild(item);
if (!users.contains(userName)) {
users.insert(userName, item); // primary row for name-based lookups
}
updateCount(); // a new row changes the divider's count
if (!bulkLoading && isItemNearViewport(item)) {
avatarProvider->requestAvatar(userName);
}
} else {
item->setUserInfo(user);
}
item->setOnline(online);
return item;
}
bool UserListWidget::dropSectionMembership(const QString &sectionId, const QString &userName)
{
QMap<QString, UserListTWI *> &sectionMap = sectionUsers[sectionId];
UserListTWI *item = sectionMap.take(userName);
if (!item) {
return false;
}
if (item->parent()) {
item->parent()->removeChild(item);
} else {
userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(item));
}
if (users.value(userName) == item) {
// Repoint the primary row at another surviving row, if any.
UserListTWI *replacement = nullptr;
for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend() && !replacement; ++it) {
replacement = it.value().value(userName);
}
if (replacement) {
users.insert(userName, replacement);
} else {
users.remove(userName);
}
}
delete item;
updateCount();
return true;
}
void UserListWidget::finishSectionedMutation()
{
if (bulkLoading) {
return; return;
} }
target->addChild(item); // reparents: the item leaves its old section sortItems();
if (!bulkLoading) { applyFilter();
sortItems(); userTree->viewport()->update();
applyFilter();
userTree->viewport()->update();
}
} }

View file

@ -175,6 +175,10 @@ private:
bool sectioned = false; bool sectioned = false;
QStringList sectionIds; QStringList sectionIds;
QMap<QString, QTreeWidgetItem *> sectionItems; QMap<QString, QTreeWidgetItem *> sectionItems;
// One row per (section, user): a user that is online AND a buddy appears in
// both the "Online" and the "Buddies" sections, so the same user can own
// several rows, each hanging off its section's divider.
QMap<QString, QMap<QString, UserListTWI *>> sectionUsers;
QSet<QString> expandedSections; QSet<QString> expandedSections;
void createSectionItems(); void createSectionItems();
QTreeWidgetItem *createSectionItem(const QString &sectionId); QTreeWidgetItem *createSectionItem(const QString &sectionId);
@ -182,11 +186,16 @@ private:
void updateSectionDivider(const QString &sectionId); void updateSectionDivider(const QString &sectionId);
void handleSectionExpansion(QTreeWidgetItem *item, bool expanded); void handleSectionExpansion(QTreeWidgetItem *item, bool expanded);
void setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded); void setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded);
void handleOnlineChange(const ServerInfo_User &user, bool online); void handleOnlineChange(const ServerInfo_User &user);
void handleOnlineChangeLeft(const QString &userName); void handleOnlineChangeLeft(const QString &userName);
void handleListAdd(const QString &sectionId, const ServerInfo_User &user); void handleListAdd(const QString &sectionId, const ServerInfo_User &user);
void handleListRemove(const QString &sectionId, const QString &userName); void handleListRemove(const QString &sectionId, const QString &userName);
void moveToSection(const QString &sectionId, const QString &userName); /** Creates or updates the row for @p user in @p sectionId. */
UserListTWI *ensureSectionMembership(const QString &sectionId, const ServerInfo_User &user, bool online);
/** Removes and deletes the row for @p userName in @p sectionId. */
bool dropSectionMembership(const QString &sectionId, const QString &userName);
/** Sorts, re-filters and repaints after a sectioned-mode mutation. */
void finishSectionedMutation();
void updateCardArtParams(const ServerInfo_User &user, const QString &userName); void updateCardArtParams(const ServerInfo_User &user, const QString &userName);
void processUserInfo(const QString &sectionId, const ServerInfo_User &user, bool online); void processUserInfo(const QString &sectionId, const ServerInfo_User &user, bool online);