collavre 0.20.3 → 0.22.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.
- checksums.yaml +4 -4
- data/app/assets/stylesheets/collavre/actiontext.css +92 -2
- data/app/assets/stylesheets/collavre/code_highlight.css +144 -26
- data/app/assets/stylesheets/collavre/comments_popup.css +133 -2
- data/app/assets/stylesheets/collavre/landing.css +507 -0
- data/app/assets/stylesheets/collavre/popup.css +148 -0
- data/app/channels/collavre/agent_channel.rb +205 -0
- data/app/channels/collavre/comments_presence_channel.rb +7 -0
- data/app/controllers/collavre/admin/integrations_controller.rb +93 -0
- data/app/controllers/collavre/admin/settings_controller.rb +22 -17
- data/app/controllers/collavre/api/v1/agents_controller.rb +777 -0
- data/app/controllers/collavre/api/v1/base_controller.rb +46 -0
- data/app/controllers/collavre/application_controller.rb +27 -0
- data/app/controllers/collavre/attachments_controller.rb +30 -2
- data/app/controllers/collavre/channels_controller.rb +23 -0
- data/app/controllers/collavre/comments_controller.rb +1 -1
- data/app/controllers/collavre/creatives/attachments_controller.rb +79 -0
- data/app/controllers/collavre/creatives_controller.rb +141 -7
- data/app/controllers/collavre/landing_controller.rb +8 -0
- data/app/controllers/collavre/public_assets_controller.rb +24 -0
- data/app/controllers/collavre/tasks_controller.rb +12 -4
- data/app/controllers/collavre/topics_controller.rb +36 -30
- data/app/controllers/concerns/collavre/comments/approval_actions.rb +57 -14
- data/app/helpers/collavre/comments_helper.rb +7 -0
- data/app/helpers/collavre/public_assets_helper.rb +14 -0
- data/app/javascript/components/InlineLexicalEditor.jsx +42 -59
- data/app/javascript/components/plugins/attachment_cleanup_plugin.jsx +3 -0
- data/app/javascript/components/plugins/image_upload_plugin.jsx +12 -0
- data/app/javascript/controllers/__tests__/link_creative_controller.test.js +447 -0
- data/app/javascript/controllers/comment_controller.js +15 -1
- data/app/javascript/controllers/comments/__tests__/presence_controller.test.js +108 -0
- data/app/javascript/controllers/comments/form_controller.js +4 -0
- data/app/javascript/controllers/comments/list_controller.js +27 -9
- data/app/javascript/controllers/comments/popup_controller.js +9 -0
- data/app/javascript/controllers/comments/presence_controller.js +137 -4
- data/app/javascript/controllers/comments/topics_controller.js +15 -0
- data/app/javascript/controllers/creatives/__tests__/sync_controller.test.js +89 -0
- data/app/javascript/controllers/creatives/__tests__/tree_controller.test.js +120 -0
- data/app/javascript/controllers/creatives/sync_controller.js +30 -9
- data/app/javascript/controllers/creatives/tree_controller.js +23 -0
- data/app/javascript/controllers/index.js +4 -1
- data/app/javascript/controllers/landing_video_controller.js +53 -0
- data/app/javascript/controllers/link_creative_controller.js +451 -29
- data/app/javascript/creatives/tree_renderer.js +6 -0
- data/app/javascript/lib/api/__tests__/queue_manager.test.js +27 -0
- data/app/javascript/lib/api/creatives.js +13 -0
- data/app/javascript/lib/api/queue_manager.js +17 -5
- data/app/javascript/lib/lexical/__tests__/color_import.test.js +318 -0
- data/app/javascript/lib/lexical/__tests__/minimize_html.test.js +259 -0
- data/app/javascript/lib/lexical/color_import.js +186 -0
- data/app/javascript/lib/lexical/minimize_html.js +182 -0
- data/app/javascript/lib/lexical/video_node.jsx +96 -0
- data/app/javascript/modules/__tests__/command_args_form.test.js +103 -0
- data/app/javascript/modules/__tests__/html_content_empty.test.js +41 -0
- data/app/javascript/modules/__tests__/markdown_source_reconcile.test.js +70 -0
- data/app/javascript/modules/command_args_form.js +22 -4
- data/app/javascript/modules/command_menu.js +27 -0
- data/app/javascript/modules/creative_row_editor.js +227 -17
- data/app/javascript/modules/html_content_empty.js +12 -0
- data/app/javascript/modules/markdown_source_reconcile.js +53 -0
- data/app/jobs/collavre/ai_agent_job.rb +89 -3
- data/app/jobs/collavre/cancel_offline_delegated_tasks_job.rb +134 -0
- data/app/jobs/collavre/claude_channel_presence_job.rb +91 -0
- data/app/jobs/collavre/drop_trigger_job.rb +37 -8
- data/app/mailers/collavre/application_mailer.rb +1 -1
- data/app/models/collavre/agent_subscription.rb +52 -0
- data/app/models/collavre/channel/injected_message.rb +5 -0
- data/app/models/collavre/channel.rb +87 -0
- data/app/models/collavre/comment/claude_channel_permission.rb +145 -0
- data/app/models/collavre/comment.rb +70 -5
- data/app/models/collavre/creative/describable.rb +202 -3
- data/app/models/collavre/creative.rb +2 -0
- data/app/models/collavre/creative_share.rb +1 -0
- data/app/models/collavre/integration_setting.rb +35 -0
- data/app/models/collavre/preview_channel.rb +93 -0
- data/app/models/collavre/system_setting.rb +13 -2
- data/app/models/collavre/task.rb +34 -5
- data/app/models/collavre/topic.rb +8 -25
- data/app/models/collavre/user.rb +4 -0
- data/app/models/concerns/collavre/ai_agent_resolvable.rb +12 -3
- data/app/services/collavre/agent_session_abort.rb +28 -0
- data/app/services/collavre/ai_agent/claude_channel_adapter.rb +79 -0
- data/app/services/collavre/ai_agent/response_finalizer.rb +4 -2
- data/app/services/collavre/ai_agent_service.rb +68 -49
- data/app/services/collavre/ai_client.rb +3 -3
- data/app/services/collavre/attachment_backfill.rb +26 -0
- data/app/services/collavre/channel_attacher.rb +58 -0
- data/app/services/collavre/comments/mcp_command.rb +31 -1
- data/app/services/collavre/creatives/breadcrumb_resolver.rb +91 -0
- data/app/services/collavre/creatives/filter_pipeline.rb +26 -42
- data/app/services/collavre/creatives/filters/search_filter.rb +12 -2
- data/app/services/collavre/creatives/index_query.rb +110 -8
- data/app/services/collavre/creatives/permission_filter.rb +50 -0
- data/app/services/collavre/creatives/reveal_path_resolver.rb +118 -0
- data/app/services/collavre/creatives/tree_builder.rb +7 -3
- data/app/services/collavre/crons/recurring_task_arguments.rb +28 -0
- data/app/services/collavre/google_calendar_service.rb +4 -2
- data/app/services/collavre/markdown_converter.rb +130 -15
- data/app/services/collavre/markdown_importer.rb +7 -2
- data/app/services/collavre/orchestration/policy_resolver.rb +11 -1
- data/app/services/collavre/orchestration/stuck_detector.rb +22 -2
- data/app/services/collavre/tools/creative_attach_files_service.rb +62 -0
- data/app/services/collavre/tools/creative_list_attachments_service.rb +42 -0
- data/app/services/collavre/tools/creative_remove_attachment_service.rb +37 -0
- data/app/services/collavre/tools/cron_list_service.rb +1 -14
- data/app/services/collavre/tools/permission_denied_error.rb +9 -0
- data/app/services/collavre/tools/preview_attach_service.rb +128 -0
- data/app/services/collavre/tools/preview_detach_service.rb +61 -0
- data/app/services/collavre/tools/topic_authorizer.rb +24 -0
- data/app/services/collavre/topic_branch_service.rb +34 -26
- data/app/services/collavre/topics/orphaned_cron_notifier.rb +68 -0
- data/app/views/admin/shared/_tabs.html.erb +1 -0
- data/app/views/collavre/admin/integrations/_category.html.erb +22 -0
- data/app/views/collavre/admin/integrations/_setting_row.html.erb +70 -0
- data/app/views/collavre/admin/integrations/index.html.erb +42 -0
- data/app/views/collavre/admin/settings/_system_tab.html.erb +8 -0
- data/app/views/collavre/comments/_channel_chips.html.erb +33 -0
- data/app/views/collavre/comments/_comment.html.erb +16 -2
- data/app/views/collavre/comments/_comments_popup.html.erb +4 -1
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +19 -0
- data/app/views/collavre/creatives/index.html.erb +10 -2
- data/app/views/collavre/landing/show.html.erb +130 -0
- data/app/views/collavre/shared/_link_creative_modal.html.erb +6 -2
- data/app/views/layouts/collavre/landing.html.erb +33 -0
- data/config/locales/admin.en.yml +4 -2
- data/config/locales/admin.ko.yml +4 -2
- data/config/locales/channels.en.yml +13 -0
- data/config/locales/channels.ko.yml +13 -0
- data/config/locales/claude_channel.en.yml +16 -0
- data/config/locales/claude_channel.ko.yml +16 -0
- data/config/locales/comments.en.yml +5 -0
- data/config/locales/comments.ko.yml +5 -0
- data/config/locales/creatives.en.yml +11 -0
- data/config/locales/creatives.ko.yml +10 -0
- data/config/locales/integrations.en.yml +55 -0
- data/config/locales/integrations.ko.yml +55 -0
- data/config/locales/landing.en.yml +51 -0
- data/config/locales/landing.ko.yml +51 -0
- data/config/routes.rb +30 -0
- data/db/migrate/20260526000000_create_channels.rb +42 -0
- data/db/migrate/20260527000000_add_dismissed_at_to_channels.rb +6 -0
- data/db/migrate/20260527000100_backfill_dismissed_at_for_legacy_detached_channels.rb +28 -0
- data/db/migrate/20260528000000_add_preview_channel_unique_index.rb +31 -0
- data/db/migrate/20260529000000_add_primary_agent_id_to_topics.rb +40 -0
- data/db/migrate/20260529100000_create_integration_settings.rb +15 -0
- data/db/migrate/20260609000000_drop_action_text_rich_texts.rb +20 -0
- data/db/migrate/20260609005000_add_session_id_to_topics.rb +16 -0
- data/db/migrate/20260609010000_create_agent_subscriptions.rb +19 -0
- data/db/migrate/20260609020000_add_last_seen_at_to_agent_subscriptions.rb +23 -0
- data/db/migrate/20260609030000_add_session_id_to_agent_subscriptions.rb +17 -0
- data/db/migrate/20260609190659_backfill_creative_files_into_description.rb +24 -0
- data/db/seeds.rb +19 -0
- data/lib/collavre/aws_credentials.rb +75 -0
- data/lib/collavre/engine.rb +50 -0
- data/lib/collavre/integration_settings/key_definition.rb +35 -0
- data/lib/collavre/integration_settings/registry.rb +60 -0
- data/lib/collavre/integration_settings/resolver.rb +71 -0
- data/lib/collavre/integration_settings.rb +46 -0
- data/lib/collavre/ses_settings_interceptor.rb +72 -0
- data/lib/collavre/version.rb +1 -1
- data/lib/collavre.rb +3 -0
- metadata +82 -2
- data/app/services/collavre/openclaw_abort_service.rb +0 -45
|
@@ -289,6 +289,15 @@ export default class extends Controller {
|
|
|
289
289
|
|
|
290
290
|
async notifyChildControllers({ creativeId, canComment, highlightId }) {
|
|
291
291
|
this.topicsController?.clearOverrideTopicId()
|
|
292
|
+
// Drop the previous creative's topic selection from the form controller
|
|
293
|
+
// synchronously, BEFORE topics loadTopics() dispatches `comments--topics:change`
|
|
294
|
+
// (which repopulates these via handleTopicChange). Doing it later — e.g. in
|
|
295
|
+
// formController.onPopupOpened, which runs after the topics await — would
|
|
296
|
+
// erase the topic that restoreSelection() just restored from the server.
|
|
297
|
+
if (this.formController) {
|
|
298
|
+
this.formController.currentTopicId = ''
|
|
299
|
+
this.formController._mainTopicId = null
|
|
300
|
+
}
|
|
292
301
|
// Pre-set creativeId on list controller BEFORE loading topics.
|
|
293
302
|
// Topics loading triggers a change event that list controller handles.
|
|
294
303
|
// Without this, list controller still holds the previous creative's ID
|
|
@@ -7,7 +7,7 @@ const TYPING_TIMEOUT = 3000
|
|
|
7
7
|
const AGENT_STATUS_TIMEOUT = 10000 // Safety timeout for agent_status (heartbeat expected every 3s)
|
|
8
8
|
|
|
9
9
|
export default class extends Controller {
|
|
10
|
-
static targets = ['participants', 'typingIndicator', 'textarea', 'privateCheckbox']
|
|
10
|
+
static targets = ['participants', 'typingIndicator', 'textarea', 'privateCheckbox', 'channelChips', 'scrollRow']
|
|
11
11
|
|
|
12
12
|
connect() {
|
|
13
13
|
this.creativeId = null
|
|
@@ -24,11 +24,13 @@ export default class extends Controller {
|
|
|
24
24
|
this.handleInput = this.handleInput.bind(this)
|
|
25
25
|
this.handleFocus = this.handleFocus.bind(this)
|
|
26
26
|
this.handleBlur = this.handleBlur.bind(this)
|
|
27
|
+
this.handleTopicChange = this.handleTopicChange.bind(this)
|
|
27
28
|
|
|
28
29
|
this.textareaTarget.addEventListener('input', this.handleInput)
|
|
29
30
|
this.textareaTarget.addEventListener('focus', this.handleFocus)
|
|
30
31
|
this.textareaTarget.addEventListener('blur', this.handleBlur)
|
|
31
32
|
this.privateCheckboxTarget?.addEventListener('change', () => this.stoppedTyping())
|
|
33
|
+
this.element.addEventListener('comments--topics:change', this.handleTopicChange)
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
disconnect() {
|
|
@@ -36,6 +38,16 @@ export default class extends Controller {
|
|
|
36
38
|
this.textareaTarget.removeEventListener('input', this.handleInput)
|
|
37
39
|
this.textareaTarget.removeEventListener('focus', this.handleFocus)
|
|
38
40
|
this.textareaTarget.removeEventListener('blur', this.handleBlur)
|
|
41
|
+
this.element.removeEventListener('comments--topics:change', this.handleTopicChange)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
handleTopicChange(event) {
|
|
45
|
+
const topicId = event.detail?.topicId
|
|
46
|
+
if (topicId) {
|
|
47
|
+
this.refreshChannelChips(topicId)
|
|
48
|
+
} else {
|
|
49
|
+
this.clearChannelChips()
|
|
50
|
+
}
|
|
39
51
|
}
|
|
40
52
|
|
|
41
53
|
get listController() {
|
|
@@ -56,6 +68,23 @@ export default class extends Controller {
|
|
|
56
68
|
this.subscribe()
|
|
57
69
|
this.renderParticipants([])
|
|
58
70
|
this.renderTypingIndicator()
|
|
71
|
+
// Bootstrap chips for the topic that is already active when the popup opens.
|
|
72
|
+
// Without this, chips only appear after a `topics:change` event fires
|
|
73
|
+
// (i.e. a topic switch) or after a webhook arrives — leaving the user
|
|
74
|
+
// unable to detach existing channels until something else triggers a paint.
|
|
75
|
+
this.bootstrapChannelChips()
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
bootstrapChannelChips() {
|
|
79
|
+
const topicsCtrl = this.application.getControllerForElementAndIdentifier(
|
|
80
|
+
this.element, 'comments--topics'
|
|
81
|
+
)
|
|
82
|
+
const topicId = topicsCtrl?.currentTopicId
|
|
83
|
+
if (topicId) {
|
|
84
|
+
this.refreshChannelChips(topicId)
|
|
85
|
+
} else {
|
|
86
|
+
this.clearChannelChips()
|
|
87
|
+
}
|
|
59
88
|
}
|
|
60
89
|
|
|
61
90
|
onPopupClosed() {
|
|
@@ -167,8 +196,14 @@ export default class extends Controller {
|
|
|
167
196
|
}
|
|
168
197
|
if (data.typing) {
|
|
169
198
|
const { id, name } = data.typing
|
|
199
|
+
const isNewTyper = !(id in this.typingUsers)
|
|
200
|
+
// The user always wants to see their OWN typing indicator the moment it
|
|
201
|
+
// appears — they just started typing. Stick-to-end (which pauses while the
|
|
202
|
+
// user is scrolled back looking at badges) must not suppress that, so force
|
|
203
|
+
// the scroll for the local user's first typing frame.
|
|
204
|
+
const isSelf = String(id) === String(this.currentUserId)
|
|
170
205
|
this.typingUsers[id] = name
|
|
171
|
-
this.renderTypingIndicator()
|
|
206
|
+
this.renderTypingIndicator({ newItem: isNewTyper, force: isNewTyper && isSelf })
|
|
172
207
|
clearTimeout(this.typingTimers[id])
|
|
173
208
|
this.typingTimers[id] = setTimeout(() => {
|
|
174
209
|
delete this.typingUsers[id]
|
|
@@ -201,12 +236,16 @@ export default class extends Controller {
|
|
|
201
236
|
this.loadParticipants({ closeOnForbidden: shareChange.has_access === false })
|
|
202
237
|
return
|
|
203
238
|
}
|
|
239
|
+
if (data.channel_chips) {
|
|
240
|
+
this.refreshChannelChips(data.channel_chips.topic_id)
|
|
241
|
+
}
|
|
204
242
|
if (data.agent_status) {
|
|
205
243
|
const { id, name, status, task_id, creative_id: agentCreativeId } = data.agent_status
|
|
206
244
|
// Only show typing indicator if agent is working on this specific creative
|
|
207
245
|
if (agentCreativeId && String(agentCreativeId) !== String(this.creativeId)) {
|
|
208
246
|
return
|
|
209
247
|
}
|
|
248
|
+
const isNewAgent = (status === 'thinking' || status === 'streaming') && !(id in this.typingUsers)
|
|
210
249
|
if (status === 'thinking' || status === 'streaming') {
|
|
211
250
|
this.typingUsers[id] = name
|
|
212
251
|
if (!this.activeAgentTasks) this.activeAgentTasks = {}
|
|
@@ -230,7 +269,7 @@ export default class extends Controller {
|
|
|
230
269
|
}
|
|
231
270
|
}
|
|
232
271
|
this.syncGlobalAgentTasks()
|
|
233
|
-
this.renderTypingIndicator()
|
|
272
|
+
this.renderTypingIndicator({ newItem: isNewAgent })
|
|
234
273
|
}
|
|
235
274
|
}
|
|
236
275
|
|
|
@@ -314,8 +353,16 @@ export default class extends Controller {
|
|
|
314
353
|
}
|
|
315
354
|
|
|
316
355
|
|
|
317
|
-
renderTypingIndicator() {
|
|
356
|
+
renderTypingIndicator({ newItem = false, force = false } = {}) {
|
|
318
357
|
if (!this.hasTypingIndicatorTarget) return
|
|
358
|
+
|
|
359
|
+
// Capture stick-to-end BEFORE mutating the DOM: only auto-scroll a newly
|
|
360
|
+
// added item into view when the user was already parked at the right edge,
|
|
361
|
+
// so we never yank them away from a chip they scrolled back to look at.
|
|
362
|
+
// `force` overrides that guard for cases where the scroll is always wanted
|
|
363
|
+
// (e.g. the local user's own typing indicator first appearing).
|
|
364
|
+
const stickToEnd = force || (newItem && this.isScrollRowAtEnd())
|
|
365
|
+
|
|
319
366
|
this.typingIndicatorTarget.innerHTML = ''
|
|
320
367
|
|
|
321
368
|
if (this.manualTypingMessage) {
|
|
@@ -381,6 +428,33 @@ export default class extends Controller {
|
|
|
381
428
|
const text = document.createElement('span')
|
|
382
429
|
text.textContent = `${names.join(', ')} ...`
|
|
383
430
|
this.typingIndicatorTarget.appendChild(text)
|
|
431
|
+
|
|
432
|
+
if (stickToEnd) this.scrollRowToEnd()
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Distance (px) from the right edge still counted as "at the end". A small
|
|
436
|
+
// slack absorbs sub-pixel rounding and momentum scroll so stick-to-end stays
|
|
437
|
+
// engaged when the user is effectively, but not exactly, at the edge.
|
|
438
|
+
static STICK_TO_END_THRESHOLD = 24
|
|
439
|
+
|
|
440
|
+
get scrollRowElement() {
|
|
441
|
+
return this.hasScrollRowTarget ? this.scrollRowTarget : null
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// True when the horizontal scroll row is at (or near) its right edge — i.e.
|
|
445
|
+
// the user is looking at the newest items rather than scrolled back. Returns
|
|
446
|
+
// true when there is no scroll row or no overflow (nothing to yank away from).
|
|
447
|
+
isScrollRowAtEnd() {
|
|
448
|
+
const el = this.scrollRowElement
|
|
449
|
+
if (!el) return true
|
|
450
|
+
const threshold = this.constructor.STICK_TO_END_THRESHOLD
|
|
451
|
+
return el.scrollLeft + el.clientWidth >= el.scrollWidth - threshold
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
scrollRowToEnd() {
|
|
455
|
+
const el = this.scrollRowElement
|
|
456
|
+
if (!el) return
|
|
457
|
+
el.scrollLeft = el.scrollWidth
|
|
384
458
|
}
|
|
385
459
|
|
|
386
460
|
syncGlobalAgentTasks() {
|
|
@@ -417,6 +491,65 @@ export default class extends Controller {
|
|
|
417
491
|
.catch((err) => console.warn('[presence] cancel agent task failed:', err))
|
|
418
492
|
}
|
|
419
493
|
|
|
494
|
+
clearChannelChips() {
|
|
495
|
+
const target = this.hasChannelChipsTarget ? this.channelChipsTarget : null
|
|
496
|
+
if (!target) return
|
|
497
|
+
target.innerHTML = ''
|
|
498
|
+
delete target.dataset.topicId
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
refreshChannelChips(topicId) {
|
|
502
|
+
const target = this.hasChannelChipsTarget ? this.channelChipsTarget : null
|
|
503
|
+
if (!target) return
|
|
504
|
+
if (!this.creativeId) return
|
|
505
|
+
if (!topicId) {
|
|
506
|
+
this.clearChannelChips()
|
|
507
|
+
return
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Source of truth for "what the user is looking at" is the topics
|
|
511
|
+
// controller's currentTopicId, NOT the chip container's data-topic-id:
|
|
512
|
+
// - On initial popup open the container is empty (no data-topic-id),
|
|
513
|
+
// so a stray broadcast for any topic in the same creative used to
|
|
514
|
+
// paint chips for the wrong topic.
|
|
515
|
+
// - After the user switches topics the container's data-topic-id is
|
|
516
|
+
// stale until something repaints it, blocking legit updates.
|
|
517
|
+
const topicsCtrl = this.application.getControllerForElementAndIdentifier(
|
|
518
|
+
this.element, 'comments--topics'
|
|
519
|
+
)
|
|
520
|
+
const activeTopicId = topicsCtrl?.currentTopicId || ''
|
|
521
|
+
if (String(activeTopicId) !== String(topicId)) return
|
|
522
|
+
|
|
523
|
+
fetch(`/creatives/${this.creativeId}/topics/${topicId}/channel_chips`, {
|
|
524
|
+
headers: { Accept: 'text/html' },
|
|
525
|
+
credentials: 'same-origin',
|
|
526
|
+
})
|
|
527
|
+
.then((r) => (r.ok ? r.text() : null))
|
|
528
|
+
.then((html) => {
|
|
529
|
+
if (!html) return
|
|
530
|
+
// A newly attached channel (e.g. a fresh PR/Preview badge) is a "new
|
|
531
|
+
// item" in the scroll row. Count chips before/after and scroll the new
|
|
532
|
+
// one into view, but only when the user was already at the right edge.
|
|
533
|
+
const row = this.scrollRowElement
|
|
534
|
+
const prevChipCount = row ? row.querySelectorAll('.channel-chip').length : 0
|
|
535
|
+
const wasAtEnd = this.isScrollRowAtEnd()
|
|
536
|
+
target.outerHTML = html
|
|
537
|
+
const newChipCount = row ? row.querySelectorAll('.channel-chip').length : 0
|
|
538
|
+
if (wasAtEnd && newChipCount > prevChipCount) this.scrollRowToEnd()
|
|
539
|
+
})
|
|
540
|
+
.catch((err) => console.warn('[presence] refresh channel chips failed:', err))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
detachChannel(event) {
|
|
544
|
+
const btn = event.currentTarget
|
|
545
|
+
const id = btn.dataset.channelId
|
|
546
|
+
if (!id) return
|
|
547
|
+
csrfFetch(`/channels/${id}`, {
|
|
548
|
+
method: 'DELETE',
|
|
549
|
+
headers: { Accept: 'application/json' },
|
|
550
|
+
}).catch((err) => console.warn('[presence] detach channel failed:', err))
|
|
551
|
+
}
|
|
552
|
+
|
|
420
553
|
clearTypingTimers() {
|
|
421
554
|
Object.values(this.typingTimers).forEach((timer) => clearTimeout(timer))
|
|
422
555
|
this.typingTimers = {}
|
|
@@ -34,6 +34,15 @@ export default class extends Controller {
|
|
|
34
34
|
|
|
35
35
|
onPopupOpened({ creativeId }) {
|
|
36
36
|
this.creativeIdValue = creativeId
|
|
37
|
+
// Clear stale cached state from the previous creative — otherwise
|
|
38
|
+
// chat-context autofill (command_menu) reads stale values during the
|
|
39
|
+
// window between popup switch and the new topics fetch completing.
|
|
40
|
+
// form_controller's currentTopicId is cleared upstream in
|
|
41
|
+
// popup_controller.notifyChildControllers; here we clear our own
|
|
42
|
+
// mainTopicId (read directly as the autofill fallback) and the cached
|
|
43
|
+
// effective_creative_id. loadTopics() repopulates both.
|
|
44
|
+
delete this.element.dataset.effectiveCreativeId
|
|
45
|
+
this.mainTopicId = null
|
|
37
46
|
this.subscribe()
|
|
38
47
|
return this.loadTopics()
|
|
39
48
|
}
|
|
@@ -88,6 +97,12 @@ export default class extends Controller {
|
|
|
88
97
|
this.isInbox = !!data.is_inbox
|
|
89
98
|
this.systemTopicId = data.system_topic_id ? String(data.system_topic_id) : null
|
|
90
99
|
this.mainTopicId = data.main_topic_id ? String(data.main_topic_id) : null
|
|
100
|
+
// Expose effective origin id so chat-context autofill (slash commands)
|
|
101
|
+
// and any other consumer can target the same creative the server uses
|
|
102
|
+
// (linked creatives resolve params[:creative_id] through effective_origin).
|
|
103
|
+
if (data.effective_creative_id) {
|
|
104
|
+
this.element.dataset.effectiveCreativeId = String(data.effective_creative_id)
|
|
105
|
+
}
|
|
91
106
|
|
|
92
107
|
// Migrate localStorage to server if server has no value
|
|
93
108
|
this.migrateLocalStorage()
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
const subscribeToCreatives = jest.fn()
|
|
8
|
+
|
|
9
|
+
jest.unstable_mockModule('../../../services/creatives_channel', () => ({
|
|
10
|
+
subscribeToCreatives,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
14
|
+
const SyncController = (await import('../sync_controller')).default
|
|
15
|
+
|
|
16
|
+
describe('CreativesSyncController subscribe timing', () => {
|
|
17
|
+
let application
|
|
18
|
+
let container
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
subscribeToCreatives.mockReset()
|
|
22
|
+
subscribeToCreatives.mockReturnValue({
|
|
23
|
+
cleanup: jest.fn(),
|
|
24
|
+
sendEditing: jest.fn(),
|
|
25
|
+
sendStoppedEditing: jest.fn(),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
container = document.createElement('div')
|
|
29
|
+
container.id = 'sync-root'
|
|
30
|
+
container.setAttribute('data-controller', 'creatives--sync')
|
|
31
|
+
container.setAttribute('data-creatives--sync-root-id-value', '991')
|
|
32
|
+
document.body.appendChild(container)
|
|
33
|
+
|
|
34
|
+
application = Application.start()
|
|
35
|
+
application.register('creatives--sync', SyncController)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
application.stop()
|
|
40
|
+
document.body.innerHTML = ''
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('does NOT subscribe immediately when rootIdValue > 0 — waits for tree to load', async () => {
|
|
44
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
45
|
+
|
|
46
|
+
expect(subscribeToCreatives).not.toHaveBeenCalled()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('subscribes after creative-tree:updated event fires', async () => {
|
|
50
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
51
|
+
expect(subscribeToCreatives).not.toHaveBeenCalled()
|
|
52
|
+
|
|
53
|
+
container.dispatchEvent(
|
|
54
|
+
new CustomEvent('creative-tree:updated', { bubbles: true }),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(subscribeToCreatives).toHaveBeenCalledTimes(1)
|
|
58
|
+
expect(subscribeToCreatives).toHaveBeenCalledWith(991, expect.any(Object))
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
test('subscribes only once even if creative-tree:updated fires multiple times', async () => {
|
|
62
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
63
|
+
|
|
64
|
+
container.dispatchEvent(new CustomEvent('creative-tree:updated', { bubbles: true }))
|
|
65
|
+
container.dispatchEvent(new CustomEvent('creative-tree:updated', { bubbles: true }))
|
|
66
|
+
container.dispatchEvent(new CustomEvent('creative-tree:updated', { bubbles: true }))
|
|
67
|
+
|
|
68
|
+
expect(subscribeToCreatives).toHaveBeenCalledTimes(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('subscribes immediately when tree is already loaded (Turbo cache restore)', async () => {
|
|
72
|
+
// Simulate Turbo restoring a cached tree page: data-loaded is already set
|
|
73
|
+
// before sync_controller connects, so tree_controller skips load() and
|
|
74
|
+
// never dispatches creative-tree:updated.
|
|
75
|
+
const cached = document.createElement('div')
|
|
76
|
+
cached.id = 'sync-cached'
|
|
77
|
+
cached.setAttribute('data-controller', 'creatives--cached-sync')
|
|
78
|
+
cached.setAttribute('data-creatives--cached-sync-root-id-value', '991')
|
|
79
|
+
cached.setAttribute('data-loaded', 'true')
|
|
80
|
+
cached.innerHTML = '<creative-tree-row creative-id="1"></creative-tree-row>'
|
|
81
|
+
document.body.appendChild(cached)
|
|
82
|
+
|
|
83
|
+
application.register('creatives--cached-sync', SyncController)
|
|
84
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
85
|
+
|
|
86
|
+
expect(subscribeToCreatives).toHaveBeenCalledTimes(1)
|
|
87
|
+
expect(subscribeToCreatives).toHaveBeenCalledWith(991, expect.any(Object))
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
jest.unstable_mockModule('../../../creatives/tree_renderer', () => ({
|
|
8
|
+
renderCreativeTree: jest.fn(),
|
|
9
|
+
dispatchCreativeTreeUpdated: jest.fn(),
|
|
10
|
+
applyRowProperties: jest.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.unstable_mockModule('../../../utils/emoji_parser', () => ({
|
|
14
|
+
parseEmojis: jest.fn(() => ['✨']),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
18
|
+
const TreeController = (await import('../tree_controller')).default
|
|
19
|
+
|
|
20
|
+
const TRANSIENT_RETRY_DELAYS = [200, 600]
|
|
21
|
+
|
|
22
|
+
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
|
|
23
|
+
|
|
24
|
+
const collectRetryDelays = (spy) =>
|
|
25
|
+
spy.mock.calls
|
|
26
|
+
.map((args) => args[1])
|
|
27
|
+
.filter((delay) => TRANSIENT_RETRY_DELAYS.includes(delay))
|
|
28
|
+
|
|
29
|
+
const installController = () => {
|
|
30
|
+
const container = document.createElement('div')
|
|
31
|
+
container.setAttribute('data-controller', 'creatives--tree')
|
|
32
|
+
container.setAttribute('data-creatives--tree-url-value', '/creatives?format=json&id=991')
|
|
33
|
+
document.body.appendChild(container)
|
|
34
|
+
|
|
35
|
+
const application = Application.start()
|
|
36
|
+
application.register('creatives--tree', TreeController)
|
|
37
|
+
|
|
38
|
+
return { container, application }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('CreativesTreeController retry on transient network errors', () => {
|
|
42
|
+
let originalFetch
|
|
43
|
+
let setTimeoutSpy
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
originalFetch = global.fetch
|
|
47
|
+
setTimeoutSpy = jest.spyOn(global, 'setTimeout')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
setTimeoutSpy.mockRestore()
|
|
52
|
+
global.fetch = originalFetch
|
|
53
|
+
document.body.innerHTML = ''
|
|
54
|
+
jest.restoreAllMocks()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('schedules 200ms then 600ms backoff retry on TypeError "Failed to fetch"', async () => {
|
|
58
|
+
global.fetch = jest
|
|
59
|
+
.fn()
|
|
60
|
+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
|
61
|
+
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
|
|
62
|
+
.mockResolvedValueOnce({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: async () => ({ creatives: [] }),
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const { application } = installController()
|
|
68
|
+
// Allow time for the 200ms + 600ms retries to fire
|
|
69
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
70
|
+
|
|
71
|
+
expect(collectRetryDelays(setTimeoutSpy)).toEqual(TRANSIENT_RETRY_DELAYS)
|
|
72
|
+
|
|
73
|
+
application.stop()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('does NOT schedule retry on HTTP error responses', async () => {
|
|
77
|
+
jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
78
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
79
|
+
ok: false,
|
|
80
|
+
status: 500,
|
|
81
|
+
json: async () => ({}),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const { application } = installController()
|
|
85
|
+
await flush()
|
|
86
|
+
await flush()
|
|
87
|
+
await flush()
|
|
88
|
+
|
|
89
|
+
expect(collectRetryDelays(setTimeoutSpy)).toEqual([])
|
|
90
|
+
|
|
91
|
+
application.stop()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('does NOT schedule retry on AbortError', async () => {
|
|
95
|
+
const abortErr = new Error('aborted')
|
|
96
|
+
abortErr.name = 'AbortError'
|
|
97
|
+
global.fetch = jest.fn().mockRejectedValue(abortErr)
|
|
98
|
+
|
|
99
|
+
const { application } = installController()
|
|
100
|
+
await flush()
|
|
101
|
+
await flush()
|
|
102
|
+
|
|
103
|
+
expect(collectRetryDelays(setTimeoutSpy)).toEqual([])
|
|
104
|
+
|
|
105
|
+
application.stop()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
test('gives up after exactly 2 retries on persistent transient errors', async () => {
|
|
109
|
+
jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
110
|
+
global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
|
111
|
+
|
|
112
|
+
const { application } = installController()
|
|
113
|
+
// Allow time for both retries to complete (200 + 600 = 800ms)
|
|
114
|
+
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
115
|
+
|
|
116
|
+
expect(collectRetryDelays(setTimeoutSpy)).toEqual(TRANSIENT_RETRY_DELAYS)
|
|
117
|
+
|
|
118
|
+
application.stop()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
@@ -24,20 +24,41 @@ export default class extends Controller {
|
|
|
24
24
|
}
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
// Defer ActionCable subscribe until tree finishes loading. Opening the
|
|
28
|
+
// WebSocket while the tree fetch is in flight triggers ERR_NETWORK_CHANGED
|
|
29
|
+
// in Chromium because the browser tears down the in-flight HTTP connection.
|
|
30
|
+
// Exception: Turbo cache restores leave data-loaded="true" so tree_controller
|
|
31
|
+
// skips load() and never dispatches creative-tree:updated — subscribe now.
|
|
32
|
+
const subscribeForRoot = () => {
|
|
33
|
+
if (this.rootIdValue > 0) {
|
|
34
|
+
this.subscribe()
|
|
35
|
+
} else {
|
|
36
|
+
this.inferAndSubscribe()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
29
39
|
|
|
30
|
-
if (this.
|
|
31
|
-
|
|
40
|
+
if (this.element.dataset.loaded === 'true') {
|
|
41
|
+
subscribeForRoot()
|
|
32
42
|
} else {
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
this._handleTreeUpdated = () => {
|
|
44
|
+
this.element.removeEventListener('creative-tree:updated', this._handleTreeUpdated)
|
|
45
|
+
this._handleTreeUpdated = null
|
|
46
|
+
subscribeForRoot()
|
|
47
|
+
}
|
|
48
|
+
this.element.addEventListener('creative-tree:updated', this._handleTreeUpdated)
|
|
35
49
|
}
|
|
50
|
+
|
|
51
|
+
document.addEventListener('creative-editing:start', this.handleEditStart)
|
|
52
|
+
document.addEventListener('creative-editing:stop', this.handleEditStop)
|
|
36
53
|
}
|
|
37
54
|
|
|
38
55
|
disconnect() {
|
|
39
56
|
document.removeEventListener('creative-editing:start', this.handleEditStart)
|
|
40
57
|
document.removeEventListener('creative-editing:stop', this.handleEditStop)
|
|
58
|
+
if (this._handleTreeUpdated) {
|
|
59
|
+
this.element.removeEventListener('creative-tree:updated', this._handleTreeUpdated)
|
|
60
|
+
this._handleTreeUpdated = null
|
|
61
|
+
}
|
|
41
62
|
|
|
42
63
|
if (this.subscription) {
|
|
43
64
|
this.subscription.cleanup()
|
|
@@ -50,9 +71,9 @@ export default class extends Controller {
|
|
|
50
71
|
this.subscription.cleanup()
|
|
51
72
|
this.subscription = null
|
|
52
73
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
74
|
+
// Skip re-subscribing here — connect() defers the first subscribe until
|
|
75
|
+
// creative-tree:updated fires so the WebSocket handshake doesn't race
|
|
76
|
+
// the tree's HTTP fetch.
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
inferAndSubscribe() {
|
|
@@ -2,6 +2,8 @@ import { Controller } from '@hotwired/stimulus'
|
|
|
2
2
|
import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
|
|
3
3
|
import { parseEmojis } from '../../utils/emoji_parser'
|
|
4
4
|
|
|
5
|
+
const TREE_RETRY_DELAYS_MS = [200, 600]
|
|
6
|
+
|
|
5
7
|
export default class extends Controller {
|
|
6
8
|
static values = {
|
|
7
9
|
url: String,
|
|
@@ -49,6 +51,10 @@ export default class extends Controller {
|
|
|
49
51
|
this.abortController.abort()
|
|
50
52
|
this.abortController = null
|
|
51
53
|
}
|
|
54
|
+
if (this._retryTimer) {
|
|
55
|
+
clearTimeout(this._retryTimer)
|
|
56
|
+
this._retryTimer = null
|
|
57
|
+
}
|
|
52
58
|
window.removeEventListener('resize', this.handleResize)
|
|
53
59
|
this.element.removeEventListener('creative-tree:updated', this.handleTreeUpdated)
|
|
54
60
|
document.removeEventListener('creative-editing:start', this._handleEditStart)
|
|
@@ -138,8 +144,12 @@ export default class extends Controller {
|
|
|
138
144
|
this.abortController.abort()
|
|
139
145
|
}
|
|
140
146
|
this.abortController = new AbortController()
|
|
147
|
+
this._retryCount = 0
|
|
141
148
|
this.showLoadingIndicator()
|
|
149
|
+
this._fetchTree()
|
|
150
|
+
}
|
|
142
151
|
|
|
152
|
+
_fetchTree() {
|
|
143
153
|
fetch(this.urlValue, {
|
|
144
154
|
headers: { Accept: 'application/json' },
|
|
145
155
|
signal: this.abortController.signal,
|
|
@@ -154,12 +164,25 @@ export default class extends Controller {
|
|
|
154
164
|
})
|
|
155
165
|
.catch((error) => {
|
|
156
166
|
if (error.name === 'AbortError') return
|
|
167
|
+
// Transient network failures (ERR_NETWORK_CHANGED, offline blips, VPN
|
|
168
|
+
// toggles) surface as TypeError "Failed to fetch". Retry briefly so a
|
|
169
|
+
// momentary network event doesn't leave the user with an empty tree.
|
|
170
|
+
if (this._isTransientNetworkError(error) && this._retryCount < TREE_RETRY_DELAYS_MS.length) {
|
|
171
|
+
const delay = TREE_RETRY_DELAYS_MS[this._retryCount]
|
|
172
|
+
this._retryCount += 1
|
|
173
|
+
this._retryTimer = setTimeout(() => this._fetchTree(), delay)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
157
176
|
console.error(error)
|
|
158
177
|
this.hideLoadingIndicator()
|
|
159
178
|
this.showEmptyState()
|
|
160
179
|
})
|
|
161
180
|
}
|
|
162
181
|
|
|
182
|
+
_isTransientNetworkError(error) {
|
|
183
|
+
return error instanceof TypeError && /fetch|network/i.test(error.message || '')
|
|
184
|
+
}
|
|
185
|
+
|
|
163
186
|
renderData(data) {
|
|
164
187
|
const nodes = Array.isArray(data?.creatives) ? data.creatives : []
|
|
165
188
|
|
|
@@ -33,6 +33,7 @@ import CommentBadgeController from "./comment_badge_controller"
|
|
|
33
33
|
import ShareModalController from "./share_modal_controller"
|
|
34
34
|
import ImageLightboxController from "./image_lightbox_controller"
|
|
35
35
|
import SearchPopupController from "./search_popup_controller"
|
|
36
|
+
import LandingVideoController from "./landing_video_controller"
|
|
36
37
|
|
|
37
38
|
// Export all controllers
|
|
38
39
|
export {
|
|
@@ -67,7 +68,8 @@ export {
|
|
|
67
68
|
ShareModalController,
|
|
68
69
|
ImageLightboxController,
|
|
69
70
|
SearchPopupController,
|
|
70
|
-
CommentBadgeController
|
|
71
|
+
CommentBadgeController,
|
|
72
|
+
LandingVideoController
|
|
71
73
|
}
|
|
72
74
|
|
|
73
75
|
// Registration function for use with a Stimulus application
|
|
@@ -105,4 +107,5 @@ export function registerControllers(application) {
|
|
|
105
107
|
application.register("image-lightbox", ImageLightboxController)
|
|
106
108
|
application.register("search-popup", SearchPopupController)
|
|
107
109
|
application.register("comment-badge", CommentBadgeController)
|
|
110
|
+
application.register("landing-video", LandingVideoController)
|
|
108
111
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["video", "progressBar", "progressFill", "toggle", "icon"]
|
|
5
|
+
|
|
6
|
+
connect() {
|
|
7
|
+
this._raf = null
|
|
8
|
+
this._updateProgress = this._updateProgress.bind(this)
|
|
9
|
+
this.videoTarget.addEventListener("play", () => this._startProgress())
|
|
10
|
+
this.videoTarget.addEventListener("pause", () => this._stopProgress())
|
|
11
|
+
this.videoTarget.addEventListener("ended", () => this._stopProgress())
|
|
12
|
+
if (!this.videoTarget.paused) this._startProgress()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
disconnect() {
|
|
16
|
+
this._stopProgress()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
togglePlay() {
|
|
20
|
+
if (this.videoTarget.paused) {
|
|
21
|
+
this.videoTarget.play()
|
|
22
|
+
this.iconTarget.textContent = "❚❚"
|
|
23
|
+
} else {
|
|
24
|
+
this.videoTarget.pause()
|
|
25
|
+
this.iconTarget.textContent = "▶"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_startProgress() {
|
|
30
|
+
this._stopProgress()
|
|
31
|
+
this._tick()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
_stopProgress() {
|
|
35
|
+
if (this._raf) {
|
|
36
|
+
cancelAnimationFrame(this._raf)
|
|
37
|
+
this._raf = null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_tick() {
|
|
42
|
+
this._updateProgress()
|
|
43
|
+
this._raf = requestAnimationFrame(() => this._tick())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_updateProgress() {
|
|
47
|
+
const v = this.videoTarget
|
|
48
|
+
if (v.duration) {
|
|
49
|
+
const pct = (v.currentTime / v.duration) * 100
|
|
50
|
+
this.progressFillTarget.style.width = `${pct}%`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|