aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js1
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js7
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/style.css30
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/transport.js38
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js13
-rw-r--r--packages/meshbay-hub/tests/test_app_settings_plugin.py72
-rw-r--r--packages/meshbay-hub/tests/test_upload_controls_hidden.py13
18 files changed, 174 insertions, 23 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
index 68569b9..5a15074 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/chat-app-settings.js
@@ -45,11 +45,14 @@ function ChatSettings({ roots, dirs, settings, saveDirectories, transport, signF
value=${directory} disabled=${busy || noWritable}
onChange=${setDirectory} />
- <button class="btn btn-small btn-secondary" style="margin-top:4px"
- disabled=${busy || !dirty}
+ <div class="app-save-row">
+ <button class="app-save" disabled=${busy || !dirty}
onClick=${() => run(() => transport.setChatDirectory(directory, signFn))}>
${busy ? t('settings_app.saving') : t('settings_app.save')}
</button>
+ ${!busy && !dirty && html`
+ <span class="app-save-why">${t('settings_app.no_changes')}</span>`}
+ </div>
<div class="settings-row" style="margin-top:12px">
<${ToggleSwitch} checked=${linkPreview} disabled=${busy}
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
index d05d3a3..d47762e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -840,6 +840,7 @@ export default {
'folder_tree.read_only_blocked': 'Nur lesen — kein Schreiben möglich',
'settings_app.save': 'Speichern',
'settings_app.saving': 'Wird gespeichert…',
+ 'settings_app.no_changes': 'Keine Änderungen zu speichern',
'settings_app.disabled_hint': 'Schalten Sie diese App ein, um sie zu konfigurieren.',
'settings_app.video_directories_label': 'Video-Ordner',
'settings_app.video_directories_hint': 'Wo die Filme und Serien dieser Gruppe liegen. Nichts außerhalb erscheint im Videos-Tab.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
index 6dbb70f..932aad3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -628,6 +628,7 @@ export default {
'folder_tree.read_only_blocked': 'Read-only — cannot be written to',
'settings_app.save': 'Save',
'settings_app.saving': 'Saving…',
+ 'settings_app.no_changes': 'No changes to save',
'settings_app.disabled_hint': 'Turn this app on to configure it.',
'settings_app.video_directories_label': 'Video folders',
'settings_app.video_directories_hint': 'Where this group\'s films and shows live. Nothing outside them appears in the Videos tab.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
index aa950cc..ca912a4 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -836,6 +836,7 @@ export default {
'folder_tree.read_only_blocked': 'Solo lectura: no se puede escribir',
'settings_app.save': 'Guardar',
'settings_app.saving': 'Guardando…',
+ 'settings_app.no_changes': 'Sin cambios que guardar',
'settings_app.disabled_hint': 'Activa esta aplicación para configurarla.',
'settings_app.video_directories_label': 'Carpetas de vídeo',
'settings_app.video_directories_hint': 'Dónde están las películas y series de este grupo. Nada fuera de ellas aparece en la pestaña Vídeos.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
index daa9f94..ccd9e25 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -854,6 +854,7 @@ export default {
'folder_tree.read_only_blocked': 'Lecture seule — écriture impossible',
'settings_app.save': 'Enregistrer',
'settings_app.saving': 'Enregistrement…',
+ 'settings_app.no_changes': 'Aucune modification à enregistrer',
'settings_app.disabled_hint': 'Activez cette application pour la configurer.',
'settings_app.video_directories_label': 'Dossiers vidéo',
'settings_app.video_directories_hint': 'Où vivent les films et séries de ce groupe. Rien en dehors n\'apparaît dans l\'onglet Vidéos.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
index 364c26c..c1c3d52 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -850,6 +850,7 @@ export default {
'folder_tree.read_only_blocked': 'Sola lettura: non vi si può scrivere',
'settings_app.save': 'Salva',
'settings_app.saving': 'Salvataggio…',
+ 'settings_app.no_changes': 'Nessuna modifica da salvare',
'settings_app.disabled_hint': 'Attiva questa applicazione per configurarla.',
'settings_app.video_directories_label': 'Cartelle video',
'settings_app.video_directories_hint': 'Dove si trovano film e serie di questo gruppo. Nulla al di fuori compare nella scheda Video.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
index f778fc4..e5392a3 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -834,6 +834,7 @@ export default {
'folder_tree.read_only_blocked': '読み取り専用 — 書き込みできません',
'settings_app.save': '保存',
'settings_app.saving': '保存中…',
+ 'settings_app.no_changes': '保存する変更はありません',
'settings_app.disabled_hint': 'このアプリを有効にすると設定できます。',
'settings_app.video_directories_label': '動画フォルダー',
'settings_app.video_directories_hint': 'このグループの映画や番組がある場所です。それ以外は動画タブに表示されません。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
index 6e6842e..09e3e8d 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -852,6 +852,7 @@ export default {
'folder_tree.read_only_blocked': 'Alleen-lezen — kan niet worden beschreven',
'settings_app.save': 'Opslaan',
'settings_app.saving': 'Opslaan…',
+ 'settings_app.no_changes': 'Geen wijzigingen om op te slaan',
'settings_app.disabled_hint': 'Zet deze app aan om hem in te stellen.',
'settings_app.video_directories_label': 'Videomappen',
'settings_app.video_directories_hint': 'Waar de films en series van deze groep staan. Niets daarbuiten verschijnt op het tabblad Video\'s.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
index c8cbd16..34847e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -878,6 +878,7 @@ export default {
'folder_tree.read_only_blocked': 'Tylko do odczytu — nie można zapisywać',
'settings_app.save': 'Zapisz',
'settings_app.saving': 'Zapisywanie…',
+ 'settings_app.no_changes': 'Brak zmian do zapisania',
'settings_app.disabled_hint': 'Włącz tę aplikację, aby ją skonfigurować.',
'settings_app.video_directories_label': 'Foldery wideo',
'settings_app.video_directories_hint': 'Gdzie znajdują się filmy i seriale tej grupy. Nic poza nimi nie pojawi się w zakładce Wideo.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
index 6eb2242..fc211c9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js
@@ -837,6 +837,7 @@ export default {
'folder_tree.read_only_blocked': 'Somente leitura — não é possível gravar',
'settings_app.save': 'Salvar',
'settings_app.saving': 'Salvando…',
+ 'settings_app.no_changes': 'Nenhuma alteração para salvar',
'settings_app.disabled_hint': 'Ative este aplicativo para configurá-lo.',
'settings_app.video_directories_label': 'Pastas de vídeo',
'settings_app.video_directories_hint': 'Onde ficam os filmes e séries deste grupo. Nada fora delas aparece na aba Vídeos.',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
index 044611e..9db7cc7 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js
@@ -821,6 +821,7 @@ export default {
'folder_tree.read_only_blocked': '只读 — 无法写入',
'settings_app.save': '保存',
'settings_app.saving': '正在保存…',
+ 'settings_app.no_changes': '没有需要保存的更改',
'settings_app.disabled_hint': '启用该应用后即可配置。',
'settings_app.video_directories_label': '视频文件夹',
'settings_app.video_directories_hint': '该群组的影片和剧集所在位置。其外的内容不会出现在「视频」标签页。',
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
index 01174b1..a3b02d9 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/music-app-settings.js
@@ -38,11 +38,14 @@ function MusicSettings({ roots, dirs, settings, saveDirectories, transport, sign
value=${directories} disabled=${busy}
onChange=${setDirectories} />
- <button class="btn btn-small btn-secondary" style="margin-top:4px"
- disabled=${busy || !dirsDirty}
+ <div class="app-save-row">
+ <button class="app-save" disabled=${busy || !dirsDirty}
onClick=${() => run(() => saveDirectories(directories))}>
${busy ? t('settings_app.saving') : t('settings_app.save')}
</button>
+ ${!busy && !dirsDirty && html`
+ <span class="app-save-why">${t('settings_app.no_changes')}</span>`}
+ </div>
<h4 class="app-settings-sub">${t('settings_node.musicbrainz_title')}</h4>
<p class="settings-hint">${t('settings_node.musicbrainz_hint')}</p>
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
index 81e6cc1..a241eea 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/photos-app-settings.js
@@ -32,11 +32,14 @@ function PhotoSettings({ roots, dirs, settings, saveDirectories }) {
value=${directories} disabled=${busy}
onChange=${setDirectories} />
- <button class="btn btn-small btn-secondary" style="margin-top:4px"
- disabled=${busy || !dirty}
+ <div class="app-save-row">
+ <button class="app-save" disabled=${busy || !dirty}
onClick=${() => run(() => saveDirectories(directories))}>
${busy ? t('settings_app.saving') : t('settings_app.save')}
</button>
+ ${!busy && !dirty && html`
+ <span class="app-save-why">${t('settings_app.no_changes')}</span>`}
+ </div>
${msg && html`<p class="settings-hint">${msg}</p>`}
</div>
`;
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css
index 9c0d869..bf4e8e1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/style.css
+++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css
@@ -1271,6 +1271,36 @@ button:disabled { opacity: 0.5; cursor: not-allowed; }
/* One app's settings pane, inside its section. */
.app-settings > .settings-row:first-child { margin-top: 0; }
+/* The Save button of an app's settings pane.
+ *
+ * It was `btn btn-small btn-secondary`, and there is no `.btn` rule at all —
+ * so it inherited `.btn-secondary`: no background, a transparent border, dim
+ * grey text. Enabled, it already looked like a disabled control; disabled, it
+ * was the same thing at 40% opacity. "You cannot always click Save, you do not
+ * notice, and it does not work" was one sentence describing all of that.
+ *
+ * It looks like a button now, and a disabled one looks disabled. */
+.app-save {
+ display: inline-flex; align-items: center; gap: 6px;
+ height: 30px; padding: 0 14px; margin-top: 4px;
+ border: 1px solid var(--accent); border-radius: 6px;
+ background: var(--accent); color: var(--accent-text);
+ font-family: inherit; font-size: 0.83em; cursor: pointer;
+}
+.app-save:hover:not(:disabled) { background: var(--accent-hover); }
+/* No transition on the enabled/disabled swap. It animates a colour change that
+ coincides with a text-colour change, which reads as a flicker — and it made
+ the state genuinely hard to observe: a measurement taken during it reports a
+ transparent background on a button that is not transparent. */
+.app-save:disabled {
+ background: none; color: var(--text-dim);
+ border-color: var(--border); opacity: 1; cursor: default;
+}
+/* Why it is inert, beside it — a disabled control that explains itself is the
+ difference between "nothing changed" and "this is broken". */
+.app-save-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
+.app-save-why { font-size: 0.82em; color: var(--text-dim); }
+
.app-settings-sub {
margin: 18px 0 4px; font-size: 0.9em; font-weight: 600;
padding-top: 12px; border-top: 1px solid var(--border);
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/transport.js b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
index 3acfd20..179292e 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/transport.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/transport.js
@@ -72,13 +72,35 @@ function _aborted() {
// change anything — `op` is already on every admin_challenge, and this
// list is what lets a response two steps later be tied back to the right
// one.
-// The acks whose payload is state no caller could have predicted: they carry
-// the node's whole roots table back. See the note where they are dispatched.
-const ROOT_ACK_TYPES = new Set([
+// Acks the node *broadcasts* to everyone in the group, which the requester
+// therefore also has to be handed.
+//
+// `_dispatch` resolves an admin ack against the pending request and returns,
+// which is right for an op whose caller already knows the value it chose. It is
+// wrong for these: every *other* connected client learns the change from the
+// broadcast, and the one that asked for it is the only one that does not,
+// because its own request swallowed its copy. Found twice — first on the root
+// table, then on Chat's directory, where it meant the pane went on showing an
+// unsaved-looking draft after a save that had worked.
+const BROADCAST_ACK_TYPES = new Set([
'root_update_ack', 'root_eject_ack', 'root_plug_ack',
'root_add_ack', 'root_remove_ack',
+ 'app_directories_ack', 'chat_directory_ack', 'chat_link_preview_ack',
]);
+/** Hand a broadcast ack to the callback that would have had it from a peer. */
+function _replayBroadcast(transport, msg) {
+ if (msg.type === 'app_directories_ack' && transport._onAppDirectories) {
+ transport._onAppDirectories(msg.app, msg.directories || []);
+ } else if (msg.type === 'chat_directory_ack' && transport._onChatDirectory) {
+ transport._onChatDirectory(msg.path || '');
+ } else if (msg.type === 'chat_link_preview_ack' && transport._onChatLinkPreview) {
+ transport._onChatLinkPreview(Boolean(msg.enabled));
+ } else if (transport._onRootsChanged) {
+ transport._onRootsChanged(msg);
+ }
+}
+
const ADMIN_OP_TYPES = new Set([
'tmdb_override', 'tmdb_rematch', 'tmdb_config', 'tmdb_enabled', 'video_root', 'audio_root',
'photo_roots',
@@ -2349,9 +2371,7 @@ class MeshBayTransport {
// name it settled on. Returning here left the operator who clicked
// Eject as the one client that never saw it happen, while every
// other peer got the broadcast. So this one type is handed on.
- if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) {
- this._onRootsChanged(msg);
- }
+ if (BROADCAST_ACK_TYPES.has(msg.type)) _replayBroadcast(this, msg);
return;
}
}
@@ -2486,8 +2506,10 @@ class MeshBayTransport {
// A root's flags changed, or one was ejected, plugged, added or removed.
// Broadcast by the node to every peer, so everyone's table updates without
// waiting for the next index_sync.
- if (ROOT_ACK_TYPES.has(msg.type) && this._onRootsChanged) {
- this._onRootsChanged(msg);
+ if (msg.type === 'root_update_ack' || msg.type === 'root_eject_ack'
+ || msg.type === 'root_plug_ack' || msg.type === 'root_add_ack'
+ || msg.type === 'root_remove_ack') {
+ if (this._onRootsChanged) this._onRootsChanged(msg);
}
// The operator's node is scanning — never the entries themselves, just
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
index b2d5015..86e47ec 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/video-app-settings.js
@@ -56,11 +56,14 @@ function VideoSettings({ roots, dirs, settings, saveDirectories, transport, sign
value=${directories} disabled=${busy}
onChange=${setDirectories} />
- <button class="btn btn-small btn-secondary" style="margin-top:4px"
- disabled=${busy || !dirsDirty}
+ <div class="app-save-row">
+ <button class="app-save" disabled=${busy || !dirsDirty}
onClick=${() => run(() => saveDirectories(directories))}>
${busy ? t('settings_app.saving') : t('settings_app.save')}
</button>
+ ${!busy && !dirsDirty && html`
+ <span class="app-save-why">${t('settings_app.no_changes')}</span>`}
+ </div>
<h4 class="app-settings-sub">${t('settings_node.tmdb_title')}</h4>
<p class="settings-hint">${t('settings_node.tmdb_hint')}</p>
@@ -108,7 +111,11 @@ function VideoSettings({ roots, dirs, settings, saveDirectories, transport, sign
<p class="settings-hint">${t('settings_node.tmdb_language_hint')}</p>
</div>
- <button class="btn btn-small btn-secondary" disabled=${busy}
+ ${/* The same control as the one above it. Two Save buttons in one pane
+ that do not look alike is worse than either looking wrong. This one
+ is never inert: the language always has a value to send, and an
+ empty token means "leave the stored one alone", not "no change". */''}
+ <button class="app-save" disabled=${busy}
onClick=${() => run(async () => {
// `undefined` for the token means "leave the stored one alone",
// which is not the same as `''` — that clears it. The input starts
diff --git a/packages/meshbay-hub/tests/test_app_settings_plugin.py b/packages/meshbay-hub/tests/test_app_settings_plugin.py
index 6a768a9..8a095af 100644
--- a/packages/meshbay-hub/tests/test_app_settings_plugin.py
+++ b/packages/meshbay-hub/tests/test_app_settings_plugin.py
@@ -320,3 +320,75 @@ def test_a_refusal_does_not_look_like_a_footnote():
assert "role=" in block and "alert" in block, (
"a refusal that appears after a click has to be announced, not just "
"drawn")
+
+
+# ── Saving, and being able to tell ──────────────────────────────────────────
+
+PANE_FLAGS = {"chat-app-settings.js": "dirty",
+ "video-app-settings.js": "dirsDirty",
+ "music-app-settings.js": "dirsDirty",
+ "photos-app-settings.js": "dirty"}
+
+
+@pytest.mark.parametrize("pane", sorted(PANE_FLAGS))
+def test_save_is_a_button_and_not_dim_text(pane):
+ """
+ It was `btn btn-small btn-secondary`, and there is no `.btn` rule in the
+ stylesheet at all — so it took `.btn-secondary`: no background, a
+ transparent border, dim grey text. Enabled it already looked like a
+ disabled control, and disabled it was that at 40% opacity.
+
+ "You cannot always click Save, you do not notice, and it does not work" is
+ one sentence describing all of that.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert 'class="app-save"' in source, f"{pane}'s Save is not the shared control"
+ assert "btn-secondary" not in source, (
+ f"{pane}'s Save is still styled as dim text")
+
+
+@pytest.mark.parametrize("pane", sorted(PANE_FLAGS))
+def test_an_inert_save_says_why(pane):
+ """
+ A disabled control that explains itself is the difference between "nothing
+ changed" and "this is broken". Without it the reader has to know what the
+ pane counts as a change.
+ """
+ source = (STATIC / pane).read_text(encoding="utf-8")
+ assert "app-save-why" in source and "settings_app.no_changes" in source
+
+
+def test_the_disabled_state_is_visually_distinct():
+ css = (STATIC / "style.css").read_text(encoding="utf-8")
+ rule = css[css.index(".app-save {"):]
+ rule = rule[:rule.index(".app-save-row")]
+ assert "var(--accent)" in rule, "an enabled Save has no fill"
+ disabled = rule[rule.index(".app-save:disabled"):]
+ assert "background: none" in disabled and "--text-dim" in disabled, (
+ "disabled differs from enabled by opacity alone, which is what made "
+ "it unreadable")
+
+
+def test_a_saved_setting_reaches_the_page_that_renders_the_pane():
+ """
+ Why Chat was the systematic case.
+
+ `_dispatch` resolves an admin ack against the pending request and returns —
+ right for an op whose caller already knows the value it chose. Chat's pane
+ calls `transport.setChatDirectory` itself, so nothing told `group-page`
+ anything: the node saved it, every *other* connected client learned it from
+ the broadcast, and the one that asked went on showing an unsaved-looking
+ draft. Clicking Save again just re-sent it.
+ """
+ transport = TRANSPORT.read_text(encoding="utf-8")
+ block = transport[transport.index("const BROADCAST_ACK_TYPES"):]
+ block = block[:block.index("]);") + 3]
+ for ack in ("chat_directory_ack", "chat_link_preview_ack",
+ "app_directories_ack"):
+ assert ack in block, f"{ack} is swallowed by its own request"
+
+ assert "_replayBroadcast" in transport
+ replay = transport[transport.index("function _replayBroadcast"):]
+ replay = replay[:replay.index("\n}") + 2]
+ for cb in ("_onChatDirectory", "_onChatLinkPreview", "_onAppDirectories"):
+ assert cb in replay, f"{cb} is never called for the requester"
diff --git a/packages/meshbay-hub/tests/test_upload_controls_hidden.py b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
index bdba373..f6f476e 100644
--- a/packages/meshbay-hub/tests/test_upload_controls_hidden.py
+++ b/packages/meshbay-hub/tests/test_upload_controls_hidden.py
@@ -212,16 +212,17 @@ def test_the_notice_also_answers_the_operators_own_request():
caused it.
Every other admin ack can be resolved and dropped, because its caller
- already knows what it asked for and updates local state from that. The root
- acks carry a whole table only the node can compute — availability, the name
- it settled on, the eject a failed plug left in place — so resolving one
- without handing it on left the operator who clicked Eject as the only
- client that never saw it happen.
+ already knows what it asked for and updates local state from that. These
+ are the ones the node *broadcasts*: every other connected client learns the
+ change from it, and the one that asked is the only one that does not,
+ because its own request swallowed its copy. Found on the root table, then
+ again on Chat's directory — where it meant the pane went on showing an
+ unsaved-looking draft after a save that had worked.
"""
transport = TRANSPORT.read_text(encoding="utf-8")
block = transport[transport.index("msg.type.endsWith('_ack')"):]
block = block[:block.index("_uploaders")]
- assert "ROOT_ACK_TYPES" in block and "_onRootsChanged" in block, (
+ assert "BROADCAST_ACK_TYPES" in block and "_replayBroadcast" in block, (
"the initiating client resolves the ack and learns nothing from it")