diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 66d06a3a1b609654400c1bf85c622fbed741f14b..ef26f160ad2792b908e289466cc20a0dd609983a 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -44,6 +44,10 @@ import { TRACKING_MULTIPLE_FILES_MODE, } from '../constants'; +import { + discussionIntersectionObserver, + discussionIntersectionObserverHandlerFactory, +} from '../utils/discussions'; import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; @@ -86,6 +90,10 @@ export default { ALERT_MERGE_CONFLICT, ALERT_COLLAPSED_FILES, }, + provide: { + discussionObserver: discussionIntersectionObserver(), + discussionObserverHandler: discussionIntersectionObserverHandlerFactory(), + }, props: { endpoint: { type: String, diff --git a/app/assets/javascripts/diffs/utils/discussions.js b/app/assets/javascripts/diffs/utils/discussions.js new file mode 100644 index 0000000000000000000000000000000000000000..e041c76b081f8682907013f4cdaa723ec3d12bd1 --- /dev/null +++ b/app/assets/javascripts/diffs/utils/discussions.js @@ -0,0 +1,93 @@ +import { create } from '~/lib/utils/intersection_observer'; + +function normalize(processable) { + const { entry } = processable; + const offset = entry.rootBounds.bottom - entry.boundingClientRect.top; + const direction = + offset < 0 ? 'Up' : 'Down'; /* eslint-disable-line @gitlab/require-i18n-strings */ + + return { + ...processable, + entry: { + time: entry.time, + type: entry.isIntersecting ? 'intersection' : `scroll${direction}`, + }, + }; +} + +function sort( + { discussionIds, entry: alpha, currentDiscussion: alphaDiscussion }, + { entry: beta, currentDiscussion: betaDiscussion }, +) { + const diff = alpha.time - beta.time; + const bottomUpDiscussions = discussionIds.reverse(); + let order = 0; + + if (diff < 0) { + order = -1; + } else if (diff > 0) { + order = 1; + } else if (alpha.type === 'intersection' && beta.type === 'scrollUp') { + order = 2; + } else if (alpha.type === 'scrollUp' && beta.type === 'intersection') { + order = -2; + } else if (alpha.type === 'scrollUp' && beta.type === 'scrollUp') { + order = + bottomUpDiscussions.indexOf(alphaDiscussion.id) - + bottomUpDiscussions.indexOf(betaDiscussion.id); + } + + return order; +} + +function filter(entry) { + return entry.type !== 'scrollDown'; +} + +export function discussionIntersectionObserver() { + return create({ + threshold: 0, + rootMargin: '0px 0px -50% 0px', + }); +} + +export function discussionIntersectionObserverHandlerFactory() { + let unprocessed = []; + let timer = null; + + return (processable) => { + unprocessed.push(processable); + + if (timer) { + clearTimeout(timer); + } + + timer = setTimeout(() => { + unprocessed + .map(normalize) + .filter(filter) + .sort(sort) + .forEach((discussionObservationContainer) => { + const { + entry: { type }, + currentDiscussionId, + isFirstUnresolved, + isDiffsPage, + functions: { setCurrentDiscussionId, getPreviousUnresolvedDiscussionId }, + } = discussionObservationContainer; + + if (type === 'intersection') { + setCurrentDiscussionId(currentDiscussionId); + } else { + setCurrentDiscussionId( + isFirstUnresolved + ? null + : getPreviousUnresolvedDiscussionId(currentDiscussionId, isDiffsPage), + ); + } + }); + + unprocessed = []; + }, 0); + }; +} diff --git a/app/assets/javascripts/notes/components/discussion_notes.vue b/app/assets/javascripts/notes/components/discussion_notes.vue index 6fcfa66ea49413acd2c39197e14387b9c06f6d3c..c0048a1bfad5bf55492335d235d71c14060f58a9 100644 --- a/app/assets/javascripts/notes/components/discussion_notes.vue +++ b/app/assets/javascripts/notes/components/discussion_notes.vue @@ -17,6 +17,7 @@ export default { NoteEditedText, DiscussionNotesRepliesWrapper, }, + inject: ['discussionObserver', 'discussionObserverHandler'], props: { discussion: { type: Object, @@ -54,7 +55,12 @@ export default { }, }, computed: { - ...mapGetters(['userCanReply']), + ...mapGetters([ + 'userCanReply', + 'previousUnresolvedDiscussionId', + 'firstUnresolvedDiscussionId', + 'unresolvedDiscussionsIdsOrdered', + ]), hasReplies() { return Boolean(this.replies.length); }, @@ -77,9 +83,44 @@ export default { url: this.discussion.discussion_path, }; }, + isFirstUnresolved() { + return this.firstUnresolvedDiscussionId() === this.discussion.id; + }, + discussionIds() { + return this.unresolvedDiscussionsIdsOrdered(!this.isOverviewTab); + }, + }, + mounted() { + const { observer, id } = this.discussionObserver; + + if (this.$refs.firstDiscussionNote) { + const note = this.$refs.firstDiscussionNote.$el; + + observer.observe(note); + + note.addEventListener(`${id}IntersectionUpdate`, (event) => { + const observerEntry = event.detail; + + this.discussionObserverHandler({ + entry: observerEntry, + isFirstUnresolved: this.isFirstUnresolved, + currentDiscussionId: this.discussion.id, + discussionIds: this.discussionIds, + isDiffsPage: !this.isOverviewTab, + functions: { + setCurrentDiscussionId: this.setCurrentDiscussionId, + getPreviousUnresolvedDiscussionId: this.previousUnresolvedDiscussionId, + }, + }); + }); + } }, methods: { - ...mapActions(['toggleDiscussion', 'setSelectedCommentPositionHover']), + ...mapActions([ + 'toggleDiscussion', + 'setSelectedCommentPositionHover', + 'setCurrentDiscussionId', + ]), componentName(note) { if (note.isPlaceholderNote) { if (note.placeholderType === SYSTEM_NOTE) { @@ -124,6 +165,7 @@ export default {