@subiz/widget-v4 4.1.215 → 4.1.217

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.
Files changed (57) hide show
  1. package/README.md +6 -0
  2. package/api.md +9 -5
  3. package/assets/img/bubble_default.svg +1 -0
  4. package/assets/less/message.less +83 -0
  5. package/assets/less/widget.less +12 -0
  6. package/compiled_src/chatbox/AudioAttachments.js +5 -0
  7. package/compiled_src/chatbox/ChatButton.js +19 -17
  8. package/compiled_src/chatbox/Event.js +9 -2
  9. package/compiled_src/chatbox/FileAttachments.js +9 -3
  10. package/compiled_src/chatbox/ImageAttachments.js +16 -6
  11. package/compiled_src/chatbox/UploadProgress.js +44 -0
  12. package/compiled_src/chatbox/VideoAttachments.js +5 -0
  13. package/compiled_src/store/api.js +77 -78
  14. package/compiled_src/store/api_fake.js +1 -1
  15. package/compiled_src/store/attachments.js +5 -1
  16. package/compiled_src/store/store.js +25 -4
  17. package/dist/{1a12a5ed6a9.278.js → 1a12ed33347.278.js} +1 -1
  18. package/dist/1a12ed33347.app.js +1 -0
  19. package/dist/{1a12a5ed6a9.chat.html → 1a12ed33347.chat.html} +1 -1
  20. package/dist/1a12ed33347.chat.js +1 -0
  21. package/dist/{1a12a5ed6a9.form.html → 1a12ed33347.form.html} +1 -1
  22. package/dist/{1a12a5ed6a9.form.js → 1a12ed33347.form.js} +1 -1
  23. package/dist/{1a12a5ed6a9.index.html → 1a12ed33347.index.html} +1 -1
  24. package/dist/{1a12a5ed6a9.library.html → 1a12ed33347.library.html} +1 -1
  25. package/dist/1a12ed33347.library.js +1 -0
  26. package/dist/{1a12a5ed6a9.preview.html → 1a12ed33347.preview.html} +1 -1
  27. package/dist/1a12ed33347.preview.js +1 -0
  28. package/dist/1a12ed33347.webview.js +1 -0
  29. package/dist/HASH +1 -0
  30. package/dist/assets/img/{bubble_default.7d5e4ab.svg → bubble_default.6f6eec8.svg} +1 -0
  31. package/dist/screenshoot.js +25 -20
  32. package/package.json +11 -10
  33. package/src/chatbox/AudioAttachments.js +2 -0
  34. package/src/chatbox/ChatButton.js +22 -20
  35. package/src/chatbox/Event.js +8 -4
  36. package/src/chatbox/FileAttachments.js +5 -2
  37. package/src/chatbox/ImageAttachments.js +6 -2
  38. package/src/chatbox/UploadProgress.js +21 -0
  39. package/src/chatbox/VideoAttachments.js +2 -0
  40. package/src/store/api.js +66 -81
  41. package/src/store/api_fake.js +1 -1
  42. package/src/store/attachments.js +5 -1
  43. package/src/store/store.js +13 -5
  44. package/up +18 -0
  45. package/dist/.HASH +0 -1
  46. package/dist/1a12a5ed6a9.app.js +0 -1
  47. package/dist/1a12a5ed6a9.chat.js +0 -1
  48. package/dist/1a12a5ed6a9.library.js +0 -1
  49. package/dist/1a12a5ed6a9.preview.js +0 -1
  50. package/dist/1a12a5ed6a9.webview.js +0 -1
  51. /package/dist/{1a12a5ed6a9.127.js → 1a12ed33347.127.js} +0 -0
  52. /package/dist/{1a12a5ed6a9.132.js → 1a12ed33347.132.js} +0 -0
  53. /package/dist/{1a12a5ed6a9.185.js → 1a12ed33347.185.js} +0 -0
  54. /package/dist/{1a12a5ed6a9.27.js → 1a12ed33347.27.js} +0 -0
  55. /package/dist/{1a12a5ed6a9.339.js → 1a12ed33347.339.js} +0 -0
  56. /package/dist/{1a12a5ed6a9.459.js → 1a12ed33347.459.js} +0 -0
  57. /package/dist/{1a12a5ed6a9.490.js → 1a12ed33347.490.js} +0 -0
package/package.json CHANGED
@@ -1,16 +1,13 @@
1
1
  {
2
2
  "name": "@subiz/widget-v4",
3
- "version": "4.1.215",
3
+ "version": "4.1.217",
4
4
  "main": "./compiled_src/lib.js",
5
5
  "scripts": {
6
6
  "dev": "npx webpack serve --config webpack.dev.js",
7
7
  "build": "npm run lib && rm -rf dist && webpack --config webpack.screenshoot.js && webpack --config webpack.prod.js",
8
8
  "report": "npx webpack --config webpack.report.js",
9
9
  "lib": "babel src --out-dir compiled_src --ignore \"**/*.test.js,**/*.test2.js\"",
10
- "test": "node test.cjs",
11
- "lint": "eslint src",
12
- "check": "npm run lint && npm test",
13
- "prepack": "npm run check && npm run lib"
10
+ "test": "node test.cjs"
14
11
  },
15
12
  "dependencies": {
16
13
  "@subiz/ajax": "^3.0.22",
@@ -32,15 +29,19 @@
32
29
  "@babel/preset-env": "^7.29.7",
33
30
  "babel-loader": "^10.1.1",
34
31
  "babel-plugin-syntax-jsx": "^6.18.0",
35
- "eslint": "^10.12.0",
36
32
  "globals": "^17.13.0",
37
33
  "html-webpack-plugin": "^5.6.8",
38
- "jsdom": "^30.1.2",
39
- "puppeteer": "^25.12.0",
40
- "tape": "^5.10.2",
41
- "webpack": "^5.111.1",
34
+ "jsdom": "^30.1.3",
35
+ "puppeteer": "^25.14.0",
36
+ "tape": "^5.10.3",
37
+ "webpack": "^5.112.0",
42
38
  "webpack-bundle-analyzer": "^5.4.0",
43
39
  "webpack-cli": "^7.2.3",
44
40
  "webpack-dev-server": "^6.0.0"
41
+ },
42
+ "allowScripts": {
43
+ "puppeteer@25.14.0": true,
44
+ "fsevents@2.3.3": true,
45
+ "core-js@3.50.0": true
45
46
  }
46
47
  }
@@ -1,3 +1,4 @@
1
+ import {UploadingFile} from './UploadProgress.js'
1
2
  import sb from '../util/index.js'
2
3
  import {h} from 'preact'
3
4
 
@@ -12,6 +13,7 @@ export default function AudioAttachments(props) {
12
13
  if (audios.length === 0) return null
13
14
 
14
15
  var $audios = sb.map(audios, (audio, index) => {
16
+ if (props.sending) return <UploadingFile key={index} attachment={audio} />
15
17
  return (
16
18
  <div key={index} class={`audio-attachment audio-attachment--${byType}`}>
17
19
  <audio class='audio-attachment__audio' controls>
@@ -391,6 +391,7 @@ export default class ChatButton extends Com {
391
391
 
392
392
  let imgurl = sb.get(accountSetting, 'desktop_button.background_image_url')
393
393
  let maskurl = sb.get(accountSetting, 'desktop_button.background_mask_url')
394
+ // Keep configured dimensions even when the image fails; default only missing dimensions to 70px.
394
395
  let imgwidth = sb.get(accountSetting, 'desktop_button.width') || 70
395
396
  let imgheight = sb.get(accountSetting, 'desktop_button.height') || 70
396
397
 
@@ -450,18 +451,10 @@ export default class ChatButton extends Com {
450
451
  appearance.maskAsset ? prepareButtonMask(appearance.maskAsset) : Promise.resolve(null),
451
452
  ])
452
453
  const stale = () => !this._isMounted || this._buttonImageVersion !== version
453
- if (stale()) {
454
- if (mask) URL.revokeObjectURL(mask)
455
- return
456
- }
454
+ if (stale()) return
457
455
  const fallback = loaded.some((success) => !success) || (!!appearance.maskAsset && !mask)
458
456
  const ready = !fallback || (await preloadButtonImage(defaultBubble))
459
- if (stale()) {
460
- if (mask) URL.revokeObjectURL(mask)
461
- return
462
- }
463
- if (this._buttonMaskUrl) URL.revokeObjectURL(this._buttonMaskUrl)
464
- this._buttonMaskUrl = mask
457
+ if (stale()) return
465
458
  // Commit the hidden style before revealing even when images are cached.
466
459
  // Otherwise older browsers can paint only the final state and skip the transition.
467
460
  if (this._buttonElement) window.getComputedStyle(this._buttonElement).transform
@@ -470,7 +463,6 @@ export default class ChatButton extends Com {
470
463
  }
471
464
 
472
465
  componentWillUnmount() {
473
- if (this._buttonMaskUrl) URL.revokeObjectURL(this._buttonMaskUrl)
474
466
  if (this._animateTimeout) clearTimeout(this._animateTimeout)
475
467
  }
476
468
 
@@ -478,12 +470,13 @@ export default class ChatButton extends Com {
478
470
  if (typeof sbz_info != 'undefined') sbz_info.render_chatbox_button = sb.Date.now()
479
471
  const appearance = this.getButtonAppearance()
480
472
  let {imgsrc, imgurl, maskurl, background, position, imgwidth, imgheight} = appearance
473
+ const fallback = this.state.readyImageKey === appearance.key && this.state.fallbackButtonImage
481
474
  // Never attach a remote CSS mask while the local copy is being prepared.
482
475
  if (appearance.maskAsset) {
483
476
  maskurl = this.state.readyImageKey === appearance.key ? this.state.buttonMaskUrl : ''
484
477
  if (imgsrc === appearance.maskAsset) imgsrc = maskurl || ''
485
478
  }
486
- if (this.state.readyImageKey === appearance.key && this.state.fallbackButtonImage) {
479
+ if (fallback) {
487
480
  imgsrc = imgurl = defaultBubble
488
481
  maskurl = ''
489
482
  }
@@ -548,7 +541,12 @@ export default class ChatButton extends Com {
548
541
  `
549
542
  }
550
543
 
544
+ if (fallback) {
545
+ imgcontainerstyle += `width: ${imgwidth}px !important; height: ${imgheight}px !important;`
546
+ }
547
+
551
548
  let cls = 'chat-button chat-button--' + position
549
+ if (fallback) cls += ' chat-button--fallback'
552
550
  // dont render till all image is loaded
553
551
 
554
552
  let whiteBgStyle = `
@@ -679,18 +677,22 @@ function preloadButtonImage(src, {crossOrigin = false} = {}) {
679
677
  })
680
678
  }
681
679
 
682
- // CSS masks use a separate CORS image request. Pin the loaded bytes locally so
683
- // revealing the white backing never races another network request by CSS.
680
+ // Pin the mask bytes with a data URL: some sites allow data: images but block blob:.
681
+ // If inline images or fetch are blocked, preload the original URL with CORS for CSS masks.
684
682
  async function prepareButtonMask(src) {
685
- let url
686
683
  try {
687
684
  const response = await fetch(src)
688
685
  if (!response.ok) return null
689
- url = URL.createObjectURL(await response.blob())
690
- if (await preloadButtonImage(url)) return url
686
+ const blob = await response.blob()
687
+ const url = await new Promise((resolve) => {
688
+ const reader = new FileReader()
689
+ reader.onload = () => resolve(reader.result)
690
+ reader.onerror = reader.onabort = () => resolve('')
691
+ reader.readAsDataURL(blob)
692
+ })
693
+ if (url && await preloadButtonImage(url)) return url
691
694
  } catch (_) {
692
- // Use the bundled fallback if the custom mask cannot be loaded.
695
+ // A blocked fetch or failed conversion does not mean the original image is unavailable.
693
696
  }
694
- if (url) URL.revokeObjectURL(url)
695
- return null
697
+ return await preloadButtonImage(src, {crossOrigin: true}) ? src : null
696
698
  }
@@ -362,6 +362,10 @@ export default class Event extends Com {
362
362
  $dot = <div class='sending_dot sending_dot__hide'></div>
363
363
  }
364
364
 
365
+ const hasFile = sb.find(attachments, att => att.type === 'file' || att._file)
366
+ const sendingAttachment = !ev._error && ev.source_event_id === ev.id
367
+ if (hasFile) $dot = null
368
+
365
369
  let $sender = null
366
370
  if (this.props.event.mode === 'lead' && sb.get(ev, 'by.type') !== 'user') $sender = <div class='pt-3'></div>
367
371
 
@@ -392,10 +396,10 @@ export default class Event extends Com {
392
396
  refresh={this.state.refresh}
393
397
  onLink={this.props.onLink}
394
398
  />
395
- <ImageAttachments store={this.props.store} ev={ev} ref={this.imageAttachmentRef} />
396
- <AudioAttachments store={this.props.store} ev={ev} />
397
- <VideoAttachments store={this.props.store} ev={ev} />
398
- <FileAttachments store={this.props.store} ev={ev} ref={this.fileAttachmentRef} />
399
+ <ImageAttachments store={this.props.store} ev={ev} sending={sendingAttachment} ref={this.imageAttachmentRef} />
400
+ <AudioAttachments store={this.props.store} ev={ev} sending={sendingAttachment} />
401
+ <VideoAttachments store={this.props.store} ev={ev} sending={sendingAttachment} />
402
+ <FileAttachments store={this.props.store} ev={ev} sending={sendingAttachment} ref={this.fileAttachmentRef} />
399
403
  <ButtonAttachments store={this.props.store} ev={ev} t={this.props.t} />
400
404
  <FormAttachment store={this.props.store} t={this.props.t} ev={ev} />
401
405
  <ProductAttachments t={this.props.t} store={this.props.store} ev={ev} />
@@ -1,3 +1,4 @@
1
+ import UploadProgress from './UploadProgress.js'
1
2
  import sb from '../util/index.js'
2
3
  import {Com} from '../common.js'
3
4
  import {h} from 'preact'
@@ -33,14 +34,16 @@ export default class FileAttachments extends Com {
33
34
 
34
35
  let $files = sb.map(files, (file, index) => {
35
36
  let cls = `file-attachment file-attachment--${byType}`
37
+ if (this.props.sending) cls += ' file-attachment--sending'
36
38
  if (file._true_idx === this.state.focusedIdx) cls += ` just-focused`
37
39
 
38
40
  return (
39
- <div key={index} class={cls}>
40
- <a class='file-attachment--link' href={this.props.store.replaceFileUrl(file.url)} target='_blank'>
41
+ <div key={index} class={cls} aria-busy={!!this.props.sending}>
42
+ <a class='file-attachment--link' href={this.props.sending ? undefined : this.props.store.replaceFileUrl(file.url)} target='_blank'>
41
43
  <span class='file-attachment--download mr-2'></span>
42
44
  <span class='file-attachment--filename'>{file.name}</span>
43
45
  </a>
46
+ {this.props.sending && <UploadProgress attachment={file} compact />}
44
47
  </div>
45
48
  )
46
49
  })
@@ -1,3 +1,4 @@
1
+ import UploadProgress from './UploadProgress.js'
1
2
  import sb from '../util/index.js'
2
3
  import {Com} from '../common.js'
3
4
  import {h, createRef} from 'preact'
@@ -82,17 +83,19 @@ export default class ImageAttachment extends Com {
82
83
 
83
84
  let cls = `image-attachment image-attachment--${byType}`
84
85
  if (image._true_idx === this.state.focusedIdx) cls += ' just-focused'
86
+ const $sending = this.props.sending ? <span class='image-attachment__sending'><UploadProgress attachment={image} /></span> : null
85
87
 
86
88
  if (!image.width || !image.height) {
87
89
  return (
88
90
  <div class={cls}>
89
- <a href={this.props.store.replaceFileUrl(image.url)} target='_blank'>
91
+ <a class='image-attachment__preview' href={this.props.sending ? undefined : this.props.store.replaceFileUrl(image.url)} target='_blank' aria-busy={!!this.props.sending}>
90
92
  <img
91
93
  alt
92
94
  class='image-attachment__img'
93
95
  src={this.props.store.replaceFileUrl(image.thumbnail_url || image.url)}
94
96
  onload={(e) => this.props.onLoad && this.props.onLoad()}
95
97
  />
98
+ {$sending}
96
99
  </a>
97
100
  </div>
98
101
  )
@@ -108,7 +111,7 @@ export default class ImageAttachment extends Com {
108
111
 
109
112
  return (
110
113
  <div class={cls}>
111
- <a href={trueUrl} target='_blank'>
114
+ <a class='image-attachment__preview' href={this.props.sending ? undefined : trueUrl} target='_blank' aria-busy={!!this.props.sending}>
112
115
  <img
113
116
  alt
114
117
  ref={(el) => {
@@ -123,6 +126,7 @@ export default class ImageAttachment extends Com {
123
126
  onload={(e) => this.props.onLoad && this.props.onLoad()}
124
127
  onerror={(e) => this.onErrorImage(index)}
125
128
  />
129
+ {$sending}
126
130
  </a>
127
131
  </div>
128
132
  )
@@ -0,0 +1,21 @@
1
+ import {h} from 'preact'
2
+
3
+ export default function UploadProgress({attachment, compact = false}) {
4
+ const progress = attachment._upload?.putDone || attachment._upload?.file ? 100 : (attachment._upload?.progress ?? 0)
5
+ return <span class={'image-attachment__progress' + (compact ? ' file-attachment__progress' : '')}>
6
+ <span class='image-attachment__progress-label'>{progress}%</span>
7
+ <span class='image-attachment__progress-track' role='progressbar' aria-label={attachment.name || 'Uploading file'} aria-valuemin={0} aria-valuemax={100} aria-valuenow={progress}>
8
+ <span class='image-attachment__progress-fill' ref={el => {
9
+ // Widget styles reset all properties with !important.
10
+ if (el) el.style.setProperty('width', `${progress}%`, 'important')
11
+ }} />
12
+ </span>
13
+ </span>
14
+ }
15
+
16
+ export function UploadingFile({attachment}) {
17
+ return <div class='file-attachment file-attachment--user file-attachment--sending' aria-busy='true'>
18
+ <span class='file-attachment--filename'>{attachment.name}</span>
19
+ <UploadProgress attachment={attachment} compact />
20
+ </div>
21
+ }
@@ -1,3 +1,4 @@
1
+ import {UploadingFile} from './UploadProgress.js'
1
2
  import sb from '../util/index.js'
2
3
  import {Com} from '../common.js'
3
4
  import {h} from 'preact'
@@ -19,6 +20,7 @@ export default class VideoAttachments extends Com {
19
20
  if (videos.length === 0) return null
20
21
 
21
22
  var $videos = sb.map(videos, (video, index) => {
23
+ if (this.props.sending) return <UploadingFile key={index} attachment={video} />
22
24
  return (
23
25
  <div key={index} class={`video-attachment video-attachment--${byType}`}>
24
26
  <video
package/src/store/api.js CHANGED
@@ -309,91 +309,76 @@ export default class {
309
309
  })
310
310
  }
311
311
 
312
- uploadToS3 = async (file) => {
313
- let name = file.name.replace(/\s+/g, '_')
314
-
315
- var {body, error} = await this.apireq
316
- .setBaseUrl('https://api5.subiz.com.vn/4.1')
317
- .setQuery({
318
- 'account-id': this.accid,
319
- description: 'upload\n user-client: web-widget',
320
- name: name,
321
- presign: true,
322
- size: file.size,
323
- // server reads 'content-type' (not 'type') and signs it into the presigned URL,
324
- // so the PUT below must send the exact same Content-Type value
325
- 'content-type': file.type,
326
- })
327
- .post('/files')
328
- if (error) return {body, error}
329
-
330
- let signedurl = body.signed_url
331
- let fileid = body.id
332
-
333
- // Step 2: Upload the file to the signed_url
334
- let err = await new Promise((resolve) => {
335
- const xhr = new sb.XMLHttpRequest()
336
- xhr.open('PUT', signedurl, true)
337
- xhr.setRequestHeader('Content-Type', file.type || '')
338
- xhr.onload = function () {
339
- if (xhr.status === 200) {
340
- resolve()
341
- } else {
342
- resolve(new Error(`Upload failed: ${xhr.statusText}`))
343
- }
344
- }
345
-
346
- xhr.onerror = function () {
347
- resolve(new Error('Network error during upload.'))
348
- }
349
-
350
- xhr.send(file)
351
- })
352
- if (err) return {error: err}
353
-
354
- // Step 3: Notify server of upload completion
355
- var {body, error} = await this.apireq
356
- .setBaseUrl('https://api5.subiz.com.vn/4.1')
357
- .setQuery({'account-id': this.accid})
358
- .post(`/files/${fileid}/uploaded`)
359
- if (error) return {error}
360
- return {url: sb.get(body, 'file.url', '')}
312
+ attachmentRequest = () => this.apireq
313
+ .setBaseUrl(new URL('/4.1', this.ApiURL).href)
314
+ .addQuery('x-account-id', this.accid)
315
+
316
+ attachmentJSON = async (path, body, method = 'post') => {
317
+ const result = await this.attachmentRequest()[method](path, body)
318
+ if (result.error || result.body?.error || result.code < 200 || result.code >= 300) {
319
+ throw Object.assign(new Error('Attachment request failed'), {code: result.body?.error?.code, response: result})
320
+ }
321
+ return result.body
361
322
  }
362
323
 
363
- uploadToS32 = async (file) => {
364
- var {body, error} = await ajax
365
- .setBaseUrl('https://api5.subiz.com.vn/4.1')
366
- .withCredentials(true)
367
- .setQuery({
368
- 'account-id': this.accid,
369
- description: 'upload\n user-client: web-widget',
370
- name: file.name.replace(/\s+/g, '_'),
371
- size: file.size,
372
- type: file.type,
373
- })
374
- .setParser('json')
375
- .beforeHook((param, done) => {
376
- if (this.token_generator) {
377
- let user_id = this.profile_id
378
- this.token_generator().then((token) => {
379
- if (user_id != this.profile_id) return done(true) // outdated
380
-
381
- if (!token) return done(false)
382
- if (token == 'error') return done(false)
383
- param.request = param.request.addQuery('x-access-token', token)
384
- return done(true)
385
- })
386
- return
324
+ uploadAttachment = async (file, state = {}, onProgress = () => {}) => {
325
+ if (file.size > 25000000) return {error: 'invalid_field_size', body: {code: 'invalid_field_size'}}
326
+ const identity = JSON.stringify([this.accid, this.uid, this.ref, this.mask, this.profile_id, this.tokensession])
327
+ if (state.identity && state.identity !== identity) throw new Error('Upload identity changed')
328
+ state.identity = identity
329
+ const checkIdentity = () => {
330
+ if (identity !== JSON.stringify([this.accid, this.uid, this.ref, this.mask, this.profile_id, this.tokensession])) throw new Error('Upload identity changed')
331
+ }
332
+ if (state.file) return state.file
333
+ // A completed PUT can still be finalized after the PUT URL expires.
334
+ if (state.upload && !state.putDone && state.upload.upload_expires_at <= sb.Date.now()) {
335
+ delete state.upload
336
+ delete state.key
337
+ }
338
+ if (!state.key) state.key = sb.randomString(36)
339
+ if (!state.metadata) state.metadata = {
340
+ category: 'attachment', name: file.name,
341
+ content_type: file.type || 'application/octet-stream', size: file.size, ttl_seconds: 0,
342
+ }
343
+ if (!state.upload) {
344
+ const created = await this.attachmentJSON('/uploads', {...state.metadata, idempotency_key: state.key})
345
+ checkIdentity()
346
+ if (!created.upload?.upload_id || !created.upload.upload_url) throw new Error('Missing upload session')
347
+ state.upload = created.upload
348
+ }
349
+ if (!state.putDone) {
350
+ checkIdentity()
351
+ const report = (percent) => {
352
+ if (state.progress === percent) return
353
+ state.progress = percent
354
+ onProgress(percent)
355
+ }
356
+ report(0)
357
+ // XHR exposes upload progress; this request still bypasses the API client.
358
+ await new Promise((resolve, reject) => {
359
+ const xhr = new sb.XMLHttpRequest()
360
+ xhr.upload.onprogress = (event) => {
361
+ if (event.lengthComputable) report(Math.min(100, Math.floor(event.loaded / event.total * 100)))
387
362
  }
388
-
389
- if (this.ref) param.request = param.request.addQuery('x-user-ref', this.ref)
390
- else if (this.mask) param.request = param.request.addQuery('x-user-mask', this.mask) // deprecating
391
-
392
- return done(true)
363
+ xhr.open('PUT', state.upload.upload_url, true)
364
+ xhr.withCredentials = false
365
+ Object.entries(state.upload.required_headers || {}).forEach(([key, value]) => xhr.setRequestHeader(key, value))
366
+ xhr.onload = () => xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new Error(`Upload failed: HTTP ${xhr.status}`))
367
+ xhr.onerror = () => reject(new Error('Network error during upload'))
368
+ xhr.onabort = () => reject(new Error('Upload aborted'))
369
+ xhr.ontimeout = () => reject(new Error('Upload timed out'))
370
+ xhr.send(file)
393
371
  })
394
- .post('/files', file)
395
- if (error) return {body, error}
396
- return {url: sb.get(body, 'file.url')}
372
+ report(100)
373
+ state.putDone = true
374
+ }
375
+ checkIdentity()
376
+ const finished = await this.attachmentJSON(`/uploads/${encodeURIComponent(state.upload.upload_id)}/finish`)
377
+ checkIdentity()
378
+ const f = finished.file
379
+ if (!f?.id || !f.url || f.error_code) throw new Error('File is not available')
380
+ state.file = {url: f.url, v: 'cdn', file_id: f.id}
381
+ return state.file
397
382
  }
398
383
 
399
384
  time = (_) => ajax.setBaseUrl(this.ApiURL).withCredentials(true).setParser('json').get('/time')
@@ -66,7 +66,7 @@ export default class {
66
66
  sendLostFocus = (data) => OK200
67
67
  sendContentView = (data) => OK200
68
68
 
69
- uploadToS3 = async (file) => OK200
69
+ uploadAttachment = async (file) => OK200
70
70
 
71
71
  time = (_) => Promise.resolve({code: 200, body: new sb.Date().toISOString()})
72
72
  updateUser = (data) => OK200
@@ -4,7 +4,7 @@ export async function uploadAttachments(attachments, upload) {
4
4
  let error, errorMessage
5
5
  for (const attachment of attachments) {
6
6
  try {
7
- const result = await upload(attachment._file)
7
+ const result = await upload(attachment._file, attachment._upload || (attachment._upload = {}))
8
8
  if (result.body && result.body.code === 'invalid_field_size') {
9
9
  errorMessage = 'cannot_send_too_big_file'
10
10
  }
@@ -13,6 +13,10 @@ export async function uploadAttachments(attachments, upload) {
13
13
  continue
14
14
  }
15
15
  attachment.url = result.url
16
+ if (result.file_id) {
17
+ attachment.v = 'cdn'
18
+ attachment.file_id = result.file_id
19
+ }
16
20
  } catch (err) {
17
21
  error = error || err
18
22
  }
@@ -959,9 +959,10 @@ export default class Store {
959
959
  await this.readyGate.entry()
960
960
  const key = websiteFormStorageKey(this.api, formid)
961
961
  const kv = this.kv
962
+ const values = sb.cloneDeep(payload.values)
962
963
  const result = formResult(await this.api.submitWebsiteForm(formid, payload))
963
964
  try {
964
- kv.put(key, {conversation_id: result.conversation_id, expires_at: sb.Date.now() + 7 * 24 * 60 * 60 * 1000})
965
+ kv.put(key, {conversation_id: result.conversation_id, values, expires_at: sb.Date.now() + 7 * 24 * 60 * 60 * 1000})
965
966
  } catch (_) { /* Storage failure must not make callers retry a successful submission. */ }
966
967
  return result
967
968
  }
@@ -979,7 +980,7 @@ export default class Store {
979
980
  this.kv.del(key)
980
981
  return {submitted: false, status: 'expired'}
981
982
  }
982
- return {submitted: true, status: 'found', conversation_id: receipt.conversation_id}
983
+ return {submitted: true, status: 'found', conversation_id: receipt.conversation_id, values: Array.isArray(receipt.values) ? sb.cloneDeep(receipt.values) : []}
983
984
  } catch (_) {
984
985
  return {submitted: false, status: 'storage_unavailable'}
985
986
  }
@@ -1449,7 +1450,7 @@ export default class Store {
1449
1450
  if (att.type !== 'file') return
1450
1451
  if (!att._file) return
1451
1452
  files.push(att)
1452
- if (ImageTypes.includes(att._file.type)) {
1453
+ if (!att._upload?.file && ImageTypes.includes(att._file.type)) {
1453
1454
  let {width, height, url} = await loadImage(att._file)
1454
1455
  att.url = url
1455
1456
  att.width = width
@@ -1462,14 +1463,21 @@ export default class Store {
1462
1463
  await this.convoGate.entry()
1463
1464
  // upload file first
1464
1465
 
1465
- const uploaded = await uploadAttachments(files, (file) => this.api.uploadToS3(file))
1466
+ const uploaded = await uploadAttachments(files, (file, state) => this.api.uploadAttachment(file, state, () => this.pubsub.publish('events', event)))
1466
1467
  event._error = uploaded.error
1467
1468
  if (uploaded.errorMessage) event._error_message = uploaded.errorMessage
1468
1469
 
1469
1470
  if (event._error) return this.onWs(event) // dont send error msg
1470
1471
  if (cid && cid.startsWith('cslight')) return
1471
1472
 
1472
- let {body: ret, error} = await this.api.sendConvoEvent(cid, event)
1473
+ // Keep browser Files and upload sessions locally for retry, never in the event JSON.
1474
+ const outgoing = {...event, data: {...event.data}}
1475
+ if (event.data.message) outgoing.data.message = {...event.data.message, attachments: event.data.message.attachments?.map(att => {
1476
+ const {_file, _upload, ...attachment} = att
1477
+ return attachment
1478
+ })}
1479
+ let {body: ret, error} = await this.api.sendConvoEvent(cid, outgoing)
1480
+ error = error || ret?.error
1473
1481
  if (error) {
1474
1482
  event._error = error
1475
1483
  this.onWs(event)
package/up CHANGED
@@ -1,6 +1,22 @@
1
1
  #!/bin/sh
2
2
  set -e # exit when any command fails
3
3
 
4
+ # The storage client still references ssl.PROTOCOL_TLSv1; the local Python 3.14
5
+ # uses OpenSSL 4, which removed the underlying legacy API.
6
+ # Respect an explicit interpreter; otherwise prefer a compatible installed Python.
7
+ if [ -z "${CLOUDSDK_PYTHON:-}" ]; then
8
+ for python in python3.13 python3.12 python3.11 python3.10; do
9
+ if command -v "$python" >/dev/null 2>&1; then
10
+ CLOUDSDK_PYTHON=$(command -v "$python")
11
+ export CLOUDSDK_PYTHON
12
+ break
13
+ fi
14
+ done
15
+ fi
16
+
17
+ # Fail before rebuilding if gcloud cannot load the upload command.
18
+ gcloud storage cp --help >/dev/null
19
+
4
20
  # safety first
5
21
  echo "Clean"
6
22
  #rm -rf node_modules && rm -rf package-lock.json && npm i --no-optional
@@ -17,8 +33,10 @@ echo "Build OK. New version hash: $(cat dist/HASH)"
17
33
  # DEPLOY TO WIDGET SERVER
18
34
  # (do not deploy dist/HASH)
19
35
  mv dist/HASH dist/.HASH
36
+ trap 'if [ -f dist/.HASH ]; then mv dist/.HASH dist/HASH; fi' 0
20
37
  gcloud storage cp -r --cache-control="public, max-age=31536000" --gzip-local="html,js,css,svg,jpg,png" dist/* gs://public-gcs.subiz-cdn.com/widget-v4/public/
21
38
  mv dist/.HASH dist/HASH
39
+ trap - 0
22
40
 
23
41
  if [ "$1" = 'prod' ]; then
24
42
  gcloud storage cp --cache-control="public, max-age=10" --content-type="text/plain" dist/HASH gs://public-gcs.subiz-cdn.com/widget-v4/public/
package/dist/.HASH DELETED
@@ -1 +0,0 @@
1
- 1a12a5ed6a9