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 <[email protected]>
This commit is contained in:
2026-09-13 17:06:30 -04:00
co-authored by Claude Opus 5
parent 2181f2d3bf
commit be45f0efce
4 changed files with 232 additions and 1 deletions
+1 -1
Submodule nobilis updated: c1f59452cf...e4e42082a9
+71
View File
@@ -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
+133
View File
@@ -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<Summary | null>(null)
const [lines, setLines] = useState<PeekLine[] | null>(null)
const [failed, setFailed] = useState('')
const [refused, setRefused] = useState('')
useEffect(() => {
void window.moho
.rpc<Summary>('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 (
<div className="room-peek">
<div className="room-peek-head">
<Icon name="visibility" size={16} />
<span className="ellipsis">{summary?.name || summary?.alias || room}</span>
<button type="button" className="button subtle" onClick={onClose}>
Close
</button>
</div>
{!summary && !failed && <div className="small muted">Looking…</div>}
{failed && <div className="small muted">Couldn’t look: {failed}</div>}
{summary && (
<>
<div className="small muted">
{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' : ''}
</div>
{summary.topic && <div className="small room-peek-topic">{summary.topic}</div>}
{/* 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 && (
<div className="small muted">
This room’s history is only for people who have joined it, so there is nothing to
read from out here.
</div>
)}
{refused && <div className="small muted">{refused}</div>}
{lines?.length === 0 && <div className="small muted">Nothing has been said here.</div>}
{lines && lines.length > 0 && (
<div className="room-peek-log">
{lines.map((line) => (
<div key={line.id} className="room-peek-line">
<span className="small muted tabular">{formatTime(line.ts)}</span>
<span className="room-peek-from">{line.isAction ? `* ${line.from}` : line.from}</span>
<span className="room-peek-body">{line.body}</span>
</div>
))}
</div>
)}
</>
)}
</div>
)
}
@@ -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<PublicRoom | null>(null)
const [joining, setJoining] = useState<Record<string, boolean>>({})
/** Which room somebody has asked to look into without joining it. */
const [peeking, setPeeking] = useState<PublicRoom | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const resultsRef = useRef<HTMLDivElement>(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}` : ''}
</div>
</div>
{/* 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 && (
<button
type="button"
className="button subtle"
title="See what is in it without joining"
onClick={() => setPeeking(peeking?.roomId === room.roomId ? null : room)}
>
<Icon name="visibility" size={15} /> Look
</button>
)}
<button
type="button"
className="button"
@@ -332,6 +350,15 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
</div>
))}
{peeking && (
<RoomPeek
account={account}
room={peeking.roomId}
via={peeking.via}
onClose={() => setPeeking(null)}
/>
)}
{knocking && (
<ReasonPrompt
title={`Ask to join ${knocking.name || knocking.alias || knocking.roomId}`}