collavre 0.24.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/stylesheets/collavre/comments_popup.css +5 -17
- 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/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 +68 -21
- data/app/views/collavre/comments/_comments_popup.html.erb +8 -0
- data/config/locales/comments.en.yml +6 -0
- data/config/locales/comments.ko.yml +6 -0
- data/lib/collavre/version.rb +1 -1
- metadata +7 -1
data/app/javascript/controllers/comments/__tests__/topics_controller_creation_container.test.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
// The add-topic button used to be rendered as the last child of the horizontally
|
|
8
|
+
// scrolling topic strip, so it drifted off-screen once enough topics existed.
|
|
9
|
+
// It now lives outside the strip, left of the topic-list button, and renderTopics
|
|
10
|
+
// only toggles it. These tests pin that it stays out of the strip, that the
|
|
11
|
+
// permission gate still applies, and that a re-render no longer wipes a name
|
|
12
|
+
// being typed.
|
|
13
|
+
jest.unstable_mockModule('../../../lib/utils/dialog', () => ({
|
|
14
|
+
confirmDialog: jest.fn(),
|
|
15
|
+
alertDialog: jest.fn(),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
19
|
+
const TopicsController = (await import('../topics_controller')).default
|
|
20
|
+
|
|
21
|
+
const TOPICS = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, name: `topic-${i + 1}` }))
|
|
22
|
+
|
|
23
|
+
describe('TopicsController create-button placement', () => {
|
|
24
|
+
let application
|
|
25
|
+
let container
|
|
26
|
+
let controller
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
container = document.createElement('div')
|
|
30
|
+
container.innerHTML = `
|
|
31
|
+
<div id="topics" data-controller="comments--topics">
|
|
32
|
+
<div class="comment-topics-bar">
|
|
33
|
+
<div data-comments--topics-target="list" class="comment-topics-list"
|
|
34
|
+
data-new-topic-placeholder="New Topic"></div>
|
|
35
|
+
<span class="topic-creation-container"
|
|
36
|
+
data-comments--topics-target="creationContainer" hidden></span>
|
|
37
|
+
<button type="button" class="topic-list-btn"></button>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
`
|
|
41
|
+
document.body.appendChild(container)
|
|
42
|
+
|
|
43
|
+
application = Application.start()
|
|
44
|
+
application.register('comments--topics', TopicsController)
|
|
45
|
+
|
|
46
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
47
|
+
const element = document.getElementById('topics')
|
|
48
|
+
controller = application.getControllerForElementAndIdentifier(element, 'comments--topics')
|
|
49
|
+
controller.creativeIdValue = '42'
|
|
50
|
+
controller.loadTopics = jest.fn()
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
document.body.innerHTML = ''
|
|
56
|
+
application.stop()
|
|
57
|
+
jest.clearAllMocks()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('renders the create button outside the scrolling strip, left of the topic-list button', () => {
|
|
61
|
+
controller.renderTopics(TOPICS, true, true)
|
|
62
|
+
|
|
63
|
+
expect(controller.listTarget.querySelector('.add-topic-btn')).toBeNull()
|
|
64
|
+
|
|
65
|
+
const button = document.querySelector('.add-topic-btn')
|
|
66
|
+
expect(button).not.toBeNull()
|
|
67
|
+
|
|
68
|
+
const creation = button.closest('.topic-creation-container')
|
|
69
|
+
expect(creation.hidden).toBe(false)
|
|
70
|
+
expect(creation.previousElementSibling).toBe(controller.listTarget)
|
|
71
|
+
expect(creation.nextElementSibling.classList.contains('topic-list-btn')).toBe(true)
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('hides and empties the container when the user cannot create topics', () => {
|
|
75
|
+
controller.renderTopics(TOPICS, true, true)
|
|
76
|
+
controller.renderTopics(TOPICS, false, false)
|
|
77
|
+
|
|
78
|
+
const creation = controller.creationContainerTarget
|
|
79
|
+
expect(creation.hidden).toBe(true)
|
|
80
|
+
expect(creation.innerHTML).toBe('')
|
|
81
|
+
expect(document.querySelector('.add-topic-btn')).toBeNull()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
test('re-shows the button when permission is regained', () => {
|
|
85
|
+
controller.renderTopics(TOPICS, false, false)
|
|
86
|
+
controller.renderTopics(TOPICS, true, true)
|
|
87
|
+
|
|
88
|
+
expect(controller.creationContainerTarget.hidden).toBe(false)
|
|
89
|
+
expect(document.querySelector('.add-topic-btn')).not.toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// A topic broadcast used to re-render the whole strip and destroy the open input.
|
|
93
|
+
test('preserves an in-progress topic name across a re-render', () => {
|
|
94
|
+
controller.renderTopics(TOPICS, true, true)
|
|
95
|
+
controller.showInput({ preventDefault() {} })
|
|
96
|
+
|
|
97
|
+
const input = controller.creationContainerTarget.querySelector('.topic-input')
|
|
98
|
+
input.value = 'half-typed'
|
|
99
|
+
|
|
100
|
+
controller.renderTopics([...TOPICS, { id: 99, name: 'from-broadcast' }], true, true)
|
|
101
|
+
|
|
102
|
+
expect(controller.creationContainerTarget.querySelector('.topic-input')).toBe(input)
|
|
103
|
+
expect(input.value).toBe('half-typed')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// createTopic keeps `creating` set across its loadTopics() await, so the
|
|
107
|
+
// re-render it triggers must not mistake the submitted input for live typing.
|
|
108
|
+
test('restores the add button after a successful creation', async () => {
|
|
109
|
+
controller.renderTopics(TOPICS, true, true)
|
|
110
|
+
controller.showInput({ preventDefault() {} })
|
|
111
|
+
|
|
112
|
+
const input = controller.creationContainerTarget.querySelector('.topic-input')
|
|
113
|
+
input.value = 'shipped'
|
|
114
|
+
|
|
115
|
+
document.head.innerHTML = '<meta name="csrf-token" content="tok">'
|
|
116
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
117
|
+
ok: true,
|
|
118
|
+
json: async () => ({ id: 99, name: 'shipped' }),
|
|
119
|
+
})
|
|
120
|
+
controller.flushSaveLastTopic = jest.fn()
|
|
121
|
+
controller.loadTopics = jest.fn(async () => {
|
|
122
|
+
controller.renderTopics([...TOPICS, { id: 99, name: 'shipped' }], true, true)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
await controller.createTopic('shipped')
|
|
126
|
+
|
|
127
|
+
expect(controller.creationContainerTarget.querySelector('.topic-input')).toBeNull()
|
|
128
|
+
expect(document.querySelector('.add-topic-btn')).not.toBeNull()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Alt+Left/Right chat navigation switches creatives without blurring the input,
|
|
132
|
+
// so a preserved draft would otherwise be posted to the wrong creative.
|
|
133
|
+
test('drops an in-progress topic name when the creative changes', () => {
|
|
134
|
+
controller.renderTopics(TOPICS, true, true)
|
|
135
|
+
controller.showInput({ preventDefault() {} })
|
|
136
|
+
controller.creationContainerTarget.querySelector('.topic-input').value = 'for-42'
|
|
137
|
+
|
|
138
|
+
controller.creativeIdValue = '77'
|
|
139
|
+
controller.renderTopics(TOPICS, true, true)
|
|
140
|
+
|
|
141
|
+
expect(controller.creationContainerTarget.querySelector('.topic-input')).toBeNull()
|
|
142
|
+
expect(document.querySelector('.add-topic-btn')).not.toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// The container is static markup, but rendering must not throw if a caller
|
|
146
|
+
// mounts the controller on a stripped-down strip.
|
|
147
|
+
test('no-ops when the container is absent', () => {
|
|
148
|
+
controller.creationContainerTarget.remove()
|
|
149
|
+
|
|
150
|
+
expect(() => controller.renderTopics(TOPICS, true, true)).not.toThrow()
|
|
151
|
+
expect(() => controller.showInput({ preventDefault() {} })).not.toThrow()
|
|
152
|
+
expect(controller.listTarget.querySelector('.topic-tag[data-id="12"]')).not.toBeNull()
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { jest } from '@jest/globals'
|
|
6
|
+
|
|
7
|
+
// setTopicPrimaryAgent used to drop the PATCH response on the floor and wait for
|
|
8
|
+
// the WebSocket broadcast to render the avatar, and it swallowed failures into
|
|
9
|
+
// console.error. These tests pin both: the avatar renders from the response with
|
|
10
|
+
// no broadcast at all, and failures reach the user.
|
|
11
|
+
jest.unstable_mockModule('../../../lib/utils/dialog', () => ({
|
|
12
|
+
confirmDialog: jest.fn(),
|
|
13
|
+
alertDialog: jest.fn(),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
const { Application } = await import('@hotwired/stimulus')
|
|
17
|
+
const TopicsController = (await import('../topics_controller')).default
|
|
18
|
+
const { alertDialog } = await import('../../../lib/utils/dialog')
|
|
19
|
+
|
|
20
|
+
const AGENT = {
|
|
21
|
+
id: 4,
|
|
22
|
+
name: 'GitHub PR Analyzer',
|
|
23
|
+
avatar_url: '/avatar.png',
|
|
24
|
+
default_avatar: false,
|
|
25
|
+
initial: 'G',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('TopicsController#setTopicPrimaryAgent', () => {
|
|
29
|
+
let application
|
|
30
|
+
let container
|
|
31
|
+
let controller
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
container = document.createElement('div')
|
|
35
|
+
container.innerHTML = `
|
|
36
|
+
<div id="topics" data-controller="comments--topics"
|
|
37
|
+
data-topic-set-agent-error="에이전트를 지정할 수 없습니다.">
|
|
38
|
+
<div data-comments--topics-target="list"></div>
|
|
39
|
+
</div>
|
|
40
|
+
`
|
|
41
|
+
document.body.appendChild(container)
|
|
42
|
+
|
|
43
|
+
application = Application.start()
|
|
44
|
+
application.register('comments--topics', TopicsController)
|
|
45
|
+
|
|
46
|
+
const meta = document.createElement('meta')
|
|
47
|
+
meta.name = 'csrf-token'
|
|
48
|
+
meta.content = 'test-csrf'
|
|
49
|
+
document.head.appendChild(meta)
|
|
50
|
+
|
|
51
|
+
return new Promise((resolve) => setTimeout(resolve, 0)).then(() => {
|
|
52
|
+
const element = document.getElementById('topics')
|
|
53
|
+
controller = application.getControllerForElementAndIdentifier(element, 'comments--topics')
|
|
54
|
+
controller.creativeIdValue = '42'
|
|
55
|
+
controller.loadTopics = jest.fn()
|
|
56
|
+
controller.topics = [{ id: 1, name: 'Main' }]
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
document.body.innerHTML = ''
|
|
62
|
+
document.head.innerHTML = ''
|
|
63
|
+
application.stop()
|
|
64
|
+
jest.clearAllMocks()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('renders the agent avatar from the PATCH response without any broadcast', async () => {
|
|
68
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
69
|
+
ok: true,
|
|
70
|
+
json: async () => ({ success: true, topic: { id: 1, name: 'Main', primary_agent: AGENT } }),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
74
|
+
|
|
75
|
+
const avatar = controller.listTarget.querySelector('.topic-agent-avatar')
|
|
76
|
+
expect(avatar).not.toBeNull()
|
|
77
|
+
expect(avatar.getAttribute('src')).toBe('/avatar.png')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('surfaces the server error when the agent is rejected (422)', async () => {
|
|
81
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
82
|
+
ok: false,
|
|
83
|
+
json: async () => ({ error: 'Selected user is not an AI agent' }),
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
87
|
+
|
|
88
|
+
expect(alertDialog).toHaveBeenCalledWith('Selected user is not an AI agent')
|
|
89
|
+
expect(controller.listTarget.querySelector('.topic-agent-avatar')).toBeNull()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// The fallback path has no server-supplied (already localized) error to show,
|
|
93
|
+
// so it must use the localized string handed down by the ERB partial rather
|
|
94
|
+
// than an English literal.
|
|
95
|
+
test('surfaces the localized fallback when the response body is not JSON', async () => {
|
|
96
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
97
|
+
ok: false,
|
|
98
|
+
json: async () => { throw new SyntaxError('Unexpected token <') },
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
102
|
+
|
|
103
|
+
expect(alertDialog).toHaveBeenCalledWith('에이전트를 지정할 수 없습니다.')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('surfaces the localized fallback when the request itself fails', async () => {
|
|
107
|
+
global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
|
108
|
+
|
|
109
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
110
|
+
|
|
111
|
+
expect(alertDialog).toHaveBeenCalledWith('에이전트를 지정할 수 없습니다.')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// A successful response with no topic payload must not throw; the broadcast
|
|
115
|
+
// remains the fallback path in that case.
|
|
116
|
+
test('is a no-op when a successful response carries no topic', async () => {
|
|
117
|
+
global.fetch = jest.fn().mockResolvedValue({
|
|
118
|
+
ok: true,
|
|
119
|
+
json: async () => ({ success: true }),
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
123
|
+
|
|
124
|
+
expect(alertDialog).not.toHaveBeenCalled()
|
|
125
|
+
expect(controller.listTarget.querySelector('.topic-agent-avatar')).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('does not issue a request when the controller has no creative', async () => {
|
|
129
|
+
controller.creativeIdValue = ''
|
|
130
|
+
global.fetch = jest.fn()
|
|
131
|
+
|
|
132
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
133
|
+
|
|
134
|
+
expect(global.fetch).not.toHaveBeenCalled()
|
|
135
|
+
expect(alertDialog).not.toHaveBeenCalled()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test('falls back to English when the partial supplied no localized string', async () => {
|
|
139
|
+
delete document.getElementById('topics').dataset.topicSetAgentError
|
|
140
|
+
global.fetch = jest.fn().mockRejectedValue(new TypeError('Failed to fetch'))
|
|
141
|
+
|
|
142
|
+
await controller.setTopicPrimaryAgent('1', AGENT)
|
|
143
|
+
|
|
144
|
+
expect(alertDialog).toHaveBeenCalledWith('Unable to assign the agent to this topic.')
|
|
145
|
+
})
|
|
146
|
+
})
|
|
@@ -884,6 +884,9 @@ export default class extends Controller {
|
|
|
884
884
|
if (quote.id === store.activeId) {
|
|
885
885
|
const commentEl = document.querySelector(`[data-comment-id="${quote.commentId}"]`)
|
|
886
886
|
if (commentEl) {
|
|
887
|
+
// Programmatic list scroll — drop the prev-message anchor so the next
|
|
888
|
+
// previous-message click resolves from the quoted comment now in view.
|
|
889
|
+
this.listController?.notifyProgrammaticScroll()
|
|
887
890
|
commentEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
888
891
|
commentEl.classList.add('comment-highlight')
|
|
889
892
|
setTimeout(() => commentEl.classList.remove('comment-highlight'), 2000)
|
|
@@ -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
|
+
}
|