Offer attachments on IRC, and a say in where they go

Issues #17 and #18. The attach button was dead on IRC, which is the
service where sending a picture most needs help: it carries text and
nothing else, so a file means uploading it somewhere and sending the
link. That now happens on the way out, with the caption on the same
line rather than as a second message that could arrive out of order or
be split by somebody else talking.

Where files go is a setting, listed by the daemon that performs the
upload rather than spelled out again here. Read at send time rather
than held in state, since it is a preference somebody may change
between one message and the next.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-28 01:25:42 -04:00
co-authored by Claude Opus 5
parent d16a925416
commit 8453f5940e
4 changed files with 65 additions and 6 deletions
+1 -1
Submodule nobilis updated: caf56c7e73...2f9c2bc7ff
+5 -3
View File
@@ -70,10 +70,12 @@ export function Composer(): JSX.Element | null {
const account = buffer && accounts.find((a) => a.id === buffer.accountId)
const service = account?.service
// Discord, Sneedchat and Matrix each have a real upload path; IRC has no
// attachment concept at all, and XMPP/Slack aren't implemented yet.
// Discord, Sneedchat and Matrix each carry a file natively. IRC has no idea
// of an attachment at all, so a file there is uploaded and the link sent -
// which is what people do by hand on IRC anyway, and is why it is offered
// here rather than left as the one service where the button is dead.
const supportsAttachments =
service === 'discord' || service === 'sockchat' || service === 'matrix'
service === 'discord' || service === 'sockchat' || service === 'matrix' || service === 'irc'
// Refocus on buffer switch so typing works immediately after clicking a
// channel, without a second click into the field.
@@ -7,7 +7,7 @@ import {
StringSetting,
ToggleSetting
} from './controls'
import { useChat, useStore } from '../../state/hooks'
import { useChat, usePref, useStore } from '../../state/hooks'
import { Icon } from '../Icon'
import type { Account } from '../../../../shared/wire'
@@ -28,6 +28,43 @@ const CATEGORIES = [
{ id: 'slack', label: 'Slack', available: false }
]
/**
* Where files go on the services that cannot carry them.
*
* IRC has no idea of an attachment and Sneedchat's own uploader takes images
* only, so sharing anything on either means putting it somewhere and sending
* the link - which is what people do by hand there anyway. The list comes from
* the daemon rather than being spelled out here, since the daemon is what
* performs the upload and a client drawing the menu should not hold a second,
* separately-maintained copy of the answer.
*/
function UploadHostSetting(): JSX.Element {
const [host, setHost] = usePref<string>('uploads.host', 'catbox')
const [hosts, setHosts] = useState<{ id: string; label: string }[]>([])
useEffect(() => {
void window.moho
.rpc<{ id: string; label: string }[]>('listUploadHosts')
.then(setHosts)
// An older daemon has no such method; the stored choice still applies.
.catch(() => setHosts([]))
}, [])
return (
<ChoiceSetting
label="Upload files to"
description="Used when sending a file on a service that cannot carry one itself - IRC, and anything Sneedchat's own uploader refuses. The file is uploaded anonymously and the link is sent."
value={host}
options={
hosts.length
? hosts.map((h) => ({ label: h.label, value: h.id }))
: [{ label: host, value: host }]
}
onChange={setHost}
/>
)
}
export function SettingsPanel(): JSX.Element {
const [selected, setSelected] = useState('general')
@@ -180,6 +217,10 @@ function GeneralSettings(): JSX.Element {
/>
</SettingsSection>
<SettingsSection title="Uploads">
<UploadHostSetting />
</SettingsSection>
<SettingsSection title="Downloads">
<DirectorySetting
settingKey="downloads.directory"
+17 -1
View File
@@ -206,6 +206,16 @@ function tidyDetail(detail: string): string {
return detail.replace(/(https?:\/\/[^\s)]+?)\?[^\s)]*/g, '$1')
}
/** The upload host the person chose, or the default if they never did. */
async function uploadHost(): Promise<string> {
try {
const prefs = await window.moho.prefs.getAll()
return (prefs['uploads.host'] as string) || 'catbox'
} catch {
return 'catbox'
}
}
function bestEffort(work: Promise<unknown>, what: string): void {
void work.catch((e: Error) => console.debug(`[moho] ${what}:`, e.message))
}
@@ -1202,7 +1212,13 @@ export class ChatStore {
await window.moho.rpc('sendMessage', {
bufferId,
body,
...(attachmentPath ? { attachmentPath } : {}),
// Where a file goes on a service that cannot carry one. Read at send
// time rather than held in state: it is a preference somebody may
// change between one message and the next, and the daemon falls back
// to its own default if this is absent or unknown to it.
...(attachmentPath
? { attachmentPath, uploadHost: await uploadHost() }
: {}),
...(replyToId ? { replyToId } : {})
})
// Success alone doesn't resolve the echo - only the real message event