From be45f0efcebcda9092860cee4f29340303c34d36 Mon Sep 17 00:00:00 2001 From: Salastil Date: Sun, 13 Sep 2026 17:06:30 -0400 Subject: [PATCH] A Look button beside Join Under the row rather than over it, so the room being decided about stays on screen while its summary is read. Not offered on a room already joined, where there is nothing left to separate. The two reasons there might be no conversation to show are said separately, because only one of them is about the room: its history may be for members only, or your own homeserver may not allow looking into rooms at all. Silence there would read as an empty room rather than as a door that is shut. Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- src/renderer/src/app.css | 71 +++++++++++ src/renderer/src/components/RoomPeek.tsx | 133 +++++++++++++++++++++ src/renderer/src/components/RoomSearch.tsx | 27 +++++ 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/components/RoomPeek.tsx diff --git a/nobilis b/nobilis index c1f5945..e4e4208 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit c1f59452cfcf30f283074cc52af7dbb1c85a109a +Subproject commit e4e42082a944abd4927ab1cb8c4be64cae34baf9 diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 3e9902d..93f1cf1 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -6676,6 +6676,77 @@ video.lightbox-media { color: var(--error); } +/* --- Looking into a room ---------------------------------------------- */ + +/* What is in a room, read from outside it. Sits under the list rather than + over it, so the row it came from stays visible - the point of looking is to + decide about that room, and a panel covering it takes away what the decision + is about. */ +.room-peek { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); + margin: var(--spacing-s) 0; + padding: var(--spacing-s); + border: 1px solid var(--outline-medium); + border-radius: var(--corner-radius-small); + background: var(--surface-container-lowest); +} + +.room-peek-head { + display: flex; + align-items: center; + gap: var(--spacing-s); + font-weight: 500; +} + +.room-peek-head .ellipsis { + flex: 1; + min-width: 0; +} + +.room-peek-topic { + color: var(--surface-variant-text); + /* A topic can be a paragraph. Three lines is enough to judge a room by and + short enough that the conversation under it stays on screen. */ + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.room-peek-log { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 240px; + overflow-y: auto; +} + +.room-peek-line { + display: flex; + gap: var(--spacing-s); + align-items: baseline; +} + +.room-peek-from { + flex: none; + max-width: 12ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--surface-variant-text); + font-size: 0.82rem; +} + +.room-peek-body { + min-width: 0; + font-size: 0.85rem; + /* Somebody else's text, from a room nobody here has joined: it wraps and + it never stretches the dialog. */ + overflow-wrap: anywhere; +} + /* --- Link previews ---------------------------------------------------- */ /* The picture a homeserver found behind a link. Capped rather than sized to diff --git a/src/renderer/src/components/RoomPeek.tsx b/src/renderer/src/components/RoomPeek.tsx new file mode 100644 index 0000000..d486e9e --- /dev/null +++ b/src/renderer/src/components/RoomPeek.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react' +import { Icon } from './Icon' +import { formatTime } from '../lib/util' +import type { Account } from '../../../shared/wire' + +interface Summary { + roomId: string + name: string + topic: string + alias: string + members: number + joinRule: string + membership: string + encrypted: boolean + worldReadable: boolean +} + +interface PeekLine { + id: string + from: string + body: string + isAction: boolean + ts: number +} + +/** + * A look inside a room nobody here has joined. + * + * Joining is a membership event everybody in the room can see, and leaving + * again leaves both of them behind - so without this, the decision to look + * and the decision to join were the same act. + * + * Two halves, because homeservers permit them separately. The summary - + * topic, size, how the room is entered, whether it is encrypted - is + * answered for any room the server can reach, and is most of what somebody + * decides on. The conversation needs the room to be world-readable *and* the + * homeserver to allow peeking at all, which many do not; where it refuses, + * that is said in as many words, because silence there reads as an empty + * room rather than as a door that is shut. + */ +export function RoomPeek({ + account, + room, + via, + onClose +}: { + account: Account + room: string + via?: string + onClose: () => void +}): JSX.Element { + const [summary, setSummary] = useState(null) + const [lines, setLines] = useState(null) + const [failed, setFailed] = useState('') + const [refused, setRefused] = useState('') + + useEffect(() => { + void window.moho + .rpc('matrixRoomSummary', { accountId: account.id, room, via: via ? [via] : [] }) + .then((answer) => { + setSummary(answer) + // Only worth asking where the room itself would allow it - a room + // that is not world-readable refuses however willing the homeserver + // is, and an error for that would blame the wrong thing. + if (!answer.worldReadable) return + void window.moho + .rpc<{ messages: PeekLine[] }>('matrixPeekRoom', { + accountId: account.id, + roomId: answer.roomId, + limit: 20 + }) + .then((r) => setLines(r.messages)) + .catch((e: Error) => setRefused(e.message)) + }) + .catch((e: Error) => setFailed(e.message)) + }, [account.id, room]) + + return ( +
+
+ + {summary?.name || summary?.alias || room} + +
+ + {!summary && !failed &&
Looking…
} + {failed &&
Couldn’t look: {failed}
} + + {summary && ( + <> +
+ {summary.members.toLocaleString()} members + {summary.joinRule === 'knock' + ? ' · asks to be knocked on' + : summary.joinRule === 'restricted' + ? ' · open to a space’s members' + : summary.joinRule === 'invite' + ? ' · invitation only' + : ''} + {summary.encrypted ? ' · encrypted' : ''} +
+ {summary.topic &&
{summary.topic}
} + + {/* Why there is no conversation below, in each of the two cases - + the room's own rule, and the homeserver's. They are different + answers and only one of them is about this room. */} + {!summary.worldReadable && ( +
+ This room’s history is only for people who have joined it, so there is nothing to + read from out here. +
+ )} + {refused &&
{refused}
} + + {lines?.length === 0 &&
Nothing has been said here.
} + {lines && lines.length > 0 && ( +
+ {lines.map((line) => ( +
+ {formatTime(line.ts)} + {line.isAction ? `* ${line.from}` : line.from} + {line.body} +
+ ))} +
+ )} + + )} +
+ ) +} diff --git a/src/renderer/src/components/RoomSearch.tsx b/src/renderer/src/components/RoomSearch.tsx index d4e9b34..2d28fac 100644 --- a/src/renderer/src/components/RoomSearch.tsx +++ b/src/renderer/src/components/RoomSearch.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { ReasonPrompt } from './ReasonPrompt' import { createPortal } from 'react-dom' import { Avatar } from './Avatar' +import { RoomPeek } from './RoomPeek' import { Icon, IconButton } from './Icon' import { useStore } from '../state/hooks' import { classes } from '../lib/util' @@ -89,6 +90,8 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: () /** The room being knocked on, while the reason is being written. */ const [knocking, setKnocking] = useState(null) const [joining, setJoining] = useState>({}) + /** Which room somebody has asked to look into without joining it. */ + const [peeking, setPeeking] = useState(null) const inputRef = useRef(null) const resultsRef = useRef(null) // Which search the answer in flight belongs to. A slow server answering a @@ -315,6 +318,21 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: () {room.topic ? ` · ${room.topic}` : ''} + {/* Looking, offered beside joining rather than instead of it. + A join is a membership event everybody in the room sees, + and leaving again leaves both behind - so the two decisions + have to be separable, and on a room already joined there is + nothing to separate. */} + {!room.joined && ( + + )}