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.
@@ -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 items = Array.from(list.querySelectorAll('.comment-item'))
1082
- if (items.length === 0) return
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 currentItem = items[currentIdx]
1099
- const currentRect = currentItem.getBoundingClientRect()
1100
- const isAtTop = Math.abs(currentRect.top - viewportTop) < 4
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 = isAtTop ? currentIdx - 1 : currentIdx
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 = items[targetIdx]
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
+ }