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 }