A button for stickers, a prompt for a reason, and spaces on the menu

The window half of the four Matrix features: a sticker button of its own
beside the emoji one, because they are different gestures - an emoji goes
into the line being written and a sticker is the message - and a picker
that shows packs and nothing else.

A shared prompt for the two things that need a sentence written before
they happen: a knock, where the reason is what the people inside decide
by, and a report, where the reason is the whole report.

Spaces on a room's own menu, since a space is the server's grouping
rather than this window's categories. And the room directory now says
which rooms are asked rather than entered, and which are open to a
space's members - one Join button for all three was a button that failed
for two of them.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-06 13:15:51 -04:00
co-authored by Claude Opus 5
parent 7e9f07b2b2
commit 8608c8d03d
9 changed files with 456 additions and 12 deletions
+1 -1
Submodule nobilis updated: d8881042da...5ddd7860b2
+61
View File
@@ -3507,6 +3507,67 @@ img.smilie {
pointer-events: none;
}
.emoji-empty {
padding: var(--spacing-s) var(--spacing-xs);
max-width: 22rem;
}
/* A sticker is a picture rather than a character, so it gets the room to be
one - an emoji cell is sized for a glyph and would draw these as thumbnails
too small to tell apart. */
.emoji-cell.sticker-cell {
width: 64px;
height: 64px;
}
.emoji-cell.sticker-cell img {
width: 100%;
height: 100%;
object-fit: contain;
}
/*
* A sentence somebody writes before something happens: a knock's reason, a
* report's. The text is the point rather than a note attached to a button, so
* the box is what the dialog is mostly made of.
*/
.reason-prompt {
display: flex;
flex-direction: column;
gap: var(--spacing-s);
width: min(440px, 90vw);
margin: auto;
padding: var(--spacing-m);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container-high);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5);
}
.reason-prompt-head {
display: flex;
align-items: center;
gap: var(--spacing-s);
font-weight: 500;
}
.reason-prompt-box {
width: 100%;
padding: var(--spacing-s);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
background: var(--surface-container);
color: var(--surface-text);
font: inherit;
resize: vertical;
}
.reason-prompt-actions {
display: flex;
justify-content: flex-end;
gap: var(--spacing-s);
}
/* Expanded media viewer. Sits above everything including the title bar, so
the picture is the only thing competing for attention. */
.lightbox-backdrop {
@@ -18,6 +18,7 @@ import {
type RailGroup
} from '../lib/groups'
import { isJoining, type BufferEntry } from '../state/store'
import type { BufferGroup } from '../../../shared/wire'
import {
bufferDisplayName,
bufferKindGlyph,
@@ -286,6 +287,14 @@ export function BufferList(): JSX.Element {
: undefined
}
autojoins={store.autojoins(b.accountId, bufferDisplayName(b.name))}
// Which spaces this account has, so a room can be put in one. Matrix
// only: a Discord guild is not something a channel is moved between.
spaces={groups.filter((g) => g.kind === 'space' && g.accountId === b.accountId)}
onSpace={(spaceId, child) =>
void store
.setMatrixSpaceChild(spaceId, b.id, child)
.catch((e: Error) => store.toast('error', e.message))
}
onWatch={b.accountId.startsWith('kick:') ? () => store.watchStream(b.id) : undefined}
onStopWatching={() => store.stopWatching()}
onOpenInBrowser={
@@ -699,6 +708,10 @@ interface BufferRowProps {
/** Kick channels only: play the stream in this window, or stop. */
onWatch?: () => void
onStopWatching?: () => void
/** The spaces this room could belong to, for the services that have them. */
spaces?: BufferGroup[]
/** Puts this room in a space, or takes it out of one. */
onSpace?: (spaceId: string, child: boolean) => void
/** Whether it is in that list now. */
autojoins?: boolean
onHide: () => void
@@ -739,6 +752,8 @@ function BufferRow({
onOpenInBrowser,
onWatch,
onStopWatching,
spaces,
onSpace,
onHide,
onClose,
onCall,
@@ -810,6 +825,31 @@ function BufferRow({
// A Kick channel is a stream as well as a chat, and the stream is not
// something this client shows - so the way to watch it belongs on the
// row, next to the way to open its chat in a window.
// Which space this room belongs to. A space is the server's own grouping,
// unlike the categories above it, so this writes to the room rather than
// to this window - and a room can be in more than one, which is why the
// one it is in now is offered for removal rather than swapped silently.
...(spaces && spaces.length > 0 && buffer.kind !== 'server'
? ([
...spaces
.filter((space) => space.id !== buffer.groupId)
.map((space) => ({
label: `Add to ${space.name}`,
icon: 'move_to_inbox',
onClick: () => void onSpace?.(space.id, true)
})),
...(spaces.some((space) => space.id === buffer.groupId)
? [
{
label: `Remove from ${spaces.find((s) => s.id === buffer.groupId)?.name ?? 'this space'}`,
icon: 'outbox',
onClick: () => void onSpace?.(buffer.groupId ?? '', false)
}
]
: []),
{ separator: true }
] as MenuEntry[])
: []),
// Watching is offered only while there is something to watch: an offline
// channel's playlist is a signed URL to nothing, and an entry that fails
// a few seconds after being pressed is worse than one that isn't there.
+55 -2
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { Avatar } from './Avatar'
import { EmojiPicker } from './EmojiPicker'
import { EmojiPicker, type StickerEntry } from './EmojiPicker'
import { useActiveBuffer, useChat, useStore } from '../state/hooks'
import { emojiPreview } from '../lib/format'
import { bufferDisplayName, classes, fileNameOf, isImageFile, resolveMediaUrl } from '../lib/util'
@@ -97,6 +97,9 @@ export function Composer(): JSX.Element | null {
const [text, setText] = useState('')
const [staged, setStaged] = useState<StagedAttachment[]>([])
const [pickerOpen, setPickerOpen] = useState(false)
/** The account's sticker packs, fetched when the picker is first opened. */
const [stickers, setStickers] = useState<StickerEntry[]>([])
const [stickerPicker, setStickerPicker] = useState(false)
const seqRef = useRef(0)
const typingSentAt = useRef(0)
/**
@@ -150,6 +153,7 @@ export function Composer(): JSX.Element | null {
/** Discord's mentionable roles here; empty everywhere else. */
const [roles, setRoles] = useState<{ id: string; name: string; colour?: string }[]>([])
const emojiButtonRef = useRef<HTMLButtonElement>(null)
const stickerButtonRef = useRef<HTMLButtonElement>(null)
const smilies = useChat((s) => s.smilies)
const bufferEmojiByBuffer = useChat((s) => s.bufferEmoji)
@@ -757,11 +761,43 @@ export function Composer(): JSX.Element | null {
type="button"
className="icon-button"
title="Emoji"
onClick={() => setPickerOpen(!pickerOpen)}
onClick={() => {
setStickerPicker(false)
setPickerOpen(!pickerOpen)
}}
>
<Icon name="mood" size={18} />
</button>
{/* Its own button beside the emoji one, because it is its own
gesture: an emoji goes into the line being written and a sticker
is the message. Only where the service has them - which today is
Matrix, and where a pack is what supplies them. */}
{service === 'matrix' && (
<button
ref={stickerButtonRef}
type="button"
className="icon-button"
title="Stickers"
onClick={() => {
const opening = !stickerPicker
setPickerOpen(false)
setStickerPicker(opening)
// Asked for when the picker opens rather than kept in sync: a
// pack changes about as often as somebody adds one, and the
// images are cached by the time they are drawn twice.
if (opening) {
void window.moho
.rpc<StickerEntry[]>('listMatrixStickers', { bufferId: buffer.id })
.then(setStickers)
.catch(() => setStickers([]))
}
}}
>
<Icon name="sticky_note_2" size={18} />
</button>
)}
<IconButton
name="add"
title={
@@ -773,6 +809,23 @@ export function Composer(): JSX.Element | null {
<IconButton name="send" title="Send" onClick={submit} />
</div>
{stickerPicker && (
<EmojiPicker
anchor={stickerButtonRef.current}
stickersOnly
stickers={stickers}
accountId={account?.id}
onSticker={(sticker) => {
setStickerPicker(false)
void window.moho
.rpc('sendMatrixSticker', { bufferId: buffer.id, mxc: sticker.mxc, body: sticker.body })
.catch((e: Error) => store.toast('error', e.message))
}}
onSelect={() => setStickerPicker(false)}
onClose={() => setStickerPicker(false)}
/>
)}
{pickerOpen && (
<EmojiPicker
anchor={emojiButtonRef.current}
+78 -7
View File
@@ -98,10 +98,35 @@ function readRecent(accountId?: string): string[] {
}
}
/** One image out of a Matrix sticker pack, as the daemon offers it. */
export interface StickerEntry {
name: string
pack: string
mxc: string
body: string
/** A local path the daemon already fetched, absent while it is fetching. */
url?: string | null
}
interface Props {
anchor: HTMLElement | null
customEmoji?: CustomEmoji[]
smilies?: SmilieEntry[]
/**
* The account's stickers, where the service has them. Separate from the
* emoji above because picking one *sends* rather than typing: a sticker is
* a message, not a character in the box.
*/
stickers?: StickerEntry[]
onSticker?: (sticker: StickerEntry) => void
/**
* Stickers and nothing else, for the button that opens exactly those.
*
* The two are different gestures rather than two tabs of one: an emoji goes
* into the line being written and a sticker *is* the message, so mixing
* them in one list would make half the cells type and half of them send.
*/
stickersOnly?: boolean
/** Whose recent picks to show. Absent means no account, so none are kept. */
accountId?: string
onSelect: (text: string) => void
@@ -112,8 +137,11 @@ export function EmojiPicker({
anchor,
customEmoji = [],
smilies = [],
stickers = [],
stickersOnly = false,
accountId,
onSelect,
onSticker,
onClose
}: Props): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
@@ -198,6 +226,21 @@ export function EmojiPicker({
[q, smilies]
)
// Grouped by the pack they came from, which is how somebody remembers where
// a sticker was: "the cat one" is a pack before it is a picture.
const packs = useMemo(() => {
const matching = q
? stickers.filter((s) => s.name.toLowerCase().includes(q) || s.pack.toLowerCase().includes(q))
: stickers
const byPack = new Map<string, StickerEntry[]>()
for (const sticker of matching) {
const list = byPack.get(sticker.pack) ?? []
list.push(sticker)
byPack.set(sticker.pack, list)
}
return [...byPack.entries()]
}, [q, stickers])
const pick = (text: string): void => {
const next = [text, ...recent.filter((r) => r !== text)].slice(0, MAX_RECENT)
setRecent(next)
@@ -218,13 +261,13 @@ export function EmojiPicker({
<input
className="text-field"
autoFocus
placeholder="Search emoji…"
placeholder={stickersOnly ? 'Search stickers…' : 'Search emoji…'}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="emoji-scroll">
{!q && recent.length > 0 && (
{!stickersOnly && !q && recent.length > 0 && (
<Section title="Recent">
{recent.map((r) => {
// A recent is stored as the text that gets sent, which for a
@@ -255,7 +298,34 @@ export function EmojiPicker({
</Section>
)}
{unicode.length > 0 && (
{packs.map(([pack, entries]) => (
<Section key={pack} title={pack}>
{entries.map((sticker) => (
<button
key={sticker.mxc}
type="button"
className="emoji-cell sticker-cell"
title={`${sticker.name} · ${pack}`}
onClick={() => onSticker?.(sticker)}
>
{sticker.url ? (
<img src={resolveMediaUrl(sticker.url)} alt={sticker.name} />
) : (
sticker.name
)}
</button>
))}
</Section>
))}
{stickersOnly && packs.length === 0 && (
<p className="small muted emoji-empty">
No sticker packs on this account. Packs added in another client - your own, or
one a room shares - turn up here.
</p>
)}
{!stickersOnly && unicode.length > 0 && (
<Section title="Emoji">
{unicode.map((e) => (
<button
@@ -276,7 +346,8 @@ export function EmojiPicker({
telling apart, since an unfamiliar name is then attributable to
whoever supplied it. Discord sends none, so its emoji stay under
the one heading they always had. */}
{customSections.map(([title, list]) => (
{!stickersOnly &&
customSections.map(([title, list]) => (
<Section key={title} title={title}>
{list.map((e) => (
<button
@@ -297,10 +368,10 @@ export function EmojiPicker({
<img src={emojiImage(e)} alt={e.name} />
</button>
))}
</Section>
))}
</Section>
))}
{smilieList.length > 0 && (
{!stickersOnly && smilieList.length > 0 && (
<Section title="Smilies">
{smilieList.map((s) => (
<button
@@ -1,6 +1,7 @@
import { useMemo, useRef, useState } from 'react'
import { Icon } from './Icon'
import { Avatar } from './Avatar'
import { ReasonPrompt } from './ReasonPrompt'
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
import { MediaEmbed } from './MediaEmbed'
import { EmojiPicker } from './EmojiPicker'
@@ -158,6 +159,8 @@ export function MessageRow({
const [draft, setDraft] = useState(message.body)
const [revealed, setRevealed] = useState<Record<number, boolean>>({})
const [pickerOpen, setPickerOpen] = useState(false)
/** Writing the reason for a report, before it is sent. */
const [reporting, setReporting] = useState(false)
const reactButtonRef = useRef<HTMLButtonElement>(null)
const permissions = useChat((s) => s.matrixPermissions)[bufferId] || {}
@@ -337,6 +340,21 @@ export function MessageRow({
? ([{ separator: true } as MenuEntry] as MenuEntry[])
: []),
...moderationEntries(message, permissions, bufferId, store),
// Telling whoever runs the homeserver. Beside the moderation entries and
// deliberately not gated like them: those need power in the room and this
// needs none, which is the point of it - it is the answer available to
// somebody with no power at all. Not for your own messages, and not for
// the client's own narration.
...(service === 'matrix' && !message.isOwn && !isSystem
? ([
{
label: 'Report to the server',
icon: 'flag',
danger: true,
onClick: () => setReporting(true)
}
] as MenuEntry[])
: []),
// Talking to somebody directly, from where they said the thing you want to
// talk to them about. Only where the sender is somebody to open one with:
// your own messages, and anything the server itself said, are not.
@@ -787,6 +805,23 @@ export function MessageRow({
)}
{menu && <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={close} />}
{reporting && (
<ReasonPrompt
title={`Report ${message.from}’s message`}
detail="This goes to the people who run your homeserver, with the message and the room it is in. They see your reason and nothing else you have not said."
placeholder="What is wrong with this message"
confirmLabel="Report"
danger
onCancel={() => setReporting(false)}
onConfirm={(reason) => {
setReporting(false)
void store
.reportMatrixMessage(bufferId, message.id, reason)
.then(() => store.toast('info', 'Reported to your homeserver'))
.catch((e: Error) => store.toast('error', e.message))
}}
/>
)}
</>
)
}
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Icon } from './Icon'
/**
* A sentence somebody has to write before something happens.
*
* Two things in moho need one and neither can be done well without it: a knock
* on a room, where the reason is all the people inside have to decide by, and
* a report to a server's moderators, where the reason is the whole report - a
* moderator receiving "this message was reported" and nothing else knows only
* that somebody pressed something.
*
* Deliberately not a confirm dialog with a text box bolted on. The text is the
* point, so it has the focus from the first frame and Enter sends it; Escape
* is how you leave without doing the thing.
*/
export function ReasonPrompt({
title,
detail,
placeholder,
confirmLabel,
/** Whether an empty reason is allowed through. A knock may be wordless. */
optional = false,
danger = false,
onConfirm,
onCancel
}: {
title: string
detail: string
placeholder: string
confirmLabel: string
optional?: boolean
danger?: boolean
onConfirm: (reason: string) => void
onCancel: () => void
}): JSX.Element {
const [reason, setReason] = useState('')
const box = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
box.current?.focus()
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onCancel])
const ready = optional || reason.trim().length > 0
return createPortal(
<div className="lightbox-backdrop" onClick={onCancel}>
<div className="reason-prompt" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
<div className="reason-prompt-head">
<Icon name={danger ? 'flag' : 'door_front'} size={18} />
<span>{title}</span>
</div>
<p className="small muted">{detail}</p>
<textarea
ref={box}
className="reason-prompt-box"
rows={3}
value={reason}
placeholder={placeholder}
onChange={(e) => setReason(e.target.value)}
onKeyDown={(e) => {
// Enter sends, because this is one sentence rather than a
// document; a newline is still there for anybody who wants two.
if (e.key === 'Enter' && !e.shiftKey && ready) {
e.preventDefault()
onConfirm(reason.trim())
}
}}
/>
<div className="reason-prompt-actions">
<button type="button" className="button" onClick={onCancel}>
Cancel
</button>
<button
type="button"
className={danger ? 'button danger' : 'button'}
disabled={!ready}
onClick={() => onConfirm(reason.trim())}
>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body
)
}
+67 -2
View File
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ReasonPrompt } from './ReasonPrompt'
import { createPortal } from 'react-dom'
import { Avatar } from './Avatar'
import { Icon, IconButton } from './Icon'
@@ -15,6 +16,12 @@ interface PublicRoom {
avatarUrl?: string
via: string
joined: boolean
/**
* How this room is entered: "public" is joined, "knock" is asked, and
* "restricted" is open to members of a space this account may not be in.
* One button for all three is a button that fails for two of them.
*/
joinRule?: string
}
/** One homeserver that was asked, and what came of asking it. */
@@ -79,6 +86,8 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
* without having to be found and enabled.
*/
const [disabled, setDisabled] = useState<Set<string>>(new Set())
/** 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>>({})
const inputRef = useRef<HTMLInputElement>(null)
const resultsRef = useRef<HTMLDivElement>(null)
@@ -160,7 +169,19 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, extraServer, account.id])
/**
* Asking to be let in, for a room that is asked rather than entered.
*
* The reason travels with the knock and is what the people inside read, so
* it is worth writing - a knock with nothing attached is what most ignored
* knocks look like.
*/
const knock = (room: PublicRoom): void => {
setKnocking(room)
}
const join = (room: PublicRoom): void => {
if (room.joinRule === 'knock') return knock(room)
setJoining((j) => ({ ...j, [room.roomId]: true }))
void store
// An alias where there is one: it carries its own server and survives
@@ -175,7 +196,14 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
.then(() => onClose())
.catch((e: Error) => {
setJoining((j) => ({ ...j, [room.roomId]: false }))
store.toast('error', e.message)
// A restricted room refuses with a bare 403, which says nothing about
// the one thing that would get you in.
store.toast(
'error',
room.joinRule === 'restricted'
? `${room.name || room.roomId} is open to members of a space - join the space first, and this room opens with it`
: e.message
)
})
}
@@ -279,6 +307,11 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
{/* Where it lives, said once, beside the room rather than
over a section of them. */}
{room.via ? ` · ${room.via}` : ' · this server'}
{room.joinRule === 'knock'
? ' · asks to be knocked on'
: room.joinRule === 'restricted'
? ' · open to a space’s members'
: ''}
{room.topic ? ` · ${room.topic}` : ''}
</div>
</div>
@@ -288,11 +321,43 @@ export function RoomSearch({ account, onClose }: { account: Account; onClose: ()
disabled={room.joined || joining[room.roomId]}
onClick={() => join(room)}
>
{room.joined ? 'Joined' : joining[room.roomId] ? 'Joining…' : 'Join'}
{room.joined
? 'Joined'
: joining[room.roomId]
? 'Joining…'
: room.joinRule === 'knock'
? 'Knock'
: 'Join'}
</button>
</div>
))}
{knocking && (
<ReasonPrompt
title={`Ask to join ${knocking.name || knocking.alias || knocking.roomId}`}
detail="This room is asked rather than entered. Whoever is inside sees your reason and decides; the answer comes back as an invitation."
placeholder="Why you would like to join"
confirmLabel="Knock"
optional
onCancel={() => setKnocking(null)}
onConfirm={(reason) => {
const room = knocking
setKnocking(null)
setJoining((j) => ({ ...j, [room.roomId]: true }))
void store
.knockMatrixRoom(account.id, room.alias || room.roomId, room.alias ? [] : [room.via], reason)
.then(() => {
setJoining((j) => ({ ...j, [room.roomId]: false }))
store.toast('info', `Asked to join ${room.name || room.roomId}. The answer arrives as an invitation.`)
})
.catch((e: Error) => {
setJoining((j) => ({ ...j, [room.roomId]: false }))
store.toast('error', e.message)
})
}}
/>
)}
{!busy && rooms.length === 0 && (answer?.rooms.length ?? 0) > 0 && (
<p className="small muted room-search-empty">
Every server with results is switched off above.
+26
View File
@@ -2313,6 +2313,32 @@ export class ChatStore {
setTimeout(() => this.dropJoining(stand_in.id), JOIN_GIVE_UP_MS)
}
/**
* Asks to be let into a room that is asked rather than entered.
*
* No stand-in row, unlike a join: knocking does not put you in the room and
* a row in the list would say it had. The answer, when it comes, is an
* invitation - which the invite list already shows.
*/
async knockMatrixRoom(accountId: string, roomIdOrAlias: string, via: string[] = [], reason = ''): Promise<void> {
await window.moho.rpc('knockMatrixRoom', { accountId, roomIdOrAlias, via, reason })
}
/**
* Tells whoever runs the homeserver about a message.
*
* Needs no power in the room, which is what makes it the answer available to
* everybody - see the moderation entries beside it, which do.
*/
async reportMatrixMessage(bufferId: string, messageId: string, reason: string): Promise<void> {
await window.moho.rpc('reportMatrixMessage', { bufferId, messageId, reason })
}
/** Puts a room in a space, or takes it out of one. */
async setMatrixSpaceChild(spaceId: string, bufferId: string, child: boolean): Promise<void> {
await window.moho.rpc('setMatrixSpaceChild', { spaceId, bufferId, child })
}
private dropJoining(id: string): void {
if (!this.state.buffers.some((b) => b.id === id)) return
this.set({ buffers: this.state.buffers.filter((b) => b.id !== id) })