// Core JS and CSS payloads injected into the Instagram WebView. // // WARNING: Do not add any network interception logic ("ghost mode") here. // All scripts in this file must be limited to UI behaviour, navigation helpers, // and local-only features that do not modify data sent to Meta's servers. // ── CSS payloads ────────────────────────────────────────────────────────────── /// Base UI polish — hides scrollbars and Instagram's nav tab-bar. /// Important: we must NOT hide [role="tablist"] inside dialogs/modals, /// because Instagram's comment input sheet also uses that role and the /// CSS would paint a grey overlay on top of the typing area. const String kGlobalUIFixesCSS = ''' ::-webkit-scrollbar { display: none !important; width: 0 !important; height: 0 !important; } * { -ms-overflow-style: none !important; scrollbar-width: none !important; -webkit-tap-highlight-color: transparent !important; } /* Only hide the PRIMARY nav tablist (bottom bar), not tablist inside dialogs */ body > div > div > [role="tablist"]:not([role="dialog"] [role="tablist"]), [aria-label="Direct"] header { display: none !important; visibility: hidden !important; height: 0 !important; pointer-events: none !important; } '''; /// Blurs images/videos in the home feed AND on Explore. /// Activated via the body[path] attribute written by the path tracker script. const String kBlurHomeFeedAndExploreCSS = ''' body[path="/"] article img, body[path="/"] article video, body[path^="/explore"] img, body[path^="/explore"] video, body[path="/explore/"] img, body[path="/explore/"] video { filter: blur(20px) !important; transition: filter 0.15s ease !important; } /* Per-post unblur override (set by kTapToUnblurJS) */ [data-fg-unblurred="1"] img, [data-fg-unblurred="1"] video { filter: none !important; -webkit-filter: none !important; } body[path="/"] article img:hover, body[path="/"] article video:hover, body[path^="/explore"] img:hover, body[path^="/explore"] video:hover { filter: blur(20px) !important; } '''; /// Prevents text selection to keep the app feeling native. const String kDisableSelectionCSS = ''' * { -webkit-user-select: none !important; user-select: none !important; } '''; /// Hides reel posts in the home feed when no Reel Session is active. /// The Reels nav tab is NOT hidden — Flutter intercepts that navigation. const String kHideReelsFeedContentCSS = ''' a[href*="/reel/"], div[data-media-type="2"] { display: none !important; visibility: hidden !important; } '''; /// Blurs reel thumbnails in the feed AND reel preview cards sent in DMs. /// /// Feed reels are wrapped in a[href*="/reel/"] — straightforward. /// DM reel previews are inline media cards NOT wrapped in a[href*="/reel/"], /// so they need separate selectors targeting img/video inside [aria-label="Direct"]. /// Profile photos are excluded via :not([alt*="rofile"]) — covers both /// "profile" and "Profile" without case-sensitivity workarounds. const String kBlurReelsCSS = ''' a[href*="/reel/"] img { filter: blur(12px) !important; } [aria-label="Direct"] img:not([alt*="rofile"]):not([alt=""]), [aria-label="Direct"] video { filter: blur(12px) !important; } '''; /// Allows users to unblur blurred media by tapping it. /// /// Behaviour: /// - Only active when `window.__fgTapToUnblur === true`. /// - Only applies on Home feed (`/`) and Explore (`/explore*`) where FocusGram blurs. /// - First tap unblurs the post media and swallows the click (prevents opening). /// - Subsequent taps behave normally (Instagram opens the post as usual). const String kTapToUnblurJS = r''' (function fgTapToUnblur() { if (window.__fgTapToUnblurPatched) return; window.__fgTapToUnblurPatched = true; function isBlurContext() { try { const p = (document.body && document.body.getAttribute('path')) || window.location.pathname || ''; return p === '/' || p.indexOf('/explore') === 0; } catch (_) { return false; } } function findMediaFromTarget(t) { try { if (!t) return null; if (t.closest) { const direct = t.closest('img,video'); if (direct) return direct; } // Walk up a few levels and look for a media element inside. let n = t; for (let i = 0; i < 6 && n; i++) { if (n.querySelector) { const m = n.querySelector('img,video'); if (m) return m; } n = n.parentElement; } } catch (_) {} return null; } function getHost(media) { try { return media.closest('article') || media.closest('a') || media.parentElement; } catch (_) { return null; } } function markUnblurred(host) { try { host.setAttribute('data-fg-unblurred', '1'); } catch (_) {} } function isUnblurred(host) { try { return host && host.getAttribute && host.getAttribute('data-fg-unblurred') === '1'; } catch (_) { return false; } } function unblurMedia(media) { try { media.style.setProperty('filter', 'none', 'important'); media.style.setProperty('-webkit-filter', 'none', 'important'); } catch (_) {} } document.addEventListener('click', function(e) { try { if (window.__fgTapToUnblur !== true) return; if (!isBlurContext()) return; const media = findMediaFromTarget(e.target); if (!media) return; const host = getHost(media); if (!host) return; if (isUnblurred(host)) return; // allow normal Instagram behaviour // First tap: unblur and swallow click so it doesn't open the post. markUnblurred(host); unblurMedia(media); if (e.cancelable) e.preventDefault(); e.stopPropagation(); } catch (_) {} }, true); })(); '''; // ── JavaScript helpers ──────────────────────────────────────────────────────── /// Removes the "Open in App" nag banner. const String kDismissAppBannerJS = ''' (function fgDismissBanner() { ['[id*="app-banner"]','[class*="app-banner"]', 'div[role="dialog"][aria-label*="app"]','[id*="openInApp"]'] .forEach(s => document.querySelectorAll(s).forEach(el => el.remove())); })(); '''; /// Intercepts clicks on /reels/ links when no session is active and redirects /// to a recognisable URL so Flutter's NavigationDelegate can catch and block it. /// /// Without this, fast SPA clicks bypass the NavigationDelegate entirely. const String kStrictReelsBlockJS = r''' (function fgReelsBlock() { if (window.__fgReelsBlockPatched) return; window.__fgReelsBlockPatched = true; document.addEventListener('click', e => { if (window.__focusgramSessionActive) return; const a = e.target && e.target.closest('a[href*="/reels/"]'); if (!a) return; e.preventDefault(); e.stopPropagation(); window.location.href = '/reels/?fg=blocked'; }, true); })(); '''; /// SPA route tracker: writes `body[path]` and notifies Flutter of path changes /// via the `UrlChange` handler so reels can be blocked on SPA navigation. const String kTrackPathJS = ''' (function fgTrackPath() { if (window.__fgPathTrackerRunning) return; window.__fgPathTrackerRunning = true; let last = window.location.pathname; function check() { const p = window.location.pathname; if (p !== last) { last = p; if (document.body) document.body.setAttribute('path', p); if (window.flutter_inappwebview) { window.flutter_inappwebview.callHandler('UrlChange', p); } } } if (document.body) document.body.setAttribute('path', last); setInterval(check, 500); })(); '''; /// Detects Instagram's current theme (dark/light) and notifies Flutter. const String kThemeDetectorJS = r''' (function fgThemeSync() { if (window.__fgThemeSyncRunning) return; window.__fgThemeSyncRunning = true; function getTheme() { try { // 1. Check Instagram's specific classes const h = document.documentElement; if (h.classList.contains('style-dark')) return 'dark'; if (h.classList.contains('style-light')) return 'light'; // 2. Check body background color const bg = window.getComputedStyle(document.body).backgroundColor; const rgb = bg.match(/\d+/g); if (rgb && rgb.length >= 3) { const luminance = (0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]) / 255; return luminance < 0.5 ? 'dark' : 'light'; } } catch(_) {} return 'dark'; // Fallback } let last = ''; function check() { const current = getTheme(); if (current !== last) { last = current; if (window.flutter_inappwebview) { window.flutter_inappwebview.callHandler('FocusGramThemeChannel', current); } } } setInterval(check, 1500); check(); })(); '''; /// Prevents swipe-to-next-reel in the isolated DM reel player and when Reels /// are blocked by FocusGram's session controls. const String kReelsMutationObserverJS = r''' (function fgReelLock() { if (window.__fgReelLockRunning) return; window.__fgReelLockRunning = true; const MODAL_SEL = '[role="dialog"],[role="menu"],[role="listbox"],[class*="Modal"],[class*="Sheet"],[class*="Drawer"],._aano,[class*="caption"]'; function lockMode() { // Only lock scroll when: DM reel playing OR disableReelsEntirely enabled with reel present const isDmReel = window.location.pathname.includes('/direct/') && !!document.querySelector('[class*="ReelsVideoPlayer"]'); if (isDmReel) return 'dm_reel'; // Only lock scroll when reel element is actually present on the page if (window.__fgDisableReelsEntirely === true && !!document.querySelector('[class*="ReelsVideoPlayer"], video')) return 'disabled'; return null; } function isLocked() { return lockMode() !== null; } function allowInteractionTarget(t) { if (!t || !t.closest) return false; if (t.closest('input,textarea,[contenteditable="true"]')) return true; if (t.closest(MODAL_SEL)) return true; return false; } let sy = 0; document.addEventListener('touchstart', e => { sy = e.touches && e.touches[0] ? e.touches[0].clientY : 0; }, { capture: true, passive: true }); document.addEventListener('touchmove', e => { if (!isLocked()) return; const dy = e.touches && e.touches[0] ? e.touches[0].clientY - sy : 0; if (Math.abs(dy) > 2) { // Mark the first DM reel as loaded on first swipe attempt if (window.location.pathname.includes('/direct/')) { window.__fgDmReelAlreadyLoaded = true; } if (allowInteractionTarget(e.target)) return; if (e.cancelable) e.preventDefault(); e.stopPropagation(); } }, { capture: true, passive: false }); function block(e) { if (!isLocked()) return; if (allowInteractionTarget(e.target)) return; if (e.cancelable) e.preventDefault(); e.stopPropagation(); } document.addEventListener('wheel', block, { capture: true, passive: false }); document.addEventListener('keydown', e => { if (!['ArrowDown','ArrowUp',' ','PageUp','PageDown'].includes(e.key)) return; if (e.target && e.target.closest && e.target.closest('input,textarea,[contenteditable="true"]')) return; block(e); }, { capture: true, passive: false }); const REEL_SEL = '[class*="ReelsVideoPlayer"], video'; let __fgOrigHtmlOverflow = null; let __fgOrigBodyOverflow = null; function applyOverflowLock() { try { const mode = lockMode(); const hasReel = !!document.querySelector(REEL_SEL); // Apply lock for dm_reel or disabled modes when reel is present if ((mode === 'dm_reel' || mode === 'disabled') && hasReel) { if (__fgOrigHtmlOverflow === null) { __fgOrigHtmlOverflow = document.documentElement.style.overflow || ''; __fgOrigBodyOverflow = document.body ? (document.body.style.overflow || '') : ''; } document.documentElement.style.overflow = 'hidden'; if (document.body) document.body.style.overflow = 'hidden'; } else if (__fgOrigHtmlOverflow !== null) { document.documentElement.style.overflow = __fgOrigHtmlOverflow; if (document.body) document.body.style.overflow = __fgOrigBodyOverflow || ''; __fgOrigHtmlOverflow = null; __fgOrigBodyOverflow = null; } } catch (_) {} } function sync() { const reels = document.querySelectorAll(REEL_SEL); applyOverflowLock(); if (window.location.pathname.includes('/direct/') && reels.length > 0) { // Give the first reel 3.5 s to buffer before activating the DM lock if (!window.__fgDmReelTimer) { window.__fgDmReelTimer = setTimeout(() => { if (document.querySelector(REEL_SEL)) { window.__fgDmReelAlreadyLoaded = true; } window.__fgDmReelTimer = null; }, 3500); } } if (reels.length === 0) { if (window.__fgDmReelTimer) { clearTimeout(window.__fgDmReelTimer); window.__fgDmReelTimer = null; } window.__fgDmReelAlreadyLoaded = false; } } sync(); new MutationObserver(ms => { if (ms.some(m => m.addedNodes.length || m.removedNodes.length)) sync(); }).observe(document.body, { childList: true, subtree: true }); // Keep __focusgramIsolatedPlayer in sync with SPA navigations if (!window.__fgIsolatedPlayerSync) { window.__fgIsolatedPlayerSync = true; let _lastPath = window.location.pathname; setInterval(() => { const p = window.location.pathname; if (p === _lastPath) return; _lastPath = p; window.__focusgramIsolatedPlayer = p.includes('/reel/') && !p.startsWith('/reels'); if (!p.includes('/direct/')) window.__fgDmReelAlreadyLoaded = false; applyOverflowLock(); }, 400); } })(); '''; /// Periodically checks Instagram's UI for unread counts (badges) on the Direct /// and Notifications icons, as well as the page title. Sends an event to /// Flutter whenever a new notification is detected. const String kBadgeMonitorJS = r''' (function fgBadgeMonitor() { if (window.__fgBadgeMonitorRunning) return; window.__fgBadgeMonitorRunning = true; const startedAt = Date.now(); let initialised = false; let lastDmCount = 0; let lastNotifCount = 0; let lastTitleUnread = 0; function parseBadgeCount(el) { if (!el) return 0; try { const raw = (el.innerText || el.textContent || '').trim(); const n = parseInt(raw, 10); return isNaN(n) ? 1 : n; } catch (_) { return 1; } } function check() { try { // 1. Check Title for (N) indicator const titleMatch = document.title.match(/\((\d+)\)/); const currentTitleUnread = titleMatch ? parseInt(titleMatch[1]) : 0; // 2. Scan for DM unread badge const dmBadge = document.querySelector([ 'a[href*="/direct/inbox/"] [style*="rgb(255, 48, 64)"]', 'a[href*="/direct/inbox/"] [style*="255, 48, 64"]', 'a[href*="/direct/inbox/"] [aria-label*="unread"]', 'div[role="button"][aria-label*="Direct"] [style*="255, 48, 64"]', 'a[href*="/direct/inbox/"] svg[aria-label*="Direct"] + div', 'a[href*="/direct/inbox/"] ._a9-v', ].join(',')); const currentDmCount = parseBadgeCount(dmBadge); // 3. Scan for Notifications unread badge const notifBadge = document.querySelector([ 'a[href*="/notifications"] [style*="rgb(255, 48, 64)"]', 'a[href*="/notifications"] [style*="255, 48, 64"]', 'a[href*="/notifications"] [aria-label*="unread"]' ].join(',')); const currentNotifCount = parseBadgeCount(notifBadge); // Establish baseline on first run and suppress false positives right after reload. if (!initialised) { lastDmCount = currentDmCount; lastNotifCount = currentNotifCount; lastTitleUnread = currentTitleUnread; initialised = true; return; } if (Date.now() - startedAt < 6000) { lastDmCount = currentDmCount; lastNotifCount = currentNotifCount; lastTitleUnread = currentTitleUnread; return; } if (currentDmCount > lastDmCount) { if (window.flutter_inappwebview) { window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'DM'); } } else if (currentNotifCount > lastNotifCount) { if (window.flutter_inappwebview) { window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity'); } } else if (currentTitleUnread > lastTitleUnread && currentTitleUnread > (currentDmCount + currentNotifCount)) { if (window.flutter_inappwebview) { window.flutter_inappwebview.callHandler('FocusGramNotificationChannel', 'Activity'); } } lastDmCount = currentDmCount; lastNotifCount = currentNotifCount; lastTitleUnread = currentTitleUnread; } catch(_) {} } // Initial check after some delay to let page settle setTimeout(check, 2000); setInterval(check, 1000); })(); '''; /// Forwards Web Notification events to the native Flutter channel. const String kNotificationBridgeJS = ''' (function fgNotifBridge() { if (!window.Notification || window.__fgNotifBridged) return; window.__fgNotifBridged = true; const startedAt = Date.now(); const _N = window.Notification; window.Notification = function(title, opts) { try { // Avoid false positives on reload / initial bootstrap. if (Date.now() - startedAt < 6000) { return new _N(title, opts); } if (window.flutter_inappwebview) { window.flutter_inappwebview.callHandler( 'FocusGramNotificationChannel', title + (opts && opts.body ? ': ' + opts.body : ''), ); } } catch(_) {} return new _N(title, opts); }; window.Notification.permission = 'granted'; window.Notification.requestPermission = () => Promise.resolve('granted'); })(); '''; /// Strips tracking query params (igsh, utm_*, fbclid…) from all links and the /// native Web Share API. Sanitised share URLs are routed to Flutter's share /// channel instead. const String kLinkSanitizationJS = r''' (function fgSanitize() { if (window.__fgSanitizePatched) return; window.__fgSanitizePatched = true; const STRIP = [ 'igsh','igshid','fbclid', 'utm_source','utm_medium','utm_campaign','utm_term','utm_content', 'ref','s','_branch_match_id','_branch_referrer', ]; function clean(raw) { try { const u = new URL(raw, location.origin); STRIP.forEach(p => u.searchParams.delete(p)); return u.toString(); } catch(_) { return raw; } } if (navigator.share) { const _s = navigator.share.bind(navigator); navigator.share = function(d) { const u = d && d.url ? clean(d.url) : null; if (window.flutter_inappwebview && u) { window.flutter_inappwebview.callHandler( 'FocusGramShareChannel', JSON.stringify({ url: u, title: (d && d.title) || '' }), ); return Promise.resolve(); } return _s({ ...d, url: u || (d && d.url) }); }; } document.addEventListener('click', e => { const a = e.target && e.target.closest('a[href]'); if (!a) return; const href = a.getAttribute('href'); if (!href || href.startsWith('#') || href.startsWith('javascript')) return; try { const u = new URL(href, location.origin); if (STRIP.some(p => u.searchParams.has(p))) { STRIP.forEach(p => u.searchParams.delete(p)); a.href = u.toString(); } } catch(_) {} }, true); })(); ''';