What's new

- Reordered Settings Page.
- Added "Click to Unblur" for posts.
- Added Persistent Notification
- Improved Grayscale Scheduling.
and more.
This commit is contained in:
Ujwal
2026-03-04 10:48:14 +05:45
commit 7bb472d212
92 changed files with 14740 additions and 0 deletions

View File

@@ -0,0 +1,225 @@
/// JavaScript to block autoplaying videos on Instagram feed/explore while:
/// - Allowing videos to play normally when "Block Autoplay Videos" is OFF
/// - Allowing user-initiated playback on click when blocking is ON
/// - NEVER blocking reels (they should always play normally per user request)
///
/// This script:
/// - Overrides HTMLVideoElement.prototype.play before Instagram initialises.
/// - PAUSES any playing videos immediately when autoplay is blocked (only for feed/explore).
/// - Returns Promise.resolve() for blocked autoplay calls (never throws).
/// - Uses a per-element flag set by user clicks to permanently allow that video to play.
/// - Strips the autoplay attribute from dynamically added <video> elements.
/// - Respects session state - allows autoplay when session is active.
/// - NEVER blocks reels - they always play normally.
/// - Once a video is explicitly played by user, it plays fully without interruption.
const String kAutoplayBlockerJS = r'''
(function fgAutoplayBlocker() {
if (window.__fgAutoplayPatched) return;
window.__fgAutoplayPatched = true;
// Default to blocking autoplay if not set
window.__fgBlockAutoplay = window.__fgBlockAutoplay !== false;
// Session state - set by FocusGram when session is active
// window.__focusgramSessionActive = true/false
// Helper to check if this is a reel video (should NEVER be blocked)
function isReelVideo() {
try {
const url = window.location.href || '';
// Check if we're on a reel page
if (url.includes('/reels/') || url.includes('/reel/')) {
return true;
}
return false;
} catch (_) {
return false;
}
}
// Helper to check if we should allow autoplay
function shouldBlockAutoplay() {
// If we're on reels page, never block
if (isReelVideo()) return false;
// If autoplay setting is false, don't block at all
if (window.__fgBlockAutoplay === false) return false;
// If session is active, don't block autoplay (allow all videos)
if (window.__focusgramSessionActive === true) return false;
// Otherwise block autoplay for feed/explore videos
return true;
}
// Key to mark a video as explicitly started by user (permanent for that video instance)
const ALLOW_KEY = '__fgUserExplicitlyPlayed';
// Mark video as allowed permanently once user explicitly plays it
function markAllow(video) {
try {
video[ALLOW_KEY] = true;
} catch (_) {}
}
// Check if user has explicitly played this video
function shouldAllow(video) {
try {
return video[ALLOW_KEY] === true;
} catch (_) {
return false;
}
}
// Pause video and strip autoplay attribute (for blocked autoplay videos)
function pauseAndFreezeVideo(video) {
try {
// Remove autoplay attribute completely
video.removeAttribute('autoplay');
try { video.autoplay = false; } catch (_) {}
// Pause the video
video.pause();
// Reset to beginning
video.currentTime = 0;
} catch (_) {}
}
// Store original play and pause
const _origPlay = HTMLVideoElement.prototype.play;
const _origPause = HTMLVideoElement.prototype.pause;
// Override play method
if (HTMLVideoElement.prototype.play) {
HTMLVideoElement.prototype.play = function() {
try {
// NEVER block reels - they always play normally
if (isReelVideo()) {
return _origPlay.apply(this, arguments);
}
// Check if we should block based on both settings and session
if (!shouldBlockAutoplay()) {
// Autoplay is OFF or session is active - allow all playback
return _origPlay.apply(this, arguments);
}
// If user has explicitly played this video before, allow it to continue
if (shouldAllow(this)) {
return _origPlay.apply(this, arguments);
}
// Block autoplay: pause immediately and return resolved promise
pauseAndFreezeVideo(this);
return Promise.resolve();
} catch (_) {
// Fall back to original behaviour
try {
return _origPlay.apply(this, arguments);
} catch (_) {
return Promise.resolve();
}
}
};
}
// Override pause method to work normally
if (HTMLVideoElement.prototype.pause) {
HTMLVideoElement.prototype.pause = function() {
try {
return _origPause.apply(this, arguments);
} catch (_) {
return Promise.resolve();
}
};
}
// Additional safeguard for dynamically created videos
try {
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('video').forEach(function(v) {
if (v.play) {
const originalPlay = v.play;
v.play = function() {
// NEVER block reels
if (isReelVideo()) {
return originalPlay.apply(this, arguments);
}
if (!shouldBlockAutoplay()) {
return originalPlay.apply(this, arguments);
}
if (shouldAllow(this)) {
return originalPlay.apply(this, arguments);
}
pauseAndFreezeVideo(this);
return Promise.resolve();
};
}
});
});
} catch (_) {}
// Also handle videos that might be created after DOMContentLoaded
try {
const originalCreateElement = document.createElement;
document.createElement = function(tagName) {
const element = originalCreateElement.apply(this, arguments);
if (tagName.toLowerCase() === 'video') {
// Intercept the play method on dynamically created videos
const originalPlay = element.play;
if (originalPlay) {
element.play = function() {
// NEVER block reels
if (isReelVideo()) {
return originalPlay.apply(this, arguments);
}
if (!shouldBlockAutoplay()) {
return originalPlay.apply(this, arguments);
}
if (shouldAllow(this)) {
return originalPlay.apply(this, arguments);
}
pauseAndFreezeVideo(this);
return Promise.resolve();
};
}
}
return element;
};
} catch (_) {}
// Mark video as allowed on user interaction (click/tap) - permanent for that video
document.addEventListener('click', function(e) {
try {
const video = e.target.closest ? e.target.closest('video') : e.target;
if (video) {
// Mark this specific video as user-initiated - permanent
markAllow(video);
// Try to play the video if it was previously blocked
if (shouldBlockAutoplay() && !shouldAllow(video)) {
// Video will be allowed now, try to play
try { video.play(); } catch (_) {}
}
}
} catch (_) {}
}, true);
document.addEventListener('touchstart', function(e) {
try {
const video = e.target.closest ? e.target.closest('video') : e.target;
if (video) {
markAllow(video);
if (shouldBlockAutoplay() && !shouldAllow(video)) {
try { video.play(); } catch (_) {}
}
}
} catch (_) {}
}, true);
// Also handle play events directly (for Instagram's internal play buttons)
document.addEventListener('play', function(e) {
if (e.target && e.target.tagName === 'VIDEO') {
markAllow(e.target);
}
}, true);
})();
''';

View File

@@ -0,0 +1,611 @@
// UI element hiding for Instagram web.
//
// SCROLL LOCK WARNING:
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
// calls inside these callbacks block the main thread = scroll jank.
// The JS hiders below use requestIdleCallback + a 300ms debounce so they run
// only during idle time and never on every single mutation.
// ─── CSS-based (reliable, zero perf cost) ────────────────────────────────────
const String kHideLikeCountsCSS =
"""
[role="button"][aria-label${r"$"}=" like"],
[role="button"][aria-label${r"$"}=" likes"],
[role="button"][aria-label${r"$"}=" view"],
[role="button"][aria-label${r"$"}=" views"],
a[href*="/liked_by/"] {
display: none !important;
}
""";
const String kHideFollowerCountsCSS = """
a[href*="/followers/"] span,
a[href*="/following/"] span {
opacity: 0 !important;
pointer-events: none !important;
}
""";
// Stories bar — broad selector covering multiple Instagram DOM layouts
const String kHideStoriesBarCSS = """
[aria-label*="Stories"],
[aria-label*="stories"],
[role="list"][aria-label*="tories"],
[role="listbox"][aria-label*="tories"],
div[style*="overflow"][style*="scroll"]:has(canvas),
section > div > div[style*="overflow-x"] {
display: none !important;
}
""";
// Also do a JS sweep for stories — CSS alone isn't reliable across Instagram versions
const String kHideStoriesBarJS = r'''
(function() {
function hideStories() {
try {
// Target the horizontal scrollable stories container
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stori')) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Fallback: find story bubbles (circular avatar containers at top of feed)
document.querySelectorAll('section > div > div').forEach(function(el) {
try {
const style = window.getComputedStyle(el);
if (style.overflowX === 'scroll' || style.overflowX === 'auto') {
const circles = el.querySelectorAll('canvas, [style*="border-radius: 50%"]');
if (circles.length > 2) {
el.style.setProperty('display', 'none', 'important');
}
}
} catch(_) {}
});
} catch(_) {}
}
hideStories();
if (!window.__fgStoriesObserver) {
let _storiesTimer = null;
window.__fgStoriesObserver = new MutationObserver(() => {
// Debounce — only run after mutations settle, not on every single one
clearTimeout(_storiesTimer);
_storiesTimer = setTimeout(hideStories, 300);
});
window.__fgStoriesObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
/// Robust stories overlay - blocks clicking and applies blur when hide stories is enabled.
/// This is a more aggressive approach that places an overlay with blur on top of stories area.
const String kStoriesOverlayJS = r'''
(function() {
if (window.__fgStoriesOverlayRunning) return;
window.__fgStoriesOverlayRunning = true;
const BLOCKED_ATTR = 'data-fg-stories-blocked';
function buildOverlay(container) {
const div = document.createElement('div');
div.setAttribute(BLOCKED_ATTR, '1');
div.style.cssText = [
'position: absolute',
'inset: 0',
'z-index: 99998',
'display: flex',
'align-items: center',
'justify-content: center',
'background: rgba(0, 0, 0, 0.6)',
'backdrop-filter: blur(10px)',
'-webkit-backdrop-filter: blur(10px)',
'border-radius: 8px',
'pointer-events: all',
'cursor: not-allowed',
].join(';');
const label = document.createElement('span');
label.textContent = 'Stories blocked';
label.style.cssText = [
'color: rgba(255, 255, 255, 0.8)',
'font-size: 12px',
'font-weight: 600',
'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
'text-align: center',
'padding: 8px 16px',
'background: rgba(0, 0, 0, 0.5)',
'border-radius: 20px',
].join(';');
div.appendChild(label);
// Swallow all interaction
['click', 'touchstart', 'touchend', 'touchmove', 'pointerdown', 'mouseenter'].forEach(function(evt) {
div.addEventListener(evt, function(e) {
e.preventDefault();
e.stopImmediatePropagation();
}, { capture: true });
});
return div;
}
function overlayStoriesContainer(container) {
if (!container) return;
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return;
// Check if this looks like a stories container
const hasStories = container.querySelector('canvas, [style*="border-radius: 50%"], [aria-label*="story"], [role="list"]');
if (!hasStories) return;
container.style.position = 'relative';
container.style.overflow = 'hidden';
container.appendChild(buildOverlay(container));
}
function findAndOverlayStories() {
try {
// Method 1: Find by role="list" with story-related aria-label
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stori')) {
overlayStoriesContainer(el.parentElement);
}
} catch(_) {}
});
// Method 2: Find horizontal scroll containers at top of feed
document.querySelectorAll('header + div > div, main > div > div > div').forEach(function(el) {
try {
const style = window.getComputedStyle(el);
if ((style.overflowX === 'scroll' || style.overflowX === 'auto') &&
(style.display === 'flex' || style.display === '')) {
const children = el.children;
let hasAvatar = false;
for (let i = 0; i < Math.min(children.length, 10); i++) {
const child = children[i];
const childStyle = window.getComputedStyle(child);
if (childStyle.width === '60px' || childStyle.width === '66px' ||
child.querySelector('canvas, [style*="border-radius: 50%"]')) {
hasAvatar = true;
break;
}
}
if (hasAvatar) {
overlayStoriesContainer(el);
}
}
} catch(_) {}
});
// Method 3: Find story avatars directly
document.querySelectorAll('[href*="/stories/"], [aria-label*="Your Story"]').forEach(function(el) {
try {
let container = el.parentElement;
for (let i = 0; i < 5 && container; i++) {
const style = window.getComputedStyle(container);
if (style.position !== 'static' && container.children.length < 20) {
overlayStoriesContainer(container);
break;
}
container = container.parentElement;
}
} catch(_) {}
});
} catch(_) {}
}
// Initial run
findAndOverlayStories();
// Watch for dynamic changes
let _overlayTimer = null;
new MutationObserver(function() {
clearTimeout(_overlayTimer);
_overlayTimer = setTimeout(findAndOverlayStories, 500);
}).observe(document.documentElement, { childList: true, subtree: true });
// Also run on scroll
let _scrollTimer = null;
window.addEventListener('scroll', function() {
clearTimeout(_scrollTimer);
_scrollTimer = setTimeout(findAndOverlayStories, 300);
}, { passive: true });
})();
''';
const String kHideExploreTabCSS = """
a[href="/explore/"],
a[href="/explore"] {
display: none !important;
}
""";
const String kHideReelsTabCSS = """
a[href="/reels/"],
a[href="/reels"] {
display: none !important;
}
""";
const String kHideShopTabCSS = """
a[href*="/shop"],
a[href*="/shopping"] {
display: none !important;
}
""";
// ─── Complete Section Disabling (CSS-based) ─────────────────────────────────
// Minimal mode - disables Reels and Explore entirely
const String kMinimalModeCssScript = r'''
(function() {
const css = `
/* Hide Reels tab */
a[href="/reels/"], a[href="/reels"] { display: none !important; }
/* Hide Explore tab */
a[href="/explore/"], a[href="/explore"] { display: none !important; }
/* Hide Create tab */
a[href="/create/"], a[href="/create"] { display: none !important; }
/* Hide Reels in feed */
article a[href*="/reel/"] { display: none !important; }
/* Hide Explore entry points */
svg[aria-label="Explore"], [aria-label="Explore"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-minimal-mode';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// Disable Reels entirely
const String kDisableReelsEntirelyCssScript = r'''
(function() {
const css = `
a[href="/reels/"], a[href="/reels"] { display: none !important; }
article a[href*="/reel/"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-disable-reels';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// Disable Explore entirely
const String kDisableExploreEntirelyCssScript = r'''
(function() {
const css = `
a[href="/explore/"], a[href="/explore"] { display: none !important; }
svg[aria-label="Explore"], [aria-label="Explore"] { display: none !important; }
`;
const style = document.createElement('style');
style.id = 'fg-disable-explore';
style.textContent = css;
(document.head || document.documentElement).appendChild(style);
})();
''';
// ─── DM-embedded Reels Scroll Control ────────────────────────────────────────
// Disables vertical scroll on reels opened from DM unless comment box or share modal is open
const String kDmReelScrollLockScript = r'''
(function() {
// Track scroll lock state
window.__fgDmReelScrollLocked = true;
window.__fgDmReelCommentOpen = false;
window.__fgDmReelShareOpen = false;
function lockScroll() {
if (window.__fgDmReelScrollLocked) {
document.body.style.overflow = 'hidden';
document.documentElement.style.overflow = 'hidden';
}
}
function unlockScroll() {
document.body.style.overflow = '';
document.documentElement.style.overflow = '';
}
function updateScrollState() {
// Only unlock if comment or share modal is open
if (window.__fgDmReelCommentOpen || window.__fgDmReelShareOpen) {
unlockScroll();
} else if (window.__fgDmReelScrollLocked) {
lockScroll();
}
}
// Listen for comment box opening/closing
function setupCommentObserver() {
const commentBox = document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
if (commentBox) {
window.__fgDmReelCommentOpen = true;
updateScrollState();
}
}
// Listen for share modal
function setupShareObserver() {
const shareModal = document.querySelector('div[role="dialog"][aria-label*="Share"], section[aria-label*="Share"]');
if (shareModal) {
window.__fgDmReelShareOpen = true;
updateScrollState();
}
}
// Set up MutationObserver to detect comment/share modals
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
const ariaLabel = node.getAttribute('aria-label') || '';
const role = node.getAttribute('role') || '';
// Check for comment box
if (ariaLabel.toLowerCase().includes('comment') ||
(role === 'dialog' && ariaLabel === '')) {
// Check if it's a comment dialog
setTimeout(function() {
window.__fgDmReelCommentOpen = !!document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
updateScrollState();
}, 100);
}
// Check for share modal
if (ariaLabel.toLowerCase().includes('share')) {
window.__fgDmReelShareOpen = true;
updateScrollState();
}
}
});
mutation.removedNodes.forEach(function(node) {
if (node.nodeType === 1) {
const ariaLabel = node.getAttribute('aria-label') || '';
if (ariaLabel.toLowerCase().includes('comment')) {
setTimeout(function() {
window.__fgDmReelCommentOpen = !!document.querySelector('div[aria-label="Comment"], section[aria-label*="Comment"]');
updateScrollState();
}, 100);
}
if (ariaLabel.toLowerCase().includes('share')) {
window.__fgDmReelShareOpen = false;
updateScrollState();
}
}
});
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
// Initial lock
lockScroll();
// Expose functions for external control
window.__fgSetDmReelScrollLock = function(locked) {
window.__fgDmReelScrollLocked = locked;
updateScrollState();
};
})();
''';
// ─── JS-based (text-content detection, debounced) ─────────────────────────────
// Sponsored posts — scans for "Sponsored" text, debounced so it doesn't
// cause scroll jank on Instagram's constantly-mutating feed DOM.
const String kHideSponsoredPostsJS = r'''
(function() {
function hideSponsoredPosts() {
try {
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
try {
if (el.__fgSponsoredChecked) return; // skip already-processed elements
const spans = el.querySelectorAll('span');
for (let i = 0; i < spans.length; i++) {
const text = spans[i].textContent.trim();
if (text === 'Sponsored' || text === 'Paid partnership') {
el.style.setProperty('display', 'none', 'important');
return;
}
}
el.__fgSponsoredChecked = true; // mark as checked (non-sponsored)
} catch(_) {}
});
} catch(_) {}
}
hideSponsoredPosts();
if (!window.__fgSponsoredObserver) {
let _timer = null;
window.__fgSponsoredObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(hideSponsoredPosts, 300);
});
window.__fgSponsoredObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
// Suggested posts — debounced same way.
const String kHideSuggestedPostsJS = r'''
(function() {
function hideSuggestedPosts() {
try {
document.querySelectorAll('span, h3, h4').forEach(function(el) {
try {
const text = el.textContent.trim();
if (
text === 'Suggested for you' ||
text === 'Suggested posts' ||
text === "You're all caught up"
) {
let parent = el.parentElement;
for (let i = 0; i < 8 && parent; i++) {
const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
}
} catch(_) {}
});
} catch(_) {}
}
hideSuggestedPosts();
if (!window.__fgSuggestedObserver) {
let _timer = null;
window.__fgSuggestedObserver = new MutationObserver(() => {
clearTimeout(_timer);
_timer = setTimeout(hideSuggestedPosts, 300);
});
window.__fgSuggestedObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
})();
''';
// ─── DM Reel Blocker ─────────────────────────────────────────────────────────
/// Overlays a "Reels are disabled" card on reel preview cards inside DMs.
///
/// DM reel previews use pushState (SPA) not <a href> navigation, so the CSS
/// display:none in kDisableReelsEntirelyCssScript doesn't remove the preview
/// card from the thread. This script finds them structurally and covers them
/// with a blocking overlay that also swallows all touch/click events.
///
/// Inject when disableReelsEntirely OR minimalMode is on.
const String kDmReelBlockerJS = r'''
(function() {
if (window.__fgDmReelBlockerRunning) return;
window.__fgDmReelBlockerRunning = true;
const BLOCKED_ATTR = 'data-fg-blocked';
function buildOverlay() {
const div = document.createElement('div');
div.setAttribute(BLOCKED_ATTR, '1');
div.style.cssText = [
'position:absolute',
'inset:0',
'z-index:99999',
'display:flex',
'flex-direction:column',
'align-items:center',
'justify-content:center',
'background:rgba(0,0,0,0.85)',
'border-radius:inherit',
'pointer-events:all',
'gap:8px',
'cursor:default',
].join(';');
const icon = document.createElement('span');
icon.textContent = '🚫';
icon.style.cssText = 'font-size:28px;line-height:1';
const label = document.createElement('span');
label.textContent = 'Reels are disabled';
label.style.cssText = [
'color:#fff',
'font-size:13px',
'font-weight:600',
'font-family:-apple-system,sans-serif',
'text-align:center',
'padding:0 12px',
].join(';');
const sub = document.createElement('span');
sub.textContent = 'Disable "Block Reels" in FocusGram settings';
sub.style.cssText = [
'color:rgba(255,255,255,0.5)',
'font-size:11px',
'font-family:-apple-system,sans-serif',
'text-align:center',
'padding:0 16px',
].join(';');
div.appendChild(icon);
div.appendChild(label);
div.appendChild(sub);
// Swallow all interaction so the reel beneath cannot be triggered
['click','touchstart','touchend','touchmove','pointerdown'].forEach(function(evt) {
div.addEventListener(evt, function(e) {
e.preventDefault();
e.stopImmediatePropagation();
}, { capture: true });
});
return div;
}
function overlayContainer(container) {
if (!container) return;
if (container.querySelector('[' + BLOCKED_ATTR + ']')) return; // already overlaid
container.style.position = 'relative';
container.style.overflow = 'hidden';
container.appendChild(buildOverlay());
}
function blockDmReels() {
try {
// Strategy 1: <a href*="/reel/"> links inside the DM thread
document.querySelectorAll('a[href*="/reel/"]').forEach(function(link) {
try {
link.style.setProperty('pointer-events', 'none', 'important');
overlayContainer(link.closest('div') || link.parentElement);
} catch(_) {}
});
// Strategy 2: <video> inside DMs (reel cards without <a> wrapper)
// Only targets videos inside the Direct thread or on /direct/ path
document.querySelectorAll('video').forEach(function(video) {
try {
const inDm = !!video.closest('[aria-label="Direct"], [aria-label*="Direct"]');
const isDmPath = window.location.pathname.includes('/direct/');
if (!inDm && !isDmPath) return;
const container = video.closest('div[class]') || video.parentElement;
if (!container) return;
video.style.setProperty('pointer-events', 'none', 'important');
overlayContainer(container);
} catch(_) {}
});
} catch(_) {}
}
blockDmReels();
let _t = null;
new MutationObserver(function() {
clearTimeout(_t);
_t = setTimeout(blockDmReels, 200);
}).observe(document.documentElement, { childList: true, subtree: true });
})();
''';

View File

@@ -0,0 +1,570 @@
// 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);
})();
''';

View File

@@ -0,0 +1,16 @@
/// JS to help Instagram's layout detect viewport changes when the Android
/// soft keyboard appears in a WebView container.
///
/// It listens for resize events and re-dispatches an `orientationchange`
/// event, which nudges Instagram's layout system out of the DM loading
/// spinner state.
const String kDmKeyboardFixJS = r'''
// Fix: tell Instagram's layout system the viewport has changed after keyboard events
// This resolves the loading state that appears on DM screens in WebView
window.addEventListener('resize', function() {
try {
window.dispatchEvent(new Event('orientationchange'));
} catch (_) {}
});
''';

View File

@@ -0,0 +1,48 @@
/// Grayscale style injector.
/// Uses a <style> tag with !important so Instagram's CSS cannot override it.
const String kGrayscaleJS = r'''
(function fgGrayscale() {
try {
const ID = 'fg-grayscale';
function inject() {
let el = document.getElementById(ID);
if (!el) {
el = document.createElement('style');
el.id = ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = 'html { filter: grayscale(100%) !important; }';
}
inject();
if (!window.__fgGrayscaleObserver) {
window.__fgGrayscaleObserver = new MutationObserver(() => {
if (!document.getElementById('fg-grayscale')) inject();
});
window.__fgGrayscaleObserver.observe(
document.documentElement,
{ childList: true, subtree: true }
);
}
} catch (_) {}
})();
''';
/// Removes grayscale AND disconnects the observer so it cannot re-add it.
/// Previously kGrayscaleOffJS only removed the style tag — the observer
/// immediately re-injected it, requiring an app restart to actually go off.
const String kGrayscaleOffJS = r'''
(function() {
try {
// 1. Disconnect the observer FIRST so it cannot react to the removal
if (window.__fgGrayscaleObserver) {
window.__fgGrayscaleObserver.disconnect();
window.__fgGrayscaleObserver = null;
}
// 2. Remove the style tag
const el = document.getElementById('fg-grayscale');
if (el) el.remove();
// 3. Clear any inline filter that may have been set by older code
document.documentElement.style.filter = '';
} catch (_) {}
})();
''';

View File

@@ -0,0 +1,12 @@
const String kHapticBridgeScript = '''
(function() {
// Trigger native haptic feedback on double-tap (like gesture on posts)
// Uses flutter_inappwebview's callHandler instead of postMessage
document.addEventListener('dblclick', function(e) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('Haptic', 'light');
}
}, true);
})();
''';

View File

@@ -0,0 +1,68 @@
// Document-start script — injected before Instagram's JS loads.
const String kNativeFeelingScript = '''
(function() {
const style = document.createElement('style');
style.id = 'fg-native-feel';
style.textContent = `
/* Hide all scrollbars */
* {
-ms-overflow-style: none !important;
scrollbar-width: none !important;
}
*::-webkit-scrollbar {
display: none !important;
}
/* Remove blue tap highlight */
* {
-webkit-tap-highlight-color: transparent !important;
}
/* Disable text selection globally except inputs */
* {
-webkit-user-select: none !important;
user-select: none !important;
}
input, textarea, [contenteditable="true"] {
-webkit-user-select: text !important;
user-select: text !important;
}
/* Momentum scrolling */
* {
-webkit-overflow-scrolling: touch !important;
}
/* Remove focus outlines */
*:focus, *:focus-visible {
outline: none !important;
}
/* Fade images in */
img {
animation: igFadeIn 0.15s ease-in-out;
}
@keyframes igFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
`;
if (document.head) {
document.head.appendChild(style);
} else {
document.addEventListener('DOMContentLoaded', () => {
document.head.appendChild(style);
});
}
})();
''';
// Post-load script — call in onLoadStop only.
// IMPORTANT: Do NOT add overscroll-behavior rules here — they lock the feed scroll.
const String kNativeFeelingPostLoadScript = '''
(function() {
// Smooth anchor scrolling only — do NOT apply to all containers.
document.documentElement.style.scrollBehavior = 'auto';
})();
''';

View File

@@ -0,0 +1,118 @@
// Reel metadata extraction for history feature.
// Extracts title and thumbnail URL from the page and sends to Flutter.
const String kReelMetadataExtractorScript = r'''
(function() {
// Track if we've already extracted for this URL to avoid duplicates
window.__fgReelExtracted = window.__fgReelExtracted || false;
window.__fgLastExtractedUrl = window.__fgLastExtractedUrl || '';
function extractAndSend() {
const currentUrl = window.location.href;
// Skip if already extracted for this URL
if (window.__fgReelExtracted && window.__fgLastExtractedUrl === currentUrl) {
return;
}
// Check if this is a reel page
if (!currentUrl.includes('/reel/')) {
return;
}
// Try multiple sources for metadata
let title = '';
let thumbnailUrl = '';
// 1. Try Open Graph tags
const ogTitle = document.querySelector('meta[property="og:title"]');
const ogImage = document.querySelector('meta[property="og:image"]');
if (ogTitle) title = ogTitle.content;
if (ogImage) thumbnailUrl = ogImage.content;
// 2. Fallback to document title if no OG title
if (!title && document.title) {
title = document.title.replace(' on Instagram', '').trim();
if (!title) title = 'Instagram Reel';
}
// 3. Try JSON-LD structured data
if (!thumbnailUrl) {
const jsonLdScripts = document.querySelectorAll('script[type="application/ld+json"]');
jsonLdScripts.forEach(function(script) {
try {
const data = JSON.parse(script.textContent);
if (data.image) {
if (Array.isArray(data.image)) {
thumbnailUrl = data.image[0];
} else if (typeof data.image === 'string') {
thumbnailUrl = data.image;
} else if (data.image.url) {
thumbnailUrl = data.image.url;
}
}
} catch(e) {}
});
}
// 4. Try Twitter card as fallback
if (!thumbnailUrl) {
const twitterImage = document.querySelector('meta[name="twitter:image"]');
if (twitterImage) thumbnailUrl = twitterImage.content;
}
// Skip if no thumbnail found
if (!thumbnailUrl) {
return;
}
// Mark as extracted
window.__fgReelExtracted = true;
window.__fgLastExtractedUrl = currentUrl;
// Send to Flutter
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'ReelMetadata',
JSON.stringify({
url: currentUrl,
title: title || 'Instagram Reel',
thumbnailUrl: thumbnailUrl
})
);
}
}
// Run immediately in case metadata is already loaded
extractAndSend();
// Set up MutationObserver to detect page changes and metadata loading
if (!window.__fgReelObserver) {
let debounceTimer = null;
window.__fgReelObserver = new MutationObserver(function(mutations) {
// Debounce to avoid excessive calls
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
extractAndSend();
}, 500);
});
window.__fgReelObserver.observe(document.documentElement, {
childList: true,
subtree: true
});
}
// Also listen for URL changes (SPA navigation)
let lastUrl = location.href;
setInterval(function() {
if (location.href !== lastUrl) {
lastUrl = location.href;
window.__fgReelExtracted = false;
window.__fgLastExtractedUrl = '';
extractAndSend();
}
}, 1000);
})();
''';

View File

@@ -0,0 +1,13 @@
/// JS to improve momentum scrolling behaviour inside the WebView, especially
/// for content-heavy feeds like Reels.
///
/// Applies touch-style overflow scrolling hints to the root element.
const String kScrollSmoothingJS = r'''
(function fgScrollSmoothing() {
try {
document.documentElement.style.setProperty('-webkit-overflow-scrolling', 'touch');
document.documentElement.style.setProperty('overflow-scrolling', 'touch');
} catch (_) {}
})();
''';

View File

@@ -0,0 +1,32 @@
const String kSpaNavigationMonitorScript = '''
(function() {
// Monitor Instagram's SPA navigation and notify Flutter on every URL change.
// Instagram uses history.pushState — onLoadStop won't fire for these transitions.
// This is injected at document start so it wraps pushState before Instagram does.
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
function notifyUrlChange(url) {
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler(
'UrlChange',
url || window.location.href
);
}
}
history.pushState = function() {
originalPushState.apply(this, arguments);
setTimeout(() => notifyUrlChange(arguments[2]), 100);
};
history.replaceState = function() {
originalReplaceState.apply(this, arguments);
setTimeout(() => notifyUrlChange(arguments[2]), 100);
};
window.addEventListener('popstate', () => notifyUrlChange());
})();
''';

263
lib/scripts/ui_hider.dart Normal file
View File

@@ -0,0 +1,263 @@
// UI element hiding for Instagram web.
//
// SCROLL LOCK WARNING:
// MutationObserver callbacks with {childList:true, subtree:true} fire hundreds
// of times/sec on Instagram's infinite scroll feed. Expensive querySelectorAll
// calls inside these callbacks block the main thread = scroll jank.
// All JS hiders below use a 300ms debounce so they run only after mutations settle.
// ─── CSS-based ────────────────────────────────────────────────────────────────
// FIX: Like count CSS.
// Instagram's like BUTTON has aria-label="Like" (the verb) — NOT the count.
// [role="button"][aria-label$=" likes"] never matches anything.
// The COUNT lives in a[href*="/liked_by/"] (e.g. "1,234 likes" link).
// We hide that link. The JS hider below catches React-rendered span variants.
const String kHideLikeCountsCSS = '''
a[href*="/liked_by/"],
section a[href*="/liked_by/"] {
display: none !important;
}
''';
const String kHideFollowerCountsCSS = '''
a[href*="/followers/"] span,
a[href*="/following/"] span {
opacity: 0 !important;
pointer-events: none !important;
}
''';
// Stories bar CSS — multiple selectors for different Instagram DOM versions.
// :has() is supported in WebKit (Instagram's engine). Targets the container,
// not individual story items which is what [aria-label*="Stories"] matches.
const String kHideStoriesBarCSS = '''
[aria-label*="Stories"],
[aria-label*="stories"],
[role="list"]:has([aria-label*="tory"]),
[role="listbox"]:has([aria-label*="tory"]),
div[style*="overflow"][style*="scroll"]:has(canvas),
section > div > div[style*="overflow-x"] {
display: none !important;
}
''';
const String kHideExploreTabCSS = '''
a[href="/explore/"],
a[href="/explore"] {
display: none !important;
}
''';
const String kHideReelsTabCSS = '''
a[href="/reels/"],
a[href="/reels"] {
display: none !important;
}
''';
const String kHideShopTabCSS = '''
a[href*="/shop"],
a[href*="/shopping"] {
display: none !important;
}
''';
// ─── JS-based ─────────────────────────────────────────────────────────────────
// Like counts — JS fallback for React-rendered count spans not caught by CSS.
// Scans for text matching "1,234 likes" / "12.3K views" patterns.
const String kHideLikeCountsJS = r'''
(function() {
function hideLikeCounts() {
try {
// Hide liked_by links and their immediate parent wrapper
document.querySelectorAll('a[href*="/liked_by/"]').forEach(function(el) {
try {
el.style.setProperty('display', 'none', 'important');
// Also hide the parent span/div that wraps the count text
if (el.parentElement) {
el.parentElement.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Scan spans for numeric like/view count text patterns
document.querySelectorAll('span').forEach(function(el) {
try {
const text = el.textContent.trim();
// Matches: "1,234 likes", "12.3K views", "1 like", "45 views", etc.
if (/^[\d,.]+[KkMm]?\s+(like|likes|view|views)$/.test(text)) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
} catch(_) {}
}
hideLikeCounts();
if (!window.__fgLikeCountObserver) {
let _t = null;
window.__fgLikeCountObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideLikeCounts, 300);
});
window.__fgLikeCountObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Stories bar JS — structural detection when CSS selectors don't match.
// Two strategies:
// 1. aria-label scan on role=list/listbox elements
// 2. BoundingClientRect check: story circles are square, narrow (<120px), appear in a row
const String kHideStoriesBarJS = r'''
(function() {
function hideStories() {
try {
// Strategy 1: aria-label on list containers
document.querySelectorAll('[role="list"], [role="listbox"]').forEach(function(el) {
try {
const label = (el.getAttribute('aria-label') || '').toLowerCase();
if (label.includes('stor')) {
el.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
// Strategy 2: BoundingClientRect — story circles are narrow square items in a row.
// Look for a <ul> or <div role=list> whose first child is roughly square and < 120px wide.
document.querySelectorAll('ul, [role="list"]').forEach(function(el) {
try {
const items = el.children;
if (items.length < 3) return;
const first = items[0].getBoundingClientRect();
// Story item: small, roughly square (width ≈ height), near top of viewport
if (
first.width > 0 &&
first.width < 120 &&
Math.abs(first.width - first.height) < 20 &&
first.top < 300
) {
el.style.setProperty('display', 'none', 'important');
// Also hide the section wrapping this if it has no article (pure stories row)
const section = el.closest('section, div[class]');
if (section && !section.querySelector('article')) {
section.style.setProperty('display', 'none', 'important');
}
}
} catch(_) {}
});
// Strategy 3: horizontal overflow container before any article in the feed
document.querySelectorAll('main > div > div > div').forEach(function(container) {
try {
if (container.querySelector('article')) return;
const inner = container.querySelector('div, ul');
if (!inner) return;
const s = window.getComputedStyle(inner);
if (s.overflowX === 'scroll' || s.overflowX === 'auto') {
container.style.setProperty('display', 'none', 'important');
}
} catch(_) {}
});
} catch(_) {}
}
hideStories();
if (!window.__fgStoriesObserver) {
let _t = null;
window.__fgStoriesObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideStories, 300);
});
window.__fgStoriesObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Sponsored posts — scans article elements for "Sponsored" text child.
// CSS cannot traverse from child text up to parent — JS only.
const String kHideSponsoredPostsJS = r'''
(function() {
function hideSponsoredPosts() {
try {
document.querySelectorAll('article, li[role="listitem"]').forEach(function(el) {
try {
if (el.__fgSponsoredChecked) return;
const spans = el.querySelectorAll('span');
for (let i = 0; i < spans.length; i++) {
const text = spans[i].textContent.trim();
if (text === 'Sponsored' || text === 'Paid partnership') {
el.style.setProperty('display', 'none', 'important');
return;
}
}
el.__fgSponsoredChecked = true;
} catch(_) {}
});
} catch(_) {}
}
hideSponsoredPosts();
if (!window.__fgSponsoredObserver) {
let _t = null;
window.__fgSponsoredObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideSponsoredPosts, 300);
});
window.__fgSponsoredObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';
// Suggested posts — scans for heading text, walks up to parent article/section.
const String kHideSuggestedPostsJS = r'''
(function() {
function hideSuggestedPosts() {
try {
document.querySelectorAll('span, h3, h4').forEach(function(el) {
try {
const text = el.textContent.trim();
if (
text === 'Suggested for you' ||
text === 'Suggested posts' ||
text === "You're all caught up"
) {
let parent = el.parentElement;
for (let i = 0; i < 8 && parent; i++) {
const tag = parent.tagName.toLowerCase();
if (tag === 'article' || tag === 'section' || tag === 'li') {
parent.style.setProperty('display', 'none', 'important');
break;
}
parent = parent.parentElement;
}
}
} catch(_) {}
});
} catch(_) {}
}
hideSuggestedPosts();
if (!window.__fgSuggestedObserver) {
let _t = null;
window.__fgSuggestedObserver = new MutationObserver(() => {
clearTimeout(_t);
_t = setTimeout(hideSuggestedPosts, 300);
});
window.__fgSuggestedObserver.observe(
document.documentElement, { childList: true, subtree: true }
);
}
})();
''';