A link to hand somebody, and a clipboard to put it on

The other half of making a Discord invite: the form that asks the three
questions Discord asks, and the link that comes back.

A form rather than one button, because an invite is not one thing - a link
posted in a public thread and one sent to a person want opposite answers to
"how long" and "how many", and picking for somebody would be picking wrong
half the time. Asked next to the gesture, with Discord's own defaults.

The link is shown as well as copied. Copying silently is a gesture with no
evidence it happened, and an invite is a thing people read before they send
it. It clears when the panel shuts: a link belongs to the options it was made
with, and offering to copy a stale one is worse than offering nothing.

Not on a one-to-one, which is the one Discord conversation with no link into
it - an invite there would have to add a third person, and Discord answers
that by making a new group instead, which is what the button beside this one
already does. A group message takes an invite exactly as a channel does, and
both arrive as `kind: 'dm'`, so the roster is what tells them apart.

Also the first thing in this client to write to the clipboard, which is done
from main: the page's own `navigator.clipboard` wants a secure context and a
permission the app has no way to grant itself. Text only - the renderer has
no business putting anything else on the system clipboard.

Closes #121.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-10 02:05:18 -04:00
co-authored by Claude Opus 5
parent 82b828b3f1
commit 84fd94ba6f
7 changed files with 239 additions and 1 deletions
+1 -1
Submodule nobilis updated: 52f712c8a5...2c024de938
+8
View File
@@ -670,6 +670,14 @@ function wireIpc(): void {
return file
})
// Putting something on the clipboard from here rather than through the
// page's own `navigator.clipboard`, which wants a secure context and a
// permission this app has no way to grant itself. Text only: the renderer
// has no business writing anything else onto the system clipboard.
ipcMain.handle(IPC.writeClipboardText, (_e, text: string) => {
if (typeof text === 'string' && text.length > 0) clipboard.writeText(text)
})
ipcMain.handle(IPC.restartDaemon, () => nobilis.restart())
ipcMain.handle(IPC.daemonStatus, () => ({
binaryPath: nobilis.binaryPath,
+3
View File
@@ -93,6 +93,9 @@ const api = {
},
openExternal: (url: string): Promise<void> => ipcRenderer.invoke(IPC.openExternal, url),
/** Puts a line of text on the system clipboard. */
copyText: (text: string): Promise<void> => ipcRenderer.invoke(IPC.writeClipboardText, text),
/**
* Where a dropped file actually lives.
*
+49
View File
@@ -4454,6 +4454,55 @@ video.lightbox-media {
padding-top: var(--spacing-xs);
}
/* Making an invite: three choices and the link they produce. A column
because each row is a question and its answer, and a form read down the
page is a form somebody finishes. */
.invite-form {
display: flex;
flex-direction: column;
gap: var(--spacing-xs);
}
.invite-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-s);
}
.invite-row select {
flex: 0 0 auto;
min-width: 130px;
padding: 2px var(--spacing-xs);
color: var(--surface-text);
background: var(--surface-container);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
}
.invite-check {
display: flex;
align-items: flex-start;
gap: var(--spacing-xs);
color: var(--surface-variant-text);
}
.invite-check input {
margin-top: 2px;
flex: 0 0 auto;
}
.invite-actions {
display: flex;
justify-content: flex-end;
}
/* The link itself, wide enough to read and selected on a click - it is a
thing to look at before sending, not only a thing to copy. */
.invite-link input {
font-family: var(--font-mono);
}
.main-header > .conversation-tools {
flex: 0 1 auto;
min-width: 0;
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { AddToConversation } from './AddToConversation'
import { DiscordInvite } from './DiscordInvite'
import { HeaderPopover } from './HeaderPopover'
import { useChat, useStore } from '../state/hooks'
import { bufferDisplayName, classes, formatFullTime } from '../lib/util'
@@ -809,6 +810,13 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
the conversation being read rather than on the window. */}
{isDiscord && isDm && <AddToConversation buffer={buffer} />}
{/* A Discord channel is joined with a link rather than by being asked,
so the invite here is a thing to make and paste rather than a person
to name. A group message can be invited into as well as a channel,
which is why this is not gated on kind - the component hides itself
on the one conversation Discord has no link into, a one-to-one. */}
{isDiscord && buffer.kind !== 'server' && <DiscordInvite buffer={buffer} />}
{/* The same job on Matrix, where it needs no picker: a Matrix address
names somebody whether or not this account has ever met them, so
there is no friend list to choose from and nothing to look up. */}
@@ -0,0 +1,169 @@
import { useRef, useState } from 'react'
import { Icon, IconButton } from './Icon'
import { HeaderPopover } from './HeaderPopover'
import { useChat, useStore } from '../state/hooks'
import type { BufferEntry } from '../state/store'
/**
* How long an invite lasts, in seconds, and what to call each.
*
* Discord's own list, in Discord's own order, with Discord's own meaning for
* zero: never expires. Offered rather than reduced to one default because the
* choice is the whole reason somebody opens this - an invite to a private
* server and one pasted in a public thread want opposite answers.
*/
const AGES: [number, string][] = [
[1800, '30 minutes'],
[3600, '1 hour'],
[21600, '6 hours'],
[43200, '12 hours'],
[86400, '1 day'],
[604800, '7 days'],
[0, 'Never']
]
/** How many people may use it. Zero is Discord's "no limit". */
const USES: [number, string][] = [
[0, 'No limit'],
[1, '1 use'],
[5, '5 uses'],
[10, '10 uses'],
[25, '25 uses'],
[50, '50 uses'],
[100, '100 uses']
]
/**
* Making an invite to a Discord conversation.
*
* Accepting one has worked for a long time and making one did not, so
* inviting anybody meant opening the official client to fetch a link. This is
* that link.
*
* A form rather than a single button, because an invite is not one thing: the
* three options Discord offers are the difference between a link posted in
* public and one sent to a person, and picking for somebody would be picking
* wrong half the time. They are asked here, next to the gesture, and the
* defaults are Discord's own.
*
* The link is shown as well as copied. Copying silently is a gesture with no
* evidence it happened, and an invite is a thing people want to read before
* they send it - who it is for is written into it.
*/
export function DiscordInvite({ buffer }: { buffer: BufferEntry }): JSX.Element | null {
const store = useStore()
const roster = useChat((s) => s.presenceByBuffer[buffer.id])
const [open, setOpen] = useState(false)
const [age, setAge] = useState(86400)
const [uses, setUses] = useState(0)
const [temporary, setTemporary] = useState(false)
const [link, setLink] = useState('')
const [busy, setBusy] = useState(false)
const button = useRef<HTMLSpanElement>(null)
// A one-to-one is the one Discord conversation with no link into it: an
// invite would have to add a third person, and Discord answers that by
// making a new group instead - which is what the button beside this one
// does. A group message takes an invite the same way a channel does, and
// both arrive here as `kind: 'dm'`, so the roster is what tells them apart.
if (buffer.kind === 'dm' && (roster?.length ?? 0) <= 1) return null
const make = (): void => {
setBusy(true)
void window.moho
.rpc<{ invite: string }>('createDiscordInvite', {
bufferId: buffer.id,
maxAge: age,
maxUses: uses,
temporary
})
.then((r) => {
setLink(r.invite)
// Copied as it is made, because that is what it is for. It stays on
// screen too - see above.
return window.moho.copyText(r.invite).then(() => store.toast('info', 'Invite copied'))
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
const shut = (): void => {
setOpen(false)
// A link belongs to the options it was made with; leaving the last one
// sitting there would offer to copy an invite the next set of choices
// has nothing to do with.
setLink('')
}
return (
<>
<span ref={button} className="header-anchor">
<IconButton
name="add_link"
title="Make an invite to this conversation"
onClick={() => (open ? shut() : setOpen(true))}
/>
</span>
{open && (
<HeaderPopover anchor={button.current} width={320} onClose={shut}>
<div className="invite-form">
<label className="invite-row small">
<span className="muted">Expires after</span>
<select value={age} disabled={busy} onChange={(e) => setAge(Number(e.target.value))}>
{AGES.map(([seconds, label]) => (
<option key={seconds} value={seconds}>
{label}
</option>
))}
</select>
</label>
<label className="invite-row small">
<span className="muted">Number of uses</span>
<select value={uses} disabled={busy} onChange={(e) => setUses(Number(e.target.value))}>
{USES.map(([count, label]) => (
<option key={count} value={count}>
{label}
</option>
))}
</select>
</label>
{/* Discord's own wording for it is "temporary membership", which
says nothing on its own - what it actually does is worth a
sentence, since it is the one option with a consequence
somebody would not expect. */}
<label className="invite-check small">
<input
type="checkbox"
checked={temporary}
disabled={busy}
onChange={(e) => setTemporary(e.target.checked)}
/>
<span>Remove them again when they go offline, unless given a role</span>
</label>
<div className="invite-actions">
<button type="button" className="button primary" disabled={busy} onClick={make}>
{link ? 'Make another' : 'Make invite'}
</button>
</div>
{link && (
<div className="popover-field invite-link">
<Icon name="link" size={16} />
<input readOnly value={link} onFocus={(e) => e.target.select()} />
<IconButton
name="content_copy"
size={16}
title="Copy it again"
onClick={() => void window.moho.copyText(link).then(() => store.toast('info', 'Invite copied'))}
/>
</div>
)}
</div>
</HeaderPopover>
)}
</>
)
}
+1
View File
@@ -16,6 +16,7 @@ export const IPC = {
importGroupIcon: 'moho:importGroupIcon',
defaultDownloadDir: 'moho:defaultDownloadDir',
readClipboardImage: 'moho:readClipboardImage',
writeClipboardText: 'moho:writeClipboardText',
restartDaemon: 'moho:restartDaemon',
daemonStatus: 'moho:daemonStatus',
smiliesDir: 'moho:smiliesDir',