Last active
June 10, 2026 14:13
-
-
Save skyless/42e0e0458803a39c28209e3c27711fe5 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name PR Selfie — Azure DevOps | |
| // @namespace https://dev.azure.com/ | |
| // @version 1.0.0 | |
| // @description Adds a 📸 Selfie button to Azure DevOps pull request create/edit pages | |
| // @author Simon Debaecke | |
| // @match https://dev.azure.com/* | |
| // @grant none | |
| // @run-at document-idle | |
| // @updateURL https://gist.github.com/skyless/42e0e0458803a39c28209e3c27711fe5 | |
| // @downloadURL https://gist.github.com/skyless/42e0e0458803a39c28209e3c27711fe5 | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // ── Constants ──────────────────────────────────────────────────────────────── | |
| const BUTTON_ID = 'pr-selfie-btn'; | |
| const MODAL_ID = 'pr-selfie-modal'; | |
| const POLL_MS = 1500; | |
| const MAX_TRIES = 30; // ~45 s total | |
| // ── Helpers ────────────────────────────────────────────────────────────────── | |
| function isPRPage() { | |
| const url = location.href; | |
| return ( | |
| /dev\.azure\.com/.test(url) && | |
| (url.includes('/pullrequestcreate') || url.includes('/pullrequest/')) | |
| ); | |
| } | |
| function findDescriptionToolbar() { | |
| const selectors = [ | |
| '[aria-label="Description editor"] .ql-toolbar', | |
| '.ql-toolbar.ql-snow', | |
| '[data-is-focusable="true"][role="toolbar"]', | |
| '.bolt-textfield [role="toolbar"]', | |
| '.markdown-toolbar' | |
| ]; | |
| for (const sel of selectors) { | |
| const el = document.querySelector(sel); | |
| if (el) return el; | |
| } | |
| return null; | |
| } | |
| // ── Styles ─────────────────────────────────────────────────────────────────── | |
| function injectStyles() { | |
| if (document.getElementById('pr-selfie-styles')) return; | |
| const style = document.createElement('style'); | |
| style.id = 'pr-selfie-styles'; | |
| style.textContent = ` | |
| #pr-selfie-backdrop { | |
| position: fixed; inset: 0; | |
| background: rgba(0,0,0,.55); | |
| z-index: 99998; | |
| backdrop-filter: blur(2px); | |
| } | |
| #pr-selfie-dialog { | |
| position: fixed; | |
| top: 50%; left: 50%; | |
| transform: translate(-50%, -50%); | |
| z-index: 99999; | |
| background: #1e1e2e; | |
| color: #cdd6f4; | |
| border-radius: 12px; | |
| box-shadow: 0 24px 64px rgba(0,0,0,.6); | |
| width: min(480px, 96vw); | |
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; | |
| overflow: hidden; | |
| } | |
| #pr-selfie-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| padding: 14px 18px; | |
| background: #181825; | |
| font-weight: 600; | |
| font-size: 15px; | |
| border-bottom: 1px solid #313244; | |
| } | |
| #pr-selfie-close { | |
| background: none; border: none; color: #cdd6f4; | |
| font-size: 22px; cursor: pointer; line-height: 1; padding: 0 4px; | |
| transition: color .15s; | |
| } | |
| #pr-selfie-close:hover { color: #f38ba8; } | |
| #pr-selfie-body { | |
| padding: 16px; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| background: #000; | |
| min-height: 270px; | |
| } | |
| #pr-selfie-video, #pr-selfie-preview { | |
| width: 100%; | |
| max-height: 340px; | |
| border-radius: 6px; | |
| object-fit: cover; | |
| transform: scaleX(-1); | |
| } | |
| #pr-selfie-status { | |
| padding: 6px 18px; | |
| font-size: 13px; | |
| min-height: 28px; | |
| color: #a6e3a1; | |
| background: #181825; | |
| } | |
| #pr-selfie-footer { | |
| display: flex; | |
| gap: 8px; | |
| padding: 14px 18px; | |
| background: #181825; | |
| border-top: 1px solid #313244; | |
| flex-wrap: wrap; | |
| } | |
| .pr-btn { | |
| padding: 8px 16px; | |
| border-radius: 6px; | |
| border: none; | |
| font-size: 13px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: opacity .15s, transform .1s; | |
| } | |
| .pr-btn:active { transform: scale(.97); } | |
| .pr-btn-primary { background: #89b4fa; color: #1e1e2e; } | |
| .pr-btn-secondary { background: #313244; color: #cdd6f4; } | |
| .pr-btn-success { background: #a6e3a1; color: #1e1e2e; } | |
| .pr-btn-ghost { background: transparent; color: #6c7086; border: 1px solid #313244; } | |
| .pr-btn:hover { opacity: .85; } | |
| #${BUTTON_ID} { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| padding: 4px 10px; | |
| margin-left: 6px; | |
| border: 1px solid #89b4fa44; | |
| border-radius: 4px; | |
| background: transparent; | |
| color: #89b4fa; | |
| font-size: 12px; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: background .15s; | |
| vertical-align: middle; | |
| } | |
| #${BUTTON_ID}:hover { background: #89b4fa22; } | |
| `; | |
| document.head.appendChild(style); | |
| } | |
| // ── Modal ──────────────────────────────────────────────────────────────────── | |
| function createModal() { | |
| if (document.getElementById(MODAL_ID)) return; | |
| const modal = document.createElement('div'); | |
| modal.id = MODAL_ID; | |
| modal.innerHTML = ` | |
| <div id="pr-selfie-backdrop"></div> | |
| <div id="pr-selfie-dialog" role="dialog" aria-modal="true" aria-label="Take a selfie"> | |
| <header id="pr-selfie-header"> | |
| <span>📸 Selfie for this PR</span> | |
| <button id="pr-selfie-close" aria-label="Close">×</button> | |
| </header> | |
| <div id="pr-selfie-body"> | |
| <video id="pr-selfie-video" autoplay playsinline muted></video> | |
| <canvas id="pr-selfie-canvas" hidden></canvas> | |
| <img id="pr-selfie-preview" alt="Selfie preview" hidden /> | |
| </div> | |
| <div id="pr-selfie-status"></div> | |
| <footer id="pr-selfie-footer"> | |
| <button id="pr-selfie-snap" class="pr-btn pr-btn-primary">📷 Take photo</button> | |
| <button id="pr-selfie-retake" class="pr-btn pr-btn-secondary" hidden>↩ Retake</button> | |
| <button id="pr-selfie-attach" class="pr-btn pr-btn-success" hidden>📎 Attach to PR</button> | |
| <button id="pr-selfie-cancel" class="pr-btn pr-btn-ghost">Cancel</button> | |
| </footer> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| injectStyles(); | |
| } | |
| // ── Camera ─────────────────────────────────────────────────────────────────── | |
| let stream = null; | |
| async function startCamera() { | |
| try { | |
| stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { facingMode: 'user', width: { ideal: 1280 }, height: { ideal: 720 } }, | |
| audio: false, | |
| }); | |
| const video = document.getElementById('pr-selfie-video'); | |
| video.srcObject = stream; | |
| setStatus('Camera ready — smile! 😄'); | |
| } catch (err) { | |
| setStatus('⚠️ Camera access denied: ' + err.message, true); | |
| } | |
| } | |
| function stopCamera() { | |
| if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; } | |
| } | |
| function setStatus(msg, isError = false) { | |
| const el = document.getElementById('pr-selfie-status'); | |
| if (!el) return; | |
| el.textContent = msg; | |
| el.style.color = isError ? '#f38ba8' : '#a6e3a1'; | |
| } | |
| // ── Capture & Attach ───────────────────────────────────────────────────────── | |
| function capturePhoto() { | |
| const video = document.getElementById('pr-selfie-video'); | |
| const canvas = document.getElementById('pr-selfie-canvas'); | |
| const preview = document.getElementById('pr-selfie-preview'); | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.translate(canvas.width, 0); | |
| ctx.scale(-1, 1); | |
| ctx.drawImage(video, 0, 0); | |
| preview.src = canvas.toDataURL('image/jpeg', 0.92); | |
| preview.hidden = false; | |
| video.hidden = true; | |
| document.getElementById('pr-selfie-snap').hidden = true; | |
| document.getElementById('pr-selfie-retake').hidden = false; | |
| document.getElementById('pr-selfie-attach').hidden = false; | |
| setStatus('Photo taken! Click "Attach to PR" to insert it.'); | |
| } | |
| function retakePhoto() { | |
| document.getElementById('pr-selfie-video').hidden = false; | |
| document.getElementById('pr-selfie-preview').hidden = true; | |
| document.getElementById('pr-selfie-snap').hidden = false; | |
| document.getElementById('pr-selfie-retake').hidden = true; | |
| document.getElementById('pr-selfie-attach').hidden = true; | |
| setStatus('Camera ready — smile! 😄'); | |
| } | |
| function attachPhoto() { | |
| const canvas = document.getElementById('pr-selfie-canvas'); | |
| setStatus('Attaching…'); | |
| canvas.toBlob((blob) => { | |
| const fileName = `selfie-${Date.now()}.jpg`; | |
| const file = new File([blob], fileName, { type: 'image/jpeg' }); | |
| // Focus the description editor | |
| const editor = | |
| document.querySelector('.ql-editor') || | |
| document.querySelector('[contenteditable="true"]') || | |
| document.querySelector('textarea[aria-label*="escription"]'); | |
| if (editor) editor.focus(); | |
| // Dispatch a synthetic paste event carrying the image file | |
| const dt = new DataTransfer(); | |
| dt.items.add(file); | |
| const pasteEvent = new ClipboardEvent('paste', { | |
| bubbles: true, | |
| cancelable: true, | |
| clipboardData: dt, | |
| }); | |
| const target = editor || document.activeElement; | |
| const dispatched = target.dispatchEvent(pasteEvent); | |
| if (dispatched) { | |
| setStatus('✅ Selfie attached! Check the description.'); | |
| setTimeout(closeModal, 1800); | |
| } else { | |
| fallbackDownload(blob, fileName); | |
| } | |
| }, 'image/jpeg', 0.92); | |
| } | |
| function fallbackDownload(blob, fileName) { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = fileName; a.click(); | |
| URL.revokeObjectURL(url); | |
| setStatus('⬇️ Downloaded — drag the file into the PR description.'); | |
| setTimeout(closeModal, 3000); | |
| } | |
| // ── Modal lifecycle ────────────────────────────────────────────────────────── | |
| function openModal() { | |
| createModal(); | |
| document.getElementById('pr-selfie-close').onclick = closeModal; | |
| document.getElementById('pr-selfie-cancel').onclick = closeModal; | |
| document.getElementById('pr-selfie-backdrop').onclick = closeModal; | |
| document.getElementById('pr-selfie-snap').onclick = capturePhoto; | |
| document.getElementById('pr-selfie-retake').onclick = retakePhoto; | |
| document.getElementById('pr-selfie-attach').onclick = attachPhoto; | |
| document.addEventListener('keydown', handleKeyboard); | |
| startCamera(); | |
| } | |
| function closeModal() { | |
| stopCamera(); | |
| const modal = document.getElementById(MODAL_ID); | |
| if (modal) modal.remove(); | |
| document.removeEventListener('keydown', handleKeyboard); | |
| } | |
| function handleKeyboard(e) { | |
| if (e.key === 'Escape') { closeModal(); return; } | |
| const snap = document.getElementById('pr-selfie-snap'); | |
| if ((e.key === ' ' || e.key === 'Enter') && snap && !snap.hidden) { | |
| e.preventDefault(); | |
| snap.click(); | |
| } | |
| } | |
| // ── Button injection ───────────────────────────────────────────────────────── | |
| function injectButton() { | |
| if (document.getElementById(BUTTON_ID)) return; | |
| const toolbar = findDescriptionToolbar(); | |
| if (!toolbar) return; | |
| const btn = document.createElement('button'); | |
| btn.id = BUTTON_ID; | |
| btn.type = 'button'; | |
| btn.title = 'Take a selfie and attach it to this PR'; | |
| btn.innerHTML = '📸 Selfie'; | |
| btn.addEventListener('click', openModal); | |
| toolbar.appendChild(btn); | |
| } | |
| // ── Polling (SPA navigation support) ──────────────────────────────────────── | |
| let tries = 0; | |
| let intervalId = null; | |
| function poll() { | |
| if (!isPRPage()) return; | |
| if (document.getElementById(BUTTON_ID)) return; | |
| injectButton(); | |
| tries++; | |
| if (tries >= MAX_TRIES) clearInterval(intervalId); | |
| } | |
| function init() { | |
| let lastUrl = location.href; | |
| new MutationObserver(() => { | |
| if (location.href !== lastUrl) { | |
| lastUrl = location.href; | |
| tries = 0; | |
| } | |
| }).observe(document.body, { subtree: true, childList: true }); | |
| intervalId = setInterval(poll, POLL_MS); | |
| } | |
| init(); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment