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 && ( + + )}