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:
+1
-1
Submodule nobilis updated: c1f59452cf...e4e42082a9
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
Reference in New Issue
Block a user