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.
Files changed (115) 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 +45 -17
  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__/form_controller_review.test.js +26 -0
  28. data/app/javascript/controllers/comments/__tests__/list_controller_prev_message.test.js +230 -0
  29. data/app/javascript/controllers/comments/__tests__/prev_message_navigator.test.js +129 -0
  30. data/app/javascript/controllers/comments/__tests__/topics_controller_action_errors.test.js +178 -0
  31. data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js +154 -0
  32. data/app/javascript/controllers/comments/__tests__/topics_controller_primary_agent.test.js +146 -0
  33. data/app/javascript/controllers/comments/__tests__/topics_controller_topic_list.test.js +61 -0
  34. data/app/javascript/controllers/comments/form_controller.js +3 -0
  35. data/app/javascript/controllers/comments/list_controller.js +42 -21
  36. data/app/javascript/controllers/comments/prev_message_navigator.js +94 -0
  37. data/app/javascript/controllers/comments/topics_controller.js +115 -21
  38. data/app/javascript/controllers/common_popup_controller.js +2 -2
  39. data/app/javascript/controllers/index.js +3 -0
  40. data/app/javascript/controllers/search_popup_controller.js +17 -1
  41. data/app/javascript/controllers/topic_list_controller.js +78 -0
  42. data/app/javascript/lib/__tests__/common_popup_bounds.test.js +58 -0
  43. data/app/javascript/lib/common_popup.js +24 -5
  44. data/app/javascript/lib/lexical/__tests__/code_block_toggle.test.js +326 -0
  45. data/app/javascript/lib/lexical/__tests__/code_fence_shortcut.test.js +179 -0
  46. data/app/javascript/lib/lexical/code_block_toggle.js +130 -0
  47. data/app/javascript/lib/lexical/code_fence_shortcut.js +60 -0
  48. data/app/javascript/modules/__tests__/creative_inline_payload.test.js +231 -0
  49. data/app/javascript/modules/__tests__/creative_save_queue.test.js +228 -0
  50. data/app/javascript/modules/__tests__/creative_tree_dom.test.js +425 -0
  51. data/app/javascript/modules/creative_inline_payload.js +86 -0
  52. data/app/javascript/modules/creative_row_editor.js +121 -401
  53. data/app/javascript/modules/creative_row_editor_helpers.js +76 -0
  54. data/app/javascript/modules/creative_save_queue.js +95 -0
  55. data/app/javascript/modules/creative_tree_dom.js +260 -0
  56. data/app/jobs/collavre/permission_cache_job.rb +20 -2
  57. data/app/jobs/collavre/trigger_loop_check_job.rb +20 -3
  58. data/app/jobs/collavre/update_mcp_tools_job.rb +17 -0
  59. data/app/models/collavre/channel.rb +16 -0
  60. data/app/models/collavre/comment/approvable.rb +16 -0
  61. data/app/models/collavre/comment/notifiable.rb +1 -1
  62. data/app/models/collavre/comment.rb +8 -1
  63. data/app/models/collavre/comment_version.rb +1 -1
  64. data/app/models/collavre/creative/describable.rb +15 -9
  65. data/app/models/collavre/creative/linkable.rb +9 -0
  66. data/app/models/collavre/creative/permissible.rb +97 -49
  67. data/app/models/collavre/creative.rb +73 -5
  68. data/app/models/collavre/creative_share.rb +78 -26
  69. data/app/models/collavre/inbox_item.rb +2 -2
  70. data/app/models/collavre/task.rb +12 -0
  71. data/app/models/collavre/user.rb +33 -9
  72. data/app/models/concerns/collavre/indexed_json_columns.rb +58 -0
  73. data/app/services/collavre/agent_type_classifier.rb +23 -0
  74. data/app/services/collavre/ai_agent/a2a_dispatcher.rb +4 -0
  75. data/app/services/collavre/ai_agent/message_builder.rb +1 -1
  76. data/app/services/collavre/ai_agent/session_provisioner.rb +126 -0
  77. data/app/services/collavre/ai_agent/task_claim_service.rb +96 -0
  78. data/app/services/collavre/ai_client.rb +53 -2
  79. data/app/services/collavre/auto_theme_generator.rb +14 -10
  80. data/app/services/collavre/creatives/children_index.rb +86 -0
  81. data/app/services/collavre/creatives/comment_badge_index.rb +73 -0
  82. data/app/services/collavre/creatives/creative_tree_serializer.rb +122 -0
  83. data/app/services/collavre/creatives/effective_creative_resolution.rb +37 -0
  84. data/app/services/collavre/creatives/permission_checker.rb +1 -1
  85. data/app/services/collavre/creatives/permission_filter.rb +144 -17
  86. data/app/services/collavre/creatives/tree_builder.rb +105 -77
  87. data/app/services/collavre/http_client.rb +119 -0
  88. data/app/services/collavre/link_preview_fetcher.rb +129 -38
  89. data/app/services/collavre/markdown_importer.rb +30 -6
  90. data/app/services/collavre/mobile/event_summarizer.rb +1 -1
  91. data/app/services/collavre/orchestration/agent_context_builder.rb +1 -15
  92. data/app/services/collavre/orchestration/agent_orchestrator.rb +1 -1
  93. data/app/services/collavre/system_events/context_builder.rb +1 -14
  94. data/app/services/collavre/tools/preview_attach_service.rb +5 -7
  95. data/app/views/collavre/comments/_comment.html.erb +4 -4
  96. data/app/views/collavre/comments/_comments_popup.html.erb +19 -3
  97. data/app/views/collavre/creatives/_inline_edit_form.html.erb +9 -0
  98. data/app/views/collavre/landing/show.html.erb +52 -10
  99. data/config/locales/comments.en.yml +7 -0
  100. data/config/locales/comments.ko.yml +7 -0
  101. data/config/locales/creatives.en.yml +6 -0
  102. data/config/locales/creatives.ko.yml +5 -0
  103. data/config/locales/landing.en.yml +22 -10
  104. data/config/locales/landing.ko.yml +22 -10
  105. data/db/migrate/20260120045354_encrypt_oauth_tokens.rb +13 -4
  106. data/db/migrate/20260702000001_enforce_system_settings_key_not_null.rb +9 -0
  107. data/db/migrate/20260702000002_add_creative_index_and_fk_to_tags.rb +11 -0
  108. data/db/migrate/20260702000003_add_creative_type_index_to_labels.rb +10 -0
  109. data/db/migrate/20260702000004_add_creative_created_at_index_to_comments.rb +10 -0
  110. data/db/migrate/20260702000005_promote_channel_config_index_columns.rb +76 -0
  111. data/db/migrate/20260713000001_add_comments_count_to_creatives.rb +20 -0
  112. data/db/migrate/20260713000002_add_comment_versions_count_to_comments.rb +20 -0
  113. data/db/migrate/20260715000000_change_creatives_description_to_plain_text.rb +16 -0
  114. data/lib/collavre/version.rb +1 -1
  115. metadata +48 -1
@@ -6,16 +6,19 @@ import {
6
6
  ORDERED_LIST,
7
7
  CODE
8
8
  } from "@lexical/markdown"
9
+ import { registerCodeFenceShortcut } from "../../lib/lexical/code_fence_shortcut"
9
10
 
10
11
  /**
11
12
  * Markdown-style shortcuts for the creative inline editor:
12
13
  *
13
14
  * - "* " / "- " / "+ " at line start → unordered list
14
15
  * - "1. " (any number) at line start → ordered list
15
- * - "```" + space at line start → code block
16
+ * - "```" (optionally "```lang") on its own line → code block
16
17
  *
17
- * These fire on text change (not on Enter), so they don't conflict
18
- * with the Enter→addNew() shortcut on desktop.
18
+ * The list transformers fire on text change. The built-in CODE transformer
19
+ * needs "``` " (trailing space); registerCodeFenceShortcut adds the Enter path,
20
+ * converting a "```" (or "```lang") line to a code block when Enter is pressed —
21
+ * so the language can be typed before the fence commits.
19
22
  */
20
23
  const CREATIVE_MARKDOWN_TRANSFORMERS = [
21
24
  UNORDERED_LIST,
@@ -27,7 +30,15 @@ export default function MarkdownShortcutsPlugin() {
27
30
  const [editor] = useLexicalComposerContext()
28
31
 
29
32
  useEffect(() => {
30
- return registerMarkdownShortcuts(editor, CREATIVE_MARKDOWN_TRANSFORMERS)
33
+ const unregisterShortcuts = registerMarkdownShortcuts(
34
+ editor,
35
+ CREATIVE_MARKDOWN_TRANSFORMERS
36
+ )
37
+ const unregisterFence = registerCodeFenceShortcut(editor)
38
+ return () => {
39
+ unregisterShortcuts()
40
+ unregisterFence()
41
+ }
31
42
  }, [editor])
32
43
 
33
44
  return null
@@ -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
+ })
@@ -343,4 +343,30 @@ describe('FormController - Review Quote Chips', () => {
343
343
  expect(controller.textareaTarget.value).toBe('fb2')
344
344
  })
345
345
  })
346
+
347
+ describe('active chip re-click - programmatic scroll', () => {
348
+ // Re-clicking the active chip scrolls its quoted comment into view. That is a
349
+ // programmatic list scroll, so it must drop the prev-message anchor the same way
350
+ // scrollToBottom/highlightComment do — otherwise a stale anchor inside the corridor
351
+ // survives and the next previous-message click skips the comment now in view.
352
+ test('re-clicking the active chip notifies the list of a programmatic scroll', () => {
353
+ const commentEl = document.createElement('div')
354
+ commentEl.setAttribute('data-comment-id', '1')
355
+ commentEl.scrollIntoView = jest.fn() // jsdom has no scrollIntoView
356
+ document.body.appendChild(commentEl)
357
+
358
+ const notifyProgrammaticScroll = jest.fn()
359
+ jest
360
+ .spyOn(controller, 'listController', 'get')
361
+ .mockReturnValue({ notifyProgrammaticScroll })
362
+
363
+ controller.appendReviewQuote(1, 'first') // freshly appended quote is active
364
+ container.querySelector('.review-quote-chip-text').click()
365
+
366
+ expect(commentEl.scrollIntoView).toHaveBeenCalled()
367
+ expect(notifyProgrammaticScroll).toHaveBeenCalled()
368
+
369
+ commentEl.remove()
370
+ })
371
+ })
346
372
  })
@@ -0,0 +1,230 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ /**
6
+ * Covers the previous-message wiring in list_controller: anchor lifecycle across
7
+ * connect/disconnect, list reloads, and the scroll action itself.
8
+ *
9
+ * jsdom has no layout engine, so item geometry is stubbed. `scrollTo` records
10
+ * the request without moving `scrollTop` — that models the smooth-scroll
11
+ * animation still being in flight, which is the state the original bug needed.
12
+ */
13
+
14
+ import { jest } from '@jest/globals'
15
+ import ListController from '../list_controller'
16
+
17
+ const ITEM_HEIGHT = 100
18
+
19
+ function buildController({ itemCount = 5 } = {}) {
20
+ const state = { scrollTop: 0 }
21
+
22
+ const element = document.createElement('div')
23
+ const list = document.createElement('div')
24
+ element.appendChild(list)
25
+ document.body.appendChild(element)
26
+
27
+ for (let i = 0; i < itemCount; i++) {
28
+ const item = document.createElement('div')
29
+ item.className = 'comment-item'
30
+ item.dataset.commentId = `c${i}`
31
+ Object.defineProperty(item, 'offsetTop', { get: () => i * ITEM_HEIGHT })
32
+ item.getBoundingClientRect = () => ({ top: i * ITEM_HEIGHT - state.scrollTop })
33
+ list.appendChild(item)
34
+ }
35
+
36
+ list.getBoundingClientRect = () => ({ top: 0 })
37
+ Object.defineProperty(list, 'offsetTop', { get: () => 0 })
38
+ list.scrollTo = jest.fn()
39
+
40
+ // Stand the controller up without a Stimulus application: `element` is a
41
+ // getter on Controller.prototype, so it has to be shadowed rather than set.
42
+ const controller = Object.create(ListController.prototype)
43
+ Object.defineProperty(controller, 'element', { value: element })
44
+ Object.defineProperty(controller, 'listTarget', { value: list })
45
+ controller.connect()
46
+
47
+ return {
48
+ controller,
49
+ list,
50
+ element,
51
+ // Apply the last requested scroll, i.e. let the smooth animation finish.
52
+ settle() {
53
+ const call = list.scrollTo.mock.calls.at(-1)
54
+ if (call) state.scrollTop = call[0].top
55
+ },
56
+ // Move the list without going through the button, as a user gesture would.
57
+ scrollTo(top) {
58
+ state.scrollTop = top
59
+ },
60
+ lastScrollTop() {
61
+ return list.scrollTo.mock.calls.at(-1)?.[0].top
62
+ },
63
+ }
64
+ }
65
+
66
+ describe('list_controller previous-message navigation', () => {
67
+ afterEach(() => {
68
+ document.body.innerHTML = ''
69
+ })
70
+
71
+ test('connect installs a navigator', () => {
72
+ const { controller } = buildController()
73
+ expect(controller.prevMsgNavigator).toBeTruthy()
74
+ })
75
+
76
+ test('walks backwards one message per click when settled', () => {
77
+ const h = buildController()
78
+ h.scrollTo(4 * ITEM_HEIGHT) // parked on the last item
79
+
80
+ h.controller.scrollToPreviousMessage()
81
+ expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
82
+ h.settle()
83
+
84
+ h.controller.scrollToPreviousMessage()
85
+ expect(h.lastScrollTop()).toBe(2 * ITEM_HEIGHT)
86
+ })
87
+
88
+ test('keeps stepping back when clicked mid-animation', () => {
89
+ const h = buildController()
90
+ h.scrollTo(4 * ITEM_HEIGHT)
91
+
92
+ // Three clicks with no settle between them: the list never reaches the
93
+ // requested offset, which is exactly when the old geometry-only code
94
+ // bounced back to the item it had just left.
95
+ h.controller.scrollToPreviousMessage()
96
+ expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
97
+ h.controller.scrollToPreviousMessage()
98
+ expect(h.lastScrollTop()).toBe(2 * ITEM_HEIGHT)
99
+ h.controller.scrollToPreviousMessage()
100
+ expect(h.lastScrollTop()).toBe(1 * ITEM_HEIGHT)
101
+ })
102
+
103
+ test('releases stick-to-bottom so incoming comments do not yank the view', () => {
104
+ const h = buildController()
105
+ h.controller.stickToBottom = true
106
+ h.scrollTo(4 * ITEM_HEIGHT)
107
+
108
+ h.controller.scrollToPreviousMessage()
109
+
110
+ expect(h.controller.stickToBottom).toBe(false)
111
+ })
112
+
113
+ test('does nothing when the list is empty', () => {
114
+ const h = buildController({ itemCount: 0 })
115
+
116
+ h.controller.scrollToPreviousMessage()
117
+
118
+ expect(h.list.scrollTo).not.toHaveBeenCalled()
119
+ })
120
+
121
+ test('loads older comments once past the oldest message', () => {
122
+ const h = buildController()
123
+ h.controller.allOlderLoaded = false
124
+ h.controller.loadOlderComments = jest.fn()
125
+
126
+ h.controller.scrollToPreviousMessage() // lands on the oldest item
127
+ h.settle()
128
+ h.controller.scrollToPreviousMessage() // nothing older in the DOM
129
+
130
+ expect(h.controller.loadOlderComments).toHaveBeenCalled()
131
+ })
132
+
133
+ test('stops at the oldest message when everything is already loaded', () => {
134
+ const h = buildController()
135
+ h.controller.allOlderLoaded = true
136
+ h.controller.loadOlderComments = jest.fn()
137
+
138
+ h.controller.scrollToPreviousMessage()
139
+ h.settle()
140
+ const before = h.list.scrollTo.mock.calls.length
141
+ h.controller.scrollToPreviousMessage()
142
+
143
+ expect(h.controller.loadOlderComments).not.toHaveBeenCalled()
144
+ expect(h.list.scrollTo.mock.calls.length).toBe(before)
145
+ })
146
+
147
+ describe.each(['wheel', 'touchstart', 'keydown', 'pointerdown'])(
148
+ 'user input via %s',
149
+ (eventName) => {
150
+ test('drops the anchor so the next click resolves from where the user landed', () => {
151
+ const h = buildController()
152
+ h.scrollTo(4 * ITEM_HEIGHT)
153
+
154
+ h.controller.scrollToPreviousMessage() // anchor = c3
155
+ h.settle()
156
+
157
+ // User scrolls back down to the bottom themselves.
158
+ h.list.dispatchEvent(new Event(eventName))
159
+ h.scrollTo(4 * ITEM_HEIGHT)
160
+
161
+ h.controller.scrollToPreviousMessage()
162
+
163
+ // Resolved from geometry (c4 at top -> c3), not from the stale anchor.
164
+ expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT)
165
+ })
166
+ }
167
+ )
168
+
169
+ test('a programmatic jump to the bottom drops the anchor', () => {
170
+ // form_controller (after sending) and the fullscreen handlers call
171
+ // scrollToBottom() with no user gesture. A small jump can leave the anchor
172
+ // inside the corridor, where geometry cannot tell it from our own in-flight
173
+ // scroll, so the next click would step off the stale anchor (c3 -> c2)
174
+ // instead of resolving from where the list actually is (c4 -> c3).
175
+ const h = buildController()
176
+ h.scrollTo(4 * ITEM_HEIGHT)
177
+
178
+ h.controller.scrollToPreviousMessage() // anchor = c3, in flight
179
+ expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
180
+
181
+ h.controller.scrollToBottom()
182
+ expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
183
+
184
+ h.controller.scrollToPreviousMessage()
185
+ expect(h.lastScrollTop()).toBe(3 * ITEM_HEIGHT) // c3 from geometry, not c2
186
+ })
187
+
188
+ test('a permalink highlight jump drops the anchor', () => {
189
+ const h = buildController()
190
+ h.scrollTo(4 * ITEM_HEIGHT)
191
+
192
+ h.controller.scrollToPreviousMessage() // anchor = c3
193
+ expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
194
+
195
+ // highlightComment looks the target up by DOM id and scrolls it into view
196
+ // (jsdom has no scrollIntoView, so stub it).
197
+ const target = h.list.querySelector('[data-comment-id="c1"]')
198
+ target.id = 'comment_c1'
199
+ target.scrollIntoView = () => {}
200
+ h.controller.highlightComment('c1')
201
+
202
+ expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
203
+ })
204
+
205
+ test('disconnect unhooks the user-input listeners', () => {
206
+ const h = buildController()
207
+ h.scrollTo(4 * ITEM_HEIGHT)
208
+ h.controller.scrollToPreviousMessage() // anchor = c3
209
+ h.settle()
210
+
211
+ h.controller.disconnect()
212
+ h.list.dispatchEvent(new Event('wheel'))
213
+
214
+ // Anchor survived because the listener is gone.
215
+ expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
216
+ })
217
+
218
+ test('reloading the list drops the anchor', () => {
219
+ const h = buildController()
220
+ h.scrollTo(4 * ITEM_HEIGHT)
221
+ h.controller.scrollToPreviousMessage()
222
+ expect(h.controller.prevMsgNavigator.anchorId).toBe('c3')
223
+
224
+ h.controller.creativeId = '1'
225
+ h.controller.fetchComments = jest.fn(() => new Promise(() => {}))
226
+ h.controller.loadInitialComments()
227
+
228
+ expect(h.controller.prevMsgNavigator.anchorId).toBeNull()
229
+ })
230
+ })
@@ -0,0 +1,129 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import PrevMessageNavigator from '../prev_message_navigator'
6
+
7
+ // Items are ordered oldest -> newest, matching DOM order of `.comment-item`.
8
+ // `top` is the item's viewport-relative top, as measured by the controller.
9
+ function items(...tops) {
10
+ return tops.map((top, i) => ({ id: String(i), top }))
11
+ }
12
+
13
+ describe('PrevMessageNavigator', () => {
14
+ const VIEWPORT_TOP = 100
15
+
16
+ let nav
17
+
18
+ beforeEach(() => {
19
+ nav = new PrevMessageNavigator()
20
+ })
21
+
22
+ describe('without an anchor (geometry fallback)', () => {
23
+ test('steps to the previous item when the current one is flush with the top', () => {
24
+ // item 2 is exactly at the viewport top -> previous message is item 1
25
+ expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
26
+ })
27
+
28
+ test('scrolls the partially visible item to the top first', () => {
29
+ // item 2 is cut off above the fold -> bring item 2 fully into view
30
+ expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
31
+ })
32
+
33
+ test('starts from the last item when everything is scrolled above the fold', () => {
34
+ expect(nav.resolveTargetIndex(items(-300, -200, -100), VIEWPORT_TOP)).toBe(2)
35
+ })
36
+
37
+ test('returns -1 at the oldest item so the caller can load older comments', () => {
38
+ expect(nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)).toBe(-1)
39
+ })
40
+
41
+ test('returns -1 for an empty list', () => {
42
+ expect(nav.resolveTargetIndex([], VIEWPORT_TOP)).toBe(-1)
43
+ })
44
+ })
45
+
46
+ describe('with an anchor (rapid clicks)', () => {
47
+ test('keeps stepping backwards while the smooth scroll is still in flight', () => {
48
+ // First click: item 2 is flush with the top, so we head for item 1.
49
+ expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
50
+ nav.commit('1', -100)
51
+
52
+ // Second click lands mid-animation: item 1 has not reached the top yet, so
53
+ // geometry alone would send us back down to item 1. The anchor must win.
54
+ expect(nav.resolveTargetIndex(items(-150, -50, 150, 350), VIEWPORT_TOP)).toBe(0)
55
+ })
56
+
57
+ test('keeps stepping when the first click started from a partially visible item', () => {
58
+ // First click from between messages: item 2 is cut off *below* the top, so
59
+ // we scroll it up into place and the anchor starts below the viewport top.
60
+ expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
61
+ nav.commit('2', 130)
62
+
63
+ // Second click mid-animation: item 2 is on its way down to the top. The
64
+ // anchor must still win, otherwise we re-target item 2 and stall.
65
+ expect(nav.resolveTargetIndex(items(-215, -115, 115, 315), VIEWPORT_TOP)).toBe(1)
66
+ })
67
+
68
+ test('survives older comments being prepended (anchor is id based)', () => {
69
+ // Prepending shifts scrollTop but leaves the anchor where it is on screen,
70
+ // so it is still on its way from -300 up to the viewport top.
71
+ nav.commit('c5', -300)
72
+ const prepended = [
73
+ { id: 'c3', top: -500 },
74
+ { id: 'c4', top: -300 },
75
+ { id: 'c5', top: -100 },
76
+ ]
77
+ expect(nav.resolveTargetIndex(prepended, VIEWPORT_TOP)).toBe(1)
78
+ })
79
+
80
+ test('falls back to geometry when the anchored comment is gone', () => {
81
+ nav.commit('deleted-comment', -100)
82
+ expect(nav.resolveTargetIndex(items(-200, -100, 100, 300), VIEWPORT_TOP)).toBe(1)
83
+ })
84
+
85
+ test('returns -1 when the anchor is already the oldest item', () => {
86
+ nav.commit('0', -100)
87
+ expect(nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)).toBe(-1)
88
+ })
89
+
90
+ test('ignores an anchor the list has scrolled away from', () => {
91
+ // A programmatic jump (new message arriving, permalink highlight) moves the
92
+ // list without any user gesture, so nothing drops the anchor. It is now far
93
+ // above where it started, which our own animation can never produce - that
94
+ // only ever walks the anchor down towards the viewport top.
95
+ nav.commit('0', -100)
96
+ expect(nav.resolveTargetIndex(items(-900, -700, 100, 300), VIEWPORT_TOP)).toBe(1)
97
+ })
98
+
99
+ test('a stale anchor stops wedging the button at the oldest message', () => {
100
+ // Regression: anchored on the oldest item, every later click returned -1
101
+ // forever once there was nothing more to load, so the button went dead.
102
+ nav.commit('0', -100)
103
+ nav.resolveTargetIndex(items(100, 300), VIEWPORT_TOP)
104
+
105
+ // The list is jumped back to the bottom; clicking must resume stepping.
106
+ expect(nav.resolveTargetIndex(items(-900, -700, 100, 300), VIEWPORT_TOP)).toBe(1)
107
+ })
108
+ })
109
+
110
+ describe('anchor invalidation', () => {
111
+ test('drops the anchor when the user moves the list', () => {
112
+ nav.commit('1', -100)
113
+ nav.notifyUserInput()
114
+ expect(nav.anchorId).toBeNull()
115
+ })
116
+
117
+ test('reset clears the anchor', () => {
118
+ nav.commit('1', -100)
119
+ nav.reset()
120
+ expect(nav.anchorId).toBeNull()
121
+ })
122
+
123
+ test('after a user scroll, geometry drives the next click again', () => {
124
+ nav.commit('1', -100)
125
+ nav.notifyUserInput()
126
+ expect(nav.resolveTargetIndex(items(-200, -100, 130, 330), VIEWPORT_TOP)).toBe(2)
127
+ })
128
+ })
129
+ })