mirror of
https://github.com/Ujwal223/FocusGram.git
synced 2026-04-01 17:10:23 +02:00
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:
225
lib/scripts/autoplay_blocker.dart
Normal file
225
lib/scripts/autoplay_blocker.dart
Normal 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);
|
||||
})();
|
||||
''';
|
||||
611
lib/scripts/content_disabling.dart
Normal file
611
lib/scripts/content_disabling.dart
Normal 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 });
|
||||
})();
|
||||
''';
|
||||
570
lib/scripts/core_injection.dart
Normal file
570
lib/scripts/core_injection.dart
Normal 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);
|
||||
})();
|
||||
''';
|
||||
16
lib/scripts/dm_keyboard_fix.dart
Normal file
16
lib/scripts/dm_keyboard_fix.dart
Normal 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 (_) {}
|
||||
});
|
||||
''';
|
||||
|
||||
48
lib/scripts/grayscale.dart
Normal file
48
lib/scripts/grayscale.dart
Normal 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 (_) {}
|
||||
})();
|
||||
''';
|
||||
12
lib/scripts/haptic_bridge.dart
Normal file
12
lib/scripts/haptic_bridge.dart
Normal 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);
|
||||
})();
|
||||
''';
|
||||
|
||||
68
lib/scripts/native_feel.dart
Normal file
68
lib/scripts/native_feel.dart
Normal 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';
|
||||
})();
|
||||
''';
|
||||
118
lib/scripts/reel_metadata_extractor.dart
Normal file
118
lib/scripts/reel_metadata_extractor.dart
Normal 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);
|
||||
})();
|
||||
''';
|
||||
13
lib/scripts/scroll_smoothing.dart
Normal file
13
lib/scripts/scroll_smoothing.dart
Normal 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 (_) {}
|
||||
})();
|
||||
''';
|
||||
|
||||
32
lib/scripts/spa_navigation_monitor.dart
Normal file
32
lib/scripts/spa_navigation_monitor.dart
Normal 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
263
lib/scripts/ui_hider.dart
Normal 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 }
|
||||
);
|
||||
}
|
||||
})();
|
||||
''';
|
||||
Reference in New Issue
Block a user