aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-18 17:46:54 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-18 17:46:54 +0200
commitcd2e89f5f5cccdb116db4fcb82d00b6325972782 (patch)
tree17be58fb00b49736f818a2f8063464960b5b1101 /packages/meshbay-hub
parent2ef5498ab1509a93691b87b4cc5d9b52bb3f52dc (diff)
downloadmeshbay-cd2e89f5f5cccdb116db4fcb82d00b6325972782.tar.gz
fix: the chat tab no longer scrolls, and a group is listed or invite-only
**The chat tab was 8px too tall, at every window size.** The panel is sized from JS to `viewport - top - 16`, which puts its bottom 16px above the fold — but it sits inside `.main`, which adds 24px of padding below it. Eight pixels of document past the window, whatever the window. Measured at 700, 900 and 1200: `scrollHeight` 708, 908, 1208. This is the second one of these — the sign-in card was `.page-center` and `.layout` each reserving `100vh - 52px` — so it is now measured in the suite rather than reasoned about. `tests/harness/scroll_probe.py` renders the real markup against the real stylesheet and **runs the real `fit()` lifted out of `app.js`**: a copy of the formula in a test would go on passing after the original changed, which is exactly the bug being guarded. The fix does not encode 24 anywhere. The first pass runs as before, then the leftover is measured and taken off, so anything added below the panel later is absorbed the same way. Now `scrollHeight == innerHeight` at all three heights, nothing below the fold, and the panel still fills the room it has — that last one has its own test, because shrinking the chat to 240px would satisfy every other assertion here and be useless. The Settings tab was measured too and is **not** a bug: it fits at 1200px and overflows only when its content is genuinely taller than the window. **Group creation asked one question twice.** Visibility and admission were separate selectors that could only ever be set together — picking Public reached over and set the policy — and two of the four combinations are meaningless. The API already refused public+invite with a 422, so the form could build a request that could not succeed. Private+open was accepted and should not have been: a group anyone may join that nobody can find is a listing with the listing removed, since joining goes through the node and there is no link to pass around. So: one selector, "who can join", and the request derives the rest. The API now refuses the other impossible pair as well, with a message that says which way to resolve it. Six locale strings the visibility box owned are deleted rather than left unread in ten files, and the two surviving descriptions now say what each choice means for who can *find* the group — with the word "public" gone from the page, nothing else would have said it, and someone would publish a group without meaning to. 865 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/groups.py33
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/app.js80
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/de.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/en.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/es.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/it.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/pt-BR.js10
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/locales/zh-CN.js10
-rw-r--r--packages/meshbay-hub/tests/harness/scroll_probe.py169
-rw-r--r--packages/meshbay-hub/tests/test_groups_self_service.py45
-rw-r--r--packages/meshbay-hub/tests/test_page_does_not_scroll.py125
-rw-r--r--packages/meshbay-hub/tests/test_transport_contracts.py58
16 files changed, 461 insertions, 149 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/groups.py b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
index 8283276..6819ff6 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/groups.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/groups.py
@@ -317,17 +317,30 @@ async def create_group(
current_user: User = Depends(require_user_scope),
db: AsyncSession = Depends(get_db),
):
+ # Being listed and being open are one question, not two.
+ #
+ # A public group that admits nobody is a contradiction: it is in the
+ # directory, so people find it and then discover they cannot get in.
+ # Admission by request was considered and dropped — between strangers the
+ # only channel is the hub, so the one-time code would travel through the
+ # very party it exists to keep out, and would protect nothing.
+ #
+ # The other way round was accepted until now and should not have been: a
+ # group anyone may join, that nobody can find, is a listing with the listing
+ # removed. Nothing could reach it but a link, and there is no link — joining
+ # goes through the node. The create form no longer offers either
+ # combination; refusing them here is what makes that true of the API too.
+ if body.visibility == "public" and body.join_policy != "open":
+ raise HTTPException(
+ status_code=422,
+ detail="A public group is open to join. Make it private if you "
+ "want to choose who comes in.")
+ if body.visibility != "public" and body.join_policy == "open":
+ raise HTTPException(
+ status_code=422,
+ detail="A private group is invite-only. Make it public if you want "
+ "anyone to be able to join.")
if body.visibility == "public":
- # A public group that admits nobody is a contradiction: it is listed in
- # the directory, so people find it and then discover they cannot get in.
- # Admission by request was considered and dropped — between strangers the
- # only channel is the hub, so the one-time code would travel through the
- # very party it exists to keep out, and would protect nothing.
- if body.join_policy != "open":
- raise HTTPException(
- status_code=422,
- detail="A public group is open to join. Make it private if you "
- "want to choose who comes in.")
await _check_public_group_quota(db, current_user)
desc = (body.description or "")[:512] if body.description else None
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/app.js b/packages/meshbay-hub/src/meshbay_hub/static/app.js
index eb11469..62bbfa5 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/app.js
@@ -1005,7 +1005,6 @@ function ExplorePage({ token, myGroupIds }) {
function CreateGroupPage({ token, onCreated }) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
- const [visibility, setVisibility] = useState('private');
const [joinPolicy, setJoinPolicy] = useState('invite');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
@@ -1016,7 +1015,10 @@ function CreateGroupPage({ token, onCreated }) {
setLoading(true);
setError('');
try {
- const body = { name: name.trim(), visibility, join_policy: joinPolicy };
+ // Derived, not asked: "open" is what makes a group listed, and there is
+ // no third combination the server would accept.
+ const body = { name: name.trim(), join_policy: joinPolicy,
+ visibility: joinPolicy === 'open' ? 'public' : 'private' };
if (description.trim()) body.description = description.trim().slice(0, 512);
const data = await hubFetch('/v1/groups', {
method: 'POST', token, body,
@@ -1055,59 +1057,39 @@ function CreateGroupPage({ token, onCreated }) {
</div>
</div>
+ ${/* One question, not two. Visibility and admission were separate
+ selectors that could only ever be set together: a public group
+ admits everyone by definition, and a private one that anyone may
+ join is a directory listing nobody can find. The server already
+ refused public+invite with a 422 — the form could build a request
+ that could not succeed. Now the answer to "who can join" settles
+ both, and the descriptions say what each one means for who can
+ *find* the group, which is the part the visibility box was there
+ to state and no longer needs to. */ html`
<div class="settings-section">
- <h3 class="settings-heading">${t('create_group.visibility')}</h3>
+ <h3 class="settings-heading">${t('create_group.join_policy')}</h3>
<div class="choice-list">
- <label class="choice ${visibility === 'private' ? 'selected' : ''}">
- <input type="radio" name="visibility" checked=${visibility === 'private'}
- onChange=${() => { setVisibility('private'); setJoinPolicy('invite'); }} />
+ <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
+ onChange=${() => setJoinPolicy('invite')} />
<${Icon} name="lock" cls="choice-icon" />
<span class="choice-text">
- <span class="choice-title">${t('create_group.private')}</span>
- <span class="choice-desc">${t('create_group.private_desc')}</span>
+ <span class="choice-title">${t('create_group.invite')}</span>
+ <span class="choice-desc">${t('create_group.invite_desc')}</span>
</span>
</label>
- <label class="choice ${visibility === 'public' ? 'selected' : ''}">
- <input type="radio" name="visibility" checked=${visibility === 'public'}
- onChange=${() => { setVisibility('public'); setJoinPolicy('open'); }} />
+ <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
+ <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
+ onChange=${() => setJoinPolicy('open')} />
<${Icon} name="globe" cls="choice-icon" />
<span class="choice-text">
- <span class="choice-title">${t('create_group.public')}</span>
- <span class="choice-desc">${t('create_group.public_desc')}</span>
+ <span class="choice-title">${t('create_group.open')}</span>
+ <span class="choice-desc">${t('create_group.open_desc')}</span>
</span>
</label>
</div>
-
- ${visibility === 'public'
- ? html`<p class="settings-hint" style="margin-top:12px">
- ${t('create_group.public_is_open')}
- </p>`
- : html`
- <h3 class="settings-heading" style="margin-top:20px">
- ${t('create_group.join_policy')}
- </h3>
- <div class="choice-list">
- <label class="choice ${joinPolicy === 'invite' ? 'selected' : ''}">
- <input type="radio" name="join_policy" checked=${joinPolicy === 'invite'}
- onChange=${() => setJoinPolicy('invite')} />
- <${Icon} name="envelope" cls="choice-icon" />
- <span class="choice-text">
- <span class="choice-title">${t('create_group.invite')}</span>
- <span class="choice-desc">${t('create_group.invite_desc')}</span>
- </span>
- </label>
- <label class="choice ${joinPolicy === 'open' ? 'selected' : ''}">
- <input type="radio" name="join_policy" checked=${joinPolicy === 'open'}
- onChange=${() => setJoinPolicy('open')} />
- <${Icon} name="door" cls="choice-icon" />
- <span class="choice-text">
- <span class="choice-title">${t('create_group.open')}</span>
- <span class="choice-desc">${t('create_group.open_desc')}</span>
- </span>
- </label>
- </div>
- `}
</div>
+ `}
<button class="btn-primary" type="submit" disabled=${loading}>
${loading ? t('create_group.creating') : t('create_group.submit')}
@@ -2875,6 +2857,18 @@ function ChatPanel({ transportRef, username, entries, gekRef, onRefreshIndex, on
// the result — the answer must be the same either way.
const top = el.getBoundingClientRect().top + window.scrollY;
el.style.height = `${Math.max(CHAT_MIN_HEIGHT, vh - top - CHAT_BOTTOM_GAP)}px`;
+ // What sits *below* the panel is not knowable from up here — today it is
+ // `.main`'s 24px bottom padding against this 16px gap, which left the
+ // document 8px taller than the window and a scrollbar on the chat tab at
+ // every window size. Rather than encode 24 somewhere and have the next
+ // change to the page break it again, the leftover is measured and taken
+ // off. Self-correcting: anything added under the panel is absorbed the
+ // same way.
+ const over = document.documentElement.scrollHeight - vh;
+ if (over > 0) {
+ el.style.height =
+ `${Math.max(CHAT_MIN_HEIGHT, el.getBoundingClientRect().height - over)}px`;
+ }
};
fit();
window.addEventListener('resize', fit);
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 7eaf86f..ddeaeeb 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/de.js
@@ -322,16 +322,11 @@ export default {
'create_group.title': 'Gruppe erstellen',
'create_group.name': 'Gruppenname',
'create_group.name_placeholder': 'z. B. Familienfotos, Projektunterlagen …',
- 'create_group.visibility': 'Sichtbarkeit',
- 'create_group.private': 'Privat',
- 'create_group.private_desc': 'Nur eingeladene Mitglieder sehen die Dateien und greifen darauf zu',
- 'create_group.public': 'Öffentlich',
- 'create_group.public_desc': 'Alle können diese Gruppe finden und durchsehen',
'create_group.join_policy': 'Beitrittsregel',
'create_group.invite': 'Nur mit Einladung',
- 'create_group.invite_desc': 'Mitglieder müssen von einem Administrator eingeladen werden',
+ 'create_group.invite_desc': "Mitglieder müssen von einem Administrator eingeladen werden. Die Gruppe wird nicht öffentlich gelistet.",
'create_group.open': 'Offen (alle können beitreten)',
- 'create_group.open_desc': 'Alle können ohne Freigabe beitreten',
+ 'create_group.open_desc': "In den öffentlichen Gruppen gelistet — jeder kann sie finden und ohne Zustimmung beitreten.",
'create_group.description': 'Beschreibung',
'create_group.description_hint': 'Worum geht es in dieser Gruppe? (optional)',
'create_group.submit': 'Erstellen',
@@ -453,5 +448,4 @@ export default {
'group.leave_confirm': '„{name}“ verlassen? Sie verlieren den Zugang zu ihren Dateien und zum Chat. Hochgeladene Dateien bleiben auf dem Node, und der Node behält die für Sie gemerkte Identität, bis sein Betreiber sie entfernt.',
'profile.title': 'Profil',
'usermenu.profile': 'Profil',
- 'create_group.public_is_open': 'Eine öffentliche Gruppe kann jeder finden und ihr beitreten. Wenn Sie auswählen möchten, wer hereinkommt, erstellen Sie sie privat.',
};
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 b68d02d..9a0f754 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/en.js
@@ -312,16 +312,11 @@ export default {
'create_group.title': 'Create Group',
'create_group.name': 'Group name',
'create_group.name_placeholder': 'e.g. Family Photos, Project Files...',
- 'create_group.visibility': 'Visibility',
- 'create_group.private': 'Private',
- 'create_group.private_desc': 'Only invited members can see and access files',
- 'create_group.public': 'Public',
- 'create_group.public_desc': 'Anyone can discover and browse this group',
'create_group.join_policy': 'Join policy',
'create_group.invite': 'Invite only',
- 'create_group.invite_desc': 'Members must be invited by an admin',
+ 'create_group.invite_desc': "Members must be invited by an admin. The group is not listed publicly.",
'create_group.open': 'Open (anyone can join)',
- 'create_group.open_desc': 'Anyone can join without approval',
+ 'create_group.open_desc': "Listed in public groups — anyone can find it and join without approval.",
'create_group.description': 'Description',
'create_group.description_hint': 'What is this group about? (optional)',
'create_group.submit': 'Create',
@@ -438,5 +433,4 @@ export default {
'group.leave_confirm': 'Leave “{name}”? You will lose access to its files and chat. Files you uploaded stay on the node, and the node keeps the identity it pinned for you until its operator removes it.',
'profile.title': 'Profile',
'usermenu.profile': 'Profile',
- 'create_group.public_is_open': 'Anyone can find and join a public group. To choose who comes in, make it private.',
};
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 20f6910..0c72445 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/es.js
@@ -318,16 +318,11 @@ export default {
'create_group.title': 'Crear un grupo',
'create_group.name': 'Nombre del grupo',
'create_group.name_placeholder': 'p. ej. Fotos de familia, Archivos del proyecto...',
- 'create_group.visibility': 'Visibilidad',
- 'create_group.private': 'Privado',
- 'create_group.private_desc': 'Solo los miembros invitados ven los archivos y acceden a ellos',
- 'create_group.public': 'Público',
- 'create_group.public_desc': 'Cualquiera puede descubrir y explorar este grupo',
'create_group.join_policy': 'Política de acceso',
'create_group.invite': 'Solo por invitación',
- 'create_group.invite_desc': 'Los miembros deben ser invitados por un administrador',
+ 'create_group.invite_desc': "Los miembros deben ser invitados por un administrador. El grupo no aparece en la lista pública.",
'create_group.open': 'Abierto (cualquiera puede unirse)',
- 'create_group.open_desc': 'Cualquiera puede unirse sin aprobación',
+ 'create_group.open_desc': "Aparece en los grupos públicos: cualquiera puede encontrarlo y unirse sin aprobación.",
'create_group.description': 'Descripción',
'create_group.description_hint': '¿De qué trata este grupo? (opcional)',
'create_group.submit': 'Crear',
@@ -448,5 +443,4 @@ export default {
'group.leave_confirm': '¿Salir de «{name}»? Perderá el acceso a sus archivos y a su chat. Los archivos que subió permanecen en el node, y este conserva la identidad que fijó para usted hasta que su operador la retire.',
'profile.title': 'Perfil',
'usermenu.profile': 'Perfil',
- 'create_group.public_is_open': 'Cualquiera puede encontrar un grupo público y unirse a él. Para elegir quién entra, créelo privado.',
};
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 c3c5580..0dec5cf 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/fr.js
@@ -322,16 +322,11 @@ export default {
'create_group.title': 'Créer un groupe',
'create_group.name': 'Nom du groupe',
'create_group.name_placeholder': 'ex. Photos de famille, Documents du projet...',
- 'create_group.visibility': 'Visibilité',
- 'create_group.private': 'Privé',
- 'create_group.private_desc': 'Seuls les membres invités voient les fichiers et y accèdent',
- 'create_group.public': 'Public',
- 'create_group.public_desc': 'Tout le monde peut découvrir et parcourir ce groupe',
'create_group.join_policy': "Politique d'adhésion",
'create_group.invite': 'Sur invitation',
- 'create_group.invite_desc': 'Les membres doivent être invités par un administrateur',
+ 'create_group.invite_desc': "Les membres doivent être invités par un administrateur. Le groupe n’est pas listé publiquement.",
'create_group.open': 'Ouvert (tout le monde peut rejoindre)',
- 'create_group.open_desc': 'Tout le monde peut rejoindre sans validation',
+ 'create_group.open_desc': "Listé dans les groupes publics — n’importe qui peut le trouver et le rejoindre sans approbation.",
'create_group.description': 'Description',
'create_group.description_hint': 'De quoi parle ce groupe ? (facultatif)',
'create_group.submit': 'Créer',
@@ -453,5 +448,4 @@ export default {
'group.leave_confirm': 'Quitter « {name} » ? Vous perdrez l’accès à ses fichiers et à sa discussion. Les fichiers que vous avez envoyés restent sur le node, et celui-ci conserve l’identité qu’il a épinglée pour vous jusqu’à ce que son opérateur la retire.',
'profile.title': 'Profil',
'usermenu.profile': 'Profil',
- 'create_group.public_is_open': 'N’importe qui peut trouver un groupe public et le rejoindre. Pour choisir qui entre, créez-le en privé.',
};
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 1ae8217..81b6a3a 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/it.js
@@ -321,16 +321,11 @@ export default {
'create_group.title': 'Crea un gruppo',
'create_group.name': 'Nome del gruppo',
'create_group.name_placeholder': 'es. Foto di famiglia, File del progetto...',
- 'create_group.visibility': 'Visibilità',
- 'create_group.private': 'Privato',
- 'create_group.private_desc': 'Solo i membri invitati vedono i file e vi accedono',
- 'create_group.public': 'Pubblico',
- 'create_group.public_desc': 'Chiunque può scoprire e sfogliare questo gruppo',
'create_group.join_policy': 'Modalità di adesione',
'create_group.invite': 'Solo su invito',
- 'create_group.invite_desc': 'I membri devono essere invitati da un amministratore',
+ 'create_group.invite_desc': "I membri devono essere invitati da un amministratore. Il gruppo non è elencato pubblicamente.",
'create_group.open': 'Aperto (chiunque può partecipare)',
- 'create_group.open_desc': 'Chiunque può partecipare senza approvazione',
+ 'create_group.open_desc': "Elencato tra i gruppi pubblici: chiunque può trovarlo e unirsi senza approvazione.",
'create_group.description': 'Descrizione',
'create_group.description_hint': 'Di cosa si occupa questo gruppo? (facoltativo)',
'create_group.submit': 'Crea',
@@ -450,5 +445,4 @@ export default {
'group.leave_confirm': 'Uscire da «{name}»? Perderà l’accesso ai suoi file e alla chat. I file che ha caricato restano sul node, e il node mantiene l’identità che ha fissato per lei finché il suo operatore non la rimuove.',
'profile.title': 'Profilo',
'usermenu.profile': 'Profilo',
- 'create_group.public_is_open': 'Chiunque può trovare un gruppo pubblico e parteciparvi. Per scegliere chi entra, lo crei privato.',
};
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 f8713f5..11c6e31 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/ja.js
@@ -315,16 +315,11 @@ export default {
'create_group.title': 'グループを作成',
'create_group.name': 'グループ名',
'create_group.name_placeholder': '例:家族の写真、プロジェクトの資料…',
- 'create_group.visibility': '公開範囲',
- 'create_group.private': '非公開',
- 'create_group.private_desc': '招待されたメンバーだけがファイルを見て利用できます',
- 'create_group.public': '公開',
- 'create_group.public_desc': '誰でもこのグループを見つけて閲覧できます',
'create_group.join_policy': '参加方法',
'create_group.invite': '招待制',
- 'create_group.invite_desc': 'メンバーは管理者から招待される必要があります',
+ 'create_group.invite_desc': "管理者による招待が必要です。グループは公開一覧に載りません。",
'create_group.open': 'オープン(誰でも参加できます)',
- 'create_group.open_desc': '承認なしで誰でも参加できます',
+ 'create_group.open_desc': "公開グループに掲載され、誰でも見つけて承認なしに参加できます。",
'create_group.description': '説明',
'create_group.description_hint': 'どんなグループですか?(任意)',
'create_group.submit': '作成',
@@ -439,5 +434,4 @@ export default {
'group.leave_confirm': '「{name}」を退出しますか?ファイルとチャットへのアクセスがなくなります。アップロードしたファイルは node に残り、node は固定した識別情報を運営者が削除するまで保持します。',
'profile.title': 'プロフィール',
'usermenu.profile': 'プロフィール',
- 'create_group.public_is_open': '公開グループは誰でも見つけて参加できます。参加者を選びたい場合は、非公開で作成してください。',
};
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 497cc4a..1af0eb1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/nl.js
@@ -322,16 +322,11 @@ export default {
'create_group.title': 'Groep aanmaken',
'create_group.name': 'Groepsnaam',
'create_group.name_placeholder': 'bijv. Familiefotos, Projectbestanden...',
- 'create_group.visibility': 'Zichtbaarheid',
- 'create_group.private': 'Privé',
- 'create_group.private_desc': 'Alleen uitgenodigde leden zien de bestanden en hebben er toegang toe',
- 'create_group.public': 'Openbaar',
- 'create_group.public_desc': 'Iedereen kan deze groep vinden en doorbladeren',
'create_group.join_policy': 'Toetredingsbeleid',
'create_group.invite': 'Alleen op uitnodiging',
- 'create_group.invite_desc': 'Leden moeten door een beheerder worden uitgenodigd',
+ 'create_group.invite_desc': "Leden moeten door een beheerder worden uitgenodigd. De groep staat niet in de openbare lijst.",
'create_group.open': 'Open (iedereen kan deelnemen)',
- 'create_group.open_desc': 'Iedereen kan zonder goedkeuring deelnemen',
+ 'create_group.open_desc': "Staat bij de openbare groepen — iedereen kan de groep vinden en zonder goedkeuring deelnemen.",
'create_group.description': 'Beschrijving',
'create_group.description_hint': 'Waar gaat deze groep over? (optioneel)',
'create_group.submit': 'Aanmaken',
@@ -452,5 +447,4 @@ export default {
'group.leave_confirm': '„{name}” verlaten? U verliest de toegang tot de bestanden en de chat ervan. Bestanden die u hebt geüpload blijven op de node, en de node houdt de voor u vastgezette identiteit tot zijn beheerder die weghaalt.',
'profile.title': 'Profiel',
'usermenu.profile': 'Profiel',
- 'create_group.public_is_open': 'Iedereen kan een openbare groep vinden en eraan deelnemen. Wilt u kiezen wie binnenkomt, maak de groep dan privé.',
};
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 af4861a..f57dc90 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/locales/pl.js
@@ -328,16 +328,11 @@ export default {
'create_group.title': 'Tworzenie grupy',
'create_group.name': 'Nazwa grupy',
'create_group.name_placeholder': 'np. Zdjęcia rodzinne, Pliki projektu...',
- 'create_group.visibility': 'Widoczność',
- 'create_group.private': 'Prywatna',
- 'create_group.private_desc': 'Tylko zaproszeni członkowie widzą pliki i mają do nich dostęp',
- 'create_group.public': 'Publiczna',
- 'create_group.public_desc': 'Każdy może odnaleźć i przeglądać tę grupę',
'create_group.join_policy': 'Zasady dołączania',
'create_group.invite': 'Tylko z zaproszeniem',
- 'create_group.invite_desc': 'Członków musi zaprosić administrator',
+ 'create_group.invite_desc': "Członków musi zaprosić administrator. Grupa nie jest publicznie widoczna.",
'create_group.open': 'Otwarta (każdy może dołączyć)',
- 'create_group.open_desc': 'Każdy może dołączyć bez zatwierdzenia',
+ 'create_group.open_desc': "Widoczna na liście grup publicznych — każdy może ją znaleźć i dołączyć bez zatwierdzenia.",
'create_group.description': 'Opis',
'create_group.description_hint': 'Czego dotyczy ta grupa? (opcjonalnie)',
'create_group.submit': 'Utwórz',
@@ -465,5 +460,4 @@ export default {
'group.leave_confirm': 'Opuścić grupę „{name}”? Utraci Pan(i) dostęp do jej plików i czatu. Wysłane pliki pozostaną na node, a node zachowa przypiętą dla Pana/Pani tożsamość do czasu, aż jego operator ją usunie.',
'profile.title': 'Profil',
'usermenu.profile': 'Profil',
- 'create_group.public_is_open': 'Grupę publiczną każdy może znaleźć i do niej dołączyć. Aby decydować, kto wchodzi, proszę utworzyć grupę prywatną.',
};
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 8e1de4c..0f1586f 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
@@ -320,16 +320,11 @@ export default {
'create_group.title': 'Criar um grupo',
'create_group.name': 'Nome do grupo',
'create_group.name_placeholder': 'ex.: Fotos da família, Arquivos do projeto...',
- 'create_group.visibility': 'Visibilidade',
- 'create_group.private': 'Privado',
- 'create_group.private_desc': 'Só os membros convidados veem os arquivos e acessam a eles',
- 'create_group.public': 'Público',
- 'create_group.public_desc': 'Qualquer pessoa pode descobrir e explorar este grupo',
'create_group.join_policy': 'Política de entrada',
'create_group.invite': 'Somente por convite',
- 'create_group.invite_desc': 'Os membros precisam ser convidados por um administrador',
+ 'create_group.invite_desc': "Os membros precisam ser convidados por um administrador. O grupo não aparece na lista pública.",
'create_group.open': 'Aberto (qualquer pessoa pode entrar)',
- 'create_group.open_desc': 'Qualquer pessoa pode entrar sem aprovação',
+ 'create_group.open_desc': "Aparece nos grupos públicos — qualquer pessoa pode encontrá-lo e entrar sem aprovação.",
'create_group.description': 'Descrição',
'create_group.description_hint': 'Sobre o que é este grupo? (opcional)',
'create_group.submit': 'Criar',
@@ -449,5 +444,4 @@ export default {
'group.leave_confirm': 'Sair de “{name}”? Você perderá o acesso aos arquivos e à conversa. Os arquivos que você enviou permanecem no node, e ele mantém a identidade que fixou para você até que o operador dele a remova.',
'profile.title': 'Perfil',
'usermenu.profile': 'Perfil',
- 'create_group.public_is_open': 'Qualquer pessoa pode encontrar um grupo público e entrar nele. Para escolher quem entra, crie-o como privado.',
};
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 4e0a332..89927bb 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
@@ -303,16 +303,11 @@ export default {
'create_group.title': '创建群组',
'create_group.name': '群组名称',
'create_group.name_placeholder': '例如:家庭照片、项目文件…',
- 'create_group.visibility': '可见性',
- 'create_group.private': '私密',
- 'create_group.private_desc': '只有受邀成员才能看到并访问文件',
- 'create_group.public': '公开',
- 'create_group.public_desc': '任何人都可以发现并浏览此群组',
'create_group.join_policy': '加入方式',
'create_group.invite': '仅限邀请',
- 'create_group.invite_desc': '成员必须由管理员邀请',
+ 'create_group.invite_desc': "成员须由管理员邀请,群组不会公开列出。",
'create_group.open': '开放(任何人都可加入)',
- 'create_group.open_desc': '任何人都可以加入,无需批准',
+ 'create_group.open_desc': "列入公开群组,任何人都能找到并无需批准即可加入。",
'create_group.description': '简介',
'create_group.description_hint': '这个群组是做什么的?(可选)',
'create_group.submit': '创建',
@@ -422,5 +417,4 @@ export default {
'group.leave_confirm': '退出“{name}”?您将失去其文件和聊天的访问权。您上传的文件仍留在 node 上,node 也会保留它为您固定的身份,直到其运营者将其移除。',
'profile.title': '个人资料',
'usermenu.profile': '个人资料',
- 'create_group.public_is_open': '任何人都可以找到并加入公开群组。若要挑选谁能进来,请创建为私密群组。',
};
diff --git a/packages/meshbay-hub/tests/harness/scroll_probe.py b/packages/meshbay-hub/tests/harness/scroll_probe.py
new file mode 100644
index 0000000..73ff0f1
--- /dev/null
+++ b/packages/meshbay-hub/tests/harness/scroll_probe.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+"""
+Does the page scroll vertically when it should not?
+
+`layout_probe.py` answers "where is this box". This one answers "is the document
+taller than the window", which is a different question and the one behind two
+separate reports of a scrollbar that would not go away.
+
+The sizing code under test is **read out of `app.js` and run here**, not
+reimplemented: a copy of the formula living in the test would go on passing
+after the real one changed, which is the failure mode worth avoiding in a file
+whose whole purpose is to catch an arithmetic slip.
+
+ scroll_probe.py <html-fragment-file> [<height>,<height>,...]
+
+Reports per viewport height: the window, the document, the difference, every
+element hanging below the fold, and the chat panel's box if there is one.
+"""
+import http.server
+import json
+import re
+import socketserver
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static"
+APP = STATIC / "app.js"
+PORT = 8736
+FRAG = Path(sys.argv[1]).read_text()
+HEIGHTS = ([int(h) for h in sys.argv[2].split(",")]
+ if len(sys.argv) > 2 else [700, 900, 1200])
+
+
+def chat_fit_body() -> str:
+ """
+ The body of the chat panel's `fit()`, lifted from `app.js`.
+
+ It closes over `el` and two constants, so those are supplied around it;
+ everything between the braces — the second pass included — is the shipped
+ code. A rename breaks this loudly, which is intended: a skipped test here
+ would be worse than a failing one.
+ """
+ source = APP.read_text(encoding="utf-8")
+ start = source.index(" const fit = () => {")
+ end = source.index("\n };", start)
+ body = source[source.index("{", start) + 1:end]
+ consts = {}
+ for name in ("CHAT_MIN_HEIGHT", "CHAT_BOTTOM_GAP"):
+ line = re.search(rf"^const {name} = (\d+);", source, re.M)
+ assert line, f"{name} is gone or was renamed"
+ consts[name] = line.group(1)
+ return ("(el, window, document) => {"
+ f"const CHAT_MIN_HEIGHT = {consts['CHAT_MIN_HEIGHT']};"
+ f"const CHAT_BOTTOM_GAP = {consts['CHAT_BOTTOM_GAP']};"
+ + body + "}")
+
+
+PAGE = """<!doctype html><html><head><meta charset=utf-8></head><body style="margin:0">
+<!-- One iframe per height: a headless window has a floor of its own, and an
+ iframe establishes the viewport we actually mean. -->
+<div id="frames"></div><script>
+const HEIGHTS = %(heights)s, FRAG = %(frag)s;
+const host = document.getElementById('frames');
+for (const h of HEIGHTS) {
+ const f = document.createElement('iframe');
+ f.id = 'f' + h;
+ f.style.cssText = `width:1100px;height:${h}px;border:0;display:block`;
+ host.appendChild(f);
+ const d = f.contentDocument;
+ d.open();
+ d.write(`<!doctype html><html><head><meta charset=utf-8>
+<link rel="stylesheet" href="/style.css"></head><body>${FRAG}</body></html>`);
+ d.close();
+}
+// The real fit() from app.js. A <script> written into the fragment does not
+// fire, so it is applied from out here once the stylesheet has settled.
+const FIT = %(fit)s;
+setTimeout(() => {
+ for (const h of HEIGHTS) {
+ const win = document.getElementById('f' + h).contentWindow;
+ const el = win.document.querySelector('.chat-panel');
+ if (el) FIT(el, win, win.document);
+ }
+}, 200);
+setTimeout(() => {
+ const out = {};
+ for (const h of HEIGHTS) {
+ const win = document.getElementById('f' + h).contentWindow;
+ const doc = win.document.documentElement;
+ const past = [];
+ for (const el of win.document.querySelectorAll('*')) {
+ const b = el.getBoundingClientRect();
+ if (b.bottom > win.innerHeight + 0.5)
+ past.push((el.className || el.tagName) + ' +' +
+ Math.round(b.bottom - win.innerHeight));
+ }
+ const panel = win.document.querySelector('.chat-panel');
+ const pb = panel && panel.getBoundingClientRect();
+ out[h] = {viewport: win.innerHeight, scrollHeight: doc.scrollHeight,
+ overflow: doc.scrollHeight - win.innerHeight,
+ past: past.slice(0, 12),
+ panel: pb ? {top: Math.round(pb.top), bottom: Math.round(pb.bottom),
+ height: Math.round(pb.height)} : null};
+ }
+ fetch('/log', {method: 'POST', body: JSON.stringify(out)});
+}, 700);
+</script></body></html>"""
+
+RECORDS = []
+socketserver.TCPServer.allow_reuse_address = True
+
+
+class H(http.server.BaseHTTPRequestHandler):
+ def log_message(self, *a):
+ pass
+
+ def do_POST(self):
+ RECORDS.append(json.loads(
+ self.rfile.read(int(self.headers["Content-Length"])).decode()))
+ self.send_response(204)
+ self.end_headers()
+
+ def do_GET(self):
+ if self.path == "/":
+ # A "</script>" inside the fragment would close the inline script
+ # it is embedded in, and the page would measure nothing.
+ body = (PAGE % {"frag": json.dumps(FRAG).replace("</", "<\\/"),
+ "heights": json.dumps(HEIGHTS),
+ "fit": chat_fit_body()}).encode()
+ ctype = "text/html; charset=utf-8"
+ elif self.path == "/style.css":
+ body = (STATIC / "style.css").read_bytes()
+ ctype = "text/css"
+ else:
+ self.send_response(404)
+ self.end_headers()
+ return
+ self.send_response(200)
+ self.send_header("Content-Type", ctype)
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+
+def main() -> int:
+ with socketserver.TCPServer(("127.0.0.1", PORT), H) as srv:
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
+ with tempfile.TemporaryDirectory() as profile:
+ subprocess.run(
+ ["google-chrome", "--headless", "--disable-gpu", "--no-sandbox",
+ f"--user-data-dir={profile}", "--window-size=1100,1300",
+ "--virtual-time-budget=6000", "--dump-dom",
+ f"http://127.0.0.1:{PORT}/"],
+ capture_output=True, timeout=120)
+ for _ in range(50):
+ if RECORDS:
+ break
+ time.sleep(0.1)
+ print(json.dumps(RECORDS[0] if RECORDS else {"error": "no measurement"},
+ indent=1))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/packages/meshbay-hub/tests/test_groups_self_service.py b/packages/meshbay-hub/tests/test_groups_self_service.py
index 3202658..def663d 100644
--- a/packages/meshbay-hub/tests/test_groups_self_service.py
+++ b/packages/meshbay-hub/tests/test_groups_self_service.py
@@ -175,3 +175,48 @@ async def test_join_triggers_notification(client):
r = await client.get(f"/v1/groups/{gid}/members",
headers={"Authorization": f"Bearer {alice_token}"})
assert len(r.json()["members"]) == 2
+
+
+# ── Listed and open are one question ────────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_a_private_group_cannot_be_open_to_everyone(client):
+ """
+ A group anyone may join that nobody can find is a listing with the listing
+ removed: it is absent from the directory, and joining goes through the node
+ rather than a link, so nothing can reach it. It was accepted until now, and
+ the create form offered it.
+ """
+ await _register(client, "pat", email="pat@x.com")
+ token = await _login(client, "pat")
+ resp = await client.post("/v1/groups", json={
+ "name": "nowhere", "visibility": "private", "join_policy": "open",
+ }, headers={"Authorization": f"Bearer {token}"})
+
+ assert resp.status_code == 422
+ assert "invite-only" in resp.json()["detail"]
+
+
+@pytest.mark.asyncio
+async def test_a_public_group_cannot_be_invite_only(client):
+ """The other half, which was already refused — kept so that removing one
+ check does not quietly remove both."""
+ await _register(client, "sam", email="sam@x.com")
+ token = await _login(client, "sam")
+ resp = await client.post("/v1/groups", json={
+ "name": "deadend", "visibility": "public", "join_policy": "invite",
+ }, headers={"Authorization": f"Bearer {token}"})
+
+ assert resp.status_code == 422
+
+
+@pytest.mark.asyncio
+async def test_the_two_combinations_that_mean_something_are_accepted(client):
+ await _register(client, "robin", email="robin@x.com")
+ token = await _login(client, "robin")
+ for name, visibility, policy in (("closed", "private", "invite"),
+ ("open-house", "public", "open")):
+ resp = await client.post("/v1/groups", json={
+ "name": name, "visibility": visibility, "join_policy": policy,
+ }, headers={"Authorization": f"Bearer {token}"})
+ assert resp.status_code == 201, f"{visibility}+{policy}: {resp.text}"
diff --git a/packages/meshbay-hub/tests/test_page_does_not_scroll.py b/packages/meshbay-hub/tests/test_page_does_not_scroll.py
new file mode 100644
index 0000000..1187a7a
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_page_does_not_scroll.py
@@ -0,0 +1,125 @@
+"""
+A page whose content fits the window must not offer a scrollbar.
+
+Twice now. First the sign-in card: `.layout` and `.page-center` each reserved
+`100vh - 52px` and the second sat inside the first's 24px padding, so the
+document was 48px too tall at every window size. Then the chat tab: the panel is
+sized from JS to `viewport - top - 16`, which puts its bottom 16px above the
+fold — but `.main` adds 24px of padding below it, so the document came out
+**exactly 8px too tall, at every window size**, which is what "there is always a
+scrollbar" means.
+
+Neither is visible in the stylesheet. Both are one subtraction against another,
+in different files, and the only way to see them is to measure the document
+against the window — which is what this does, running the real `fit()` lifted
+out of `app.js` rather than a copy of it.
+"""
+
+import json
+import shutil
+import subprocess
+import textwrap
+from pathlib import Path
+
+import pytest
+
+HARNESS = Path(__file__).parent / "harness" / "scroll_probe.py"
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("google-chrome") is None or not (STATIC / "style.css").exists(),
+ reason="Chrome or the SPA stylesheet is not available")
+
+HEIGHTS = [700, 900, 1200]
+
+NAV_AND_SIDEBAR = """
+<nav class="nav">
+ <div class="nav-left"><button class="nav-hamburger">&#9776;</button>
+ <a class="nav-brand" href="#/">MeshBay</a></div>
+ <div class="nav-right"><a class="nav-notif" href="#/">&#128276;</a>
+ <div class="user-menu"><button class="nav-btn">someone</button></div></div>
+</nav>
+"""
+
+CHAT_TAB = NAV_AND_SIDEBAR + """
+<div class="layout">
+ <aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
+ <main class="main">
+ <div class="group-header"><h2>a group</h2></div>
+ <div class="group-tabs">
+ <button class="group-tab active">Chat</button>
+ <button class="group-tab">Files</button>
+ <button class="group-tab">Settings</button>
+ </div>
+ <div class="chat-panel">
+ <div class="chat-messages"><p>hello</p></div>
+ <div class="chat-composer"><input type="text" /><button class="admin-btn">Send</button></div>
+ </div>
+ </main>
+</div>
+"""
+
+SHORT_PAGE = NAV_AND_SIDEBAR + """
+<div class="layout">
+ <aside class="sidebar"><div class="sidebar-section">Groups</div></aside>
+ <main class="main">
+ <h2>a group</h2>
+ <div class="settings-section"><p>not much here</p></div>
+ </main>
+</div>
+"""
+
+
+def _measure(fragment: str, tmp_path: Path) -> dict:
+ path = tmp_path / "fragment.html"
+ path.write_text(fragment, encoding="utf-8")
+ proc = subprocess.run(
+ ["python3", str(HARNESS), str(path), ",".join(str(h) for h in HEIGHTS)],
+ capture_output=True, text=True, timeout=180)
+ assert proc.returncode == 0, f"probe failed: {proc.stdout}{proc.stderr}"
+ out = json.loads(proc.stdout)
+ assert "error" not in out, f"no measurement: {out}"
+ return out
+
+
+@pytest.fixture(scope="module")
+def chat(tmp_path_factory):
+ return _measure(CHAT_TAB, tmp_path_factory.mktemp("chat"))
+
+
+@pytest.fixture(scope="module")
+def short(tmp_path_factory):
+ return _measure(SHORT_PAGE, tmp_path_factory.mktemp("short"))
+
+
+@pytest.mark.parametrize("height", HEIGHTS)
+def test_the_chat_tab_fits_its_window(chat, height):
+ r = chat[str(height)]
+ assert r["overflow"] <= 0, (
+ f"the document is {r['overflow']}px taller than the {height}px window — "
+ f"a scrollbar on the chat tab. Past the fold: {r['past']}")
+
+
+@pytest.mark.parametrize("height", HEIGHTS)
+def test_nothing_on_the_chat_tab_hangs_below_the_fold(chat, height):
+ """The composer is the one that matters: a chat you cannot type in."""
+ assert chat[str(height)]["past"] == []
+
+
+@pytest.mark.parametrize("height", HEIGHTS)
+def test_the_chat_panel_uses_the_room_it_has(chat, height):
+ """The correction must not overshoot. The panel should end just above the
+ fold, not halfway up the page — a 240px chat in a 1200px window would pass
+ every assertion above and be useless."""
+ panel = chat[str(height)]["panel"]
+ assert panel, "no chat panel in the measurement"
+ gap = height - panel["bottom"]
+ assert 0 <= gap <= 40, (
+ f"the panel ends {gap}px above the fold at {height}px")
+
+
+@pytest.mark.parametrize("height", HEIGHTS)
+def test_a_short_page_does_not_scroll_either(short, height):
+ """The control: without this, a chat panel shrunk to nothing would pass."""
+ r = short[str(height)]
+ assert r["overflow"] <= 0, f"{r['overflow']}px of overflow with no content"
diff --git a/packages/meshbay-hub/tests/test_transport_contracts.py b/packages/meshbay-hub/tests/test_transport_contracts.py
index 8d975d7..5da8104 100644
--- a/packages/meshbay-hub/tests/test_transport_contracts.py
+++ b/packages/meshbay-hub/tests/test_transport_contracts.py
@@ -129,6 +129,14 @@ def test_presence_has_three_states_and_a_label_for_each(app):
"the dot needs a title and an aria-label, not just a colour")
+def _string(source: str, key: str) -> str:
+ """One locale entry's text, whether it is written on one line or spliced
+ across several with `+`."""
+ start = source.index(f"'{key}':") + len(f"'{key}':")
+ end = source.index("\n '", start)
+ return source[start:end]
+
+
def test_a_refusal_from_the_node_counts_as_present(app):
"""The node answering "no" proves it is up; only silence proves nothing."""
assert "err.reason ? 'online' : 'offline'" in app
@@ -136,30 +144,48 @@ def test_a_refusal_from_the_node_counts_as_present(app):
# ── The create-group form ─────────────────────────────────────────────────────
-def test_choosing_public_settles_the_admission_question(app):
- """Public implies open, so the policy selector has nothing left to ask.
-
- Enforced twice on purpose: the API refuses public+invite with a 422, and the
- form never offers the combination. A form that can build a request the server
- rejects is a form that produces an error message instead of a group.
+def test_the_form_asks_one_question_not_two(app):
"""
- assert "setVisibility('public'); setJoinPolicy('open');" in app, (
- "picking Public must settle the policy, not leave the previous one")
- assert "setVisibility('private'); setJoinPolicy('invite');" in app, (
- "going back to Private must not leave the group open by accident")
+ Visibility and admission were separate selectors that could only ever be set
+ together, and the form knew it — picking Public reached over and set the
+ policy. Two of the four combinations were impossible: the API refused
+ public+invite with a 422, and private+open is a directory listing nobody can
+ find, joining being through the node rather than a link.
+ So there is one selector. "Open" is what makes a group listed, and the
+ request derives the rest.
+ """
form = app[app.index("function CreateGroupPage"):]
form = form[:form.index("\n}\n")]
- selector = form.index("t('create_group.join_policy')")
- guard = form.rindex("visibility === 'public'", 0, selector)
- assert guard != -1, "the policy selector must sit behind a visibility guard"
- assert "create_group.public_is_open" in form[guard:selector], (
- "a public group should say why there is nothing to choose")
+
+ assert "setVisibility(" not in form, "the visibility selector is back"
+ assert "t('create_group.join_policy')" in form
+ assert "joinPolicy === 'open' ? 'public' : 'private'" in form, (
+ "the request must derive visibility rather than leave it unset")
+
+
+def test_the_form_says_what_each_choice_means_for_finding_the_group(app):
+ """Dropping the visibility box removes the words "public" and "private"
+ from the page. If the descriptions do not say it, nothing does — and
+ somebody publishes a group without meaning to."""
+ en = (STATIC / "locales" / "en.js").read_text(encoding="utf-8")
+ invite = _string(en, "create_group.invite_desc")
+ open_ = _string(en, "create_group.open_desc")
+ assert "not listed" in invite.lower()
+ assert "listed" in open_.lower() and "anyone" in open_.lower()
+
+
+def test_the_strings_the_visibility_box_used_are_gone(app):
+ """A key nobody reads is a key that rots, and ten locales carry each one."""
+ for locale in (STATIC / "locales").glob("*.js"):
+ text = locale.read_text(encoding="utf-8")
+ for key in ("create_group.visibility", "create_group.private",
+ "create_group.public_is_open", "create_group.public_desc"):
+ assert f"'{key}'" not in text, f"{locale.name} still carries {key}"
def test_the_form_starts_on_a_combination_the_api_accepts(app):
form = app[app.index("function CreateGroupPage"):]
- assert "useState('private')" in form[:form.index("return html")]
assert "useState('invite')" in form[:form.index("return html")]