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 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: d7370ed357...10d75ac7df
@@ -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 (
|
||||
<div className="settings">
|
||||
@@ -37,16 +38,17 @@ export function DownloadsPanel(): JSX.Element {
|
||||
{transfers.length === 0 ? (
|
||||
<div className="placeholder muted">
|
||||
<Icon name="download" size={32} />
|
||||
<span>Nothing has been offered to you yet</span>
|
||||
<span>No files have come or gone yet</span>
|
||||
<span className="small">
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{active.length > 0 && (
|
||||
<section className="settings-section">
|
||||
<h4 className="settings-section-title">Arriving</h4>
|
||||
<h4 className="settings-section-title">In progress</h4>
|
||||
<div className="downloads-list">
|
||||
{active.map((t) => (
|
||||
<Row key={t.id} transfer={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 (
|
||||
<div className="download-row">
|
||||
@@ -96,14 +100,16 @@ function Row({ transfer }: { transfer: DccTransfer }): JSX.Element {
|
||||
{transfer.fileName}
|
||||
</div>
|
||||
<div className="small muted download-detail">
|
||||
<span className="download-from">from {transfer.from}</span>
|
||||
<span className="download-from">
|
||||
{transfer.outgoing ? 'to' : 'from'} {transfer.from}
|
||||
</span>
|
||||
{/* Titled as well as shown, because the whole of a long failure is
|
||||
worth being able to read even where it has been wrapped. */}
|
||||
<span className="download-status" title={describe(transfer)}>
|
||||
{describe(transfer)}
|
||||
</span>
|
||||
</div>
|
||||
{(running || offered) && <TransferBar transfer={transfer} />}
|
||||
{running && <TransferBar transfer={transfer} />}
|
||||
</div>
|
||||
|
||||
<div className="download-actions">
|
||||
@@ -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) && (
|
||||
<IconButton
|
||||
name="close"
|
||||
title={offered ? 'Decline this file' : 'Cancel this transfer'}
|
||||
title={offered ? 'Decline this file' : waiting ? 'Withdraw this offer' : 'Cancel this transfer'}
|
||||
className="calling"
|
||||
onClick={() => 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
|
||||
|
||||
@@ -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
|
||||
? ([
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
<div className="transfer-panel">
|
||||
{showing.map((t) => (
|
||||
<div key={t.id} className="transfer-card">
|
||||
{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 ? (
|
||||
<Offer transfer={t} onAccept={() => void store.acceptTransfer(t.id)} onDecline={() => void store.cancelTransfer(t.id)} />
|
||||
) : (
|
||||
<Running
|
||||
@@ -104,26 +107,51 @@ function Running({
|
||||
return (
|
||||
<>
|
||||
<div className="transfer-head">
|
||||
<Icon name="download" size={18} />
|
||||
<Icon name={transfer.outgoing ? 'upload' : 'download'} size={18} />
|
||||
<span className="ellipsis">
|
||||
Receiving from <b>{transfer.from}</b>
|
||||
{transfer.outgoing ? (
|
||||
transfer.state === 'offered' ? (
|
||||
<>
|
||||
Waiting for <b>{transfer.from}</b> to accept
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Sending to <b>{transfer.from}</b>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Receiving from <b>{transfer.from}</b>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{/* 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. */}
|
||||
<IconButton name="expand_more" title="Hide this - it keeps going, under Downloads" onClick={onMinimise} />
|
||||
<IconButton name="close" title="Cancel this transfer" className="calling" onClick={onCancel} />
|
||||
<IconButton
|
||||
name="close"
|
||||
title={transfer.outgoing && transfer.state === 'offered' ? 'Withdraw this offer' : 'Cancel this transfer'}
|
||||
className="calling"
|
||||
onClick={onCancel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FileLine transfer={transfer} />
|
||||
<TransferBar transfer={transfer} />
|
||||
<p className="small muted transfer-stats">
|
||||
<span>
|
||||
{humanSize(transfer.received)} of {humanSize(transfer.size)}
|
||||
</span>
|
||||
<span>{humanRate(transfer.rate)}</span>
|
||||
</p>
|
||||
{/* Nothing has moved yet while an offer is out, so a bar sitting at zero
|
||||
would only look like a stall. */}
|
||||
{transfer.state !== 'offered' && (
|
||||
<>
|
||||
<TransferBar transfer={transfer} />
|
||||
<p className="small muted transfer-stats">
|
||||
<span>
|
||||
{humanSize(transfer.received)} of {humanSize(transfer.size)}
|
||||
</span>
|
||||
<span>{humanRate(transfer.rate)}</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -367,7 +367,7 @@ function TransferSettings(): JSX.Element {
|
||||
return (
|
||||
<SettingsSection
|
||||
title="XDCC file transfers"
|
||||
description="XDCC bots and other people on IRC can offer you a file directly. moho asks before taking one, saves it under a name of its own choosing rather than theirs, and never listens for a connection - it only ever dials out, which is also what lets this work on an account routed through Tor."
|
||||
description="XDCC bots and other people on IRC can offer you a file directly, and you can send one back from a name in the member list or from something they said. Receiving asks first, saves under a name of moho's own choosing rather than theirs, and only ever dials out - which is what lets it work on an account routed through Tor. Sending has to listen instead, so it is refused on such an account rather than quietly giving the address away."
|
||||
>
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
@@ -470,6 +470,25 @@ function TransferSettings(): JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>Address to give out when sending</div>
|
||||
<div className="small muted">
|
||||
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.
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<input
|
||||
className="text-field"
|
||||
placeholder="automatic"
|
||||
defaultValue={prefs?.advertisedIp ?? ''}
|
||||
onBlur={(e) => save({ advertisedIp: e.target.value.trim() })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>Accept offers without asking</div>
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
try {
|
||||
|
||||
+10
-2
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user