From 823dc932227c02bc3b646df7d3fc02b7bd747ff9 Mon Sep 17 00:00:00 2001 From: Salastil Date: Mon, 31 Aug 2026 12:38:16 -0400 Subject: [PATCH] Offer a file from a name or from what they said Send a file sits beside Whisper in the two places you already are when you decide to: a name in the member list, and a message somebody wrote - which is usually where the thought comes from. The file is chosen in the system's own picker, so it looks and behaves like every other open dialog on that machine, and picking one is the consent to send it. Both panels now speak about sending as well as receiving. An offer we made is not a question for us to answer, so it shows as a transfer waiting on them rather than as a prompt with buttons that would do nothing, and its bar stays hidden until something is actually moving - at zero it only looks like a stall. Cancelling it withdraws the offer and says so. There is a setting for the address to give out, which matters behind a router: the machine only knows its own side of one, and the address worth publishing is the one the router answers on. Points at nobilis 10d75ac. Co-Authored-By: Claude Opus 5 --- nobilis | 2 +- .../src/components/DownloadsPanel.tsx | 36 ++++++++----- src/renderer/src/components/MessageRow.tsx | 14 +++++ src/renderer/src/components/NickList.tsx | 8 +++ src/renderer/src/components/TransferPanel.tsx | 52 ++++++++++++++----- .../src/components/settings/SettingsPanel.tsx | 21 +++++++- src/renderer/src/state/store.ts | 18 +++++++ src/shared/wire.ts | 12 ++++- 8 files changed, 133 insertions(+), 30 deletions(-) diff --git a/nobilis b/nobilis index d7370ed..10d75ac 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit d7370ed3570f6852e07e8116ad0ee53c1b137a83 +Subproject commit 10d75ac7dfec5d9102874e59fef0821f87676808 diff --git a/src/renderer/src/components/DownloadsPanel.tsx b/src/renderer/src/components/DownloadsPanel.tsx index 385decc..5cb4371 100644 --- a/src/renderer/src/components/DownloadsPanel.tsx +++ b/src/renderer/src/components/DownloadsPanel.tsx @@ -28,8 +28,9 @@ export function DownloadsPanel(): JSX.Element { .catch(() => undefined) }, []) - const active = transfers.filter((t) => t.state === 'offered' || t.state === 'receiving') - const finished = transfers.filter((t) => t.state !== 'offered' && t.state !== 'receiving') + const settled = (t: DccTransfer): boolean => t.state === 'done' || t.state === 'declined' || t.state === 'failed' + const active = transfers.filter((t) => !settled(t)) + const finished = transfers.filter(settled) return (
@@ -37,16 +38,17 @@ export function DownloadsPanel(): JSX.Element { {transfers.length === 0 ? (
- Nothing has been offered to you yet + No files have come or gone yet - On IRC, people and XDCC bots can send you a file directly. moho asks before taking one. + On IRC, people and XDCC bots can send you a file directly, and you can send one back - from a name in the + member list, or from something they said. moho asks before taking one.
) : ( <> {active.length > 0 && (
-

Arriving

+

In progress

{active.map((t) => ( @@ -82,8 +84,10 @@ export function DownloadsPanel(): JSX.Element { function Row({ transfer }: { transfer: DccTransfer }): JSX.Element { const store = useStore() - const running = transfer.state === 'receiving' - const offered = transfer.state === 'offered' + const running = transfer.state === 'receiving' || transfer.state === 'sending' + // Only an offer somebody made to us is a question we can answer. + const offered = transfer.state === 'offered' && !transfer.outgoing + const waiting = transfer.state === 'offered' && transfer.outgoing return (
@@ -96,14 +100,16 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element { {transfer.fileName}
- from {transfer.from} + + {transfer.outgoing ? 'to' : 'from'} {transfer.from} + {/* Titled as well as shown, because the whole of a long failure is worth being able to read even where it has been wrapped. */} {describe(transfer)}
- {(running || offered) && } + {running && }
@@ -115,10 +121,10 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element { {/* Only while there is something to stop. A finished row has nothing this button could do, and one that did nothing would still look like it might delete the file. */} - {(running || offered) && ( + {(running || offered || waiting) && ( void store.cancelTransfer(transfer.id)} /> @@ -132,20 +138,22 @@ function markFor(t: DccTransfer): string { if (t.state === 'done') return 'check_circle' if (t.state === 'failed') return 'error' if (t.state === 'declined') return 'block' - if (t.state === 'offered') return 'help' + if (t.state === 'offered') return t.outgoing ? 'schedule' : 'help' + if (t.state === 'sending') return 'upload' return looksExecutable(t.fileName) ? 'warning' : 'download' } /** The one line that says where this transfer got to. */ function describe(t: DccTransfer): string { switch (t.state) { - case 'receiving': { + case 'receiving': + case 'sending': { const rate = humanRate(t.rate) const moved = `${humanSize(t.received)} of ${humanSize(t.size)}` return rate ? `${moved} · ${rate}` : moved } case 'offered': - return `${humanSize(t.size)} · waiting for an answer` + return `${humanSize(t.size)} · ${t.outgoing ? 'waiting for them to accept' : 'waiting for an answer'}` case 'done': return humanSize(t.size) // The reason matters more than the word: "failed" on its own sends diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index 5d09ee3..b3853ed 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -165,6 +165,20 @@ export function MessageRow({ ...(service === 'sockchat' && !message.isOwn && message.from && !isSystem ? ([{ label: `Whisper ${message.from}`, icon: 'lock', onClick: () => store.startWhisper(message.from) }] as MenuEntry[]) : []), + // From the message rather than from a member list, because the person you + // want to send something to is usually the one who just said something. + ...(service === 'irc' && !message.isOwn && message.from && !isSystem + ? ([ + { + label: `Send a file to ${message.from}`, + icon: 'upload_file', + onClick: () => { + const account = store.accountFor(bufferId) + if (account) void store.sendFileTo(account.id, message.from) + } + } + ] as MenuEntry[]) + : []), ...(canEditDelete ? ([ { diff --git a/src/renderer/src/components/NickList.tsx b/src/renderer/src/components/NickList.tsx index 2286970..77ed290 100644 --- a/src/renderer/src/components/NickList.tsx +++ b/src/renderer/src/components/NickList.tsx @@ -110,6 +110,7 @@ export function NickList(): JSX.Element { blocked={isBlocked(`${buffer?.accountId}|${member.nick}`)} canOpenDm={hasDirectMessages(account?.service)} canWhisper={account?.service === 'sockchat'} + canSendFile={account?.service === 'irc'} canCall={canCall && !!member.userId} perms={perms} onToggleBlock={toggleBlocked} @@ -144,6 +145,7 @@ export function NickList(): JSX.Element { .catch((e: Error) => store.toast('error', `Couldn't call: ${e.message}`)) }} onWhisper={() => store.startWhisper(member.nick)} + onSendFile={() => account && void store.sendFileTo(account.id, member.nick)} onOpenDm={() => { if (!account) return // Which method each service wants, and whether it can work @@ -170,12 +172,15 @@ interface MemberRowProps { canOpenDm: boolean /** Sneedchat has no conversations, but it does have private messages. */ canWhisper: boolean + /** IRC carries a file directly between two people; nothing else here does. */ + canSendFile: boolean perms: { canKick?: boolean; canBan?: boolean; canMute?: boolean } onToggleBlock: (key: string) => void onMention: () => void onModerate: (action: 'kick' | 'ban' | 'mute') => void onOpenDm: () => void onWhisper: () => void + onSendFile: () => void /** Only where the protocol supports it and the member is identifiable. */ canCall: boolean onCall: () => void @@ -187,12 +192,14 @@ function MemberRow({ blocked, canOpenDm, canWhisper, + canSendFile, perms, onToggleBlock, onMention, onModerate, onOpenDm, onWhisper, + onSendFile, canCall, onCall }: MemberRowProps): JSX.Element { @@ -205,6 +212,7 @@ function MemberRow({ ...(canCall ? ([{ label: 'Call', icon: 'call', onClick: onCall }] as MenuEntry[]) : []), ...(canOpenDm ? ([{ label: 'Open DM', icon: 'chat', onClick: onOpenDm }] as MenuEntry[]) : []), ...(canWhisper ? ([{ label: 'Whisper', icon: 'lock', onClick: onWhisper }] as MenuEntry[]) : []), + ...(canSendFile ? ([{ label: 'Send a file', icon: 'upload_file', onClick: onSendFile }] as MenuEntry[]) : []), { separator: true }, { label: blocked ? 'Unblock' : 'Block', diff --git a/src/renderer/src/components/TransferPanel.tsx b/src/renderer/src/components/TransferPanel.tsx index 2da6fbd..a41c215 100644 --- a/src/renderer/src/components/TransferPanel.tsx +++ b/src/renderer/src/components/TransferPanel.tsx @@ -25,7 +25,7 @@ export function TransferPanel(): JSX.Element | null { // under Downloads instead - they leave here once they are done. So does // anything put away, which is still running and still listed there. const showing = transfers.filter( - (t) => (t.state === 'offered' || t.state === 'receiving') && !minimised.includes(t.id) + (t) => t.state !== 'done' && t.state !== 'declined' && t.state !== 'failed' && !minimised.includes(t.id) ) if (showing.length === 0) return null @@ -33,7 +33,10 @@ export function TransferPanel(): JSX.Element | null {
{showing.map((t) => (
- {t.state === 'offered' ? ( + {/* An offer we made is not a question for us to answer - it is + waiting on them - so it shows as a transfer rather than as a + prompt with buttons that would do nothing. */} + {t.state === 'offered' && !t.outgoing ? ( void store.acceptTransfer(t.id)} onDecline={() => void store.cancelTransfer(t.id)} /> ) : (
- + - Receiving from {transfer.from} + {transfer.outgoing ? ( + transfer.state === 'offered' ? ( + <> + Waiting for {transfer.from} to accept + + ) : ( + <> + Sending to {transfer.from} + + ) + ) : ( + <> + Receiving from {transfer.from} + + )} {/* Two buttons rather than one, because they are opposite actions and a single X would have to mean one of them. Putting it away is the harmless one, so it sits first and away from the corner a window's close button trains people to aim at. */} - +
- -

- - {humanSize(transfer.received)} of {humanSize(transfer.size)} - - {humanRate(transfer.rate)} -

+ {/* Nothing has moved yet while an offer is out, so a bar sitting at zero + would only look like a stall. */} + {transfer.state !== 'offered' && ( + <> + +

+ + {humanSize(transfer.received)} of {humanSize(transfer.size)} + + {humanRate(transfer.rate)} +

+ + )} ) } diff --git a/src/renderer/src/components/settings/SettingsPanel.tsx b/src/renderer/src/components/settings/SettingsPanel.tsx index 66d5c8d..a7507a9 100644 --- a/src/renderer/src/components/settings/SettingsPanel.tsx +++ b/src/renderer/src/components/settings/SettingsPanel.tsx @@ -367,7 +367,7 @@ function TransferSettings(): JSX.Element { return (
@@ -470,6 +470,25 @@ function TransferSettings(): JSX.Element {
+
+
+
Address to give out when sending
+
+ Sending a file means opening a port and telling the other person where to find it. Left blank, moho uses + the address this machine reaches the server from - which is right on a machine facing the internet and + wrong behind a router, where the address worth giving out is the one the router answers on. +
+
+
+ save({ advertisedIp: e.target.value.trim() })} + /> +
+
+
Accept offers without asking
diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 4b024b5..177a3c3 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -845,6 +845,24 @@ export class ChatStore { setTimeout(() => this.awaitBuffer(accountId, name, tries - 1), 250) } + /** + * Offers somebody a file, asking for one first. + * + * The picker is the system's own, so it looks and behaves like every other + * open dialog on that machine - and picking a file is consent to send that + * one, which is why the path never comes from anywhere but here. + */ + async sendFileTo(accountId: string, nick: string): Promise { + const path = await window.moho.pickFile() + if (!path) return + try { + await window.moho.rpc('sendFile', { accountId, nick, path }) + this.toast('info', `Offering that file to ${nick}…`) + } catch (e) { + this.toast('error', `Couldn't send to ${nick}: ${(e as Error).message}`) + } + } + /** Takes a file that has been offered. */ async acceptTransfer(id: string): Promise { try { diff --git a/src/shared/wire.ts b/src/shared/wire.ts index a10d06e..395af9f 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -145,7 +145,13 @@ export interface VoiceMember { export interface DccTransfer { id: string accountId: string - /** Who offered it. */ + /** + * Which way it is going. Sending and receiving differ in almost nothing + * else, so they share a list - what somebody wants to see is their + * transfers, not two separate accounts of them. + */ + outgoing: boolean + /** Who offered it, or who it is going to. */ from: string /** * What it will be called on disk. Derived by the daemon from what they @@ -162,7 +168,7 @@ export interface DccTransfer { received: number /** Bytes a second over the last interval, while it is running. */ rate: number - state: 'offered' | 'receiving' | 'done' | 'declined' | 'failed' + state: 'offered' | 'receiving' | 'sending' | 'done' | 'declined' | 'failed' /** Where it landed, once it has. */ path?: string | null /** Why it failed, or why it was turned down. */ @@ -177,6 +183,8 @@ export interface DccPrefs { /** Bytes a second across every transfer at once; 0 is unlimited. */ maxRate: number autoAccept: boolean + /** What to publish as this machine's address when sending; blank is automatic. */ + advertisedIp?: string | null /** Where files will actually land, with the platform default filled in. */ resolvedDirectory?: string }