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:
+1
-1
Submodule nobilis updated: d8881042da...5ddd7860b2
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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) })
|
||||
|
||||
Reference in New Issue
Block a user