diff --git a/html/css/app.css b/html/css/app.css
index 074be61..e06f5bd 100644
--- a/html/css/app.css
+++ b/html/css/app.css
@@ -234,6 +234,101 @@ a:hover { color: var(--accent-hover); text-decoration: underline; }
font-size: 0.82rem;
}
+/* Label row: puts the label and an inline transient status message on the
+ same line without disturbing the vertical rhythm of surrounding fields. */
+.fieldLabelRow {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.75em;
+ margin-bottom: 0.35em;
+}
+
+.fieldLabelRow > label {
+ margin-bottom: 0;
+ font-weight: 500;
+ font-size: 0.9rem;
+ color: var(--text);
+}
+
+.fieldStatus {
+ font-size: 0.8rem;
+ color: var(--accent, #2563eb);
+ opacity: 0;
+ transition: opacity 0.2s;
+ min-height: 1em;
+}
+
+.fieldStatus:not(:empty) {
+ opacity: 1;
+}
+
+.fieldStatus--error {
+ color: #b91c1c;
+}
+
+/* Chip strip: compact list of removable pills used by the "Manage
+ favorites" affordance below the favorite-groups picker. Chips are
+ click-target friendly on touch (min 32px tall) and truncate long
+ labels with ellipsis so a wall of group names doesn't blow out the
+ card width. */
+.chipStrip {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4em;
+ margin-top: 0.55em;
+}
+
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.35em;
+ max-width: 100%;
+ padding: 0.3em 0.35em 0.3em 0.7em;
+ background: var(--surface-muted, #f1f5f9);
+ border: 1px solid var(--border-strong, #cbd5e1);
+ border-radius: 999px;
+ font-size: 0.82rem;
+ line-height: 1.3;
+ color: var(--text, #0f172a);
+}
+
+.chip__label {
+ max-width: 22ch;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.chip__close {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 1.35em;
+ height: 1.35em;
+ padding: 0;
+ border: none;
+ background: transparent;
+ color: var(--text-muted, #64748b);
+ font-size: 1.05rem;
+ line-height: 1;
+ border-radius: 999px;
+ cursor: pointer;
+ transition: background 0.15s, color 0.15s;
+}
+
+.chip__close:hover:not(:disabled),
+.chip__close:focus-visible {
+ background: #fee2e2;
+ color: #b91c1c;
+ outline: none;
+}
+
+.chip__close:disabled {
+ opacity: 0.5;
+ cursor: default;
+}
+
.field-row {
display: grid;
grid-template-columns: 1fr 1fr;
diff --git a/html/sendMessage.html b/html/sendMessage.html
index d416d1e..c88bc8f 100644
--- a/html/sendMessage.html
+++ b/html/sendMessage.html
@@ -53,9 +53,14 @@
-
+
+
+
+
+
+
Selecting a group under Additional groups saves it to your favorites. Click the × on a chip to remove that favorite.
diff --git a/html/sendMessage.js b/html/sendMessage.js
index 06a6f44..aa33f55 100644
--- a/html/sendMessage.js
+++ b/html/sendMessage.js
@@ -16,6 +16,12 @@ var message = new EasyMDE({
maxHeight: '260px',
});
+// In-memory mirror of the server-side favorites list for this user + bot.
+// Kept in sync with every /user/groups/add and /user/groups/remove response
+// so the chip strip and the "already a favorite?" check stay authoritative
+// without a round-trip.
+var favoriteGroups = [];
+
CollabCentral.init(function (ctx) {
// User is authenticated and authorized. Reveal the form + populate the
// group selectors from the backend.
@@ -72,21 +78,142 @@ function showImagePreview(event) {
preview.style.display = 'block';
}
+// ---- Favorites -----------------------------------------------------------
+
function loadFavoriteGroups(appName) {
var el = document.getElementById('favGroups');
fetch('/CollabCentral/' + appName + '/user/groups/list')
.then(function (res) { return res.json(); })
.then(function (groups) {
- for (var i = 0; i < groups.length; i++) {
+ favoriteGroups = Array.isArray(groups) ? groups.slice() : [];
+ for (var i = 0; i < favoriteGroups.length; i++) {
var option = document.createElement('option');
- option.text = groups[i].name;
- option.value = groups[i].id;
+ option.text = favoriteGroups[i].name;
+ option.value = favoriteGroups[i].id;
el.add(option);
}
VirtualSelect.init({ ele: '#favGroups' });
+ renderFavoritesManager();
});
}
+// Renders the chip strip below the favorites picker. Each chip has an
+// × button that removes the group from favorites. The chip strip is
+// intentionally separate from the VirtualSelect widget: the picker answers
+// "which favorites am I sending to right now?", the chip strip answers
+// "which groups do I want to remember for next time?".
+function renderFavoritesManager() {
+ var el = document.getElementById('favGroupsManager');
+ if (!el) return;
+ el.innerHTML = '';
+ if (!favoriteGroups.length) {
+ el.classList.add('hidden');
+ return;
+ }
+ el.classList.remove('hidden');
+ for (var i = 0; i < favoriteGroups.length; i++) {
+ var g = favoriteGroups[i];
+ var chip = document.createElement('span');
+ chip.className = 'chip';
+ var label = document.createElement('span');
+ label.className = 'chip__label';
+ label.textContent = g.alias ? g.alias : g.name;
+ if (g.alias && g.name && g.alias !== g.name) {
+ chip.title = g.name;
+ }
+ var btn = document.createElement('button');
+ btn.type = 'button';
+ btn.className = 'chip__close';
+ btn.setAttribute('aria-label', 'Remove ' + (g.name || 'favorite'));
+ btn.textContent = '×';
+ btn.dataset.groupId = g.id;
+ btn.addEventListener('click', onRemoveFavorite);
+ chip.appendChild(label);
+ chip.appendChild(btn);
+ el.appendChild(chip);
+ }
+}
+
+function onRemoveFavorite(event) {
+ var groupId = event.currentTarget.dataset.groupId;
+ if (!groupId) return;
+ event.currentTarget.disabled = true;
+ fetch('/CollabCentral/' + CollabCentral.appName + '/user/groups/remove', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: groupId })
+ })
+ .then(function (res) {
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.json();
+ })
+ .then(function (updatedFavorites) {
+ favoriteGroups = updatedFavorites || [];
+ renderFavoritesManager();
+ flashFavoritesStatus('Removed from favorites.');
+ })
+ .catch(function (err) {
+ console.error('Failed to remove favorite:', err);
+ flashFavoritesStatus('Could not remove favorite.', true);
+ event.currentTarget.disabled = false;
+ });
+}
+
+// Fires when the "Additional groups" multi-select changes. For each newly
+// selected id that isn't already a favorite we POST /user/groups/add. The
+// picker itself keeps the selection (so this send still uses the group);
+// the group starts appearing in the Favorite Groups picker on the next
+// page load, and immediately in the chip strip below.
+function onNewGroupsChange(newlySelectedIds) {
+ if (!newlySelectedIds || !newlySelectedIds.length) return;
+ var newGroupsEl = document.getElementById('newGroups');
+ var pending = 0;
+ var successes = 0;
+
+ newlySelectedIds.forEach(function (id) {
+ if (favoriteGroups.some(function (g) { return g.id === id; })) return;
+ pending++;
+ fetch('/CollabCentral/' + CollabCentral.appName + '/user/groups/add', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ id: id })
+ })
+ .then(function (res) {
+ if (!res.ok) throw new Error('HTTP ' + res.status);
+ return res.json();
+ })
+ .then(function (updatedFavorites) {
+ favoriteGroups = updatedFavorites || favoriteGroups;
+ successes++;
+ renderFavoritesManager();
+ })
+ .catch(function (err) {
+ console.error('Failed to add favorite ' + id + ':', err);
+ flashFavoritesStatus('Could not save one or more favorites.', true);
+ })
+ .finally(function () {
+ pending--;
+ if (pending === 0 && successes > 0) {
+ flashFavoritesStatus(successes === 1
+ ? 'Added to favorites.'
+ : 'Added ' + successes + ' to favorites.');
+ }
+ });
+ });
+}
+
+function flashFavoritesStatus(text, isError) {
+ var el = document.getElementById('favGroupsStatus');
+ if (!el) return;
+ el.textContent = text;
+ el.classList.toggle('fieldStatus--error', !!isError);
+ clearTimeout(flashFavoritesStatus._t);
+ flashFavoritesStatus._t = setTimeout(function () {
+ el.textContent = '';
+ el.classList.remove('fieldStatus--error');
+ }, 4000);
+}
+
function loadNewGroups(appName) {
var el = document.getElementById('newGroups');
fetch('/CollabCentral/' + appName + '/user/groups/find')
@@ -99,9 +226,25 @@ function loadNewGroups(appName) {
el.add(option);
}
VirtualSelect.init({ ele: '#newGroups' });
+
+ // VirtualSelect emits a standard 'change' event on the original
+ //