feat(room-preview): join-rule + encryption chips, Request-to-join for knock rooms
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 <noreply@anthropic.com>
This commit is contained in:
+5
-5
@@ -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`)
|
- Live Location Sharing (**MSC3489** + **MSC3672** — both `false`)
|
||||||
- Reaction / relation redaction (**MSC3892** — `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`)
|
- 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).
|
**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] 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] 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).
|
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 Reference
|
## 📖 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`).
|
- **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` (flag on but v1 summary 404s) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`.
|
- **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) ✅.
|
- **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.
|
- **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 `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -139,11 +139,23 @@ type RoomCardProps = {
|
|||||||
topic?: string;
|
topic?: string;
|
||||||
memberCount?: number;
|
memberCount?: number;
|
||||||
roomType?: string;
|
roomType?: string;
|
||||||
|
joinRule?: string;
|
||||||
|
encrypted?: boolean;
|
||||||
viaServers?: string[];
|
viaServers?: string[];
|
||||||
onView?: (roomId: string) => void;
|
onView?: (roomId: string) => void;
|
||||||
renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode;
|
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>(
|
export const RoomCard = as<'div', RoomCardProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
@@ -154,6 +166,8 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||||||
topic,
|
topic,
|
||||||
memberCount,
|
memberCount,
|
||||||
roomType,
|
roomType,
|
||||||
|
joinRule,
|
||||||
|
encrypted,
|
||||||
viaServers,
|
viaServers,
|
||||||
onView,
|
onView,
|
||||||
renderTopicViewer,
|
renderTopicViewer,
|
||||||
@@ -203,8 +217,28 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||||||
[mx, roomIdOrAlias, viaServers],
|
[mx, roomIdOrAlias, viaServers],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const joining =
|
const [knockState, knock] = useAsyncCallback<{ room_id: string }, MatrixError, []>(
|
||||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
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 [viewTopic, setViewTopic] = useState(false);
|
||||||
const closeTopic = () => setViewTopic(false);
|
const closeTopic = () => setViewTopic(false);
|
||||||
@@ -258,6 +292,20 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||||||
<Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
|
<Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
{!joinedRoom && (chip || encrypted) && (
|
||||||
|
<Box gap="100" wrap="Wrap">
|
||||||
|
{chip && (
|
||||||
|
<Badge variant="Secondary" fill="Soft" outlined>
|
||||||
|
<Text size="L400">{chip}</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{encrypted && (
|
||||||
|
<Badge variant="Success" fill="Soft" outlined>
|
||||||
|
<Text size="L400">Encrypted</Text>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
{typeof joinedRoomId === 'string' && (
|
{typeof joinedRoomId === 'string' && (
|
||||||
<Button
|
<Button
|
||||||
onClick={onView ? () => onView(joinedRoomId) : undefined}
|
onClick={onView ? () => onView(joinedRoomId) : undefined}
|
||||||
@@ -270,23 +318,23 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && (
|
{typeof joinedRoomId !== 'string' && actionState.status !== AsyncStatus.Error && (
|
||||||
<Button
|
<Button
|
||||||
onClick={join}
|
onClick={action}
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="300"
|
size="300"
|
||||||
disabled={joining}
|
disabled={acting}
|
||||||
before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
before={acting && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate>
|
<Text size="B300" truncate>
|
||||||
{joining ? 'Joining' : 'Join'}
|
{actionLabel}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && (
|
{typeof joinedRoomId !== 'string' && actionState.status === AsyncStatus.Error && (
|
||||||
<Box gap="200">
|
<Box gap="200">
|
||||||
<Button
|
<Button
|
||||||
onClick={join}
|
onClick={action}
|
||||||
className={css.ActionButton}
|
className={css.ActionButton}
|
||||||
variant="Critical"
|
variant="Critical"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
@@ -297,8 +345,8 @@ export const RoomCard = as<'div', RoomCardProps>(
|
|||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<ErrorDialog
|
<ErrorDialog
|
||||||
title="Join Error"
|
title={isKnock ? 'Request Error' : 'Join Error'}
|
||||||
message={joinState.error.message || 'Failed to join. Unknown Error.'}
|
message={actionState.error.message || 'Failed to join. Unknown Error.'}
|
||||||
>
|
>
|
||||||
{(openError) => (
|
{(openError) => (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ export function JoinBeforeNavigate({
|
|||||||
topic={summary?.topic}
|
topic={summary?.topic}
|
||||||
memberCount={summary?.num_joined_members}
|
memberCount={summary?.num_joined_members}
|
||||||
roomType={summary?.room_type}
|
roomType={summary?.room_type}
|
||||||
|
joinRule={summary?.join_rule}
|
||||||
|
encrypted={!!summary?.['im.nheko.summary.encryption']}
|
||||||
viaServers={viaServers}
|
viaServers={viaServers}
|
||||||
renderTopicViewer={(name, topic, requestClose) => (
|
renderTopicViewer={(name, topic, requestClose) => (
|
||||||
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
|
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
|
||||||
|
|||||||
Reference in New Issue
Block a user