collavre 0.23.0 → 0.24.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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/app/assets/images/collavre/landing/interface-en-dark.png +0 -0
  3. data/app/assets/images/collavre/landing/interface-en.png +0 -0
  4. data/app/assets/images/collavre/landing/interface-ko-dark.png +0 -0
  5. data/app/assets/images/collavre/landing/interface-ko.png +0 -0
  6. data/app/assets/stylesheets/collavre/comments_popup.css +40 -0
  7. data/app/assets/stylesheets/collavre/landing.css +92 -0
  8. data/app/assets/stylesheets/collavre/popup.css +4 -2
  9. data/app/controllers/collavre/admin/settings_controller.rb +52 -67
  10. data/app/controllers/collavre/api/v1/agents_controller.rb +17 -188
  11. data/app/controllers/collavre/api/v1/base_controller.rb +9 -27
  12. data/app/controllers/collavre/attachments_controller.rb +13 -3
  13. data/app/controllers/collavre/concerns/slide_viewable.rb +8 -32
  14. data/app/controllers/collavre/creatives_controller.rb +32 -99
  15. data/app/controllers/collavre/inbox_items_controller.rb +10 -4
  16. data/app/controllers/collavre/landing_controller.rb +5 -2
  17. data/app/controllers/collavre/topics_controller.rb +5 -0
  18. data/app/controllers/concerns/collavre/api/authenticatable.rb +40 -0
  19. data/app/errors/collavre/api_error.rb +20 -0
  20. data/app/helpers/collavre/creatives_helper.rb +30 -8
  21. data/app/javascript/components/InlineLexicalEditor.jsx +2 -34
  22. data/app/javascript/components/__tests__/creative_tree_row_parent_id.test.js +87 -0
  23. data/app/javascript/components/creative_tree_row.js +8 -2
  24. data/app/javascript/components/plugins/markdown_shortcuts_plugin.jsx +15 -4
  25. data/app/javascript/controllers/__tests__/search_popup_controller.test.js +104 -0
  26. data/app/javascript/controllers/__tests__/topic_list_controller.test.js +82 -0
  27. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  28. data/app/javascript/controllers/comments/topics_controller.js +47 -0
  29. data/app/javascript/controllers/common_popup_controller.js +2 -2
  30. data/app/javascript/controllers/index.js +3 -0
  31. data/app/javascript/controllers/search_popup_controller.js +17 -1
  32. data/app/javascript/controllers/topic_list_controller.js +78 -0
  33. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  34. data/app/javascript/lib/common_popup.js +24 -5
  35. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  36. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  37. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  38. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  39. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  40. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  41. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  42. data/app/javascript/modules/creative_inline_payload.js +86 -0
  43. data/app/javascript/modules/creative_row_editor.js +121 -401
  44. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  45. data/app/javascript/modules/creative_save_queue.js +95 -0
  46. data/app/javascript/modules/creative_tree_dom.js +260 -0
  47. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  48. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  49. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  50. data/app/models/collavre/channel.rb +16 -0
  51. data/app/models/collavre/comment/approvable.rb +16 -0
  52. data/app/models/collavre/comment/notifiable.rb +1 -1
  53. data/app/models/collavre/comment.rb +8 -1
  54. data/app/models/collavre/comment_version.rb +1 -1
  55. data/app/models/collavre/creative/describable.rb +15 -9
  56. data/app/models/collavre/creative/linkable.rb +9 -0
  57. data/app/models/collavre/creative/permissible.rb +97 -49
  58. data/app/models/collavre/creative.rb +73 -5
  59. data/app/models/collavre/creative_share.rb +78 -26
  60. data/app/models/collavre/inbox_item.rb +2 -2
  61. data/app/models/collavre/task.rb +12 -0
  62. data/app/models/collavre/user.rb +33 -9
  63. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  64. data/app/services/collavre/agent_type_classifier.rb +23 -0
  65. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  66. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  67. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  68. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  69. data/app/services/collavre/ai_client.rb +53 -2
  70. data/app/services/collavre/auto_theme_generator.rb +14 -10
  71. data/app/services/collavre/creatives/children_index.rb +86 -0
  72. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  73. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  74. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  75. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  76. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  77. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  78. data/app/services/collavre/http_client.rb +119 -0
  79. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  80. data/app/services/collavre/markdown_importer.rb +30 -6
  81. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  82. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  83. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  84. data/app/services/collavre/system_events/context_builder.rb +1 -14
  85. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  86. data/app/views/collavre/comments/_comment.html.erb +4 -4
  87. data/app/views/collavre/comments/_comments_popup.html.erb +11 -3
  88. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  89. data/app/views/collavre/landing/show.html.erb +52 -10
  90. data/config/locales/comments.en.yml +1 -0
  91. data/config/locales/comments.ko.yml +1 -0
  92. data/config/locales/creatives.en.yml +6 -0
  93. data/config/locales/creatives.ko.yml +5 -0
  94. data/config/locales/landing.en.yml +22 -10
  95. data/config/locales/landing.ko.yml +22 -10
  96. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  97. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  98. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  99. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  100. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  101. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  102. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  103. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  104. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  105. data/lib/collavre/version.rb +1 -1
  106. metadata +42 -1
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import { Application } from '@hotwired/stimulus'
6
+
7
+ const { default: SearchPopupController } = await import('../search_popup_controller')
8
+
9
+ describe('SearchPopupController Ctrl+K shortcut', () => {
10
+ let application
11
+ let container
12
+ let popup
13
+
14
+ function pressCtrlK(target) {
15
+ const event = new KeyboardEvent('keydown', {
16
+ key: 'k',
17
+ ctrlKey: true,
18
+ bubbles: true,
19
+ cancelable: true
20
+ })
21
+ target.dispatchEvent(event)
22
+ return event
23
+ }
24
+
25
+ beforeEach(async () => {
26
+ document.body.innerHTML = ''
27
+
28
+ container = document.createElement('div')
29
+ container.innerHTML = `
30
+ <div data-controller="search-popup">
31
+ <input type="text" data-search-popup-target="input" />
32
+ <div data-search-popup-target="popup"></div>
33
+ </div>
34
+ `
35
+ document.body.appendChild(container)
36
+
37
+ application = Application.start()
38
+ application.register('search-popup', SearchPopupController)
39
+ await new Promise(resolve => setTimeout(resolve, 0))
40
+
41
+ popup = container.querySelector('[data-search-popup-target="popup"]')
42
+ })
43
+
44
+ afterEach(() => {
45
+ application?.stop()
46
+ document.body.innerHTML = ''
47
+ jest.restoreAllMocks()
48
+ })
49
+
50
+ test('opens the popup on Ctrl+K when focus is not in an editable element', () => {
51
+ const event = pressCtrlK(document.body)
52
+
53
+ expect(popup.classList.contains('open')).toBe(true)
54
+ expect(event.defaultPrevented).toBe(true)
55
+ })
56
+
57
+ test('does not open the popup when Ctrl+K is pressed inside a text input', () => {
58
+ const input = document.createElement('input')
59
+ input.type = 'text'
60
+ document.body.appendChild(input)
61
+ input.focus()
62
+
63
+ const event = pressCtrlK(input)
64
+
65
+ expect(popup.classList.contains('open')).toBe(false)
66
+ expect(event.defaultPrevented).toBe(false)
67
+ })
68
+
69
+ test('does not open the popup when Ctrl+K is pressed inside a textarea', () => {
70
+ const textarea = document.createElement('textarea')
71
+ document.body.appendChild(textarea)
72
+ textarea.focus()
73
+
74
+ const event = pressCtrlK(textarea)
75
+
76
+ expect(popup.classList.contains('open')).toBe(false)
77
+ expect(event.defaultPrevented).toBe(false)
78
+ })
79
+
80
+ test('does not open the popup when Ctrl+K is pressed inside a contenteditable element', () => {
81
+ const editable = document.createElement('div')
82
+ editable.setAttribute('contenteditable', 'true')
83
+ document.body.appendChild(editable)
84
+ editable.focus()
85
+
86
+ const event = pressCtrlK(editable)
87
+
88
+ expect(popup.classList.contains('open')).toBe(false)
89
+ expect(event.defaultPrevented).toBe(false)
90
+ })
91
+
92
+ test('does not open the popup when Ctrl+K is pressed inside the inline Lexical editor', () => {
93
+ const lexicalRoot = document.createElement('div')
94
+ lexicalRoot.setAttribute('data-lexical-editor-root', '')
95
+ const inner = document.createElement('p')
96
+ lexicalRoot.appendChild(inner)
97
+ document.body.appendChild(lexicalRoot)
98
+
99
+ const event = pressCtrlK(inner)
100
+
101
+ expect(popup.classList.contains('open')).toBe(false)
102
+ expect(event.defaultPrevented).toBe(false)
103
+ })
104
+ })
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import { Application } from '@hotwired/stimulus'
6
+ import TopicListController from '../topic_list_controller'
7
+
8
+ describe('TopicListController', () => {
9
+ let application, controller
10
+
11
+ const mount = () => {
12
+ document.body.innerHTML = `
13
+ <div id="topic-list-modal" class="common-popup" data-controller="topic-list">
14
+ <button data-topic-list-target="close">×</button>
15
+ <input data-topic-list-target="input">
16
+ <ul class="common-popup-list" data-popup-list data-topic-list-target="list"></ul>
17
+ </div>
18
+ `
19
+ application = Application.start()
20
+ application.register('topic-list', TopicListController)
21
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
22
+ controller = application.getControllerForElementAndIdentifier(
23
+ document.getElementById('topic-list-modal'), 'topic-list'
24
+ )
25
+ })
26
+ }
27
+
28
+ const RECT = { top: 0, left: 0, bottom: 0, right: 0, width: 0, height: 0 }
29
+ const DATA = {
30
+ topics: [{ id: 1, name: 'Main' }, { id: 2, name: 'Alpha' }],
31
+ archivedTopics: [{ id: 3, name: 'Zeta' }],
32
+ mainTopicId: '1',
33
+ allMessagesLabel: 'All Messages'
34
+ }
35
+
36
+ const items = () => Array.from(document.querySelectorAll('#topic-list-modal li.common-popup-item'))
37
+
38
+ beforeEach(() => {
39
+ global.requestAnimationFrame = (fn) => { fn(); return 0 }
40
+ return mount()
41
+ })
42
+
43
+ afterEach(() => {
44
+ document.body.innerHTML = ''
45
+ application.stop()
46
+ jest.clearAllMocks()
47
+ })
48
+
49
+ test('builds items in bar order: main, others, All Messages, archived', () => {
50
+ controller.openForTopics(DATA, RECT, () => {})
51
+ const labels = items().map((li) => li.textContent.trim())
52
+ expect(labels).toEqual(['#Main', '#Alpha', '📋 All Messages', '#Zeta'])
53
+ })
54
+
55
+ test('marks archived items with the distinguishing class', () => {
56
+ controller.openForTopics(DATA, RECT, () => {})
57
+ const archived = items().filter((li) => li.querySelector('.topic-list-item--archived'))
58
+ expect(archived).toHaveLength(1)
59
+ expect(archived[0].textContent).toContain('#Zeta')
60
+ })
61
+
62
+ test('filters by label substring and adds NO create option', () => {
63
+ controller.openForTopics(DATA, RECT, () => {})
64
+ controller.inputTarget.value = 'alpha'
65
+ controller._onInput()
66
+ const labels = items().map((li) => li.textContent.trim())
67
+ expect(labels).toEqual(['#Alpha'])
68
+
69
+ controller.inputTarget.value = 'brandnew'
70
+ controller._onInput()
71
+ expect(items()).toHaveLength(0) // no "create and move" pseudo-item
72
+ })
73
+
74
+ test('select invokes the callback with the item and closes', () => {
75
+ const cb = jest.fn()
76
+ controller.openForTopics(DATA, RECT, cb)
77
+ const item = { id: 2, label: '#Alpha', archived: false }
78
+ controller.select(item)
79
+ expect(cb).toHaveBeenCalledWith(item)
80
+ expect(controller.popup.isOpen()).toBe(false)
81
+ })
82
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import { jest } from '@jest/globals'
5
+ import { Application } from '@hotwired/stimulus'
6
+ import TopicsController from '../topics_controller'
7
+
8
+ describe('TopicsController#openTopicListPopup', () => {
9
+ let application, controller
10
+
11
+ beforeEach(() => {
12
+ global.requestAnimationFrame = (fn) => { fn(); return 0 }
13
+ document.body.innerHTML = `
14
+ <div id="comments-popup" data-controller="comments--topics"
15
+ data-topic-main-text="All Messages"
16
+ data-topic-search-placeholder-text="Search topics...">
17
+ <div data-comments--topics-target="list"></div>
18
+ </div>
19
+ `
20
+ application = Application.start()
21
+ application.register('comments--topics', TopicsController)
22
+ return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
23
+ controller = application.getControllerForElementAndIdentifier(
24
+ document.getElementById('comments-popup'), 'comments--topics'
25
+ )
26
+ controller.topics = [{ id: 2, name: 'Alpha' }]
27
+ controller.archivedTopics = [{ id: 3, name: 'Zeta' }]
28
+ controller.mainTopicId = null
29
+ })
30
+ })
31
+
32
+ afterEach(() => {
33
+ document.body.innerHTML = ''
34
+ application.stop()
35
+ jest.clearAllMocks()
36
+ })
37
+
38
+ test('creates the topic-list modal with the required targets', () => {
39
+ const btn = document.createElement('button')
40
+ document.body.appendChild(btn)
41
+ controller.openTopicListPopup({ currentTarget: btn })
42
+
43
+ const modal = document.getElementById('topic-list-modal')
44
+ expect(modal).not.toBeNull()
45
+ expect(modal.dataset.controller).toBe('topic-list')
46
+ expect(modal.querySelector('[data-topic-list-target="input"]')).not.toBeNull()
47
+ expect(modal.querySelector('[data-topic-list-target="list"]')).not.toBeNull()
48
+ expect(modal.querySelector('[data-topic-list-target="close"]')).not.toBeNull()
49
+ expect(modal.querySelector('input').placeholder).toBe('Search topics...')
50
+ })
51
+
52
+ test('appends the modal inside the chat box so it is caged within it', () => {
53
+ const btn = document.createElement('button')
54
+ document.body.appendChild(btn)
55
+ controller.openTopicListPopup({ currentTarget: btn })
56
+
57
+ const modal = document.getElementById('topic-list-modal')
58
+ // Bounded to the chat popup, not body-level.
59
+ expect(modal.parentElement).toBe(document.getElementById('comments-popup'))
60
+ })
61
+ })
@@ -468,6 +468,53 @@ export default class extends Controller {
468
468
  this.restoreSelection()
469
469
  }
470
470
 
471
+ openTopicListPopup(event) {
472
+ const btnRect = event.currentTarget.getBoundingClientRect()
473
+
474
+ const openWith = (popup) => {
475
+ popup.openForTopics(
476
+ {
477
+ topics: this.topics || [],
478
+ archivedTopics: this.archivedTopics || [],
479
+ mainTopicId: this.mainTopicId,
480
+ allMessagesLabel: this.element.dataset.topicMainText || 'All Messages'
481
+ },
482
+ btnRect,
483
+ (item) => this.selectTopic(item.id),
484
+ this.element
485
+ )
486
+ }
487
+
488
+ let modal = document.getElementById('topic-list-modal')
489
+ if (modal) {
490
+ const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
491
+ if (popup) openWith(popup)
492
+ return
493
+ }
494
+
495
+ modal = document.createElement('div')
496
+ modal.id = 'topic-list-modal'
497
+ modal.className = 'common-popup'
498
+ modal.style.display = 'none'
499
+ modal.dataset.controller = 'topic-list'
500
+ modal.innerHTML = `
501
+ <button type="button" class="popup-close-btn" data-topic-list-target="close">&times;</button>
502
+ <input type="text" class="shared-input-surface" style="width:100%;margin-bottom:0.5em;"
503
+ placeholder="${this.element.dataset.topicSearchPlaceholderText || 'Search topics...'}"
504
+ data-topic-list-target="input">
505
+ <ul class="common-popup-list" data-popup-list data-topic-list-target="list"></ul>
506
+ `
507
+ // Append into the chat box (this.element === #comments-popup) so the popup
508
+ // is caged within it and shares its stacking context.
509
+ this.element.appendChild(modal)
510
+
511
+ requestAnimationFrame(() => {
512
+ const popup = this.application.getControllerForElementAndIdentifier(modal, 'topic-list')
513
+ if (popup) openWith(popup)
514
+ else console.error('topic-list controller not found after creation')
515
+ })
516
+ }
517
+
471
518
  showInput(event) {
472
519
  event.preventDefault()
473
520
  const container = this.element.querySelector('[data-comments--topics-target="creationContainer"]')
@@ -22,8 +22,8 @@ export default class extends Controller {
22
22
  this.popup = null
23
23
  }
24
24
 
25
- open(anchorRect) {
26
- this.popup.showAt(anchorRect)
25
+ open(anchorRect, boundsElement = null) {
26
+ this.popup.showAt(anchorRect, boundsElement)
27
27
  }
28
28
 
29
29
  close() {
@@ -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, '&amp;')
66
+ .replace(/</g, '&lt;')
67
+ .replace(/>/g, '&gt;')
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
+ })
@@ -12,9 +12,13 @@ export default class CommonPopup {
12
12
  this.handleOutsideClick = this.handleOutsideClick.bind(this)
13
13
  }
14
14
 
15
- showAt(anchorRect) {
15
+ showAt(anchorRect, boundsElement = null) {
16
16
  if (!this.element) return
17
17
 
18
+ // When set, the popup is caged inside this element's rect (e.g. the chat
19
+ // box) instead of the viewport — see updatePosition.
20
+ this._boundsElement = boundsElement
21
+
18
22
  // Re-opening while already open (e.g. clicking the same typo mark twice):
19
23
  // a listener from the previous open is still live, so the opening mousedown
20
24
  // would bubble to it and hide() the popup right after we set display:block,
@@ -58,11 +62,26 @@ export default class CommonPopup {
58
62
  let viewportTop = (rect?.bottom || 0) + 4
59
63
 
60
64
  const { offsetWidth: width, offsetHeight: height } = this.element
61
- const maxLeft = window.innerWidth - width - boundsPadding
62
- const maxTop = window.innerHeight - height - boundsPadding
63
65
 
64
- viewportLeft = Math.max(boundsPadding, Math.min(viewportLeft, maxLeft))
65
- viewportTop = Math.max(boundsPadding, Math.min(viewportTop, maxTop))
66
+ // Clamp within a container's rect when bounded (keeps the popup caged inside
67
+ // the chat box), otherwise within the viewport.
68
+ const bounds = this._boundsElement?.getBoundingClientRect?.()
69
+ let minLeft = boundsPadding
70
+ let minTop = boundsPadding
71
+ let maxLeft = window.innerWidth - width - boundsPadding
72
+ let maxTop = window.innerHeight - height - boundsPadding
73
+ if (bounds) {
74
+ minLeft = bounds.left + boundsPadding
75
+ minTop = bounds.top + boundsPadding
76
+ maxLeft = bounds.right - width - boundsPadding
77
+ maxTop = bounds.bottom - height - boundsPadding
78
+ // Cap size so a long list scrolls inside the box instead of spilling past it.
79
+ this.element.style.maxWidth = `${bounds.width - boundsPadding * 2}px`
80
+ this.element.style.maxHeight = `${bounds.height - boundsPadding * 2}px`
81
+ }
82
+
83
+ viewportLeft = Math.max(minLeft, Math.min(viewportLeft, maxLeft))
84
+ viewportTop = Math.max(minTop, Math.min(viewportTop, maxTop))
66
85
 
67
86
  const left = viewportLeft - parentRect.left + parentScrollX
68
87
  const top = viewportTop - parentRect.top + parentScrollY