Ask before taking a file somebody offers over IRC
The prompt, the progress, and the settings for nobilis 1fdc8a5. Floats over everything the way an incoming call does, and for the same reason: a file is offered while you are reading something else, often in a conversation you are not. It shows the three things that make an offer obviously wrong - who it is from, what it will be called, and how big - and where the name had to be changed, it says what they had called it, so a substitution is never silent. Declining takes the focus, so a stray keypress gives the safe answer, and a file that would run rather than open says so. safeName had the same job as the daemon's new sanitiser and the same gaps - Windows device names, and the trailing dots and spaces Windows drops on its own - so the two are brought into step. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+1
-1
Submodule nobilis updated: 9f17ff229e...1fdc8a5424
+29
-3
@@ -27,6 +27,12 @@ export function uniquePath(target: string): string {
|
||||
return path.join(dir, `${stem}-${Date.now()}${ext}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows keeps these reserved whatever extension follows them, and opening
|
||||
* one talks to a device rather than to a file.
|
||||
*/
|
||||
const RESERVED = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])$/i
|
||||
|
||||
/**
|
||||
* A filename safe to join onto the download directory.
|
||||
*
|
||||
@@ -34,12 +40,32 @@ export function uniquePath(target: string): string {
|
||||
* basename strips any directory part (including "../" traversal), and the
|
||||
* remaining separators and Windows-reserved characters are replaced so the
|
||||
* result can only ever name a file directly inside the chosen folder.
|
||||
*
|
||||
* The Windows-specific rules are applied on every platform, not only there.
|
||||
* A name that is harmless on Linux can be a device on Windows, and a trailing
|
||||
* dot or space is silently dropped there - so "evil.exe." and "evil.exe" are
|
||||
* one file on one platform and two on the other. Deciding that per-platform
|
||||
* would mean the same message produced a different file depending on where it
|
||||
* was read, which is the kind of difference nobody tests.
|
||||
*
|
||||
* Kept in step with `safe_file_name` in nobilis's backend/irc_dcc.rs, which
|
||||
* does this same job for files arriving over IRC.
|
||||
*/
|
||||
export function safeName(filename: string | undefined, source: string): string {
|
||||
const raw = filename || source.split('?')[0] || 'download'
|
||||
const base = path.basename(raw)
|
||||
const cleaned = base.replace(/[/\\:*?"<>|]/g, '_').replace(/^\.+/, '').slice(0, 120)
|
||||
return cleaned || 'download'
|
||||
// Both separators regardless of platform: the name came off the network,
|
||||
// not off this filesystem. path.basename only knows about this one.
|
||||
const base = raw.split(/[/\\]/).pop() ?? ''
|
||||
// Drops a drive letter, and an alternate data stream with it.
|
||||
const withoutDrive = base.split(':').pop() ?? ''
|
||||
const cleaned = withoutDrive
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[/\\:*?"<>|\u0000-\u001f\u007f]/g, '_')
|
||||
.replace(/^\.+/, '')
|
||||
.replace(/[. ]+$/, '')
|
||||
.slice(0, 120)
|
||||
if (!cleaned) return 'download'
|
||||
return RESERVED.test(cleaned.split('.')[0]) ? `_${cleaned}` : cleaned
|
||||
}
|
||||
|
||||
/** A local path if this source is already on disk, else null. */
|
||||
|
||||
@@ -16,6 +16,7 @@ import { JoinPanel } from './components/JoinPanel'
|
||||
import { Toasts } from './components/Toasts'
|
||||
import { IncomingCallPanel } from './components/IncomingCallPanel'
|
||||
import { FileDrop } from './components/FileDrop'
|
||||
import { TransferPanel } from './components/TransferPanel'
|
||||
import { Icon, IconButton } from './components/Icon'
|
||||
import { Avatar } from './components/Avatar'
|
||||
import { dmStatus } from './components/BufferList'
|
||||
@@ -169,6 +170,9 @@ export default function App(): JSX.Element {
|
||||
|
||||
{/* Over everything: a call arrives while you are looking elsewhere. */}
|
||||
<IncomingCallPanel />
|
||||
{/* Over everything for the same reason: a file is offered while you are
|
||||
reading something else, and often in a conversation you are not. */}
|
||||
<TransferPanel />
|
||||
<FileDrop />
|
||||
<Toasts />
|
||||
</div>
|
||||
|
||||
@@ -3562,3 +3562,84 @@ video.lightbox-media {
|
||||
font-weight: 500;
|
||||
color: var(--surface-text);
|
||||
}
|
||||
|
||||
/* --- File transfers --------------------------------------------------- */
|
||||
|
||||
/* Sits above the calls rather than beside them: both float bottom-left, and
|
||||
two stacks in the same corner would overlap. A ringing call is the more
|
||||
urgent of the two, so it keeps the corner. */
|
||||
.transfer-panel {
|
||||
position: fixed;
|
||||
right: var(--spacing-m);
|
||||
bottom: var(--spacing-m);
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
gap: var(--spacing-s);
|
||||
z-index: 2100;
|
||||
width: 340px;
|
||||
max-width: calc(100vw - var(--spacing-xl));
|
||||
}
|
||||
|
||||
.transfer-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-s);
|
||||
padding: var(--spacing-m);
|
||||
border-radius: var(--corner-radius);
|
||||
background: var(--surface-container-highest);
|
||||
border: 1px solid var(--outline-medium);
|
||||
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.transfer-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
/* The name takes the slack so the close button stays at the right edge. */
|
||||
.transfer-head > .ellipsis {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.transfer-file {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--spacing-s);
|
||||
border-radius: var(--corner-radius-small);
|
||||
background: var(--surface-container-low);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.transfer-warning {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xs);
|
||||
margin: 0;
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.transfer-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-s);
|
||||
}
|
||||
|
||||
.transfer-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-container-low);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.transfer-bar-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
transition: width 0.2s linear;
|
||||
}
|
||||
|
||||
.transfer-panel p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Icon, IconButton } from './Icon'
|
||||
import { useChat, useStore } from '../state/hooks'
|
||||
import type { DccTransfer } from '../../../shared/wire'
|
||||
|
||||
/**
|
||||
* Files offered over IRC: the asking, and then the watching.
|
||||
*
|
||||
* Floats over everything for the same reason the call panel does - an offer
|
||||
* arrives while you are looking somewhere else, often in a conversation you
|
||||
* are not reading, and a prompt buried in a channel you have scrolled away
|
||||
* from is a prompt nobody answers.
|
||||
*
|
||||
* It asks first. Somebody you have never spoken to can send an offer, and
|
||||
* accepting one puts their file on your disk; that is worth a sentence and a
|
||||
* button rather than a surprise in your downloads folder. What it shows is
|
||||
* chosen for the decision being made: who it is from, what it will be called,
|
||||
* and how big - the three things that make an offer obviously wrong.
|
||||
*/
|
||||
export function TransferPanel(): JSX.Element | null {
|
||||
const store = useStore()
|
||||
const transfers = useChat((s) => s.transfers)
|
||||
|
||||
// Finished ones are history rather than something to answer, and belong in
|
||||
// the list this panel is not - they leave once they are done.
|
||||
const showing = transfers.filter((t) => t.state === 'offered' || t.state === 'receiving')
|
||||
if (showing.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="transfer-panel">
|
||||
{showing.map((t) => (
|
||||
<div key={t.id} className="transfer-card">
|
||||
{t.state === 'offered' ? (
|
||||
<Offer transfer={t} onAccept={() => void store.acceptTransfer(t.id)} onDecline={() => void store.cancelTransfer(t.id)} />
|
||||
) : (
|
||||
<Running transfer={t} onCancel={() => void store.cancelTransfer(t.id)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Offer({
|
||||
transfer,
|
||||
onAccept,
|
||||
onDecline
|
||||
}: {
|
||||
transfer: DccTransfer
|
||||
onAccept: () => void
|
||||
onDecline: () => void
|
||||
}): JSX.Element {
|
||||
const risky = looksExecutable(transfer.fileName)
|
||||
return (
|
||||
<>
|
||||
<div className="transfer-head">
|
||||
<Icon name="save" size={18} />
|
||||
<span className="ellipsis">
|
||||
<b>{transfer.from}</b> is offering you a file
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<FileLine transfer={transfer} />
|
||||
|
||||
{/* Not a block on anything - it is their disk and their decision - but
|
||||
the one property of a file that changes what accepting it means. */}
|
||||
{risky && (
|
||||
<p className="transfer-warning small">
|
||||
<Icon name="warning" size={14} />
|
||||
<span>This is a program. Opening it would run whatever they sent.</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="transfer-actions">
|
||||
{/* Declining is the default focus: the safe answer should be the one
|
||||
a stray keypress gives. */}
|
||||
<button type="button" className="button" onClick={onDecline} autoFocus>
|
||||
Decline
|
||||
</button>
|
||||
<button type="button" className="button primary" onClick={onAccept}>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Running({ transfer, onCancel }: { transfer: DccTransfer; onCancel: () => void }): JSX.Element {
|
||||
const pct = transfer.size > 0 ? Math.min(100, (transfer.received / transfer.size) * 100) : 0
|
||||
return (
|
||||
<>
|
||||
<div className="transfer-head">
|
||||
<Icon name="download" size={18} />
|
||||
<span className="ellipsis">
|
||||
Receiving from <b>{transfer.from}</b>
|
||||
</span>
|
||||
<IconButton name="close" title="Stop this transfer" onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<FileLine transfer={transfer} />
|
||||
|
||||
<div className="transfer-bar" role="progressbar" aria-valuenow={Math.round(pct)} aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className="transfer-bar-fill" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<p className="small muted">
|
||||
{humanSize(transfer.received)} of {humanSize(transfer.size)}
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FileLine({ transfer }: { transfer: DccTransfer }): JSX.Element {
|
||||
return (
|
||||
<div className="transfer-file">
|
||||
<span className="ellipsis" title={transfer.fileName}>
|
||||
{transfer.fileName}
|
||||
</span>
|
||||
<span className="small muted">{humanSize(transfer.size)}</span>
|
||||
{/* Shown only where the name had to be changed, so a file arriving
|
||||
under a different name than was offered is never a silent
|
||||
substitution - that difference is usually the interesting part. */}
|
||||
{transfer.rawName !== transfer.fileName && (
|
||||
<span className="small muted ellipsis" title={transfer.rawName}>
|
||||
they called it “{transfer.rawName}”
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening this would run it rather than show it.
|
||||
*
|
||||
* Extension-based, which is exactly as reliable as the extension - the point
|
||||
* is to catch the ordinary case where somebody is handed a .exe they were not
|
||||
* expecting, not to be a verdict on the file.
|
||||
*/
|
||||
export function looksExecutable(name: string): boolean {
|
||||
const ext = name.toLowerCase().split('.').pop() ?? ''
|
||||
return [
|
||||
'exe', 'scr', 'com', 'pif', 'bat', 'cmd', 'msi', 'msp', 'jar', 'app',
|
||||
'ps1', 'psm1', 'vbs', 'vbe', 'js', 'jse', 'wsf', 'wsh', 'hta', 'lnk',
|
||||
'dll', 'sys', 'sh', 'run', 'deb', 'rpm', 'apk', 'dmg', 'pkg'
|
||||
].includes(ext)
|
||||
}
|
||||
|
||||
export function humanSize(bytes: number): string {
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let size = bytes
|
||||
let unit = 0
|
||||
while (size >= 1024 && unit < units.length - 1) {
|
||||
size /= 1024
|
||||
unit += 1
|
||||
}
|
||||
return unit === 0 ? `${bytes} B` : `${size.toFixed(1)} ${units[unit]}`
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from './controls'
|
||||
import { useChat, usePref, useStore } from '../../state/hooks'
|
||||
import { Icon } from '../Icon'
|
||||
import type { Account } from '../../../../shared/wire'
|
||||
import type { Account, DccPrefs } from '../../../../shared/wire'
|
||||
|
||||
/**
|
||||
* Structured by service, so adding a protocol later means adding a rail entry
|
||||
@@ -327,6 +327,8 @@ function IrcSettings(): JSX.Element {
|
||||
<UploadHostSetting service="irc" kind="media" />
|
||||
</SettingsSection>
|
||||
|
||||
<TransferSettings />
|
||||
|
||||
<SettingsSection
|
||||
title="Connection defaults for new accounts"
|
||||
description="Applied when you connect a new IRC account. SASL, autojoin and NickServ auto-identify are per-account instead, from that account's own entry in the Accounts pane."
|
||||
@@ -338,6 +340,127 @@ function IrcSettings(): JSX.Element {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Receiving files people send over IRC.
|
||||
*
|
||||
* Backed by the daemon rather than by a stored pref, because the daemon is
|
||||
* what writes the files: a download folder held out here would mean a
|
||||
* network-driven write aimed by whichever window happened to ask last.
|
||||
*/
|
||||
function TransferSettings(): JSX.Element {
|
||||
const [prefs, setPrefs] = useState<DccPrefs | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void window.moho
|
||||
.rpc<DccPrefs>('getDccPrefs')
|
||||
.then(setPrefs)
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
const save = (patch: Partial<DccPrefs>): void => {
|
||||
void window.moho
|
||||
.rpc<DccPrefs>('setDccPrefs', patch)
|
||||
.then(setPrefs)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title="Receiving files"
|
||||
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."
|
||||
>
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>Save files to</div>
|
||||
<div className="small muted ellipsis">{prefs?.resolvedDirectory ?? '…'}</div>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="button"
|
||||
onClick={() => void window.moho.pickDirectory().then((dir) => dir && save({ directory: dir }))}
|
||||
>
|
||||
Browse…
|
||||
</button>
|
||||
{prefs?.directory && (
|
||||
<button type="button" className="button" onClick={() => save({ directory: '' })}>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>Largest file to accept</div>
|
||||
<div className="small muted">
|
||||
Anything offered above this is turned down without asking. A sender that goes past what it offered is cut
|
||||
off at the same limit.
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<select
|
||||
className="text-field"
|
||||
value={String(prefs?.maxBytes ?? 0)}
|
||||
onChange={(e) => save({ maxBytes: Number(e.target.value) })}
|
||||
>
|
||||
{[
|
||||
[100 * 1024 * 1024, '100 MB'],
|
||||
[1024 * 1024 * 1024, '1 GB'],
|
||||
[4 * 1024 * 1024 * 1024, '4 GB'],
|
||||
[16 * 1024 * 1024 * 1024, '16 GB'],
|
||||
[0, 'No limit']
|
||||
].map(([bytes, label]) => (
|
||||
<option key={String(bytes)} value={String(bytes)}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>At most this many at once</div>
|
||||
<div className="small muted">Further offers are turned down while this many are already going.</div>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<select
|
||||
className="text-field"
|
||||
value={String(prefs?.maxTransfers ?? 3)}
|
||||
onChange={(e) => save({ maxTransfers: Number(e.target.value) })}
|
||||
>
|
||||
{[1, 2, 3, 5, 10].map((n) => (
|
||||
<option key={n} value={String(n)}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="setting-row">
|
||||
<div className="setting-text">
|
||||
<div>Accept offers without asking</div>
|
||||
{/* Worded so what it gives up is plain rather than implied. The
|
||||
limits above still apply - this only removes the question. */}
|
||||
<div className="small muted">
|
||||
Off by default, and worth leaving off: anyone who can message you can offer you a file, and this takes them
|
||||
all. The size and count limits still apply.
|
||||
</div>
|
||||
</div>
|
||||
<div className="setting-actions">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={prefs?.autoAccept ?? false}
|
||||
onChange={(e) => save({ autoAccept: e.target.checked })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
|
||||
function useSockchatAccount(): Account | undefined {
|
||||
return useChat((s) => s.accounts).find((a) => a.service === 'sockchat')
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
BufferGroup,
|
||||
CustomEmoji,
|
||||
IncomingCall,
|
||||
DccTransfer,
|
||||
MatrixVerification,
|
||||
Member,
|
||||
Message,
|
||||
@@ -82,6 +83,8 @@ export interface ChatState {
|
||||
voiceSessions: VoiceSession[]
|
||||
/** Conversations ringing right now, newest last. */
|
||||
incomingCalls: IncomingCall[]
|
||||
/** Files offered over IRC, newest first, offers and transfers together. */
|
||||
transfers: DccTransfer[]
|
||||
/**
|
||||
* Everything that has mentioned you, newest first, across every service.
|
||||
*
|
||||
@@ -164,6 +167,7 @@ const INITIAL: ChatState = {
|
||||
voiceGuildId: '',
|
||||
voiceSessions: [],
|
||||
incomingCalls: [],
|
||||
transfers: [],
|
||||
mentions: [],
|
||||
lastReadTs: {},
|
||||
jumpTarget: '',
|
||||
@@ -571,6 +575,9 @@ export class ChatStore {
|
||||
async refreshIncomingCalls(): Promise<void> {
|
||||
try {
|
||||
this.set({ incomingCalls: await window.moho.rpc<IncomingCall[]>('getIncomingCalls') })
|
||||
// A window opened while a transfer is already running should show it,
|
||||
// not start from empty and only notice the next one.
|
||||
this.set({ transfers: await window.moho.rpc<DccTransfer[]>('listTransfers') })
|
||||
} catch {
|
||||
// An older daemon has no such method; no calls is the honest answer.
|
||||
this.set({ incomingCalls: [] })
|
||||
@@ -657,6 +664,29 @@ export class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes a file that has been offered. */
|
||||
async acceptTransfer(id: string): Promise<void> {
|
||||
try {
|
||||
await window.moho.rpc('acceptTransfer', { id })
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't accept that file: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns down an offer, or stops one already running.
|
||||
*
|
||||
* The same call for both: from where the person is sitting these are one
|
||||
* request - they want it to stop - and the daemon knows which it was.
|
||||
*/
|
||||
async cancelTransfer(id: string): Promise<void> {
|
||||
try {
|
||||
await window.moho.rpc('cancelTransfer', { id })
|
||||
} catch (e) {
|
||||
this.toast('error', `Couldn't stop that transfer: ${(e as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async leaveVoice(accountId: string): Promise<void> {
|
||||
try {
|
||||
await window.moho.rpc('leaveVoiceChannel', { accountId })
|
||||
@@ -837,6 +867,16 @@ export class ChatStore {
|
||||
this.setRinging(data as IncomingCall)
|
||||
break
|
||||
|
||||
// One event carries every state a transfer passes through, so the list
|
||||
// is updated in place rather than re-fetched on each step of a progress
|
||||
// bar that ticks several times a second.
|
||||
case 'dccTransfer': {
|
||||
const t = data as unknown as DccTransfer
|
||||
const rest = this.state.transfers.filter((x) => x.id !== t.id)
|
||||
this.set({ transfers: [t, ...rest] })
|
||||
break
|
||||
}
|
||||
|
||||
// Somebody joined or left a voice channel in a guild we may be showing.
|
||||
case 'voiceMembershipChanged':
|
||||
if (data.guildId === this.state.voiceGuildId) {
|
||||
|
||||
@@ -134,6 +134,49 @@ export interface VoiceMember {
|
||||
deafened?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A file somebody has offered over IRC, from the offer through to it being
|
||||
* on disk.
|
||||
*
|
||||
* One type for both because they are one thing to whoever is watching - a
|
||||
* file arriving - and being offered is its first state rather than a
|
||||
* different kind of object.
|
||||
*/
|
||||
export interface DccTransfer {
|
||||
id: string
|
||||
accountId: string
|
||||
/** Who offered it. */
|
||||
from: string
|
||||
/**
|
||||
* What it will be called on disk. Derived by the daemon from what they
|
||||
* said, never taken from it: a name off the network cannot be allowed to
|
||||
* decide where a file lands.
|
||||
*/
|
||||
fileName: string
|
||||
/**
|
||||
* What they called it. Shown beside `fileName` where the two differ, so a
|
||||
* name that had to be changed is visible rather than quietly substituted.
|
||||
*/
|
||||
rawName: string
|
||||
size: number
|
||||
received: number
|
||||
state: 'offered' | 'receiving' | 'done' | 'declined' | 'failed'
|
||||
/** Where it landed, once it has. */
|
||||
path?: string | null
|
||||
/** Why it failed, or why it was turned down. */
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
/** What receiving files is allowed to do. Lives with the daemon, which is what writes them. */
|
||||
export interface DccPrefs {
|
||||
directory?: string | null
|
||||
maxBytes: number
|
||||
maxTransfers: number
|
||||
autoAccept: boolean
|
||||
/** Where files will actually land, with the platform default filled in. */
|
||||
resolvedDirectory?: string
|
||||
}
|
||||
|
||||
export interface VoiceChannel {
|
||||
id: string
|
||||
name: string
|
||||
|
||||
Reference in New Issue
Block a user