collavre 0.23.0 → 0.24.1
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/images/collavre/landing/interface-en-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-en.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
- data/app/assets/images/collavre/landing/interface-ko.png +0 -0
- data/app/assets/stylesheets/collavre/comments_popup.css +45 -17
- data/app/assets/stylesheets/collavre/landing.css +92 -0
- data/app/assets/stylesheets/collavre/popup.css +4 -2
- data/app/controllers/collavre/admin/settings_controller.rb +52 -67
- data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
- data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
- data/app/controllers/collavre/attachments_controller.rb +13 -3
- data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
- data/app/controllers/collavre/creatives_controller.rb +32 -99
- data/app/controllers/collavre/inbox_items_controller.rb +10 -4
- data/app/controllers/collavre/landing_controller.rb +5 -2
- data/app/controllers/collavre/topics_controller.rb +5 -0
- data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
- data/app/errors/collavre/api_error.rb +20 -0
- data/app/helpers/collavre/creatives_helper.rb +30 -8
- data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
- data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
- data/app/javascript/components/creative_tree_row.js +8 -2
- data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
- data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
- data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
- data/app/javascript/controllers/comments/__tests__/form_controller_review.test.js +26 -0
- data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +230 -0
- data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +178 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +146 -0
- data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
- data/app/javascript/controllers/comments/form_controller.js +3 -0
- data/app/javascript/controllers/comments/list_controller.js +42 -21
- data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
- data/app/javascript/controllers/comments/topics_controller.js +115 -21
- data/app/javascript/controllers/common_popup_controller.js +2 -2
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/search_popup_controller.js +17 -1
- data/app/javascript/controllers/topic_list_controller.js +78 -0
- data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
- data/app/javascript/lib/common_popup.js +24 -5
- data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
- data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
- data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
- data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
- data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
- data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
- data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
- data/app/javascript/modules/creative_inline_payload.js +86 -0
- data/app/javascript/modules/creative_row_editor.js +121 -401
- data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
- data/app/javascript/modules/creative_save_queue.js +95 -0
- data/app/javascript/modules/creative_tree_dom.js +260 -0
- data/app/jobs/collavre/permission_cache_job.rb +20 -2
- data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
- data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
- data/app/models/collavre/channel.rb +16 -0
- data/app/models/collavre/comment/approvable.rb +16 -0
- data/app/models/collavre/comment/notifiable.rb +1 -1
- data/app/models/collavre/comment.rb +8 -1
- data/app/models/collavre/comment_version.rb +1 -1
- data/app/models/collavre/creative/describable.rb +15 -9
- data/app/models/collavre/creative/linkable.rb +9 -0
- data/app/models/collavre/creative/permissible.rb +97 -49
- data/app/models/collavre/creative.rb +73 -5
- data/app/models/collavre/creative_share.rb +78 -26
- data/app/models/collavre/inbox_item.rb +2 -2
- data/app/models/collavre/task.rb +12 -0
- data/app/models/collavre/user.rb +33 -9
- data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
- data/app/services/collavre/agent_type_classifier.rb +23 -0
- data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
- data/app/services/collavre/ai_agent/message_builder.rb +1 -1
- data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
- data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
- data/app/services/collavre/ai_client.rb +53 -2
- data/app/services/collavre/auto_theme_generator.rb +14 -10
- data/app/services/collavre/creatives/children_index.rb +86 -0
- data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
- data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
- data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
- data/app/services/collavre/creatives/permission_checker.rb +1 -1
- data/app/services/collavre/creatives/permission_filter.rb +144 -17
- data/app/services/collavre/creatives/tree_builder.rb +105 -77
- data/app/services/collavre/http_client.rb +119 -0
- data/app/services/collavre/link_preview_fetcher.rb +129 -38
- data/app/services/collavre/markdown_importer.rb +30 -6
- data/app/services/collavre/mobile/event_summarizer.rb +1 -1
- data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
- data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
- data/app/services/collavre/system_events/context_builder.rb +1 -14
- data/app/services/collavre/tools/preview_attach_service.rb +5 -7
- data/app/views/collavre/comments/_comment.html.erb +4 -4
- data/app/views/collavre/comments/_comments_popup.html.erb +19 -3
- data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
- data/app/views/collavre/landing/show.html.erb +52 -10
- data/config/locales/comments.en.yml +7 -0
- data/config/locales/comments.ko.yml +7 -0
- data/config/locales/creatives.en.yml +6 -0
- data/config/locales/creatives.ko.yml +5 -0
- data/config/locales/landing.en.yml +22 -10
- data/config/locales/landing.ko.yml +22 -10
- data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
- data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
- data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
- data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
- data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
- data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
- data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
- data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
- data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
- data/lib/collavre/version.rb +1 -1
- metadata +48 -1
|
@@ -6,8 +6,14 @@ import creativesApi from '../../lib/api/creatives'
|
|
|
6
6
|
import { renderCreativeTree, dispatchCreativeTreeUpdated } from '../../creatives/tree_renderer'
|
|
7
7
|
import { updateCsrfTokenFromResponse } from '../../lib/api/csrf_fetch'
|
|
8
8
|
import { alertDialog, confirmDialog } from '../../lib/utils/dialog'
|
|
9
|
+
import PrevMessageNavigator from './prev_message_navigator'
|
|
9
10
|
// CommonPopup is now used via TopicSearchController (Stimulus)
|
|
10
11
|
|
|
12
|
+
// Gestures that mean the user moved the list themselves, invalidating the
|
|
13
|
+
// previous-message anchor. Only user input fires these — our own smooth scroll
|
|
14
|
+
// does not.
|
|
15
|
+
const PREV_MSG_USER_INPUT_EVENTS = ['wheel', 'touchstart', 'keydown', 'pointerdown']
|
|
16
|
+
|
|
11
17
|
export default class extends Controller {
|
|
12
18
|
static targets = ['list']
|
|
13
19
|
|
|
@@ -20,8 +26,10 @@ export default class extends Controller {
|
|
|
20
26
|
this.movingComments = false
|
|
21
27
|
this.manualSearchQuery = null
|
|
22
28
|
this.initialLoadComplete = false
|
|
29
|
+
this.prevMsgNavigator = new PrevMessageNavigator()
|
|
23
30
|
|
|
24
31
|
this.handleScroll = this.handleScroll.bind(this)
|
|
32
|
+
this.handlePrevMsgUserInput = this.handlePrevMsgUserInput.bind(this)
|
|
25
33
|
this.handleChange = this.handleChange.bind(this)
|
|
26
34
|
this.handleClick = this.handleClick.bind(this)
|
|
27
35
|
this.handleSubmit = this.handleSubmit.bind(this)
|
|
@@ -33,6 +41,9 @@ export default class extends Controller {
|
|
|
33
41
|
this.handleStreamRender = this.handleStreamRender.bind(this)
|
|
34
42
|
|
|
35
43
|
this.listTarget.addEventListener('scroll', this.handleScroll)
|
|
44
|
+
PREV_MSG_USER_INPUT_EVENTS.forEach((name) => {
|
|
45
|
+
this.listTarget.addEventListener(name, this.handlePrevMsgUserInput)
|
|
46
|
+
})
|
|
36
47
|
this.listTarget.addEventListener('change', this.handleChange)
|
|
37
48
|
this.listTarget.addEventListener('click', this.handleClick)
|
|
38
49
|
this.listTarget.addEventListener('submit', this.handleSubmit)
|
|
@@ -75,6 +86,9 @@ export default class extends Controller {
|
|
|
75
86
|
|
|
76
87
|
disconnect() {
|
|
77
88
|
this.listTarget.removeEventListener('scroll', this.handleScroll)
|
|
89
|
+
PREV_MSG_USER_INPUT_EVENTS.forEach((name) => {
|
|
90
|
+
this.listTarget.removeEventListener(name, this.handlePrevMsgUserInput)
|
|
91
|
+
})
|
|
78
92
|
this.listTarget.removeEventListener('change', this.handleChange)
|
|
79
93
|
this.listTarget.removeEventListener('click', this.handleClick)
|
|
80
94
|
this.listTarget.removeEventListener('submit', this.handleSubmit)
|
|
@@ -152,6 +166,9 @@ export default class extends Controller {
|
|
|
152
166
|
if (!this.creativeId) return
|
|
153
167
|
if (this.selection.size > 0) return
|
|
154
168
|
|
|
169
|
+
// The list is about to be replaced wholesale; any anchor we hold is stale.
|
|
170
|
+
this.prevMsgNavigator.reset()
|
|
171
|
+
|
|
155
172
|
const params = {}
|
|
156
173
|
if (this.highlightAfterLoad) {
|
|
157
174
|
params.around_comment_id = this.highlightAfterLoad
|
|
@@ -334,9 +351,17 @@ export default class extends Controller {
|
|
|
334
351
|
return parseInt(last.dataset.commentId)
|
|
335
352
|
}
|
|
336
353
|
|
|
354
|
+
// Public seam for sibling controllers that scroll the list on their own
|
|
355
|
+
// (e.g. the review-quote chip). Lets them drop the prev-message anchor at the
|
|
356
|
+
// choke point without reaching into the navigator's internals.
|
|
357
|
+
notifyProgrammaticScroll() {
|
|
358
|
+
this.prevMsgNavigator?.notifyProgrammaticScroll()
|
|
359
|
+
}
|
|
360
|
+
|
|
337
361
|
highlightComment(commentId) {
|
|
338
362
|
const comment = document.getElementById(`comment_${commentId}`)
|
|
339
363
|
if (!comment) return
|
|
364
|
+
this.prevMsgNavigator?.notifyProgrammaticScroll()
|
|
340
365
|
comment.scrollIntoView({ behavior: 'auto', block: 'center' })
|
|
341
366
|
comment.classList.add('highlight-flash')
|
|
342
367
|
comment.dataset.highlighted = 'true'
|
|
@@ -357,6 +382,10 @@ export default class extends Controller {
|
|
|
357
382
|
}, 2000);
|
|
358
383
|
}
|
|
359
384
|
|
|
385
|
+
handlePrevMsgUserInput() {
|
|
386
|
+
this.prevMsgNavigator.notifyUserInput()
|
|
387
|
+
}
|
|
388
|
+
|
|
360
389
|
handleScroll() {
|
|
361
390
|
if (!this.initialLoadComplete) return
|
|
362
391
|
|
|
@@ -1078,28 +1107,16 @@ export default class extends Controller {
|
|
|
1078
1107
|
|
|
1079
1108
|
scrollToPreviousMessage() {
|
|
1080
1109
|
const list = this.listTarget
|
|
1081
|
-
const
|
|
1082
|
-
if (
|
|
1083
|
-
|
|
1084
|
-
const listRect = list.getBoundingClientRect()
|
|
1085
|
-
const viewportTop = listRect.top
|
|
1086
|
-
|
|
1087
|
-
let currentIdx = -1
|
|
1088
|
-
for (let i = 0; i < items.length; i++) {
|
|
1089
|
-
const rect = items[i].getBoundingClientRect()
|
|
1090
|
-
if (rect.top >= viewportTop - 2) {
|
|
1091
|
-
currentIdx = i
|
|
1092
|
-
break
|
|
1093
|
-
}
|
|
1094
|
-
}
|
|
1095
|
-
|
|
1096
|
-
if (currentIdx === -1) currentIdx = items.length - 1
|
|
1110
|
+
const elements = Array.from(list.querySelectorAll('.comment-item'))
|
|
1111
|
+
if (elements.length === 0) return
|
|
1097
1112
|
|
|
1098
|
-
const
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1113
|
+
const viewportTop = list.getBoundingClientRect().top
|
|
1114
|
+
const measured = elements.map((el) => ({
|
|
1115
|
+
id: el.dataset.commentId,
|
|
1116
|
+
top: el.getBoundingClientRect().top,
|
|
1117
|
+
}))
|
|
1101
1118
|
|
|
1102
|
-
const targetIdx =
|
|
1119
|
+
const targetIdx = this.prevMsgNavigator.resolveTargetIndex(measured, viewportTop)
|
|
1103
1120
|
if (targetIdx < 0) {
|
|
1104
1121
|
if (!this.allOlderLoaded) {
|
|
1105
1122
|
this.loadOlderComments()
|
|
@@ -1107,8 +1124,9 @@ export default class extends Controller {
|
|
|
1107
1124
|
return
|
|
1108
1125
|
}
|
|
1109
1126
|
|
|
1110
|
-
const target =
|
|
1127
|
+
const target = elements[targetIdx]
|
|
1111
1128
|
const targetTop = target.offsetTop - list.offsetTop
|
|
1129
|
+
this.prevMsgNavigator.commit(measured[targetIdx].id, measured[targetIdx].top)
|
|
1112
1130
|
list.scrollTo({ top: targetTop, behavior: 'smooth' })
|
|
1113
1131
|
this.stickToBottom = false
|
|
1114
1132
|
|
|
@@ -1126,6 +1144,9 @@ export default class extends Controller {
|
|
|
1126
1144
|
}
|
|
1127
1145
|
|
|
1128
1146
|
scrollToBottom() {
|
|
1147
|
+
// A jump to latest we did not initiate via the button - drop the anchor so
|
|
1148
|
+
// the next previous-message click resolves from here, not a stale target.
|
|
1149
|
+
this.prevMsgNavigator?.notifyProgrammaticScroll()
|
|
1129
1150
|
// In column reverse, bottom of scroll might be tricky.
|
|
1130
1151
|
// Easiest is to set scrollTop to a large value.
|
|
1131
1152
|
requestAnimationFrame(() => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Tracks where the "previous message" button left off.
|
|
2
|
+
//
|
|
3
|
+
// The button scrolls with `behavior: 'smooth'`, so a rapid second click arrives
|
|
4
|
+
// while the list is still mid-animation. Deriving the current position purely
|
|
5
|
+
// from geometry at that moment picks the item we just left (it has not reached
|
|
6
|
+
// the top yet), which makes the list bounce up and down instead of walking
|
|
7
|
+
// backwards. Remembering the last target as an anchor keeps each click stepping
|
|
8
|
+
// one message further back.
|
|
9
|
+
//
|
|
10
|
+
// The anchor is dropped once the user moves the list themselves, so geometry
|
|
11
|
+
// takes over from wherever they landed. Invalidation is driven by input events
|
|
12
|
+
// rather than scroll events: a scroll event cannot be attributed to the user
|
|
13
|
+
// without guessing how long our own animation runs, and guessing short brings
|
|
14
|
+
// the bounce back. Dropping the anchor is always the safe direction — a settled
|
|
15
|
+
// list resolves correctly from geometry alone.
|
|
16
|
+
//
|
|
17
|
+
// Kept free of DOM access so it can be unit tested: jsdom has no layout engine,
|
|
18
|
+
// so the controller measures the items and passes `{ id, top }` pairs in.
|
|
19
|
+
|
|
20
|
+
// Sub-pixel scroll offsets (HiDPI, browser rounding) mean an item parked at the
|
|
21
|
+
// top is rarely at exactly `viewportTop`.
|
|
22
|
+
const TOP_TOLERANCE = 4
|
|
23
|
+
|
|
24
|
+
export default class PrevMessageNavigator {
|
|
25
|
+
constructor() {
|
|
26
|
+
this.anchorId = null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// items: `{ id, top }` in DOM order (oldest first), top relative to viewport.
|
|
30
|
+
// Returns the index to scroll to, or -1 when there is nothing older in the
|
|
31
|
+
// list (the caller then loads older comments).
|
|
32
|
+
resolveTargetIndex(items, viewportTop) {
|
|
33
|
+
if (items.length === 0) return -1
|
|
34
|
+
|
|
35
|
+
if (this.anchorId !== null) {
|
|
36
|
+
const anchorIdx = items.findIndex((item) => item.id === this.anchorId)
|
|
37
|
+
if (anchorIdx !== -1 && this.anchorIsOnItsWay(items[anchorIdx].top, viewportTop)) {
|
|
38
|
+
return anchorIdx - 1
|
|
39
|
+
}
|
|
40
|
+
this.anchorId = null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let currentIdx = items.findIndex((item) => item.top >= viewportTop - TOP_TOLERANCE)
|
|
44
|
+
if (currentIdx === -1) currentIdx = items.length - 1
|
|
45
|
+
|
|
46
|
+
const isAtTop = Math.abs(items[currentIdx].top - viewportTop) < TOP_TOLERANCE
|
|
47
|
+
return isAtTop ? currentIdx - 1 : currentIdx
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The anchor travels from wherever it sat when we committed to `viewportTop`,
|
|
51
|
+
// and nothing else moves it. Outside that corridor the list was scrolled by
|
|
52
|
+
// something other than us - a new message arriving, a permalink jump - none of
|
|
53
|
+
// which fire a user input event, so the anchor would otherwise survive and wedge
|
|
54
|
+
// the button (anchored on the oldest item, every click resolves to -1 forever).
|
|
55
|
+
//
|
|
56
|
+
// The corridor spans both endpoints because the anchor can approach the top from
|
|
57
|
+
// either side: usually from above (the next message back), but from below when
|
|
58
|
+
// the click started between messages and we pulled the partially visible one up.
|
|
59
|
+
//
|
|
60
|
+
// Comparing the anchor against its own start position rather than the list's
|
|
61
|
+
// scroll offset keeps this correct when older comments are prepended: that
|
|
62
|
+
// shifts `scrollTop` but leaves the anchor where it is on screen.
|
|
63
|
+
anchorIsOnItsWay(anchorTop, viewportTop) {
|
|
64
|
+
const low = Math.min(this.anchorStartTop, viewportTop) - TOP_TOLERANCE
|
|
65
|
+
const high = Math.max(this.anchorStartTop, viewportTop) + TOP_TOLERANCE
|
|
66
|
+
return anchorTop >= low && anchorTop <= high
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Called once we start scrolling towards `id`, which currently sits at `top`.
|
|
70
|
+
commit(id, top) {
|
|
71
|
+
this.anchorId = id
|
|
72
|
+
this.anchorStartTop = top
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Called when the user interacts with the list (wheel, touch, key, pointer).
|
|
76
|
+
notifyUserInput() {
|
|
77
|
+
this.anchorId = null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Called when our own code repositions the list without going through the
|
|
81
|
+
// button - jumping to the newest message, a permalink highlight. These fire no
|
|
82
|
+
// user input event, and a small jump can leave the anchor inside the corridor
|
|
83
|
+
// above, where geometry cannot tell it apart from our own in-flight scroll. The
|
|
84
|
+
// corridor still catches larger jumps, but the caller drops the anchor at these
|
|
85
|
+
// known sites so a still-visible message is never skipped.
|
|
86
|
+
notifyProgrammaticScroll() {
|
|
87
|
+
this.anchorId = null
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Called when the list contents are replaced wholesale.
|
|
91
|
+
reset() {
|
|
92
|
+
this.anchorId = null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -7,7 +7,7 @@ const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none
|
|
|
7
7
|
const ICON_RESTORE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7v6h6"/><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6.69 3L3 13"/></svg>`
|
|
8
8
|
|
|
9
9
|
export default class extends Controller {
|
|
10
|
-
static targets = ["list"]
|
|
10
|
+
static targets = ["list", "creationContainer"]
|
|
11
11
|
|
|
12
12
|
connect() {
|
|
13
13
|
this.topics = []
|
|
@@ -189,15 +189,37 @@ export default class extends Controller {
|
|
|
189
189
|
}
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
192
|
+
this.listTarget.innerHTML = html
|
|
193
|
+
|
|
194
|
+
// The create button lives outside the scrolling strip so it stays reachable
|
|
195
|
+
// without horizontal scrolling, no matter how many topics there are.
|
|
196
|
+
this.renderCreationContainer(canCreateTopic)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Write permission is sufficient for topic creation.
|
|
200
|
+
renderCreationContainer(canCreateTopic) {
|
|
201
|
+
if (!this.hasCreationContainerTarget) return
|
|
202
|
+
const container = this.creationContainerTarget
|
|
203
|
+
|
|
204
|
+
container.hidden = !canCreateTopic
|
|
205
|
+
if (!canCreateTopic) {
|
|
206
|
+
container.innerHTML = ''
|
|
207
|
+
return
|
|
198
208
|
}
|
|
199
209
|
|
|
200
|
-
|
|
210
|
+
// renderTopics re-runs on every topic broadcast; don't wipe a name being typed.
|
|
211
|
+
// `creating` marks an already-submitted name, whose input must give way to the button.
|
|
212
|
+
// A draft only survives re-renders of the creative it was typed for: chat-nav
|
|
213
|
+
// switches creatives without blurring, and posting it there is the wrong creative.
|
|
214
|
+
const draftIsCurrent = String(this._draftCreativeId) === String(this.creativeId)
|
|
215
|
+
if (!this.creating && draftIsCurrent && container.querySelector('.topic-input')) return
|
|
216
|
+
|
|
217
|
+
this.renderAddButton()
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
renderAddButton() {
|
|
221
|
+
this.creationContainerTarget.innerHTML =
|
|
222
|
+
`<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>`
|
|
201
223
|
}
|
|
202
224
|
|
|
203
225
|
handleDragOver(event) {
|
|
@@ -404,7 +426,7 @@ export default class extends Controller {
|
|
|
404
426
|
}
|
|
405
427
|
this.loadTopics()
|
|
406
428
|
} else {
|
|
407
|
-
alertDialog("
|
|
429
|
+
alertDialog(this._i18n("delete_error"))
|
|
408
430
|
}
|
|
409
431
|
} catch (e) {
|
|
410
432
|
console.error("Error deleting topic", e)
|
|
@@ -431,7 +453,7 @@ export default class extends Controller {
|
|
|
431
453
|
}
|
|
432
454
|
this.loadTopics()
|
|
433
455
|
} else {
|
|
434
|
-
alertDialog("
|
|
456
|
+
alertDialog(this._i18n("archive_error"))
|
|
435
457
|
}
|
|
436
458
|
} catch (e) {
|
|
437
459
|
console.error("Error archiving topic", e)
|
|
@@ -454,7 +476,7 @@ export default class extends Controller {
|
|
|
454
476
|
if (response.ok) {
|
|
455
477
|
this.loadTopics()
|
|
456
478
|
} else {
|
|
457
|
-
alertDialog("
|
|
479
|
+
alertDialog(this._i18n("restore_error"))
|
|
458
480
|
}
|
|
459
481
|
} catch (e) {
|
|
460
482
|
console.error("Error restoring topic", e)
|
|
@@ -468,11 +490,59 @@ export default class extends Controller {
|
|
|
468
490
|
this.restoreSelection()
|
|
469
491
|
}
|
|
470
492
|
|
|
493
|
+
openTopicListPopup(event) {
|
|
494
|
+
const btnRect = event.currentTarget.getBoundingClientRect()
|
|
495
|
+
|
|
496
|
+
const openWith = (popup) => {
|
|
497
|
+
popup.openForTopics(
|
|
498
|
+
{
|
|
499
|
+
topics: this.topics || [],
|
|
500
|
+
archivedTopics: this.archivedTopics || [],
|
|
501
|
+
mainTopicId: this.mainTopicId,
|
|
502
|
+
allMessagesLabel: this.element.dataset.topicMainText || 'All Messages'
|
|
503
|
+
},
|
|
504
|
+
btnRect,
|
|
505
|
+
(item) => this.selectTopic(item.id),
|
|
506
|
+
this.element
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
let modal = document.getElementById('topic-list-modal')
|
|
511
|
+
if (modal) {
|
|
512
|
+
const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
|
|
513
|
+
if (popup) openWith(popup)
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
modal = document.createElement('div')
|
|
518
|
+
modal.id = 'topic-list-modal'
|
|
519
|
+
modal.className = 'common-popup'
|
|
520
|
+
modal.style.display = 'none'
|
|
521
|
+
modal.dataset.controller = 'topic-list'
|
|
522
|
+
modal.innerHTML = `
|
|
523
|
+
<button type="button" class="popup-close-btn" data-topic-list-target="close">×</button>
|
|
524
|
+
<input type="text" class="shared-input-surface" style="width:100%;margin-bottom:0.5em;"
|
|
525
|
+
placeholder="${this.element.dataset.topicSearchPlaceholderText || 'Search topics...'}"
|
|
526
|
+
data-topic-list-target="input">
|
|
527
|
+
<ul class="common-popup-list" data-popup-list data-topic-list-target="list"></ul>
|
|
528
|
+
`
|
|
529
|
+
// Append into the chat box (this.element === #comments-popup) so the popup
|
|
530
|
+
// is caged within it and shares its stacking context.
|
|
531
|
+
this.element.appendChild(modal)
|
|
532
|
+
|
|
533
|
+
requestAnimationFrame(() => {
|
|
534
|
+
const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
|
|
535
|
+
if (popup) openWith(popup)
|
|
536
|
+
else console.error('topic-list controller not found after creation')
|
|
537
|
+
})
|
|
538
|
+
}
|
|
539
|
+
|
|
471
540
|
showInput(event) {
|
|
472
541
|
event.preventDefault()
|
|
473
|
-
|
|
474
|
-
|
|
542
|
+
if (!this.hasCreationContainerTarget) return
|
|
543
|
+
const container = this.creationContainerTarget
|
|
475
544
|
|
|
545
|
+
this._draftCreativeId = this.creativeId
|
|
476
546
|
const placeholder = this.listTarget.dataset.newTopicPlaceholder || "New Topic"
|
|
477
547
|
container.innerHTML = `<input type="text" class="topic-input" placeholder="${placeholder}"
|
|
478
548
|
data-action="keydown->comments--topics#handleInputKey blur->comments--topics#resetInput"
|
|
@@ -485,9 +555,8 @@ export default class extends Controller {
|
|
|
485
555
|
resetInput() {
|
|
486
556
|
// Small delay to allow enter key to process first if that was the cause
|
|
487
557
|
setTimeout(() => {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
container.innerHTML = `<button class="add-topic-btn" data-action="click->comments--topics#showInput">+</button>`
|
|
558
|
+
if (this.hasCreationContainerTarget && !this.creating) {
|
|
559
|
+
this.renderAddButton()
|
|
491
560
|
}
|
|
492
561
|
}, 200)
|
|
493
562
|
}
|
|
@@ -614,7 +683,7 @@ export default class extends Controller {
|
|
|
614
683
|
this.renderTopics(this.topics, this.canManageTopics, this.canCreateTopic)
|
|
615
684
|
this.restoreSelection()
|
|
616
685
|
} else {
|
|
617
|
-
alertDialog("
|
|
686
|
+
alertDialog(this._i18n("update_error"))
|
|
618
687
|
this.loadTopics() // Reload to restore state
|
|
619
688
|
}
|
|
620
689
|
} catch (e) {
|
|
@@ -705,7 +774,7 @@ export default class extends Controller {
|
|
|
705
774
|
// Dispatch change event manually since we skipped the click handler
|
|
706
775
|
this.dispatch("change", { detail: { topicId: topic.id, mainTopicId: this.mainTopicId } })
|
|
707
776
|
} else {
|
|
708
|
-
alertDialog("
|
|
777
|
+
alertDialog(this._i18n("create_error"))
|
|
709
778
|
}
|
|
710
779
|
} catch (e) {
|
|
711
780
|
console.error("Error creating topic", e)
|
|
@@ -953,6 +1022,21 @@ export default class extends Controller {
|
|
|
953
1022
|
}
|
|
954
1023
|
}
|
|
955
1024
|
|
|
1025
|
+
// Localized strings are handed down from the ERB partial as data
|
|
1026
|
+
// attributes; the English literals are last-resort fallbacks for when the
|
|
1027
|
+
// controller is mounted without them.
|
|
1028
|
+
_i18n(key) {
|
|
1029
|
+
const translations = {
|
|
1030
|
+
set_agent_error: this.element.dataset.topicSetAgentError || 'Unable to assign the agent to this topic.',
|
|
1031
|
+
create_error: this.element.dataset.topicCreateError || 'Unable to create the topic.',
|
|
1032
|
+
update_error: this.element.dataset.topicUpdateError || 'Unable to update the topic.',
|
|
1033
|
+
delete_error: this.element.dataset.topicDeleteError || 'Unable to delete the topic.',
|
|
1034
|
+
archive_error: this.element.dataset.topicArchiveError || 'Unable to archive the topic.',
|
|
1035
|
+
restore_error: this.element.dataset.topicRestoreError || 'Unable to restore the topic.'
|
|
1036
|
+
}
|
|
1037
|
+
return translations[key] || key
|
|
1038
|
+
}
|
|
1039
|
+
|
|
956
1040
|
async setTopicPrimaryAgent(topicId, agent) {
|
|
957
1041
|
if (!this.creativeId) return
|
|
958
1042
|
|
|
@@ -966,13 +1050,23 @@ export default class extends Controller {
|
|
|
966
1050
|
body: JSON.stringify({ agent_id: agent.id })
|
|
967
1051
|
})
|
|
968
1052
|
|
|
1053
|
+
const data = await response.json().catch(() => ({}))
|
|
1054
|
+
|
|
969
1055
|
if (!response.ok) {
|
|
970
|
-
|
|
971
|
-
|
|
1056
|
+
alertDialog(data.error || this._i18n("set_agent_error"))
|
|
1057
|
+
return
|
|
972
1058
|
}
|
|
973
|
-
|
|
1059
|
+
|
|
1060
|
+
// Render the avatar from the response rather than waiting for the
|
|
1061
|
+
// WebSocket broadcast. A dropped broadcast (e.g. the topics channel
|
|
1062
|
+
// subscription was refused) would otherwise leave the avatar
|
|
1063
|
+
// invisible until the next page load. The broadcast still runs and
|
|
1064
|
+
// propagates the change to other connected users; re-applying it
|
|
1065
|
+
// here is a no-op merge.
|
|
1066
|
+
if (data.topic) this.updateTopicInList(data.topic)
|
|
974
1067
|
} catch (e) {
|
|
975
1068
|
console.error('Error setting primary agent', e)
|
|
1069
|
+
alertDialog(this._i18n("set_agent_error"))
|
|
976
1070
|
}
|
|
977
1071
|
}
|
|
978
1072
|
|
|
@@ -22,6 +22,7 @@ import ClickTargetController from "./click_target_controller"
|
|
|
22
22
|
import TabsController from "./tabs_controller"
|
|
23
23
|
import LinkCreativeController from "./link_creative_controller"
|
|
24
24
|
import TopicSearchController from "./topic_search_controller"
|
|
25
|
+
import TopicListController from "./topic_list_controller"
|
|
25
26
|
import CommonPopupController from "./common_popup_controller"
|
|
26
27
|
import CommentController from "./comment_controller"
|
|
27
28
|
import ReactionPickerController from "./reaction_picker_controller"
|
|
@@ -59,6 +60,7 @@ export {
|
|
|
59
60
|
TabsController,
|
|
60
61
|
LinkCreativeController,
|
|
61
62
|
TopicSearchController,
|
|
63
|
+
TopicListController,
|
|
62
64
|
CommonPopupController,
|
|
63
65
|
CommentController,
|
|
64
66
|
ReactionPickerController,
|
|
@@ -98,6 +100,7 @@ export function registerControllers(application) {
|
|
|
98
100
|
application.register("tabs", TabsController)
|
|
99
101
|
application.register("link-creative", LinkCreativeController)
|
|
100
102
|
application.register("topic-search", TopicSearchController)
|
|
103
|
+
application.register("topic-list", TopicListController)
|
|
101
104
|
application.register("common-popup", CommonPopupController)
|
|
102
105
|
application.register("comment", CommentController)
|
|
103
106
|
application.register("reaction-picker", ReactionPickerController)
|
|
@@ -9,9 +9,14 @@ export default class extends Controller {
|
|
|
9
9
|
}
|
|
10
10
|
document.addEventListener('keydown', this._escHandler)
|
|
11
11
|
|
|
12
|
-
// Global keyboard shortcut: Cmd/Ctrl + K to open search
|
|
12
|
+
// Global keyboard shortcut: Cmd/Ctrl + K to open search.
|
|
13
|
+
// Skip when focus is inside an editable element (input, textarea,
|
|
14
|
+
// contenteditable, or the inline Lexical editor) so that element's own
|
|
15
|
+
// Ctrl+K behavior (e.g. delete-to-end-of-line) takes precedence over the
|
|
16
|
+
// global search popup.
|
|
13
17
|
this._shortcutHandler = (e) => {
|
|
14
18
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
19
|
+
if (this._isEditableTarget(e.target)) return
|
|
15
20
|
e.preventDefault()
|
|
16
21
|
this.toggle()
|
|
17
22
|
}
|
|
@@ -19,6 +24,17 @@ export default class extends Controller {
|
|
|
19
24
|
document.addEventListener('keydown', this._shortcutHandler)
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
_isEditableTarget(target) {
|
|
28
|
+
const el = target instanceof Element ? target : document.activeElement
|
|
29
|
+
if (!el || typeof el.closest !== 'function') return false
|
|
30
|
+
const tag = el.tagName
|
|
31
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
|
|
32
|
+
if (el.isContentEditable) return true
|
|
33
|
+
return Boolean(
|
|
34
|
+
el.closest('[contenteditable]:not([contenteditable="false"]), [data-lexical-editor-root]')
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
disconnect() {
|
|
23
39
|
document.removeEventListener('keydown', this._escHandler)
|
|
24
40
|
document.removeEventListener('keydown', this._shortcutHandler)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import CommonPopupController from './common_popup_controller'
|
|
2
|
+
|
|
3
|
+
const ICON_ARCHIVE = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="5" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>`
|
|
4
|
+
|
|
5
|
+
export default class extends CommonPopupController {
|
|
6
|
+
static targets = ['input', 'list', 'close']
|
|
7
|
+
|
|
8
|
+
connect() {
|
|
9
|
+
super.connect()
|
|
10
|
+
this._allItems = []
|
|
11
|
+
this._onInputBound = this._onInput.bind(this)
|
|
12
|
+
this._onKeydownBound = this.handleInputKeydown.bind(this)
|
|
13
|
+
this._onCloseBound = () => this.close()
|
|
14
|
+
this.inputTarget.addEventListener('input', this._onInputBound)
|
|
15
|
+
this.inputTarget.addEventListener('keydown', this._onKeydownBound)
|
|
16
|
+
this.closeTarget.addEventListener('click', this._onCloseBound)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
disconnect() {
|
|
20
|
+
this.inputTarget?.removeEventListener('input', this._onInputBound)
|
|
21
|
+
this.inputTarget?.removeEventListener('keydown', this._onKeydownBound)
|
|
22
|
+
this.closeTarget?.removeEventListener('click', this._onCloseBound)
|
|
23
|
+
super.disconnect()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
openForTopics({ topics = [], archivedTopics = [], mainTopicId = null, allMessagesLabel = 'All Messages' }, anchorRect, onSelectCallback, boundsElement = null) {
|
|
27
|
+
this.onSelectCallback = onSelectCallback
|
|
28
|
+
this._allItems = this._buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel })
|
|
29
|
+
this.inputTarget.value = ''
|
|
30
|
+
this.setItems(this._allItems)
|
|
31
|
+
super.open(anchorRect, boundsElement)
|
|
32
|
+
requestAnimationFrame(() => this.inputTarget.focus())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_buildItems({ topics, archivedTopics, mainTopicId, allMessagesLabel }) {
|
|
36
|
+
const main = mainTopicId ? topics.find(t => String(t.id) === String(mainTopicId)) : null
|
|
37
|
+
const others = topics.filter(t => !main || String(t.id) !== String(mainTopicId))
|
|
38
|
+
const items = []
|
|
39
|
+
if (main) items.push({ id: main.id, label: `#${main.name}`, archived: false })
|
|
40
|
+
others.forEach(t => items.push({ id: t.id, label: `#${t.name}`, archived: false }))
|
|
41
|
+
items.push({ id: '', label: `📋 ${allMessagesLabel}`, archived: false })
|
|
42
|
+
archivedTopics.forEach(t => items.push({ id: t.id, label: `#${t.name}`, archived: true }))
|
|
43
|
+
return items
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_onInput() {
|
|
47
|
+
const q = this.inputTarget.value.toLowerCase().trim()
|
|
48
|
+
if (!q) { this.setItems(this._allItems); return }
|
|
49
|
+
const filtered = this._allItems.filter(i => (i.label || '').toLowerCase().includes(q))
|
|
50
|
+
this.setItems(filtered)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
handleInputKeydown(event) {
|
|
54
|
+
if (this.handleKey(event)) return
|
|
55
|
+
if (event.key === 'Escape') this.close()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
select(item) {
|
|
59
|
+
if (this.onSelectCallback) this.onSelectCallback(item)
|
|
60
|
+
this.close()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
renderItem(item) {
|
|
64
|
+
const escaped = String(item.label || '')
|
|
65
|
+
.replace(/&/g, '&')
|
|
66
|
+
.replace(/</g, '<')
|
|
67
|
+
.replace(/>/g, '>')
|
|
68
|
+
if (item.archived) {
|
|
69
|
+
return `<span class="topic-list-item topic-list-item--archived">${ICON_ARCHIVE} ${escaped}</span>`
|
|
70
|
+
}
|
|
71
|
+
return `<span class="topic-list-item">${escaped}</span>`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
dispatchClose(reason) {
|
|
75
|
+
this.onSelectCallback = null
|
|
76
|
+
super.dispatchClose(reason)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import CommonPopup from '../common_popup'
|
|
5
|
+
|
|
6
|
+
// Cage-inside-bounds positioning: when a boundsElement is passed to showAt,
|
|
7
|
+
// updatePosition must clamp the popup within that element's rect (not the
|
|
8
|
+
// viewport) and cap its size so a long list scrolls inside instead of spilling.
|
|
9
|
+
describe('CommonPopup bounds clamping', () => {
|
|
10
|
+
const BOUNDS = { left: 100, top: 100, right: 500, bottom: 700, width: 400, height: 600 }
|
|
11
|
+
|
|
12
|
+
let element, boundsElement
|
|
13
|
+
|
|
14
|
+
const build = ({ popupWidth, popupHeight }) => {
|
|
15
|
+
boundsElement = document.createElement('div')
|
|
16
|
+
boundsElement.getBoundingClientRect = () => BOUNDS
|
|
17
|
+
|
|
18
|
+
element = document.createElement('div')
|
|
19
|
+
// offsetParent is the bounds (chat box); parentRect === BOUNDS.
|
|
20
|
+
Object.defineProperty(element, 'offsetParent', { value: boundsElement, configurable: true })
|
|
21
|
+
Object.defineProperty(element, 'offsetWidth', { value: popupWidth, configurable: true })
|
|
22
|
+
Object.defineProperty(element, 'offsetHeight', { value: popupHeight, configurable: true })
|
|
23
|
+
document.body.appendChild(element)
|
|
24
|
+
return new CommonPopup(element, { listElement: document.createElement('ul') })
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
afterEach(() => { document.body.innerHTML = '' })
|
|
28
|
+
|
|
29
|
+
test('clamps an anchor near the right/bottom edge back inside the bounds', () => {
|
|
30
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
31
|
+
popup._boundsElement = boundsElement
|
|
32
|
+
// Anchor button hard against the right edge — would overflow the box.
|
|
33
|
+
// maxLeft = right - width - pad = 500 - 320 - 8 = 172; parent coords: 172 - 100 = 72
|
|
34
|
+
popup.updatePosition({ left: 450, bottom: 130 })
|
|
35
|
+
expect(element.style.left).toBe('72px')
|
|
36
|
+
// viewportTop = 130 + 4 = 134; within [108, 292] → 134; parent coords: 134 - 100 = 34
|
|
37
|
+
expect(element.style.top).toBe('34px')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test('caps max size to fit inside the bounds', () => {
|
|
41
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
42
|
+
popup._boundsElement = boundsElement
|
|
43
|
+
popup.updatePosition({ left: 200, bottom: 200 })
|
|
44
|
+
// inner box = dimension - 2*pad
|
|
45
|
+
expect(element.style.maxWidth).toBe('384px')
|
|
46
|
+
expect(element.style.maxHeight).toBe('584px')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('falls back to viewport clamping when no bounds element is set', () => {
|
|
50
|
+
const popup = build({ popupWidth: 320, popupHeight: 400 })
|
|
51
|
+
popup._boundsElement = null
|
|
52
|
+
// jsdom viewport defaults 1024x768; anchor at 0 → left clamps to padding 8, parent 0
|
|
53
|
+
Object.defineProperty(element, 'offsetParent', { value: null, configurable: true })
|
|
54
|
+
popup.updatePosition({ left: 0, bottom: 0 })
|
|
55
|
+
expect(element.style.maxHeight).toBe('')
|
|
56
|
+
expect(element.style.left).toBe('8px')
|
|
57
|
+
})
|
|
58
|
+
})
|