Skip to content

Instantly share code, notes, and snippets.

@skyless
Last active June 10, 2026 14:13
Show Gist options
  • Select an option

  • Save skyless/42e0e0458803a39c28209e3c27711fe5 to your computer and use it in GitHub Desktop.

Select an option

Save skyless/42e0e0458803a39c28209e3c27711fe5 to your computer and use it in GitHub Desktop.
// ==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">&times;</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