@shhhum/xftp-web 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shhhum/xftp-web",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "XFTP file transfer protocol client for web/browser environments",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {
package/src/client.ts CHANGED
@@ -97,7 +97,7 @@ interface Transport {
97
97
 
98
98
  // -- Transport implementations
99
99
 
100
- const isNode = typeof globalThis.process !== "undefined" && globalThis.process.versions?.node
100
+ const isNode = typeof (globalThis as any).process !== "undefined" && (globalThis as any).process.versions?.node
101
101
 
102
102
  // In development mode, use HTTP proxy to avoid self-signed cert issues in browser
103
103
  // __XFTP_PROXY_PORT__ is injected by vite build (null in production)
@@ -112,7 +112,8 @@ async function createTransport(baseUrl: string, config: TransportConfig): Promis
112
112
  }
113
113
 
114
114
  async function createNodeTransport(baseUrl: string, config: TransportConfig): Promise<Transport> {
115
- const http2 = await import("node:http2")
115
+ // @ts-ignore node:http2 unavailable in browser tsconfig
116
+ const http2: any = await import("node:http2")
116
117
  const session = http2.connect(baseUrl, {rejectUnauthorized: false})
117
118
  return {
118
119
  async post(body: Uint8Array, headers?: Record<string, string>): Promise<Uint8Array> {
@@ -122,11 +123,14 @@ async function createNodeTransport(baseUrl: string, config: TransportConfig): Pr
122
123
  req.close()
123
124
  reject(Object.assign(new Error("Request timeout"), {name: "AbortError"}))
124
125
  })
125
- const chunks: Buffer[] = []
126
- req.on("data", (chunk: Buffer) => chunks.push(chunk))
127
- req.on("end", () => resolve(new Uint8Array(Buffer.concat(chunks))))
126
+ const chunks: any[] = []
127
+ req.on("data", (chunk: any) => chunks.push(chunk))
128
+ req.on("end", () => {
129
+ const B = (globalThis as any).Buffer
130
+ resolve(new Uint8Array(B.concat(chunks)))
131
+ })
128
132
  req.on("error", reject)
129
- req.end(Buffer.from(body))
133
+ req.end(body)
130
134
  })
131
135
  },
132
136
  close() {
@@ -149,7 +153,7 @@ function createBrowserTransport(baseUrl: string, config: TransportConfig): Trans
149
153
  const resp = await fetch(effectiveUrl, {
150
154
  method: "POST",
151
155
  headers,
152
- body,
156
+ body: body as any,
153
157
  signal: controller.signal
154
158
  })
155
159
  if (!resp.ok) {
@@ -1,4 +1,5 @@
1
1
  import type {FileHeader} from '../src/crypto/file.js'
2
+ import {t} from './i18n.js'
2
3
 
3
4
  export interface CryptoBackend {
4
5
  encrypt(data: Uint8Array, fileName: string,
@@ -134,7 +135,7 @@ class WorkerBackend implements CryptoBackend {
134
135
 
135
136
  export function createCryptoBackend(): CryptoBackend {
136
137
  if (typeof Worker === 'undefined') {
137
- throw new Error('Web Workers required update your browser')
138
+ throw new Error(t('workersRequired', 'Web Workers required \u2014 update your browser'))
138
139
  }
139
140
  return new WorkerBackend()
140
141
  }
@@ -190,7 +190,7 @@ async function handleVerifyAndDecrypt(
190
190
  }
191
191
 
192
192
  // Compute per-chunk SHA-512 incrementally to find divergence point
193
- const state = sodium.crypto_hash_sha512_init()
193
+ const state = sodium.crypto_hash_sha512_init() as unknown as import('libsodium-wrappers').StateAddress
194
194
  for (let i = 0; i < chunks.length; i++) {
195
195
  const chunk = chunks[i]
196
196
  const SEG = 4 * 1024 * 1024
@@ -202,7 +202,7 @@ async function handleVerifyAndDecrypt(
202
202
  if (!digestEqual(actualDigest, digest)) {
203
203
  console.error(`[WORKER-DBG] DIGEST MISMATCH: expected=${_whex(digest, 64)} actual=${_whex(actualDigest, 64)} chunks=${chunks.length} totalSize=${totalSize}`)
204
204
  // Log per-chunk incremental hash to find divergence
205
- const state2 = sodium.crypto_hash_sha512_init()
205
+ const state2 = sodium.crypto_hash_sha512_init() as unknown as import('libsodium-wrappers').StateAddress
206
206
  for (let i = 0; i < chunks.length; i++) {
207
207
  const chunk = chunks[i]
208
208
  const SEG = 4 * 1024 * 1024
package/web/download.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {createCryptoBackend} from './crypto-backend.js'
2
2
  import {createProgressRing} from './progress.js'
3
+ import {t} from './i18n.js'
3
4
  import {
4
5
  newXFTPAgent, closeXFTPAgent,
5
6
  decodeDescriptionURI, downloadFileRaw
@@ -11,30 +12,30 @@ export function initDownload(app: HTMLElement, hash: string) {
11
12
  try {
12
13
  fd = decodeDescriptionURI(hash)
13
14
  } catch (err: any) {
14
- app.innerHTML = `<div class="card"><p class="error">Invalid or corrupted link.</p></div>`
15
+ app.innerHTML = `<div class="card"><p class="error">${t('invalidLink', 'Invalid or corrupted link.')}</p></div>`
15
16
  return
16
17
  }
17
18
 
18
19
  const size = fd.redirect ? fd.redirect.size : fd.size
19
20
  app.innerHTML = `
20
21
  <div class="card">
21
- <h1>SimpleX File Transfer</h1>
22
+ <h1>${t('title', 'SimpleX File Transfer')}</h1>
22
23
  <div id="dl-ready" class="stage">
23
- <p>File available (~${formatSize(size)})</p>
24
- <button id="dl-btn" class="btn">Download</button>
24
+ <p>${t('fileAvailable', 'File available (~%size%)').replace('%size%', formatSize(size))}</p>
25
+ <button id="dl-btn" class="btn">${t('download', 'Download')}</button>
25
26
  <div class="security-note">
26
- <p>This file is encrypted the server never sees file contents.</p>
27
- <p>The decryption key is in the link's hash fragment, which your browser never sends to any server.</p>
28
- <p>For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.</p>
27
+ <p>${t('dlSecurityNote1', 'This file is encrypted \u2014 the server never sees file contents.')}</p>
28
+ <p>${t('dlSecurityNote2', 'The decryption key is in the link\u2019s hash fragment, which your browser never sends to any server.')}</p>
29
+ <p>${t('dlSecurityNote3', 'For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.')}</p>
29
30
  </div>
30
31
  </div>
31
32
  <div id="dl-progress" class="stage" hidden>
32
33
  <div id="dl-progress-container"></div>
33
- <p id="dl-status">Downloading…</p>
34
+ <p id="dl-status">${t('downloading', 'Downloading\u2026')}</p>
34
35
  </div>
35
36
  <div id="dl-error" class="stage" hidden>
36
37
  <p class="error" id="dl-error-msg"></p>
37
- <button id="dl-retry-btn" class="btn">Retry</button>
38
+ <button id="dl-retry-btn" class="btn">${t('retry', 'Retry')}</button>
38
39
  </div>
39
40
  </div>`
40
41
 
@@ -65,7 +66,7 @@ export function initDownload(app: HTMLElement, hash: string) {
65
66
  const ring = createProgressRing()
66
67
  progressContainer.innerHTML = ''
67
68
  progressContainer.appendChild(ring.canvas)
68
- statusText.textContent = 'Downloading'
69
+ statusText.textContent = t('downloading', 'Downloading\u2026')
69
70
 
70
71
  const backend = createCryptoBackend()
71
72
  const agent = newXFTPAgent()
@@ -81,7 +82,7 @@ export function initDownload(app: HTMLElement, hash: string) {
81
82
  }
82
83
  })
83
84
 
84
- statusText.textContent = 'Decrypting'
85
+ statusText.textContent = t('decrypting', 'Decrypting\u2026')
85
86
  ring.update(0.85)
86
87
 
87
88
  const {header, content} = await backend.verifyAndDecrypt({
@@ -107,7 +108,8 @@ export function initDownload(app: HTMLElement, hash: string) {
107
108
  setTimeout(() => URL.revokeObjectURL(url), 1000)
108
109
 
109
110
  ring.update(1)
110
- statusText.textContent = 'Download complete'
111
+ statusText.textContent = t('downloadComplete', 'Download complete')
112
+ app.dispatchEvent(new CustomEvent('xftp:download-complete', {detail: {fileName}, bubbles: true}))
111
113
  } catch (err: any) {
112
114
  const msg = err?.message ?? String(err)
113
115
  showError(msg)
package/web/i18n.ts ADDED
@@ -0,0 +1,9 @@
1
+ declare global {
2
+ interface Window {
3
+ __XFTP_I18N__?: Record<string, string>
4
+ }
5
+ }
6
+
7
+ export function t(key: string, fallback: string): string {
8
+ return window.__XFTP_I18N__?.[key] ?? fallback
9
+ }
package/web/main.ts CHANGED
@@ -1,17 +1,26 @@
1
1
  import sodium from 'libsodium-wrappers-sumo'
2
2
  import {initUpload} from './upload.js'
3
3
  import {initDownload} from './download.js'
4
+ import {t} from './i18n.js'
5
+
6
+ function getAppElement(): HTMLElement | null {
7
+ return (document.querySelector('[data-xftp-app]') as HTMLElement | null) ?? document.getElementById('app')
8
+ }
4
9
 
5
10
  async function main() {
6
11
  await sodium.ready
7
- initApp()
8
12
 
9
- // Handle hash changes (SPA navigation)
10
- window.addEventListener('hashchange', initApp)
13
+ const app = getAppElement()
14
+ if (!app?.hasAttribute('data-defer-init')) {
15
+ initApp()
16
+ }
17
+ if (!app?.hasAttribute('data-no-hashchange')) {
18
+ window.addEventListener('hashchange', initApp)
19
+ }
11
20
  }
12
21
 
13
22
  function initApp() {
14
- const app = document.getElementById('app')!
23
+ const app = getAppElement()!
15
24
  const hash = window.location.hash.slice(1)
16
25
 
17
26
  if (hash) {
@@ -21,10 +30,12 @@ function initApp() {
21
30
  }
22
31
  }
23
32
 
33
+ ;(window as any).__xftp_initApp = initApp
34
+
24
35
  main().catch(err => {
25
- const app = document.getElementById('app')
36
+ const app = getAppElement()
26
37
  if (app) {
27
- app.innerHTML = `<div class="error"><p>Failed to initialize: ${err.message}</p></div>`
38
+ app.innerHTML = `<div class="error"><p>${t('initError', 'Failed to initialize: %error%').replace('%error%', err.message)}</p></div>`
28
39
  }
29
40
  console.error(err)
30
41
  })
package/web/progress.ts CHANGED
@@ -2,8 +2,6 @@ const SIZE = 120
2
2
  const LINE_WIDTH = 8
3
3
  const RADIUS = (SIZE - LINE_WIDTH) / 2
4
4
  const CENTER = SIZE / 2
5
- const BG_COLOR = '#e0e0e0'
6
- const FG_COLOR = '#3b82f6'
7
5
 
8
6
  export interface ProgressRing {
9
7
  canvas: HTMLCanvasElement
@@ -21,11 +19,17 @@ export function createProgressRing(): ProgressRing {
21
19
  ctx.scale(devicePixelRatio, devicePixelRatio)
22
20
 
23
21
  function draw(fraction: number) {
22
+ const appEl = document.querySelector('[data-xftp-app]') ?? document.getElementById('app')
23
+ const s = appEl ? getComputedStyle(appEl) : null
24
+ const bgColor = s?.getPropertyValue('--xftp-ring-bg').trim() || '#e0e0e0'
25
+ const fgColor = s?.getPropertyValue('--xftp-ring-fg').trim() || '#3b82f6'
26
+ const textColor = s?.getPropertyValue('--xftp-ring-text').trim() || '#333'
27
+
24
28
  ctx.clearRect(0, 0, SIZE, SIZE)
25
29
  // Background arc
26
30
  ctx.beginPath()
27
31
  ctx.arc(CENTER, CENTER, RADIUS, 0, 2 * Math.PI)
28
- ctx.strokeStyle = BG_COLOR
32
+ ctx.strokeStyle = bgColor
29
33
  ctx.lineWidth = LINE_WIDTH
30
34
  ctx.lineCap = 'round'
31
35
  ctx.stroke()
@@ -33,14 +37,14 @@ export function createProgressRing(): ProgressRing {
33
37
  if (fraction > 0) {
34
38
  ctx.beginPath()
35
39
  ctx.arc(CENTER, CENTER, RADIUS, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * fraction)
36
- ctx.strokeStyle = FG_COLOR
40
+ ctx.strokeStyle = fgColor
37
41
  ctx.lineWidth = LINE_WIDTH
38
42
  ctx.lineCap = 'round'
39
43
  ctx.stroke()
40
44
  }
41
45
  // Percentage text
42
46
  const pct = Math.round(fraction * 100)
43
- ctx.fillStyle = '#333'
47
+ ctx.fillStyle = textColor
44
48
  ctx.font = '600 20px system-ui, sans-serif'
45
49
  ctx.textAlign = 'center'
46
50
  ctx.textBaseline = 'middle'
package/web/servers.ts CHANGED
@@ -8,5 +8,6 @@ declare const __XFTP_SERVERS__: string[]
8
8
  const serverAddresses: string[] = __XFTP_SERVERS__
9
9
 
10
10
  export function getServers(): XFTPServer[] {
11
- return serverAddresses.map(parseXFTPServer)
11
+ const addrs = (window as any).__XFTP_SERVERS__ ?? serverAddresses
12
+ return addrs.map(parseXFTPServer)
12
13
  }
package/web/style.css CHANGED
@@ -1,22 +1,13 @@
1
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
2
-
3
- body {
1
+ #app {
4
2
  font-family: system-ui, -apple-system, sans-serif;
5
- background: #f5f5f5;
6
3
  color: #333;
7
- min-height: 100vh;
8
- display: flex;
9
- align-items: center;
10
- justify-content: center;
11
- }
12
-
13
- #app {
14
4
  width: 100%;
15
5
  max-width: 480px;
16
6
  padding: 16px;
7
+ box-sizing: border-box;
17
8
  }
18
9
 
19
- .card {
10
+ #app .card {
20
11
  background: #fff;
21
12
  border-radius: 12px;
22
13
  padding: 32px 24px;
@@ -24,28 +15,28 @@ body {
24
15
  text-align: center;
25
16
  }
26
17
 
27
- h1 {
18
+ #app h1 {
28
19
  font-size: 1.25rem;
29
20
  font-weight: 600;
30
21
  margin-bottom: 24px;
31
22
  }
32
23
 
33
- .stage { margin-top: 16px; }
24
+ #app .stage { margin-top: 16px; }
34
25
 
35
26
  /* Drop zone */
36
- .drop-zone {
27
+ #app .drop-zone {
37
28
  border: 2px dashed #ccc;
38
29
  border-radius: 8px;
39
30
  padding: 32px 16px;
40
31
  transition: border-color .15s, background .15s;
41
32
  }
42
- .drop-zone.drag-over {
33
+ #app .drop-zone.drag-over {
43
34
  border-color: #3b82f6;
44
35
  background: #eff6ff;
45
36
  }
46
37
 
47
38
  /* Buttons */
48
- .btn {
39
+ #app .btn {
49
40
  display: inline-block;
50
41
  padding: 10px 24px;
51
42
  border: none;
@@ -57,25 +48,25 @@ h1 {
57
48
  cursor: pointer;
58
49
  transition: background .15s;
59
50
  }
60
- .btn:hover { background: #2563eb; }
61
- .btn-secondary { background: #6b7280; }
62
- .btn-secondary:hover { background: #4b5563; }
51
+ #app .btn:hover { background: #2563eb; }
52
+ #app .btn-secondary { background: #6b7280; }
53
+ #app .btn-secondary:hover { background: #4b5563; }
63
54
 
64
55
  /* Hints */
65
- .hint { color: #999; font-size: .85rem; margin-top: 8px; }
66
- .expiry { margin-top: 12px; }
56
+ #app .hint { color: #999; font-size: .85rem; margin-top: 8px; }
57
+ #app .expiry { margin-top: 12px; }
67
58
 
68
59
  /* Progress */
69
- .progress-ring { display: block; margin: 0 auto 12px; }
70
- #upload-status, #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
60
+ #app .progress-ring { display: block; margin: 0 auto 12px; }
61
+ #app #upload-status, #app #dl-status { font-size: .9rem; color: #666; margin-bottom: 12px; }
71
62
 
72
63
  /* Share link row */
73
- .link-row {
64
+ #app .link-row {
74
65
  display: flex;
75
66
  gap: 8px;
76
67
  margin-top: 12px;
77
68
  }
78
- .link-row input {
69
+ #app .link-row input {
79
70
  flex: 1;
80
71
  padding: 8px 10px;
81
72
  border: 1px solid #ccc;
@@ -85,11 +76,11 @@ h1 {
85
76
  }
86
77
 
87
78
  /* Messages */
88
- .success { color: #16a34a; font-weight: 600; }
89
- .error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
79
+ #app .success { color: #16a34a; font-weight: 600; }
80
+ #app .error { color: #dc2626; font-weight: 500; margin-bottom: 12px; }
90
81
 
91
82
  /* Security note */
92
- .security-note {
83
+ #app .security-note {
93
84
  margin-top: 20px;
94
85
  padding: 12px;
95
86
  background: #f0fdf4;
@@ -98,6 +89,40 @@ h1 {
98
89
  color: #555;
99
90
  text-align: left;
100
91
  }
101
- .security-note p + p { margin-top: 6px; }
102
- .security-note a { color: #3b82f6; text-decoration: none; }
103
- .security-note a:hover { text-decoration: underline; }
92
+ #app .security-note p + p { margin-top: 6px; }
93
+ #app .security-note a { color: #3b82f6; text-decoration: none; }
94
+ #app .security-note a:hover { text-decoration: underline; }
95
+
96
+ /* ── Dark mode ─────────────────────────────────── */
97
+ .dark #app {
98
+ color: #e5e7eb;
99
+ --xftp-ring-bg: #374151;
100
+ --xftp-ring-fg: #60a5fa;
101
+ --xftp-ring-text: #e5e7eb;
102
+ }
103
+ .dark #app .card {
104
+ background: #1f2937;
105
+ box-shadow: 0 1px 3px rgba(0,0,0,.4);
106
+ }
107
+ .dark #app .drop-zone { border-color: #4b5563; }
108
+ .dark #app .drop-zone.drag-over {
109
+ border-color: #60a5fa;
110
+ background: rgba(59,130,246,.15);
111
+ }
112
+ .dark #app .btn-secondary { background: #4b5563; }
113
+ .dark #app .btn-secondary:hover { background: #374151; }
114
+ .dark #app .hint { color: #9ca3af; }
115
+ .dark #app #upload-status,
116
+ .dark #app #dl-status { color: #9ca3af; }
117
+ .dark #app .link-row input {
118
+ background: #374151;
119
+ border-color: #4b5563;
120
+ color: #e5e7eb;
121
+ }
122
+ .dark #app .success { color: #4ade80; }
123
+ .dark #app .error { color: #f87171; }
124
+ .dark #app .security-note {
125
+ background: rgba(34,197,94,.1);
126
+ color: #d1d5db;
127
+ }
128
+ .dark #app .security-note a { color: #60a5fa; }
package/web/upload.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import {createCryptoBackend} from './crypto-backend.js'
2
2
  import {getServers} from './servers.js'
3
3
  import {createProgressRing} from './progress.js'
4
+ import {t} from './i18n.js'
4
5
  import {
5
6
  newXFTPAgent, closeXFTPAgent, uploadFile, encodeDescriptionURI,
6
7
  type EncryptedFileMetadata
@@ -12,35 +13,35 @@ const MAX_SIZE = 100 * 1024 * 1024
12
13
  export function initUpload(app: HTMLElement) {
13
14
  app.innerHTML = `
14
15
  <div class="card">
15
- <h1>SimpleX File Transfer</h1>
16
+ <h1>${t('title', 'SimpleX File Transfer')}</h1>
16
17
  <div id="drop-zone" class="drop-zone">
17
- <p>Drag & drop a file here</p>
18
- <p class="hint">or</p>
19
- <label class="btn" for="file-input">Choose file</label>
18
+ <p>${t('dropZone', 'Drag & drop a file here')}</p>
19
+ <p class="hint">${t('dropZoneHint', 'or')}</p>
20
+ <label class="btn" for="file-input">${t('chooseFile', 'Choose file')}</label>
20
21
  <input id="file-input" type="file" hidden>
21
- <p class="hint">Max 100 MB</p>
22
+ <p class="hint">${t('maxSizeHint', 'Max 100 MB')}</p>
22
23
  </div>
23
24
  <div id="upload-progress" class="stage" hidden>
24
25
  <div id="progress-container"></div>
25
- <p id="upload-status">Encrypting…</p>
26
- <button id="cancel-btn" class="btn btn-secondary">Cancel</button>
26
+ <p id="upload-status">${t('encrypting', 'Encrypting\u2026')}</p>
27
+ <button id="cancel-btn" class="btn btn-secondary">${t('cancel', 'Cancel')}</button>
27
28
  </div>
28
29
  <div id="upload-complete" class="stage" hidden>
29
- <p class="success">File uploaded</p>
30
+ <p class="success">${t('fileUploaded', 'File uploaded')}</p>
30
31
  <div class="link-row">
31
32
  <input id="share-link" data-testid="share-link" readonly>
32
- <button id="copy-btn" class="btn">Copy</button>
33
+ <button id="copy-btn" class="btn">${t('copy', 'Copy')}</button>
33
34
  </div>
34
- <p class="hint expiry">Files are typically available for 48 hours.</p>
35
+ <p class="hint expiry">${t('expiryHint', 'Files are typically available for 48 hours.')}</p>
35
36
  <div class="security-note">
36
- <p>Your file was encrypted in the browser before upload the server never sees file contents.</p>
37
- <p>The link contains the decryption key in the hash fragment, which the browser never sends to any server.</p>
38
- <p>For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.</p>
37
+ <p>${t('securityNote1', 'Your file was encrypted in the browser before upload \u2014 the server never sees file contents.')}</p>
38
+ <p>${t('securityNote2', 'The link contains the decryption key in the hash fragment, which the browser never sends to any server.')}</p>
39
+ <p>${t('securityNote3', 'For maximum security, use the <a href="https://simplex.chat" target="_blank" rel="noopener">SimpleX app</a>.')}</p>
39
40
  </div>
40
41
  </div>
41
42
  <div id="upload-error" class="stage" hidden>
42
43
  <p class="error" id="error-msg"></p>
43
- <button id="retry-btn" class="btn">Retry</button>
44
+ <button id="retry-btn" class="btn">${t('retry', 'Retry')}</button>
44
45
  </div>
45
46
  </div>`
46
47
 
@@ -57,6 +58,16 @@ export function initUpload(app: HTMLElement) {
57
58
  const errorMsg = document.getElementById('error-msg')!
58
59
  const retryBtn = document.getElementById('retry-btn')!
59
60
 
61
+ const shareBtn = typeof navigator.share === 'function'
62
+ ? (() => {
63
+ const btn = document.createElement('button')
64
+ btn.className = 'btn btn-secondary'
65
+ btn.textContent = t('share', 'Share')
66
+ shareLink.parentElement!.appendChild(btn)
67
+ return btn
68
+ })()
69
+ : null
70
+
60
71
  let aborted = false
61
72
  let pendingFile: File | null = null
62
73
 
@@ -90,11 +101,11 @@ export function initUpload(app: HTMLElement) {
90
101
  aborted = false
91
102
 
92
103
  if (file.size > MAX_SIZE) {
93
- showError(`File too large (${formatSize(file.size)}). Maximum is 100 MB.`)
104
+ showError(t('fileTooLarge', 'File too large (%size%). Maximum is 100 MB. The SimpleX app supports files up to 1 GB.').replace('%size%', formatSize(file.size)))
94
105
  return
95
106
  }
96
107
  if (file.size === 0) {
97
- showError('File is empty.')
108
+ showError(t('fileEmpty', 'File is empty.'))
98
109
  return
99
110
  }
100
111
 
@@ -102,7 +113,7 @@ export function initUpload(app: HTMLElement) {
102
113
  const ring = createProgressRing()
103
114
  progressContainer.innerHTML = ''
104
115
  progressContainer.appendChild(ring.canvas)
105
- statusText.textContent = 'Encrypting'
116
+ statusText.textContent = t('encrypting', 'Encrypting\u2026')
106
117
 
107
118
  const backend = createCryptoBackend()
108
119
  const agent = newXFTPAgent()
@@ -123,7 +134,7 @@ export function initUpload(app: HTMLElement) {
123
134
  })
124
135
  if (aborted) return
125
136
 
126
- statusText.textContent = 'Uploading'
137
+ statusText.textContent = t('uploading', 'Uploading\u2026')
127
138
  const metadata: EncryptedFileMetadata = {
128
139
  digest: encrypted.digest,
129
140
  key: encrypted.key,
@@ -142,12 +153,16 @@ export function initUpload(app: HTMLElement) {
142
153
  const url = window.location.origin + window.location.pathname + '#' + result.uri
143
154
  shareLink.value = url
144
155
  showStage(completeStage)
156
+ app.dispatchEvent(new CustomEvent('xftp:upload-complete', {detail: {url}, bubbles: true}))
145
157
  copyBtn.onclick = () => {
146
158
  navigator.clipboard.writeText(url).then(() => {
147
- copyBtn.textContent = 'Copied!'
148
- setTimeout(() => { copyBtn.textContent = 'Copy' }, 2000)
159
+ copyBtn.textContent = t('copied', 'Copied!')
160
+ setTimeout(() => { copyBtn.textContent = t('copy', 'Copy') }, 2000)
149
161
  })
150
162
  }
163
+ if (shareBtn) {
164
+ shareBtn.onclick = () => navigator.share({url}).catch(() => {})
165
+ }
151
166
  } catch (err: any) {
152
167
  if (!aborted) {
153
168
  const msg = err?.message ?? String(err)