Somewhere to send a message on to
"Forward…" on a message, and a searchable list of this window's conversations to choose from - ordered by where you have been talking, since a message being sent on usually goes somewhere you were just now. The row above a forwarded message says "Forwarded" rather than wearing a reply's arrow, and fills in whose message it was when the daemon has read the original. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: df52e49867...81f1b0b913
@@ -3545,6 +3545,52 @@ img.smilie {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Where to send a message on to: a search box and the conversations this
|
||||
window has, which on a busy account is a list that must be searchable. */
|
||||
.forward-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-s);
|
||||
width: min(460px, 92vw);
|
||||
max-height: 70vh;
|
||||
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);
|
||||
}
|
||||
|
||||
.forward-picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.forward-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-xs) var(--spacing-s);
|
||||
border: none;
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: none;
|
||||
color: var(--surface-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.forward-choice:hover {
|
||||
background: var(--surface-container-highest);
|
||||
}
|
||||
|
||||
.forward-choice .ellipsis:first-of-type {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* A form a bot asked for: a title, its boxes, and a send. Sized by what it
|
||||
holds rather than fixed, since a form is one field as often as five. */
|
||||
.discord-modal {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Icon } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import { bufferDisplayName } from '../lib/util'
|
||||
|
||||
/**
|
||||
* Where to send somebody else's message on to.
|
||||
*
|
||||
* A list of the conversations this window has, filtered as you type, because
|
||||
* on an account with three hundred channels a picker without a search box is
|
||||
* a picker nobody can use.
|
||||
*
|
||||
* Ordered by what was said in recently, since a message being forwarded is
|
||||
* usually going somewhere the person has just been.
|
||||
*/
|
||||
export function ForwardPicker({
|
||||
bufferId,
|
||||
messageId,
|
||||
onClose
|
||||
}: {
|
||||
bufferId: string
|
||||
messageId: string
|
||||
onClose: () => void
|
||||
}): JSX.Element {
|
||||
const store = useStore()
|
||||
const buffers = useChat((s) => s.buffers)
|
||||
const accounts = useChat((s) => s.accounts)
|
||||
const [query, setQuery] = useState('')
|
||||
const [sending, setSending] = useState('')
|
||||
const box = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
box.current?.focus()
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [onClose])
|
||||
|
||||
const source = buffers.find((b) => b.id === bufferId)
|
||||
const choices = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return buffers
|
||||
.filter((b) => b.id !== bufferId && b.kind !== 'server')
|
||||
.filter((b) => !q || bufferDisplayName(b.name).toLowerCase().includes(q))
|
||||
.sort((a, b) => (b.lastActivityTs ?? 0) - (a.lastActivityTs ?? 0))
|
||||
.slice(0, 60)
|
||||
}, [buffers, bufferId, query])
|
||||
|
||||
const send = (toBufferId: string): void => {
|
||||
setSending(toBufferId)
|
||||
void window.moho
|
||||
.rpc('forwardMessage', { fromBufferId: bufferId, messageId, toBufferId })
|
||||
.then(() => {
|
||||
store.toast('info', 'Sent it on')
|
||||
onClose()
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
setSending('')
|
||||
store.toast('error', e.message)
|
||||
})
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="lightbox-backdrop" onClick={onClose}>
|
||||
<div className="forward-picker" onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true">
|
||||
<div className="discord-modal-head">
|
||||
<Icon name="forward" size={18} />
|
||||
<span>Send this on</span>
|
||||
</div>
|
||||
<input
|
||||
ref={box}
|
||||
className="text-field"
|
||||
placeholder="Which conversation…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="forward-picker-list">
|
||||
{choices.map((buffer) => {
|
||||
const account = accounts.find((a) => a.id === buffer.accountId)
|
||||
// Discord carries a forward itself; everywhere else it is a
|
||||
// quoted copy, and it is worth saying which before it is sent.
|
||||
const native =
|
||||
source?.accountId === buffer.accountId && buffer.accountId.startsWith('discord:')
|
||||
return (
|
||||
<button
|
||||
key={buffer.id}
|
||||
type="button"
|
||||
className="forward-choice"
|
||||
disabled={!!sending}
|
||||
onClick={() => send(buffer.id)}
|
||||
>
|
||||
<Avatar name={buffer.name} url={buffer.avatarUrl} size={22} />
|
||||
<span className="ellipsis">{bufferDisplayName(buffer.name)}</span>
|
||||
<span className="small muted ellipsis">
|
||||
{account?.displayName ?? ''}
|
||||
{native ? '' : ' · as a quote'}
|
||||
</span>
|
||||
{sending === buffer.id && <span className="spinner" aria-label="Sending" />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{choices.length === 0 && (
|
||||
<p className="small muted emoji-empty">No conversation here by that name.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useMemo, useRef, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { Avatar } from './Avatar'
|
||||
import { ReasonPrompt } from './ReasonPrompt'
|
||||
import { ForwardPicker } from './ForwardPicker'
|
||||
import { ContextMenu, useContextMenu, type MenuEntry } from './ContextMenu'
|
||||
import { MediaEmbed } from './MediaEmbed'
|
||||
import { EmojiPicker } from './EmojiPicker'
|
||||
@@ -161,6 +162,8 @@ export function MessageRow({
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
/** Writing the reason for a report, before it is sent. */
|
||||
const [reporting, setReporting] = useState(false)
|
||||
/** Choosing where to send this message on to. */
|
||||
const [forwarding, setForwarding] = useState(false)
|
||||
const reactButtonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const permissions = useChat((s) => s.matrixPermissions)[bufferId] || {}
|
||||
@@ -260,6 +263,12 @@ export function MessageRow({
|
||||
] as MenuEntry[])
|
||||
: []),
|
||||
{ label: 'Reply', icon: 'reply', onClick: () => reply() },
|
||||
// Sending somebody else's message on. Native where the service has one -
|
||||
// Discord's forward carries the original itself, pictures and all - and a
|
||||
// quoted copy where it does not.
|
||||
...(!isSystem && message.id
|
||||
? ([{ label: 'Forward…', icon: 'forward', onClick: () => setForwarding(true) }] as MenuEntry[])
|
||||
: []),
|
||||
// Never hearing from them again. What that costs differs by service and
|
||||
// the daemon says which: Matrix keeps the list on the homeserver and
|
||||
// Discord has a real block, so both hold on every client that account is
|
||||
@@ -547,8 +556,24 @@ export function MessageRow({
|
||||
</button>
|
||||
) : (
|
||||
<div className="reply-preview small muted">
|
||||
<Icon name={message.replyTo.thread ? 'forum' : 'reply'} size={13} />
|
||||
<span className="reply-from" style={{ color: nickColor(message.replyTo.from) }}>
|
||||
{/* A forward is not a reply and does not get the reply's
|
||||
arrow: it is somebody else's message arriving, which is a
|
||||
different thing from an answer to one. Its own word for
|
||||
it, too - until the original is read the row says only
|
||||
"Forwarded", because whose message it was is not in what
|
||||
Discord sends. */}
|
||||
<Icon
|
||||
name={
|
||||
message.replyTo.forwarded ? 'forward' : message.replyTo.thread ? 'forum' : 'reply'
|
||||
}
|
||||
size={13}
|
||||
/>
|
||||
<span
|
||||
className="reply-from"
|
||||
style={
|
||||
message.replyTo.forwarded ? undefined : { color: nickColor(message.replyTo.from) }
|
||||
}
|
||||
>
|
||||
{message.replyTo.from}
|
||||
</span>
|
||||
<span className="ellipsis">{message.replyTo.body}</span>
|
||||
@@ -812,6 +837,13 @@ export function MessageRow({
|
||||
)}
|
||||
|
||||
{menu && <ContextMenu x={menu.x} y={menu.y} entries={entries} onClose={close} />}
|
||||
{forwarding && (
|
||||
<ForwardPicker
|
||||
bufferId={bufferId}
|
||||
messageId={message.id}
|
||||
onClose={() => setForwarding(false)}
|
||||
/>
|
||||
)}
|
||||
{reporting && (
|
||||
<ReasonPrompt
|
||||
title={`Report ${message.from}’s message`}
|
||||
|
||||
@@ -2186,6 +2186,16 @@ export class ChatStore {
|
||||
}))
|
||||
break
|
||||
|
||||
// Who wrote a forwarded message, once the daemon has read the original:
|
||||
// the row above it says "Forwarded" until then, since Discord sends a
|
||||
// forward without an author.
|
||||
case 'messageReplyRenamed': {
|
||||
this.mapMessage(data.bufferId, data.id, (m) =>
|
||||
m.replyTo ? { ...m, replyTo: { ...m.replyTo, from: data.from } } : m
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
case 'messageUpdated':
|
||||
// `edited` comes from the event rather than being hardcoded true: a
|
||||
// backend-internal body rewrite (Sneedchat swapping in a Tor-fetched
|
||||
|
||||
@@ -280,6 +280,15 @@ export interface ReplyPreview {
|
||||
* thread is somewhere, and only the second can be opened and continued.
|
||||
*/
|
||||
thread?: boolean
|
||||
/**
|
||||
* Whether this names a message brought here rather than one being answered.
|
||||
*
|
||||
* The same field carries both because Discord says both the same way - a
|
||||
* reference to another message - and only its `type` tells them apart. What
|
||||
* differs on screen is the whole meaning: a reply points back at something,
|
||||
* a forward is somebody else's message arriving.
|
||||
*/
|
||||
forwarded?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user