diff --git a/.changeset/close-pr-banner-toggle.md b/.changeset/close-pr-banner-toggle.md new file mode 100644 index 0000000..d5d4ab7 --- /dev/null +++ b/.changeset/close-pr-banner-toggle.md @@ -0,0 +1,5 @@ +--- +'elden-ring-github': minor +--- + +Add a PR close banner option with settings toggle, popup control, and reusable banner rendering helper. diff --git a/README.md b/README.md index 84ed011..a184684 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A Chrome extension that displays epic Elden Ring-themed banners when you create, - 🆕 **PR Creation Banner** - Celebrate new pull request creation with a dedicated banner - ✅ **PR Approval Banner** - Epic celebration when you approve pull requests - 🎉 **PR Merge Banner** - Shows an epic "MERGE ACCOMPLISHED" banner when PRs are merged +- ☠️ **PR Close Banner** - Dramatic "You Died" moment whenever a pull request is closed - 🔊 **Sound Effects** - Plays the iconic Elden Ring achievement sound - ⚙️ **Independent Controls** - Separate settings to enable/disable creation, approval, and merge banners @@ -106,7 +107,8 @@ src/ ├── lost-grace-discovered.mp3 # Lost Grace discovery sound ├── pull-request-created.png # PR creation banner ├── pull-request-merged.png # PR merge banner - ├── approve-pull-request.webp # PR approval banner + ├── approve-pull-request.png # PR approval banner + ├── close-pull-request.png # PR close banner └── icon*.png # Extension icons dist/ # Built extension (Chrome loads this) diff --git a/src/assets/close-pull-request.png b/src/assets/close-pull-request.png new file mode 100644 index 0000000..507d884 Binary files /dev/null and b/src/assets/close-pull-request.png differ diff --git a/src/content/banner.test.ts b/src/content/banner.test.ts new file mode 100644 index 0000000..e4e7c6f --- /dev/null +++ b/src/content/banner.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderBanner, type BannerType } from './banner'; + +describe('renderBanner', () => { + const soundUrl = 'chrome-extension://mock/sound.mp3'; + + beforeEach(() => { + document.body.innerHTML = ''; + vi.useFakeTimers(); + + const chromeGlobal = globalThis as unknown as { + chrome?: { runtime?: { getURL?: (path: string) => string } }; + }; + chromeGlobal.chrome = chromeGlobal.chrome || {}; + chromeGlobal.chrome.runtime = chromeGlobal.chrome.runtime || {}; + chromeGlobal.chrome.runtime.getURL = vi.fn((path: string) => `chrome-extension://mock/${path}`); + + global.Audio = vi.fn().mockImplementation(() => { + return { + play: vi.fn().mockResolvedValue(undefined), + volume: 0, + } as unknown as HTMLAudioElement; + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should render banners for each type', () => { + const types: BannerType[] = ['merged', 'created', 'approved', 'closed']; + + types.forEach((type) => { + const onHide = vi.fn(); + renderBanner({ + type, + soundUrl, + soundEnabled: true, + onHide, + }); + + const banner = document.getElementById('elden-ring-banner'); + expect(banner).toBeTruthy(); + expect(banner?.innerHTML).toContain('.png'); + const chromeRuntime = (globalThis as any).chrome.runtime; + expect(chromeRuntime.getURL).toHaveBeenCalled(); + + vi.runAllTimers(); + expect(onHide).toHaveBeenCalled(); + + document.body.innerHTML = ''; + }); + }); + + it('should skip audio when sound is disabled', () => { + const onHide = vi.fn(); + renderBanner({ + type: 'merged', + soundUrl, + soundEnabled: false, + onHide, + }); + + expect(global.Audio).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(onHide).toHaveBeenCalled(); + }); +}); diff --git a/src/content/banner.ts b/src/content/banner.ts new file mode 100644 index 0000000..50cc6e4 --- /dev/null +++ b/src/content/banner.ts @@ -0,0 +1,73 @@ +export type BannerType = 'merged' | 'created' | 'approved' | 'closed'; + +interface RenderBannerOptions { + type: BannerType; + soundUrl: string; + soundEnabled: boolean; + onHide: () => void; +} + +const bannerAssetMap: Record = { + merged: { + image: 'pull-request-merged.png', + alt: 'Pull Request Merged', + }, + created: { + image: 'pull-request-created.png', + alt: 'Pull Request Created', + }, + approved: { + image: 'approve-pull-request.png', + alt: 'Pull Request Approved', + }, + closed: { + image: 'close-pull-request.png', + alt: 'Pull Request Closed', + }, +}; + +/** + * Renders the Elden Ring banner with correct imagery and optional audio with safe cleanup. + */ +export const renderBanner = ({ + type, + soundUrl, + soundEnabled, + onHide, +}: RenderBannerOptions): boolean => { + try { + const banner = document.createElement('div'); + banner.id = 'elden-ring-banner'; + + const asset = bannerAssetMap[type]; + const imgPath = chrome.runtime.getURL(`assets/${asset.image}`); + + const img = document.createElement('img'); + img.src = imgPath; + img.alt = asset.alt; + banner.appendChild(img); + document.body.appendChild(banner); + + if (soundEnabled) { + const audio = new Audio(soundUrl); + audio.volume = 1.0; + audio.play().catch((err) => console.log('Sound playback failed:', err)); + } + + setTimeout(() => banner.classList.add('show'), 50); + setTimeout(() => { + banner.classList.remove('show'); + setTimeout(() => { + if (banner.parentNode) { + banner.remove(); + } + onHide(); + }, 500); + }, 3000); + + return true; + } catch (error) { + console.error('Banner rendering failed:', error); + return false; + } +}; diff --git a/src/content/closeWatcher.test.ts b/src/content/closeWatcher.test.ts new file mode 100644 index 0000000..3a02326 --- /dev/null +++ b/src/content/closeWatcher.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { waitForCloseComplete } from './closeWatcher'; + +describe('waitForCloseComplete', () => { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + it('should trigger callback immediately if closed state already exists', () => { + document.body.innerHTML = ` + Closed + `; + + const onClose = vi.fn(); + waitForCloseComplete(onClose); + + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should trigger callback when closed state is added later', async () => { + const onClose = vi.fn(); + waitForCloseComplete(onClose); + + const closedElement = document.createElement('span'); + closedElement.className = 'State State--closed'; + closedElement.textContent = 'Closed'; + document.body.appendChild(closedElement); + + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/content/closeWatcher.ts b/src/content/closeWatcher.ts new file mode 100644 index 0000000..a837304 --- /dev/null +++ b/src/content/closeWatcher.ts @@ -0,0 +1,95 @@ +let closeHandled = false; +// Track timeouts so we can cancel them once a close is confirmed +let checkTimeout: ReturnType | null = null; +let cleanupTimeout: ReturnType | null = null; + +const isClosedState = (element: Element | null): boolean => { + if (!element) return false; + if (!element.matches('.State.State--closed')) { + return false; + } + const text = element.textContent?.toLowerCase().trim() || ''; + return text.includes('closed'); +}; + +const handleClose = (observer: MutationObserver, onClose: () => void): void => { + if (closeHandled) return; + closeHandled = true; + console.log('☠️ Pull request closed!'); + if (checkTimeout) { + clearTimeout(checkTimeout); + checkTimeout = null; + } + if (cleanupTimeout) { + clearTimeout(cleanupTimeout); + cleanupTimeout = null; + } + onClose(); + observer.disconnect(); +}; + +const checkExistingClosedState = (observer: MutationObserver, onClose: () => void): boolean => { + const closedElement = document.querySelector('.State.State--closed'); + if (isClosedState(closedElement)) { + handleClose(observer, onClose); + return true; + } + return false; +}; + +export const waitForCloseComplete = (onClose: () => void, timeoutMs: number = 10000): void => { + closeHandled = false; + if (checkTimeout) { + clearTimeout(checkTimeout); + checkTimeout = null; + } + if (cleanupTimeout) { + clearTimeout(cleanupTimeout); + cleanupTimeout = null; + } + + const observer = new MutationObserver((mutations) => { + if (closeHandled) return; + + mutations.forEach((mutation) => { + mutation.addedNodes.forEach((node) => { + if (node.nodeType !== Node.ELEMENT_NODE || closeHandled) { + return; + } + + const element = node as Element; + if (isClosedState(element)) { + handleClose(observer, onClose); + return; + } + + const closedElement = element.querySelector('.State.State--closed'); + if (isClosedState(closedElement)) { + handleClose(observer, onClose); + } + }); + }); + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + if (checkExistingClosedState(observer, onClose)) { + return; + } + + checkTimeout = setTimeout(() => { + if (!closeHandled) { + checkExistingClosedState(observer, onClose); + } + }, 100); + + cleanupTimeout = setTimeout(() => { + if (!closeHandled) { + observer.disconnect(); + console.log('⏰ Close detection timeout'); + } + }, timeoutMs); +}; diff --git a/src/content/content.test.ts b/src/content/content.test.ts index 537e7b6..8f5b61c 100644 --- a/src/content/content.test.ts +++ b/src/content/content.test.ts @@ -111,7 +111,7 @@ describe('EldenRingMerger', () => { }); it('should handle different banner types', () => { - const types = ['merged', 'created', 'approved'] as const; + const types = ['merged', 'created', 'approved', 'closed'] as const; types.forEach((type) => { let imageName: string; @@ -121,8 +121,11 @@ describe('EldenRingMerger', () => { imageName = 'pull-request-created.png'; altText = 'Pull Request Created'; } else if (type === 'approved') { - imageName = 'approve-pull-request.webp'; + imageName = 'approve-pull-request.png'; altText = 'Pull Request Approved'; + } else if (type === 'closed') { + imageName = 'close-pull-request.png'; + altText = 'Pull Request Closed'; } else { imageName = 'pull-request-merged.png'; altText = 'Pull Request Merged'; @@ -135,8 +138,11 @@ describe('EldenRingMerger', () => { expect(imageName).toBe('pull-request-created.png'); expect(altText).toBe('Pull Request Created'); } else if (type === 'approved') { - expect(imageName).toBe('approve-pull-request.webp'); + expect(imageName).toBe('approve-pull-request.png'); expect(altText).toBe('Pull Request Approved'); + } else if (type === 'closed') { + expect(imageName).toBe('close-pull-request.png'); + expect(altText).toBe('Pull Request Closed'); } else { expect(imageName).toBe('pull-request-merged.png'); expect(altText).toBe('Pull Request Merged'); @@ -225,12 +231,43 @@ describe('EldenRingMerger', () => { expect(mergedElement?.textContent).toBe('Merged'); }); + it('should detect close pull request buttons by text', () => { + document.body.innerHTML = ` +
+ + +
+ `; + + const container = document.querySelector('#partial-new-comment-form-actions'); + const closeButtons = Array.from(container?.querySelectorAll('button') || []).filter((button) => + button.textContent?.toLowerCase().includes('close pull request'), + ); + + expect(closeButtons.length).toBe(1); + expect(closeButtons[0]?.textContent?.trim()).toBe('Close pull request'); + }); + + it('should detect closed state element with closed class', () => { + document.body.innerHTML = ` + + + Closed + + `; + + const closedElement = document.querySelector('.State.State--closed'); + expect(closedElement).toBeTruthy(); + expect(closedElement?.textContent?.toLowerCase()).toContain('closed'); + }); + it('should handle storage changes', () => { const mockCallback = vi.fn(); const changes = { soundEnabled: { newValue: false }, showOnPRMerged: { newValue: false }, showOnPRCreate: { newValue: true }, + showOnPRClose: { newValue: false }, }; // Simulate storage change handling @@ -243,11 +280,15 @@ describe('EldenRingMerger', () => { if (changes.showOnPRCreate) { mockCallback('showOnPRCreate', changes.showOnPRCreate.newValue); } + if (changes.showOnPRClose) { + mockCallback('showOnPRClose', changes.showOnPRClose.newValue); + } - expect(mockCallback).toHaveBeenCalledTimes(3); + expect(mockCallback).toHaveBeenCalledTimes(4); expect(mockCallback).toHaveBeenCalledWith('soundEnabled', false); expect(mockCallback).toHaveBeenCalledWith('showOnPRMerged', false); expect(mockCallback).toHaveBeenCalledWith('showOnPRCreate', true); + expect(mockCallback).toHaveBeenCalledWith('showOnPRClose', false); }); it('should handle PR creation flag storage', () => { diff --git a/src/content/content.ts b/src/content/content.ts index 9d1312e..b3b4bc2 100644 --- a/src/content/content.ts +++ b/src/content/content.ts @@ -1,9 +1,13 @@ +import { renderBanner, type BannerType } from './banner'; +import { waitForCloseComplete } from './closeWatcher'; + class EldenRingMerger { private bannerShown: boolean = false; private soundEnabled: boolean = true; private showOnPRMerged: boolean = true; private showOnPRCreate: boolean = true; private showOnPRApprove: boolean = true; + private showOnPRClose: boolean = true; private soundType: 'you-die-sound' | 'lost-grace-discovered' = 'you-die-sound'; private soundUrl: string; @@ -23,18 +27,27 @@ class EldenRingMerger { private loadSettings(): void { chrome.storage.sync.get( - ['soundEnabled', 'showOnPRMerged', 'showOnPRCreate', 'showOnPRApprove', 'soundType'], + [ + 'soundEnabled', + 'showOnPRMerged', + 'showOnPRCreate', + 'showOnPRApprove', + 'showOnPRClose', + 'soundType', + ], (result: { soundEnabled?: boolean; showOnPRMerged?: boolean; showOnPRCreate?: boolean; showOnPRApprove?: boolean; + showOnPRClose?: boolean; soundType?: 'you-die-sound' | 'lost-grace-discovered'; }) => { this.soundEnabled = result.soundEnabled !== false; // default true this.showOnPRMerged = result.showOnPRMerged !== false; // default true this.showOnPRCreate = result.showOnPRCreate !== false; // default true this.showOnPRApprove = result.showOnPRApprove !== false; // default true + this.showOnPRClose = result.showOnPRClose !== false; // default true this.soundType = result.soundType || 'you-die-sound'; // default you-die-sound this.updateSoundUrl(); }, @@ -55,6 +68,9 @@ class EldenRingMerger { if (changes.showOnPRApprove) { this.showOnPRApprove = changes.showOnPRApprove.newValue; } + if (changes.showOnPRClose) { + this.showOnPRClose = changes.showOnPRClose.newValue; + } if (changes.soundType) { this.soundType = changes.soundType.newValue; this.updateSoundUrl(); @@ -82,6 +98,9 @@ class EldenRingMerger { // Detect merge button clicks this.detectMergeButtons(); + // Detect close button clicks + this.detectCloseButtons(); + // Detect PR creation button clicks this.detectPRCreationButtons(); @@ -126,10 +145,39 @@ class EldenRingMerger { } } + private detectCloseButtons(): void { + const currentUrl = window.location.href; + const isPRPage = /\/pull\/\d+/.test(currentUrl); + if (!isPRPage || !this.showOnPRClose) { + return; + } + + const closeButtonContainer = document.querySelector('#partial-new-comment-form-actions'); + if (!closeButtonContainer) { + return; + } + + const closeButtons = closeButtonContainer.querySelectorAll('button'); + closeButtons.forEach((button) => { + const buttonText = button.textContent?.toLowerCase().trim() || ''; + if ( + buttonText.includes('close pull request') && + !button.hasAttribute('data-elden-ring-close-listener') + ) { + button.addEventListener('click', () => { + console.log('🛑 Close pull request button clicked'); + waitForCloseComplete(() => this.handleCloseCelebration()); + }); + button.setAttribute('data-elden-ring-close-listener', 'true'); + } + }); + } + private observeDOMChanges(): void { // Observe for GitHub's dynamic content loading const observer = new MutationObserver(() => { this.detectMergeButtons(); + this.detectCloseButtons(); this.detectPRCreationButtons(); this.detectPRApprovalButtons(); }); @@ -325,59 +373,33 @@ class EldenRingMerger { }, 10000); } - public showEldenRingBanner(type: 'merged' | 'created' | 'approved' = 'merged'): void { + public showEldenRingBanner(type: BannerType = 'merged'): void { if (this.bannerShown) return; this.bannerShown = true; - // Only show image banner - this.showImageBanner(type); - } + const defaultSoundUrl = + type === 'closed' ? chrome.runtime.getURL('assets/you-die-sound.mp3') : this.soundUrl; - private showImageBanner(type: 'merged' | 'created' | 'approved' = 'merged'): boolean { - try { - const banner = document.createElement('div'); - banner.id = 'elden-ring-banner'; - let imageName: string; - let altText: string; - - if (type === 'created') { - imageName = 'pull-request-created.png'; - altText = 'Pull Request Created'; - } else if (type === 'approved') { - imageName = 'approve-pull-request.png'; - altText = 'Pull Request Approved'; - } else { - imageName = 'pull-request-merged.png'; - altText = 'Pull Request Merged'; - } - - const imgPath = chrome.runtime.getURL(`assets/${imageName}`); - banner.innerHTML = `${altText}`; - document.body.appendChild(banner); + const success = renderBanner({ + type, + soundUrl: defaultSoundUrl, + soundEnabled: this.soundEnabled, + onHide: () => { + this.bannerShown = false; + }, + }); - // Play sound effect - if (this.soundEnabled) { - const audio = new Audio(this.soundUrl); - audio.volume = 1.0; - audio.play().catch((err) => console.log('Sound playback failed:', err)); - } + if (!success) { + this.bannerShown = false; + } + } - setTimeout(() => banner.classList.add('show'), 50); - setTimeout(() => { - banner.classList.remove('show'); - setTimeout(() => { - if (banner.parentNode) { - banner.remove(); - } - this.bannerShown = false; - }, 500); - }, 3000); - - return true; - } catch (error) { - console.log('Image banner failed, using text banner:', error); - return false; + private handleCloseCelebration(): void { + if (!this.showOnPRClose) { + console.log('🚫 PR close banner disabled in settings'); + return; } + this.showEldenRingBanner('closed'); } } diff --git a/src/popup/popup.html b/src/popup/popup.html index cec8bd9..0fc3a69 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -51,6 +51,12 @@ Show on PR approve +
+ +