Skip to content

Commit abb7d3c

Browse files
committed
refactor(popup): unify stagger animation for all mark-as-read flows
- Extract beginStaggerAnimation() returning {rollback, waitForCompletion} to consolidate animation startup and rollback boilerplate - Extract startStaggerFadeOut() for consistent cascading fade-out - Cap stagger at 20 items to limit animation duration (max 1.15s) - Extract calcStaggerDuration() and waitForAnimation() helpers - Wait for animation to complete before DOM removal - Use local timing snapshots to avoid global variable race conditions - Add missing error log in markAllAsRead failure branch - Remove unused applyOverlayFadeOut()
1 parent 1b8a586 commit abb7d3c

1 file changed

Lines changed: 74 additions & 44 deletions

File tree

src/popup/popup.js

Lines changed: 74 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -554,44 +554,27 @@ async function markAllAsRead() {
554554

555555
// Immediate visual feedback: start overlay animation with stagger
556556
const items = [...notificationsList.querySelectorAll('.repo-group-header, .notification-item')];
557-
lastAnimationDuration =
558-
Math.max(items.length - 1, 0) * ANIMATION_DURATION.STAGGER_DELAY + ANIMATION_DURATION.FADE_OUT;
559-
lastUserActionTime = Date.now();
560-
561-
// Add marking-read to all items first for ::after pseudo-element
562-
items.forEach((item) => item.classList.add('marking-read'));
563-
// eslint-disable-next-line no-unused-expressions
564-
document.body.offsetHeight; // Force reflow
565-
566-
// Stagger fade-out and track timeout IDs for rollback
567-
const staggerTimeouts = [];
568-
items.forEach((item, index) => {
569-
const timeoutId = setTimeout(() => {
570-
item.classList.add('fade-out');
571-
}, index * ANIMATION_DURATION.STAGGER_DELAY);
572-
staggerTimeouts.push(timeoutId);
573-
});
557+
const anim = beginStaggerAnimation(items);
574558

575559
function rollback() {
576-
staggerTimeouts.forEach((id) => clearTimeout(id));
577-
removeOverlayFadeOut(items);
560+
anim.rollback();
578561
markAllBtn.disabled = false;
579562
markAllBtn.innerHTML = originalText;
580563
}
581564

582565
try {
583566
const result = await sendMessage(MESSAGE_TYPES.MARK_ALL_AS_READ);
584567
if (result.success) {
585-
// Success: clear pending animations, DOM and show empty state
586-
staggerTimeouts.forEach((id) => clearTimeout(id));
568+
// Wait for stagger animation to finish before clearing DOM
569+
await anim.waitForCompletion();
587570
clearNotificationCache();
588-
await new Promise(requestAnimationFrame);
589571
notificationsList.innerHTML = '';
590572
emptyState.hidden = false;
591573
markAllBtn.disabled = true;
592574
markAllBtn.innerHTML = originalText;
593575
} else {
594576
rollback();
577+
console.error('Failed to mark all as read:', result.error);
595578
}
596579
} catch (error) {
597580
rollback();
@@ -772,21 +755,75 @@ async function logout() {
772755
await showView('login');
773756
}
774757

758+
// Cap stagger to avoid long animation when many items are off-screen
759+
const MAX_STAGGER_COUNT = 20;
760+
761+
/**
762+
* Calculate total stagger animation duration for a given number of elements.
763+
*/
764+
function calcStaggerDuration(count) {
765+
const capped = Math.min(count, MAX_STAGGER_COUNT);
766+
return Math.max(capped - 1, 0) * ANIMATION_DURATION.STAGGER_DELAY + ANIMATION_DURATION.FADE_OUT;
767+
}
768+
775769
/**
776-
* Apply overlay fade-out animation via CSS classes.
777-
* Uses ::after pseudo-element overlay to avoid compositing artifacts.
770+
* Wait until a stagger animation that started at `startTime` has finished.
771+
* Resolves immediately if the animation has already completed.
772+
*/
773+
async function waitForAnimation(startTime, duration) {
774+
const remaining = Math.max(0, duration - (Date.now() - startTime));
775+
if (remaining > 0) {
776+
await new Promise((resolve) => setTimeout(resolve, remaining));
777+
}
778+
}
779+
780+
/**
781+
* Start stagger animation on elements and return control handles.
782+
* Sets global timing state and returns rollback/wait helpers.
778783
* @param {HTMLElement[]} elements - Elements to animate
784+
* @returns {{ rollback: () => void, waitForCompletion: () => Promise<void> }}
779785
*/
780-
function applyOverlayFadeOut(elements) {
786+
function beginStaggerAnimation(elements) {
787+
lastAnimationDuration = calcStaggerDuration(elements.length);
788+
lastUserActionTime = Date.now();
789+
const animationStart = lastUserActionTime;
790+
const animationDuration = lastAnimationDuration;
791+
792+
const timeoutIds = startStaggerFadeOut(elements, ANIMATION_DURATION.STAGGER_DELAY);
793+
794+
return {
795+
rollback() {
796+
timeoutIds.forEach((id) => clearTimeout(id));
797+
removeOverlayFadeOut(elements);
798+
},
799+
waitForCompletion() {
800+
return waitForAnimation(animationStart, animationDuration);
801+
},
802+
};
803+
}
804+
805+
/**
806+
* Start staggered overlay fade-out animation.
807+
* @param {HTMLElement[]} elements - Elements to animate
808+
* @param {number} staggerDelay - Delay in ms between each element's fade
809+
* @returns {number[]} Timeout IDs for cancellation
810+
*/
811+
function startStaggerFadeOut(elements, staggerDelay) {
781812
for (const el of elements) {
782813
el.classList.add('marking-read');
783814
}
784-
// Force reflow so ::after pseudo-element starts at opacity 0
785815
// eslint-disable-next-line no-unused-expressions
786-
document.body.offsetHeight;
787-
for (const el of elements) {
788-
el.classList.add('fade-out');
789-
}
816+
document.body.offsetHeight; // Force reflow
817+
818+
const timeoutIds = [];
819+
elements.forEach((el, index) => {
820+
const delay = Math.min(index, MAX_STAGGER_COUNT - 1) * staggerDelay;
821+
const id = setTimeout(() => {
822+
el.classList.add('fade-out');
823+
}, delay);
824+
timeoutIds.push(id);
825+
});
826+
return timeoutIds;
790827
}
791828

792829
/**
@@ -806,27 +843,22 @@ function removeOverlayFadeOut(elements) {
806843
async function handleMarkRepoAsRead(repoFullName) {
807844
const [owner, repo] = repoFullName.split('/');
808845

809-
lastUserActionTime = Date.now();
810-
811846
// Immediate visual feedback: start animation before API response
812847
const escapedRepo = CSS.escape(repoFullName);
813848
const repoHeader = document.querySelector(`.repo-group-header[data-repo="${escapedRepo}"]`);
814849
const items = [...document.querySelectorAll(`.notification-item[data-repo="${escapedRepo}"]`)];
815850

816-
const animationDuration = ANIMATION_DURATION.FADE_OUT;
817-
lastAnimationDuration = animationDuration;
818-
819851
const allElements = repoHeader ? [repoHeader, ...items] : items;
820-
applyOverlayFadeOut(allElements);
852+
853+
const anim = beginStaggerAnimation(allElements);
821854

822855
try {
823856
const response = await sendMessage(MESSAGE_TYPES.MARK_REPO_AS_READ, { owner, repo });
824857

825858
if (response.success) {
826-
// Success: remove elements from DOM
827-
await new Promise(requestAnimationFrame);
828-
if (repoHeader) repoHeader.remove();
829-
items.forEach((item) => item.remove());
859+
// Wait for stagger animation to finish before removing DOM
860+
await anim.waitForCompletion();
861+
allElements.forEach((el) => el.remove());
830862

831863
const remainingItems = notificationsList.querySelectorAll('.notification-item');
832864
if (remainingItems.length === 0) {
@@ -842,13 +874,11 @@ async function handleMarkRepoAsRead(repoFullName) {
842874
console.error('Failed to reload notifications:', error);
843875
}
844876
} else {
845-
// Failure: rollback animation
846-
removeOverlayFadeOut(allElements);
877+
anim.rollback();
847878
console.error('Failed to mark repo as read:', response.error);
848879
}
849880
} catch (error) {
850-
// Failure: rollback animation
851-
removeOverlayFadeOut(allElements);
881+
anim.rollback();
852882
console.error('Error marking repo as read:', error);
853883
}
854884
}

0 commit comments

Comments
 (0)