From 44420220d40b1b7ef1e2d763294fbf8916b09880 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 7 Jul 2026 13:36:41 -0400 Subject: [PATCH] feat(room-preview): join-rule + encryption chips, Request-to-join for knock rooms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Room preview (JoinBeforeNavigate -> RoomCard via getRoomSummary) was already built and, verified after the Synapse 1.156 upgrade, works via the SDK's unstable im.nheko.summary endpoint (the old 'blocked' flag tested the wrong /v1 path). Polish the preview card with the summary fields the endpoint returns: - join-rule chip (Restricted / Ask to join / Invite only / Private; public shows none) + an Encrypted badge (from im.nheko.summary.encryption). - knock-rule rooms now show a 'Request to join' button (mx.knockRoom) instead of a plain Join that would fail — mirrors the RoomIntro knock flow. Props are optional so other RoomCard usages are unaffected. LOTUS_TODO updated: Room Preview BLOCKED -> done; Synapse 1.155 -> 1.156.0. 738 tests pass. Co-Authored-By: Claude Opus 4.8 --- LOTUS_TODO.md | 10 +-- src/app/components/room-card/RoomCard.tsx | 72 +++++++++++++++---- .../JoinBeforeNavigate.tsx | 2 + 3 files changed, 67 insertions(+), 17 deletions(-) diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index c20d51171..81373970a 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -138,7 +138,7 @@ After Phases A–C the client spec is ~complete. What's left, flagged by **what - Live Location Sharing (**MSC3489** + **MSC3672** — both `false`) - Reaction / relation redaction (**MSC3892** — `false`) -- Room preview before joining (**MSC3266** — summary endpoint 404s on 1.155) +- ~~Room preview before joining (MSC3266)~~ — **DONE** (client was always built; unstable `im.nheko.summary` endpoint returns 200 — verified on 1.156) - Thread subscriptions (**MSC4306** — `false`) **Niche / low-value (noted, not planned):** E2EE history-key-on-invite (MSC3061), voice broadcast (MSC3888), a native account-deactivation flow (currently delegated to the OIDC provider for OIDC accounts). @@ -201,17 +201,17 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra - **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share. - **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable. -- **[BLOCKED] Room Preview before joining** (MSC3266) — `GET /v1/rooms/{id}/summary` returns 404 `M_UNRECOGNIZED` on Synapse 1.155 despite `msc3266_enabled:true`. +- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate` → `RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the *unstable* `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card. - **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel). --- ## 📖 Reference -### Server Capabilities (as of 2026-06) +### Server Capabilities (as of 2026-07) -- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.155.0` · **Matrix spec** up to `v1.12` (+ MSC `unstable_features`). -- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (flag on but v1 summary 404s) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`. +- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.156.0+trixie1` (upgraded 2026-07-07 from 1.155; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`). +- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`. - **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅. - **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151. - **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips ``/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`. diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index fd98675b2..22d557563 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -139,11 +139,23 @@ type RoomCardProps = { topic?: string; memberCount?: number; roomType?: string; + joinRule?: string; + encrypted?: boolean; viaServers?: string[]; onView?: (roomId: string) => void; renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode; }; +// Map a room's join rule to a short preview chip. Public is the common case and +// gets no chip (avoids noise); the rest tell the user how entry works. +const joinRuleLabel = (joinRule?: string): string | undefined => { + if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') return 'Restricted'; + if (joinRule === JoinRule.Knock) return 'Ask to join'; + if (joinRule === JoinRule.Invite) return 'Invite only'; + if (joinRule === JoinRule.Private) return 'Private'; + return undefined; +}; + export const RoomCard = as<'div', RoomCardProps>( ( { @@ -154,6 +166,8 @@ export const RoomCard = as<'div', RoomCardProps>( topic, memberCount, roomType, + joinRule, + encrypted, viaServers, onView, renderTopicViewer, @@ -203,8 +217,28 @@ export const RoomCard = as<'div', RoomCardProps>( [mx, roomIdOrAlias, viaServers], ), ); - const joining = - joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; + const [knockState, knock] = useAsyncCallback<{ room_id: string }, MatrixError, []>( + useCallback( + () => mx.knockRoom(roomIdOrAlias, { viaServers }), + [mx, roomIdOrAlias, viaServers], + ), + ); + // A knock-rule room can't be joined directly — request to join instead. + const isKnock = joinRule === JoinRule.Knock; + const action = isKnock ? knock : join; + const actionState = isKnock ? knockState : joinState; + const acting = + actionState.status === AsyncStatus.Loading || actionState.status === AsyncStatus.Success; + let actionLabel = acting ? 'Joining' : 'Join'; + if (isKnock) { + actionLabel = + knockState.status === AsyncStatus.Success + ? 'Requested' + : acting + ? 'Requesting' + : 'Request to join'; + } + const chip = joinRuleLabel(joinRule); const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); @@ -258,6 +292,20 @@ export const RoomCard = as<'div', RoomCardProps>( {`${millify(joinedMemberCount)} Members`} )} + {!joinedRoom && (chip || encrypted) && ( + + {chip && ( + + {chip} + + )} + {encrypted && ( + + Encrypted + + )} + + )} {typeof joinedRoomId === 'string' && ( )} - {typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && ( + {typeof joinedRoomId !== 'string' && actionState.status !== AsyncStatus.Error && ( } + disabled={acting} + before={acting && } > - {joining ? 'Joining' : 'Join'} + {actionLabel} )} - {typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && ( + {typeof joinedRoomId !== 'string' && actionState.status === AsyncStatus.Error && ( {(openError) => (