diff --git a/Ported/app/src/main/AndroidManifest.xml b/Ported/app/src/main/AndroidManifest.xml
index e8c5cfa..12f0510 100644
--- a/Ported/app/src/main/AndroidManifest.xml
+++ b/Ported/app/src/main/AndroidManifest.xml
@@ -69,10 +69,6 @@
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:windowSoftInputMode="adjustResize">
-
-
-
-
@@ -94,6 +90,193 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Array<{pk, username, fullName, avatar, hasLiked}>
+
+ browser.webRequest.onBeforeRequest.addListener(
+ function (details) {
+ const url = details.url || '';
+ const match = url.match(/\/api\/v1\/media\/(\d+)\/list_reel_media_viewer\//);
+ if (!match) return;
+
+ const mediaId = match[1];
+
+ // Immediately broadcast detection when request is SENT so search bar appears right away!
+ broadcastToTabs({
+ action: 'storyViewersDetected',
+ phase: 'loading',
+ mediaId: mediaId,
+ url: url
+ });
+
+ if (typeof browser.webRequest.filterResponseData === 'function') {
+ try {
+ const filter = browser.webRequest.filterResponseData(details.requestId);
+ const chunks = [];
+
+ filter.ondata = function (event) {
+ chunks.push(event.data);
+ filter.write(event.data); // Transparently stream data to Instagram web client
+ };
+
+ filter.onstop = async function () {
+ filter.close();
+ try {
+ const blob = new Blob(chunks);
+ const text = await blob.text();
+ const json = JSON.parse(text);
+ const users = json.users || [];
+ const rawViewers = json.viewers || [];
+
+ const existing = storyViewersCache.get(mediaId) || [];
+ const userMap = new Map(existing.map(u => [String(u.pk || u.id), u]));
+
+ users.forEach(u => {
+ const pk = String(u.pk || u.id);
+ if (!pk) return;
+ userMap.set(pk, {
+ pk: pk,
+ username: u.username || '',
+ fullName: u.full_name || '',
+ avatar: u.profile_pic_url || '',
+ isVerified: !!u.is_verified,
+ hasLiked: false
+ });
+ });
+
+ rawViewers.forEach(v => {
+ const u = v.user || v;
+ const pk = String(u.pk || u.id);
+ if (!pk) return;
+ const existingEntry = userMap.get(pk) || {};
+ userMap.set(pk, {
+ pk: pk,
+ username: u.username || existingEntry.username || '',
+ fullName: u.full_name || existingEntry.fullName || '',
+ avatar: u.profile_pic_url || existingEntry.avatar || '',
+ isVerified: !!(u.is_verified || existingEntry.isVerified),
+ hasLiked: !!(v.has_liked || existingEntry.hasLiked)
+ });
+ });
+
+ const viewerList = Array.from(userMap.values());
+ storyViewersCache.set(mediaId, viewerList);
+ console.log(`[FocusGram] Story viewers captured for ${mediaId}: ${viewerList.length} users`);
+
+ // Broadcast captured viewers to active tabs
+ broadcastToTabs({
+ action: 'storyViewersDetected',
+ phase: 'ready',
+ mediaId: mediaId,
+ viewers: viewerList
+ });
+ broadcastToTabs({
+ action: 'storyViewersCaptured',
+ mediaId: mediaId,
+ viewers: viewerList
+ });
+ } catch (e) {
+ console.warn('[FocusGram] Error parsing story viewers payload:', e);
+ }
+ };
+ } catch (err) {
+ console.warn('[FocusGram] filterResponseData error:', err);
+ }
+ }
+ },
+ { urls: ["*://*.instagram.com/api/v1/media/*/list_reel_media_viewer/*"] },
+ ["blocking"]
+ );
}
// Establish native messaging port with FocusGram Android host
let nativePort = null;
+const pendingNativeMessages = [];
+
+function sendToNative(msg) {
+ if (!msg) return;
+ if (nativePort) {
+ try {
+ nativePort.postMessage(msg);
+ console.log('[FocusGram Background] Forwarded to native host:', JSON.stringify(msg));
+ return;
+ } catch (e) {
+ console.warn('[FocusGram Background] postMessage failed, queueing message:', e);
+ nativePort = null;
+ }
+ }
+ pendingNativeMessages.push(msg);
+ if (pendingNativeMessages.length > 50) pendingNativeMessages.shift();
+ connectNativePort();
+}
+
+function flushPendingMessages() {
+ if (!nativePort) return;
+ while (pendingNativeMessages.length > 0) {
+ const msg = pendingNativeMessages.shift();
+ try {
+ nativePort.postMessage(msg);
+ console.log('[FocusGram Background] Flushed queued message to native host:', JSON.stringify(msg));
+ } catch (e) {
+ console.error('[FocusGram Background] Error flushing queued message:', e);
+ pendingNativeMessages.unshift(msg);
+ break;
+ }
+ }
+}
function connectNativePort() {
try {
if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.connectNative) {
+ if (nativePort) return;
nativePort = browser.runtime.connectNative("focusgram");
console.log('[FocusGram Background] Connected to native host "focusgram"');
@@ -136,12 +274,14 @@ function connectNativePort() {
nativePort.onDisconnect.addListener((p) => {
console.warn('[FocusGram Background] Native port disconnected, will retry...');
nativePort = null;
- setTimeout(connectNativePort, 5000);
+ setTimeout(connectNativePort, 3000);
});
+
+ flushPendingMessages();
}
} catch (e) {
console.error('[FocusGram Background] Error connecting native port:', e);
- setTimeout(connectNativePort, 5000);
+ setTimeout(connectNativePort, 3000);
}
}
@@ -175,33 +315,37 @@ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessa
return true;
}
- if (!nativePort) {
- connectNativePort();
+ if (message.action === 'getStoryViewers') {
+ const mediaId = message.mediaId;
+ const viewers = mediaId ? (storyViewersCache.get(mediaId) || []) : [];
+ sendResponse({ viewers: viewers });
+ return true;
}
- if (nativePort) {
- try {
- if (message.type === 'ReelMetadata' || message.action === 'reelViewed' || message.action === 'reelWatched') {
- const payload = message.payload || message.data || message.reel || message;
- nativePort.postMessage({
- action: 'reelViewed',
- type: message.type || 'reelViewed',
- code: payload.code || message.code || '',
- url: payload.url || message.url || '',
- title: payload.caption || payload.title || message.caption || message.title || '',
- caption: payload.caption || message.caption || '',
- author: payload.author || message.author || '',
- thumbnailUrl: payload.thumbnailUrl || message.thumbnailUrl || ''
- });
- } else {
- nativePort.postMessage(message);
- }
- console.log('[FocusGram Background] Forwarded to native host:', JSON.stringify(message));
- } catch (e) {
- console.error('[FocusGram Background] Failed to forward message to native host:', e);
- }
+ if (message.action === 'broadcastToTabs' && message.payload) {
+ broadcastToTabs(message.payload);
+ sendResponse({ status: 'broadcasted' });
+ return true;
}
+
+ if (message.type === 'ReelMetadata' || message.action === 'reelViewed' || message.action === 'reelWatched') {
+ const payload = message.payload || message.data || message.reel || message;
+ sendToNative({
+ action: 'reelViewed',
+ type: message.type || 'reelViewed',
+ code: payload.code || message.code || '',
+ url: payload.url || message.url || '',
+ title: payload.caption || payload.title || message.caption || message.title || '',
+ caption: payload.caption || message.caption || '',
+ author: payload.author || message.author || '',
+ thumbnailUrl: payload.thumbnailUrl || message.thumbnailUrl || ''
+ });
+ } else {
+ sendToNative(message);
+ }
+
sendResponse({ status: 'forwarded' });
return true;
});
}
+
diff --git a/Ported/app/src/main/assets/extensions/ublock/content.js b/Ported/app/src/main/assets/extensions/ublock/content.js
index cbeb70b..134d1f7 100644
--- a/Ported/app/src/main/assets/extensions/ublock/content.js
+++ b/Ported/app/src/main/assets/extensions/ublock/content.js
@@ -14,6 +14,9 @@
function isAuthPage() {
try {
const path = (window.location.pathname || '').toLowerCase();
+ if (path.includes('/accounts/edit')) {
+ return false;
+ }
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) {
return true;
}
diff --git a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js b/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js
index 1e8ea06..e6d0f1c 100644
--- a/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js
+++ b/Ported/app/src/main/assets/extensions/ublock/focusgram_content.js
@@ -33,6 +33,9 @@
function isAuthPage() {
try {
const path = (window.location.pathname || '').toLowerCase();
+ if (path.includes('/accounts/edit')) {
+ return false;
+ }
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) {
return true;
}
@@ -557,19 +560,41 @@
const response = await originalFetch.apply(this, arguments);
- // Main-World GraphQL, Clips, & Feed Ad Filtering & Reel Meta Harvesting
- if (url.includes('/graphql') || url.includes('/api/v1/feed') || url.includes('/api/v1/clips/') || url.includes('/clips/')) {
+ // Main-World GraphQL, Clips, & Feed Ad Filtering & Reel Meta Harvesting & DM Masking
+ if (url.includes('/graphql') || url.includes('/api/v1/feed') || url.includes('/api/v1/clips/') || url.includes('/clips/') || url.includes('/direct/')) {
try {
const contentType = response.headers.get('content-type') || '';
- if (contentType.includes('application/json')) {
+ if (contentType.includes('json') || contentType.includes('javascript')) {
const cloned = response.clone();
- const data = await cloned.json();
- walkAndFilter(data);
+ let data;
+ try {
+ data = await cloned.json();
+ } catch (_) {
+ try {
+ let text = await cloned.text();
+ text = text.replace(/^for\s*\(\s*;\s*;\s*\)\s*;\s*/, '');
+ data = JSON.parse(text);
+ } catch (e) {
+ return response;
+ }
+ }
+
+ let directModified = false;
+ if (typeof window.__fgProcessDirectMessagesPayload === 'function') {
+ directModified = window.__fgProcessDirectMessagesPayload(data);
+ }
+
+ let adModified = false;
if (window.__fgFilterConfig && window.__fgFilterConfig.blockAds !== false) {
+ walkAndFilter(data);
+ adModified = true;
+ }
+
+ if (directModified || adModified) {
const newHeaders = new Headers(response.headers);
newHeaders.delete('content-length');
newHeaders.delete('content-encoding');
- newHeaders.set('content-type', 'application/json; charset=utf-8');
+ newHeaders.set('content-type', contentType || 'application/json; charset=utf-8');
return new Response(JSON.stringify(data), {
status: response.status,
statusText: response.statusText,
@@ -2176,6 +2201,9 @@
function isAnyModalOpen() {
const path = window.location.pathname.toLowerCase();
+ if (path.includes('/accounts/edit')) {
+ return false;
+ }
// 1. Route-based detection (DM chats, stories, auth, create)
if (isAuthRoute(path) || path.includes('/comments') || path.startsWith('/direct/t/') || path.startsWith('/stories/') || path.startsWith('/create')) {
@@ -2276,7 +2304,580 @@
return mode;
}
+ /* ==========================================================================
+ CHAT PRIVACY VEIL & INBOX PREVIEW MASKING
+ Zero-latency pure black cover over locked chat threads + snippet redaction.
+ ========================================================================== */
+ let chatLockMode = 'individual';
+ let chatLockAll = false;
+ let lockedChatIds = new Set();
+ const unlockedThreads = new Set();
+ const threadUsernameMap = new Map();
+
+ function syncGlobalsToWindow() {
+ const targets = [window];
+ if (typeof window.wrappedJSObject !== 'undefined' && window.wrappedJSObject) {
+ targets.push(window.wrappedJSObject);
+ }
+ const lockedArray = Array.from(lockedChatIds);
+ const unlockedArray = Array.from(unlockedThreads);
+ const userMapObj = {};
+ for (const [k, v] of threadUsernameMap.entries()) {
+ userMapObj[k] = v;
+ }
+
+ for (const target of targets) {
+ try {
+ target.__fgChatLockMode = chatLockMode;
+ target.__fgChatLockAll = chatLockAll;
+ target.__fgLockedChatIds = lockedArray;
+ target.__fgUnlockedThreads = unlockedArray;
+ target.__fgThreadUsernameMap = userMapObj;
+ } catch (_) {}
+ }
+
+ const syncDetail = {
+ chatLockMode: chatLockMode,
+ chatLockAll: chatLockAll,
+ lockedChatIds: lockedArray,
+ unlockedThreads: unlockedArray,
+ threadUsernameMap: userMapObj
+ };
+ try {
+ window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { detail: syncDetail }));
+ document.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { detail: syncDetail }));
+ } catch (_) {}
+ }
+
+ function syncChatLockSettings(settings) {
+ if (!settings) return;
+ if (typeof settings.chatLockMode !== 'undefined') {
+ chatLockMode = String(settings.chatLockMode).toLowerCase();
+ }
+ if (typeof settings.chatLockAll !== 'undefined') {
+ chatLockAll = !!settings.chatLockAll;
+ if (chatLockMode === 'whole_dm') {
+ chatLockAll = true;
+ } else if (chatLockMode === 'off') {
+ chatLockAll = false;
+ }
+ } else if (chatLockMode === 'whole_dm') {
+ chatLockAll = true;
+ } else if (chatLockMode === 'off') {
+ chatLockAll = false;
+ }
+ if (Array.isArray(settings.lockedChatIds)) {
+ lockedChatIds = new Set(settings.lockedChatIds.map(id => String(id).toLowerCase().trim().replace(/^@/, '')));
+ }
+ if (settings.lockedChatUsernames && typeof settings.lockedChatUsernames === 'object') {
+ for (const [tid, uname] of Object.entries(settings.lockedChatUsernames)) {
+ const cleanTid = String(tid).toLowerCase().trim();
+ const cleanUname = String(uname).toLowerCase().trim().replace(/^@/, '');
+ if (cleanTid && cleanUname) {
+ threadUsernameMap.set(cleanTid, cleanUname);
+ threadUsernameMap.set(cleanUname, cleanTid);
+ if (lockedChatIds.has(cleanTid)) lockedChatIds.add(cleanUname);
+ if (lockedChatIds.has(cleanUname)) lockedChatIds.add(cleanTid);
+ }
+ }
+ }
+
+ syncGlobalsToWindow();
+ evaluateChatLockState();
+ maskInboxChatPreviews();
+ }
+
+ const handleThreadMatched = (e) => {
+ const detail = e.detail;
+ if (detail && detail.threadId) {
+ const cleanTid = String(detail.threadId).toLowerCase().trim();
+ const cleanKey = detail.threadKey ? String(detail.threadKey).toLowerCase().trim() : '';
+ if (detail.isLocked) {
+ lockedChatIds.add(cleanTid);
+ if (cleanKey) lockedChatIds.add(cleanKey);
+ }
+ if (Array.isArray(detail.usernames)) {
+ for (const u of detail.usernames) {
+ const cleanUser = String(u).toLowerCase().trim().replace(/^@/, '');
+ if (cleanUser) {
+ threadUsernameMap.set(cleanTid, cleanUser);
+ threadUsernameMap.set(cleanUser, cleanTid);
+ if (cleanKey) {
+ threadUsernameMap.set(cleanKey, cleanUser);
+ threadUsernameMap.set(cleanUser, cleanKey);
+ }
+ if (lockedChatIds.has(cleanUser)) {
+ lockedChatIds.add(cleanTid);
+ if (cleanKey) lockedChatIds.add(cleanKey);
+ }
+ if (lockedChatIds.has(cleanTid) || (cleanKey && lockedChatIds.has(cleanKey))) {
+ lockedChatIds.add(cleanUser);
+ }
+ }
+ }
+ }
+ postToNative({
+ action: 'threadUsernameMapped',
+ threadId: cleanTid,
+ threadKey: cleanKey,
+ username: (detail.usernames && detail.usernames[0]) || '',
+ usernames: detail.usernames || [],
+ displayName: detail.displayName || '',
+ isLocked: !!detail.isLocked
+ });
+ syncGlobalsToWindow();
+ evaluateChatLockState();
+ maskInboxChatPreviews();
+ }
+ };
+ window.addEventListener('focusgram:thread-matched', handleThreadMatched);
+ document.addEventListener('focusgram:thread-matched', handleThreadMatched);
+
+ function handleUnlockChat(threadId, isAll) {
+ if (isAll) {
+ unlockedThreads.add('__all__');
+ }
+ if (threadId) {
+ const clean = String(threadId).toLowerCase().trim();
+ unlockedThreads.add(clean);
+ const mapped = threadUsernameMap.get(clean);
+ if (mapped) unlockedThreads.add(mapped);
+ }
+ syncGlobalsToWindow();
+ removePrivacyVeil();
+ evaluateChatLockState();
+ maskInboxChatPreviews();
+ }
+
+ function handleLockChat(threadId) {
+ if (threadId) {
+ const clean = String(threadId).toLowerCase().trim();
+ unlockedThreads.delete(clean);
+ const mapped = threadUsernameMap.get(clean);
+ if (mapped) unlockedThreads.delete(mapped);
+ lockedChatIds.add(clean);
+ if (mapped) lockedChatIds.add(mapped);
+ }
+ syncGlobalsToWindow();
+ evaluateChatLockState();
+ maskInboxChatPreviews();
+ }
+
+ function extractCurrentThreadId() {
+ const path = window.location.pathname.toLowerCase();
+ if (!path.startsWith('/direct/t/')) return null;
+ const parts = path.replace('/direct/t/', '').split('/').filter(Boolean);
+ return parts[0] ? parts[0].trim() : null;
+ }
+
+ function isCurrentThreadExplicitlyLocked(threadId) {
+ if (!threadId) return false;
+ const cleanId = String(threadId).toLowerCase().trim();
+ if (lockedChatIds.has(cleanId)) return true;
+ const mappedUser = threadUsernameMap.get(cleanId);
+ if (mappedUser && lockedChatIds.has(mappedUser)) return true;
+ for (const lockedId of lockedChatIds) {
+ if (window.location.href.toLowerCase().includes(lockedId)) return true;
+ }
+ return false;
+ }
+
+ function extractThreadParticipants() {
+ const currentThreadId = extractCurrentThreadId();
+ if (!currentThreadId) return null;
+
+ let username = '';
+ let displayName = '';
+
+ // 1. Inspect document.title
+ const docTitle = document.title || '';
+ const titleMatch = docTitle.match(/@([a-zA-Z0-9_\.]+)/);
+ if (titleMatch && titleMatch[1]) {
+ username = titleMatch[1].trim();
+ } else if (docTitle.includes('โข')) {
+ const candidate = docTitle.split('โข')[0].trim();
+ if (candidate && !['direct', 'messages', 'instagram'].includes(candidate.toLowerCase())) {
+ displayName = candidate;
+ }
+ }
+
+ // 2. Inspect header avatar image alt text
+ if (!username) {
+ const headerImgs = document.querySelectorAll('header img[alt], [role="banner"] img[alt], [role="main"] header img[alt]');
+ for (const img of headerImgs) {
+ const alt = img.getAttribute('alt') || '';
+ const m = alt.match(/(.+?)'s profile picture/i) || alt.match(/profile picture of (.+)/i);
+ if (m && m[1]) {
+ username = m[1].trim().replace(/^@/, '');
+ break;
+ }
+ }
+ }
+
+ // 3. Instagram header profile link inspection
+ if (!username) {
+ const profileLinks = document.querySelectorAll(
+ 'header a[aria-label*="profile page of" i], [role="banner"] a[aria-label*="profile page of" i], ' +
+ '[role="main"] header a[aria-label*="profile page of" i], a[aria-label*="Open the profile page of" i], ' +
+ 'header a[href^="/"], [role="banner"] a[href^="/"], [role="main"] header a[href^="/"]'
+ );
+
+ for (const link of profileLinks) {
+ const aria = link.getAttribute('aria-label') || '';
+ const matchAria = aria.match(/profile page of (.+)/i);
+ if (matchAria && matchAria[1]) {
+ username = matchAria[1].trim().replace(/^@/, '');
+ break;
+ }
+ const href = link.getAttribute('href') || '';
+ const cleanHref = href.replace(/^https?:\/\/[^\/]+/, '');
+ const matchHref = cleanHref.match(/^\/([a-zA-Z0-9_\.]+)\/?(?:\?.*)?$/);
+ if (matchHref && matchHref[1]) {
+ const candidate = matchHref[1].trim();
+ const ignored = ['direct', 'explore', 'reels', 'reel', 'stories', 'home', 'accounts', 'p', 'tv'];
+ if (!ignored.includes(candidate.toLowerCase())) {
+ username = candidate;
+ break;
+ }
+ }
+ }
+ }
+
+ // 4. Header heading / title elements
+ const headingEl = document.querySelector(
+ 'header [role="heading"], [role="banner"] [role="heading"], ' +
+ 'header h1, header h2, header h3, [role="banner"] h1, [role="banner"] h2, ' +
+ 'header a span, [role="banner"] a span'
+ );
+ if (headingEl) {
+ const hText = headingEl.textContent.trim().replace(/^@/, '');
+ if (!displayName) displayName = hText;
+ if (!username && hText && !hText.includes(' ') && hText.length < 32) {
+ username = hText;
+ }
+ }
+
+ // 5. In-page bio banner at top of message scroller (View profile button)
+ if (!username) {
+ const viewProfileLink = document.querySelector('a[href^="/"][role="link"]:not([href*="/direct/"])');
+ if (viewProfileLink && viewProfileLink.textContent.toLowerCase().includes('profile')) {
+ const href = (viewProfileLink.getAttribute('href') || '').replace(/^https?:\/\/[^\/]+/, '');
+ const m = href.match(/^\/([a-zA-Z0-9_\.]+)\/?/);
+ if (m && m[1]) {
+ username = m[1].trim();
+ }
+ }
+ }
+
+ const cleanUser = username ? username.toLowerCase().trim().replace(/^@/, '') : '';
+ const effectiveUser = cleanUser || (displayName ? displayName.toLowerCase().trim().replace(/^@/, '') : '');
+
+ if (effectiveUser) {
+ threadUsernameMap.set(currentThreadId, effectiveUser);
+ threadUsernameMap.set(effectiveUser, currentThreadId);
+ postToNative({
+ action: 'threadUsernameMapped',
+ threadId: currentThreadId,
+ username: effectiveUser,
+ usernames: [effectiveUser],
+ displayName: displayName || effectiveUser
+ });
+ window.__fgActiveThread = { threadId: currentThreadId, username: effectiveUser, displayName: displayName || effectiveUser };
+ return { username: effectiveUser, displayName: displayName || effectiveUser };
+ }
+ return null;
+ }
+ window.__fgGetThreadParticipant = extractThreadParticipants;
+
+ function isCurrentChatLocked() {
+ const path = window.location.pathname.toLowerCase();
+ if (!path.startsWith('/direct')) return false;
+ if (chatLockMode === 'off') return false;
+ if (unlockedThreads.has('__all__')) return false;
+
+ const currentThreadId = extractCurrentThreadId();
+ if (currentThreadId) {
+ if (unlockedThreads.has(currentThreadId)) return false;
+ const mappedUser = threadUsernameMap.get(currentThreadId);
+ if (mappedUser && unlockedThreads.has(mappedUser)) return false;
+
+ if (chatLockMode === 'whole_dm' || chatLockAll) return true;
+ if (isCurrentThreadExplicitlyLocked(currentThreadId)) return true;
+
+ // Search header text as additional safeguard
+ const header = document.querySelector('header, [role="banner"], [role="main"] header');
+ if (header) {
+ const headerText = header.textContent.toLowerCase();
+ for (const lockedId of lockedChatIds) {
+ if (headerText.includes(lockedId)) {
+ lockedChatIds.add(currentThreadId);
+ threadUsernameMap.set(currentThreadId, lockedId);
+ postToNative({ action: 'threadUsernameMapped', threadId: currentThreadId, usernames: [lockedId], isLocked: true });
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ if ((chatLockMode === 'whole_dm' || chatLockAll) && (path === '/direct' || path === '/direct/' || path.startsWith('/direct/inbox'))) {
+ return !unlockedThreads.has('__all__');
+ }
+
+ return false;
+ }
+
+ function applyPrivacyVeil() {
+ let veil = document.getElementById('focusgram-chat-privacy-veil');
+ if (!veil) {
+ veil = document.createElement('div');
+ veil.id = 'focusgram-chat-privacy-veil';
+ veil.style.cssText = 'position:fixed!important;top:0!important;left:0!important;width:100vw!important;height:100vh!important;background:#000000!important;z-index:2147483647!important;pointer-events:all!important;display:block!important;';
+ (document.body || document.documentElement).appendChild(veil);
+ }
+ veil.style.display = 'block';
+ }
+ window.__fgApplyPrivacyVeil = applyPrivacyVeil;
+
+ function removePrivacyVeil() {
+ const veil = document.getElementById('focusgram-chat-privacy-veil');
+ if (veil) {
+ veil.style.display = 'none';
+ }
+ }
+ window.__fgRemovePrivacyVeil = removePrivacyVeil;
+
+ function evaluateChatLockState() {
+ if (isCurrentChatLocked()) {
+ try {
+ document.body?.setAttribute('data-fg-chat-locked', 'true');
+ document.documentElement?.setAttribute('data-fg-chat-locked', 'true');
+ } catch (_) {}
+ applyPrivacyVeil();
+ } else {
+ try {
+ document.body?.removeAttribute('data-fg-chat-locked');
+ document.documentElement?.removeAttribute('data-fg-chat-locked');
+ } catch (_) {}
+ removePrivacyVeil();
+ }
+ }
+
+ // Capture-phase link click interception for instant pre-render black cover & blocking server queries
+ document.addEventListener('click', (e) => {
+ if (chatLockMode === 'off') return;
+ const link = e.target.closest('a[href*="/direct/t/"]');
+ if (!link) return;
+ const href = (link.getAttribute('href') || '').toLowerCase();
+ const match = href.match(/\/direct\/t\/([^\/\?]+)/);
+ const targetThreadId = match ? match[1].trim() : '';
+ if (!targetThreadId) return;
+
+ let shouldLock = (chatLockMode === 'whole_dm' || chatLockAll);
+ const mapped = threadUsernameMap.get(targetThreadId);
+ if (!shouldLock) {
+ if (lockedChatIds.has(targetThreadId)) shouldLock = true;
+ if (mapped && lockedChatIds.has(mapped)) shouldLock = true;
+ if (!shouldLock) {
+ const row = link.closest('[role="listitem"], [role="button"], li') || link;
+ const rowText = row.textContent.toLowerCase();
+ for (const lockedId of lockedChatIds) {
+ if (rowText.includes(lockedId) || href.includes(lockedId)) {
+ shouldLock = true;
+ lockedChatIds.add(targetThreadId);
+ break;
+ }
+ }
+ }
+ }
+
+ const isUnlocked = unlockedThreads.has('__all__') ||
+ unlockedThreads.has(targetThreadId) ||
+ (mapped && unlockedThreads.has(mapped));
+
+ if (shouldLock && !isUnlocked) {
+ // Immediately BLOCK the browser and React router from querying the server or rendering the locked chat!
+ e.preventDefault();
+ e.stopPropagation();
+ e.stopImmediatePropagation();
+
+ applyPrivacyVeil();
+
+ const mappedUser = mapped || '';
+ postToNative({
+ action: 'requestUnlockChat',
+ threadId: targetThreadId,
+ username: mappedUser,
+ targetUrl: link.href || ('https://www.instagram.com/direct/t/' + targetThreadId + '/')
+ });
+ }
+ }, true);
+
+ function updateChatMaskCSS() {
+ let style = document.getElementById('focusgram-chat-mask-style');
+ if (!style) {
+ style = document.createElement('style');
+ style.id = 'focusgram-chat-mask-style';
+ (document.head || document.documentElement).appendChild(style);
+ }
+
+ const css = `
+ /* FocusGram Chat Lock Veil */
+ body[data-fg-chat-locked="true"] [role="list"] [role="row"],
+ body[data-fg-chat-locked="true"] div[dir="auto"],
+ body[data-fg-chat-locked="true"] [data-message-id] {
+ filter: blur(14px) !important;
+ user-select: none !important;
+ pointer-events: none !important;
+ }
+ `;
+ style.textContent = css;
+ }
+
+ function maskInboxChatPreviews() {
+ updateChatMaskCSS();
+
+ const path = window.location.pathname.toLowerCase();
+ if (!path.startsWith('/direct')) return;
+
+ const isLockOff = (chatLockMode === 'off');
+
+ const chatLinks = document.querySelectorAll('a[href*="/direct/t/"]');
+ for (const link of chatLinks) {
+ const href = (link.getAttribute('href') || '').toLowerCase();
+ const match = href.match(/\/direct\/t\/([^\/\?]+)/);
+ const threadId = match ? match[1].trim() : '';
+ if (!threadId) continue;
+
+ const rowContainer = link.closest('[role="listitem"], [role="button"], li') || link;
+
+ // Strategy A: Avatar image alt inspection
+ let detectedUser = '';
+ let displayName = '';
+ const avatarImg = rowContainer.querySelector('img[alt]');
+ if (avatarImg) {
+ const alt = (avatarImg.getAttribute('alt') || '').trim();
+ const m = alt.match(/(.+?)'s profile picture/i) || alt.match(/profile picture of (.+)/i);
+ if (m && m[1]) {
+ detectedUser = m[1].trim().replace(/^@/, '').toLowerCase();
+ displayName = m[1].trim();
+ }
+ }
+
+ // Strategy B: TreeWalker text node for title element
+ const walker = document.createTreeWalker(
+ rowContainer,
+ NodeFilter.SHOW_TEXT,
+ {
+ acceptNode: function(node) {
+ const val = (node.nodeValue || '').trim();
+ if (!val || val === '๐' || val === 'โข' || val === 'ยท') {
+ return NodeFilter.FILTER_REJECT;
+ }
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ }
+ );
+
+ let candidateTitleEl = null;
+ const firstNode = walker.nextNode();
+ if (firstNode) {
+ candidateTitleEl = firstNode.parentElement;
+ if (!detectedUser) {
+ const txt = (firstNode.nodeValue || '').trim();
+ detectedUser = txt.replace(/^@/, '').trim().toLowerCase();
+ displayName = txt;
+ }
+ }
+
+ if (detectedUser && !threadUsernameMap.has(threadId)) {
+ threadUsernameMap.set(threadId, detectedUser);
+ threadUsernameMap.set(detectedUser, threadId);
+ postToNative({
+ action: 'threadUsernameMapped',
+ threadId: threadId,
+ username: detectedUser,
+ usernames: [detectedUser],
+ displayName: displayName || detectedUser
+ });
+ }
+
+ let isLocked = false;
+ if (!isLockOff) {
+ if (chatLockMode === 'whole_dm' || chatLockAll) {
+ isLocked = !unlockedThreads.has('__all__');
+ } else {
+ if (unlockedThreads.has(threadId)) {
+ isLocked = false;
+ } else {
+ if (lockedChatIds.has(threadId)) isLocked = true;
+ const mappedUser = threadUsernameMap.get(threadId);
+ if (mappedUser && lockedChatIds.has(mappedUser)) isLocked = true;
+ if (detectedUser && lockedChatIds.has(detectedUser)) isLocked = true;
+ if (!isLocked) {
+ const rowText = rowContainer.textContent.toLowerCase();
+ for (const lockedId of lockedChatIds) {
+ if (rowText.includes(lockedId) || href.includes(lockedId)) {
+ isLocked = true;
+ lockedChatIds.add(threadId);
+ break;
+ }
+ }
+ }
+ if (mappedUser && unlockedThreads.has(mappedUser)) isLocked = false;
+ if (detectedUser && unlockedThreads.has(detectedUser)) isLocked = false;
+ }
+ }
+ }
+
+ // Clean up any legacy preview masking classes or attributes
+ const legacyMasked = rowContainer.querySelectorAll('.fg-masked-snippet-text, [data-fg-mask-preview]');
+ for (const el of legacyMasked) {
+ el.classList.remove('fg-masked-snippet-text');
+ el.removeAttribute('data-fg-mask-preview');
+ }
+
+ if (isLocked) {
+ link.setAttribute('data-fg-locked-chat', 'true');
+ rowContainer.setAttribute('data-fg-locked-chat', 'true');
+
+ const titleTarget = candidateTitleEl || rowContainer.querySelector('[role="heading"], h2, h3, span');
+ if (titleTarget && !rowContainer.querySelector('.fg-chat-lock-badge')) {
+ const badge = document.createElement('span');
+ badge.className = 'fg-chat-lock-badge';
+ badge.style.cssText = 'margin-left: 6px; font-size: 11px; opacity: 0.85; vertical-align: middle; display: inline-block;';
+ badge.textContent = '๐';
+ titleTarget.appendChild(badge);
+ }
+ } else {
+ link.removeAttribute('data-fg-locked-chat');
+ rowContainer.removeAttribute('data-fg-locked-chat');
+ const badge = rowContainer.querySelector('.fg-chat-lock-badge');
+ if (badge) badge.remove();
+ }
+ }
+ }
+
+ // Attach dynamic MutationObserver on direct messaging container to ensure previews are masked in real time
+ if (!window.__fgInboxObserverInstalled) {
+ window.__fgInboxObserverInstalled = true;
+ const inboxObserver = new MutationObserver(() => {
+ const path = window.location.pathname.toLowerCase();
+ if (path.startsWith('/direct')) {
+ maskInboxChatPreviews();
+ }
+ });
+ inboxObserver.observe(document.body || document.documentElement, {
+ childList: true,
+ subtree: true
+ });
+ }
+
function checkStateAndNotify() {
+ evaluateChatLockState();
+ extractThreadParticipants();
+ maskInboxChatPreviews();
+
const currentUrl = window.location.href;
if (currentUrl !== lastReportedUrl) {
lastReportedUrl = currentUrl;
@@ -2328,6 +2929,9 @@
}
window.addEventListener('focusgram:urlchange', () => {
+ lastInputFocused = false;
+ postToNative({ action: 'inputFocused', focused: false });
+ postToNative({ action: 'bioInputFocused', focused: false });
checkStateAndNotify();
setTimeout(checkStateAndNotify, 100);
setTimeout(checkStateAndNotify, 300);
@@ -2335,6 +2939,9 @@
});
window.addEventListener('popstate', () => {
+ lastInputFocused = false;
+ postToNative({ action: 'inputFocused', focused: false });
+ postToNative({ action: 'bioInputFocused', focused: false });
checkStateAndNotify();
setTimeout(checkStateAndNotify, 100);
setTimeout(checkStateAndNotify, 300);
@@ -2342,6 +2949,9 @@
});
window.addEventListener('hashchange', () => {
+ lastInputFocused = false;
+ postToNative({ action: 'inputFocused', focused: false });
+ postToNative({ action: 'bioInputFocused', focused: false });
checkStateAndNotify();
setTimeout(checkStateAndNotify, 100);
hideNav();
@@ -2849,6 +3459,13 @@
}
updateGuardrailsCSS();
syncGhostSettings();
+ syncChatLockSettings(msg.settings);
+ return;
+ } else if (msg.action === 'unlockChat') {
+ handleUnlockChat(msg.threadId, msg.all);
+ return;
+ } else if (msg.action === 'lockChat') {
+ handleLockChat(msg.threadId);
return;
} else if (msg.action === 'navigateSpa' && msg.path) {
performNavClick(msg.path);
@@ -3038,6 +3655,7 @@
}
updateGuardrailsCSS();
syncGhostSettings();
+ syncChatLockSettings(response.settings);
}
});
}
@@ -3139,5 +3757,553 @@
}
} catch (_) {}
+ /* ==========================================================================
+ 8. LIVE BIO FONT STYLIST (KEYBOARD ACCESSORY BAR & REAL-TIME INPUT)
+ ========================================================================== */
+ const BIO_FONT_MAPS = {
+ normal: null,
+ bold: { u: 0x1D400, l: 0x1D41A, d: 0x1D7CE }, // Mathematical Bold (Zero gaps)
+ sans: { u: 0x1D5D4, l: 0x1D5EE, d: 0x1D7EC }, // Sans-Serif Bold (Zero gaps)
+ sans_italic: { u: 0x1D608, l: 0x1D622, d: null }, // Sans-Serif Italic (Zero gaps)
+ mono: { u: 0x1D670, l: 0x1D68A, d: 0x1D7F6 }, // Monospace (Zero gaps)
+ gothic: { u: 0x1D56C, l: 0x1D586, d: null }, // Bold Fraktur/Gothic (Zero gaps)
+ script: { u: 0x1D4D0, l: 0x1D4EA, d: null } // Bold Script/Cursive (Zero gaps)
+ };
+
+ let activeBioStyle = 'bold';
+ let bioToolbarEl = null;
+ let bioCounterEl = null;
+ let activeBioTarget = null;
+
+ function normalizeChar(char) {
+ if (!char) return '';
+ const cp = char.codePointAt(0);
+ for (const [key, map] of Object.entries(BIO_FONT_MAPS)) {
+ if (!map) continue;
+ if (cp >= map.u && cp <= map.u + 25) {
+ return String.fromCharCode(65 + (cp - map.u));
+ }
+ if (cp >= map.l && cp <= map.l + 25) {
+ return String.fromCharCode(97 + (cp - map.l));
+ }
+ if (map.d && cp >= map.d && cp <= map.d + 9) {
+ return String.fromCharCode(48 + (cp - map.d));
+ }
+ }
+ return char;
+ }
+
+ function convertCharToStyle(char, styleKey) {
+ const map = BIO_FONT_MAPS[styleKey];
+ if (!map || styleKey === 'normal') return normalizeChar(char);
+ const norm = normalizeChar(char);
+ const cp = norm.codePointAt(0);
+ if (cp >= 65 && cp <= 90) { // A-Z
+ return String.fromCodePoint(map.u + (cp - 65));
+ }
+ if (cp >= 97 && cp <= 122) { // a-z
+ return String.fromCodePoint(map.l + (cp - 97));
+ }
+ if (map.d && cp >= 48 && cp <= 57) { // 0-9
+ return String.fromCodePoint(map.d + (cp - 48));
+ }
+ return norm;
+ }
+
+ function convertStringToStyle(text, styleKey) {
+ if (!text) return '';
+ return Array.from(text).map(ch => convertCharToStyle(ch, styleKey)).join('');
+ }
+
+ function isBioTarget(el) {
+ if (!el) return false;
+ if (el.id === 'pepBio') return true;
+ const name = (el.name || '').toLowerCase();
+ const id = (el.id || '').toLowerCase();
+ const placeholder = (el.placeholder || '').toLowerCase();
+ const aria = (el.getAttribute('aria-label') || '').toLowerCase();
+ if (name === 'biography' || name.includes('bio') || id.includes('bio') || placeholder.includes('bio') || aria.includes('bio')) return true;
+ const path = (window.location.pathname || '').toLowerCase();
+ if (path.includes('/accounts/edit') && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) return true;
+ return false;
+ }
+
+ function updateBioCharCounter() {
+ if (!bioCounterEl || !activeBioTarget) return;
+ const text = activeBioTarget.value || activeBioTarget.textContent || '';
+ const codePoints = Array.from(text).length;
+ const utf16Length = text.length;
+
+ bioCounterEl.textContent = `${codePoints} / 150`;
+ if (codePoints > 140 || utf16Length > 140) {
+ bioCounterEl.style.color = '#ef4444';
+ } else if (codePoints > 120) {
+ bioCounterEl.style.color = '#f59e0b';
+ } else {
+ bioCounterEl.style.color = '#8e8e8e';
+ }
+ }
+
+ function updateBioToolbarPosition() {
+ if (!bioToolbarEl) return;
+ if (window.visualViewport) {
+ const kbHeight = window.innerHeight - (window.visualViewport.offsetTop + window.visualViewport.height);
+ bioToolbarEl.style.bottom = Math.max(0, Math.round(kbHeight)) + 'px';
+ } else {
+ bioToolbarEl.style.bottom = '0px';
+ }
+ }
+
+ function createBioFontToolbar() {
+ if (document.getElementById('fg-bio-font-toolbar')) {
+ return document.getElementById('fg-bio-font-toolbar');
+ }
+
+ const toolbar = document.createElement('div');
+ toolbar.id = 'fg-bio-font-toolbar';
+ toolbar.style.cssText = `
+ position: fixed !important;
+ bottom: 0 !important;
+ left: 0 !important;
+ right: 0 !important;
+ z-index: 2147483647 !important;
+ background: rgba(18, 18, 18, 0.96) !important;
+ backdrop-filter: blur(24px) !important;
+ -webkit-backdrop-filter: blur(24px) !important;
+ border-top: 1px solid rgba(255, 255, 255, 0.15) !important;
+ padding: 8px 12px !important;
+ display: none !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+ gap: 8px !important;
+ box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.7) !important;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
+ user-select: none !important;
+ -webkit-user-select: none !important;
+ transition: bottom 0.15s ease-out !important;
+ `;
+
+ const chipsScroll = document.createElement('div');
+ chipsScroll.style.cssText = `
+ display: flex !important;
+ align-items: center !important;
+ gap: 6px !important;
+ overflow-x: auto !important;
+ scrollbar-width: none !important;
+ -webkit-overflow-scrolling: touch !important;
+ flex: 1 !important;
+ padding: 2px 0 !important;
+ `;
+
+ const STYLES = [
+ { id: 'normal', label: 'Normal' },
+ { id: 'bold', label: 'Bold' },
+ { id: 'sans', label: 'Sans' },
+ { id: 'sans_italic', label: 'Italic' },
+ { id: 'mono', label: 'Mono' },
+ { id: 'gothic', label: 'Gothic' },
+ { id: 'script', label: 'Script' }
+ ];
+
+ STYLES.forEach(style => {
+ const chip = document.createElement('button');
+ chip.type = 'button';
+ chip.className = 'fg-font-chip';
+ chip.dataset.styleId = style.id;
+ chip.textContent = style.label;
+ const isSelected = style.id === activeBioStyle;
+ chip.style.cssText = `
+ padding: 6px 14px !important;
+ border-radius: 16px !important;
+ border: 1px solid ${isSelected ? '#ffffff' : 'rgba(255, 255, 255, 0.15)'} !important;
+ background: ${isSelected ? '#ffffff' : '#262626'} !important;
+ color: ${isSelected ? '#000000' : '#d1d5db'} !important;
+ font-size: 13px !important;
+ font-weight: ${isSelected ? '600' : '500'} !important;
+ white-space: nowrap !important;
+ cursor: pointer !important;
+ outline: none !important;
+ transition: background 0.15s ease, color 0.15s ease !important;
+ `;
+
+ chip.addEventListener('click', (e) => {
+ e.preventDefault();
+ setBioActiveStyle(style.id);
+ });
+
+ chipsScroll.appendChild(chip);
+ });
+
+ const counter = document.createElement('span');
+ counter.id = 'fg-bio-counter';
+ counter.style.cssText = `
+ font-size: 11px !important;
+ font-weight: 600 !important;
+ color: #8e8e8e !important;
+ white-space: nowrap !important;
+ padding: 4px 8px !important;
+ border-radius: 12px !important;
+ background: rgba(255, 255, 255, 0.05) !important;
+ border: 1px solid rgba(255, 255, 255, 0.08) !important;
+ `;
+ counter.textContent = '0 / 150';
+
+ toolbar.appendChild(chipsScroll);
+ toolbar.appendChild(counter);
+ (document.body || document.documentElement).appendChild(toolbar);
+
+ bioToolbarEl = toolbar;
+ bioCounterEl = counter;
+ return toolbar;
+ }
+
+ function setBioActiveStyle(styleId) {
+ activeBioStyle = styleId;
+ if (bioToolbarEl) {
+ bioToolbarEl.querySelectorAll('.fg-font-chip').forEach(c => {
+ const isActive = c.dataset.styleId === activeBioStyle;
+ c.style.border = `1px solid ${isActive ? '#ffffff' : 'rgba(255, 255, 255, 0.15)'}`;
+ c.style.background = isActive ? '#ffffff' : '#262626';
+ c.style.color = isActive ? '#000000' : '#d1d5db';
+ c.style.fontWeight = isActive ? '600' : '500';
+ });
+ }
+
+ const target = activeBioTarget || document.getElementById('pepBio') || document.querySelector('textarea[aria-label="Bio"]');
+ if (target) {
+ const val = target.value || '';
+ if (val && target.selectionStart !== undefined && target.selectionEnd !== undefined && target.selectionStart !== target.selectionEnd) {
+ const start = target.selectionStart;
+ const end = target.selectionEnd;
+ const before = val.substring(0, start);
+ const selectedText = val.substring(start, end);
+ const after = val.substring(end);
+ const converted = convertStringToStyle(selectedText, activeBioStyle);
+ try {
+ const proto = window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : Object.getPrototypeOf(target);
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
+ if (setter) setter.call(target, before + converted + after);
+ else target.value = before + converted + after;
+ target.dispatchEvent(new Event('input', { bubbles: true }));
+ } catch (_) {
+ target.value = before + converted + after;
+ }
+ target.setSelectionRange(start, start + converted.length);
+ }
+ // If no text selected, do NOT alter existing text!
+ updateBioCharCounter();
+ }
+ }
+
+ window.__fgSetBioStyle = setBioActiveStyle;
+
+ function showBioFontToolbar(target) {
+ activeBioTarget = target;
+ createBioFontToolbar();
+ if (bioToolbarEl) {
+ updateBioToolbarPosition();
+ bioToolbarEl.style.display = 'flex';
+ updateBioCharCounter();
+ }
+ }
+
+ function hideBioFontToolbar() {
+ if (bioToolbarEl) {
+ bioToolbarEl.style.display = 'none';
+ }
+ }
+
+ // Intercept keystrokes on document capture level for instant Unicode glyph conversion
+ document.addEventListener('beforeinput', (e) => {
+ if (!activeBioTarget || activeBioStyle === 'normal') return;
+ if (e.data && e.inputType === 'insertText') {
+ e.preventDefault();
+ const converted = convertStringToStyle(e.data, activeBioStyle);
+ document.execCommand('insertText', false, converted);
+ updateBioCharCounter();
+ }
+ }, true);
+
+ document.addEventListener('input', (e) => {
+ if (activeBioTarget && (e.target === activeBioTarget || isBioTarget(e.target))) {
+ updateBioCharCounter();
+ }
+ }, true);
+
+ // Dynamic focusin detection across document
+ document.addEventListener('focusin', (e) => {
+ if (isBioTarget(e.target)) {
+ showBioFontToolbar(e.target);
+ postToNative({ action: 'bioInputFocused', focused: true });
+ }
+ }, true);
+
+ document.addEventListener('focusout', () => {
+ setTimeout(() => {
+ const active = document.activeElement;
+ if (!isBioTarget(active) && (!bioToolbarEl || !bioToolbarEl.contains(active))) {
+ hideBioFontToolbar();
+ postToNative({ action: 'bioInputFocused', focused: false });
+ }
+ }, 250);
+ }, true);
+
+ if (window.visualViewport) {
+ window.visualViewport.addEventListener('resize', updateBioToolbarPosition);
+ window.visualViewport.addEventListener('scroll', updateBioToolbarPosition);
+ }
+
+ // Listen for font style changes from background or native host
+ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessage) {
+ browser.runtime.onMessage.addListener((msg) => {
+ if (msg.action === 'setBioStyle' && msg.style) {
+ setBioActiveStyle(msg.style);
+ }
+ });
+ }
+
+ /* ==========================================================================
+ 9. NETWORK-TRIGGERED STORY VIEWER SEARCH
+ - Directly displays the search bar when the /list_reel_media_viewer/
+ network request is sent or received.
+ ========================================================================== */
+ let storySearchCardEl = null;
+ let storyViewersData = [];
+
+ function findViewersDialog() {
+ const dialogs = document.querySelectorAll('div[aria-modal="true"][role="dialog"], div[role="dialog"]');
+ for (const d of dialogs) {
+ const headings = Array.from(d.querySelectorAll('h2, [role="heading"], div'));
+ const h2 = headings.find(el => el.textContent.trim().toLowerCase() === 'viewers' && el.children.length === 0);
+ if (h2) {
+ const headerRow = h2.parentElement;
+ const scrollContainer = d.querySelector('div[style*="overflow"]') ||
+ d.querySelector('div[style*="height"]') ||
+ (headerRow ? headerRow.nextElementSibling : null);
+ return { dialog: d, header: headerRow, scrollContainer: scrollContainer, h2: h2 };
+ }
+ }
+ return null;
+ }
+
+ function ensureStorySearchCard() {
+ if (document.getElementById('fg-story-search-card')) {
+ return document.getElementById('fg-story-search-card');
+ }
+
+ const card = document.createElement('div');
+ card.id = 'fg-story-search-card';
+ card.style.cssText = `
+ margin: 6px 16px 10px 16px !important;
+ padding: 0 12px !important;
+ height: 38px !important;
+ background: #262626 !important;
+ border: 1px solid rgba(255, 255, 255, 0.12) !important;
+ border-radius: 8px !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 10px !important;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
+ color: #FAFAFA !important;
+ box-sizing: border-box !important;
+ flex-shrink: 0 !important;
+ `;
+
+ const searchIcon = document.createElement('span');
+ searchIcon.style.cssText = 'display: flex; align-items: center; color: #8e8e8e;';
+ searchIcon.innerHTML = ``;
+
+ const searchInput = document.createElement('input');
+ searchInput.id = 'fg-story-search-input';
+ searchInput.type = 'text';
+ searchInput.placeholder = 'Search viewers';
+ searchInput.autocomplete = 'off';
+ searchInput.style.cssText = `
+ flex: 1 !important;
+ background: transparent !important;
+ border: none !important;
+ color: #FAFAFA !important;
+ font-size: 14px !important;
+ outline: none !important;
+ padding: 0 !important;
+ height: 100% !important;
+ font-family: inherit !important;
+ `;
+
+ const clearBtn = document.createElement('button');
+ clearBtn.type = 'button';
+ clearBtn.textContent = 'โ';
+ clearBtn.style.cssText = `
+ background: transparent !important;
+ border: none !important;
+ color: #8e8e8e !important;
+ font-size: 13px !important;
+ cursor: pointer !important;
+ padding: 4px !important;
+ display: none !important;
+ line-height: 1 !important;
+ `;
+
+ clearBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ searchInput.value = '';
+ clearBtn.style.display = 'none';
+ filterViewers('');
+ searchInput.focus();
+ });
+
+ searchInput.addEventListener('input', () => {
+ const val = searchInput.value;
+ clearBtn.style.display = val.length > 0 ? 'block' : 'none';
+ filterViewers(val.trim().toLowerCase());
+ });
+
+ card.appendChild(searchIcon);
+ card.appendChild(searchInput);
+ card.appendChild(clearBtn);
+
+ const info = findViewersDialog();
+ if (info && info.header) {
+ info.header.after(card);
+ }
+
+ storySearchCardEl = card;
+ return card;
+ }
+
+ function filterViewers(query) {
+ const info = findViewersDialog();
+ if (!info || !info.dialog) return;
+
+ const scrollContainer = info.scrollContainer ||
+ info.dialog.querySelector('div[style*="overflow"]') ||
+ info.dialog.querySelector('div[style*="height"]') ||
+ info.dialog;
+
+ const rows = scrollContainer.querySelectorAll('.x1qnrgzn, div[style*="display: flex"] > div:has(a[href^="/"]), div[style*="padding"] > div:has(a[href^="/"])');
+ let matchedCount = 0;
+ let totalCount = 0;
+
+ rows.forEach(row => {
+ if (row.id === 'fg-story-search-card' || row.contains(document.getElementById('fg-story-search-card'))) return;
+ const links = row.querySelectorAll('a[href^="/"]');
+ if (links.length === 0) return;
+
+ totalCount++;
+ if (!query) {
+ row.style.display = '';
+ matchedCount++;
+ return;
+ }
+
+ const text = (row.textContent || '').toLowerCase();
+ let matched = text.includes(query);
+ for (let i = 0; i < links.length; i++) {
+ const href = (links[i].getAttribute('href') || '').toLowerCase().replace(/\//g, '');
+ if (href.includes(query)) {
+ matched = true;
+ break;
+ }
+ }
+
+ row.style.display = matched ? '' : 'none';
+ if (matched) matchedCount++;
+ });
+
+ let noResultsEl = scrollContainer.querySelector('#fg-no-viewer-results');
+ if (query && matchedCount === 0 && totalCount > 0) {
+ if (!noResultsEl) {
+ noResultsEl = document.createElement('div');
+ noResultsEl.id = 'fg-no-viewer-results';
+ noResultsEl.style.cssText = 'padding: 24px; text-align: center; color: #8e8e8e; font-size: 14px;';
+ noResultsEl.textContent = 'No viewers found';
+ scrollContainer.appendChild(noResultsEl);
+ }
+ noResultsEl.style.display = 'block';
+ } else if (noResultsEl) {
+ noResultsEl.style.display = 'none';
+ }
+ }
+
+ function checkAndMountStorySearch() {
+ const info = findViewersDialog();
+ if (!info || !info.header) {
+ const existing = document.getElementById('fg-story-search-card');
+ if (existing && !document.querySelector('div[aria-modal="true"][role="dialog"]')) {
+ existing.remove();
+ storySearchCardEl = null;
+ }
+ return;
+ }
+
+ if (info.dialog.querySelector('#fg-story-search-card')) {
+ return;
+ }
+
+ ensureStorySearchCard();
+ }
+
+ function handleStoryViewersDetected(detail) {
+ if (!detail) return;
+ checkAndMountStorySearch();
+
+ if (detail.phase === 'ready' && detail.data) {
+ const data = detail.data;
+ const users = data.users || [];
+ const viewers = data.viewers || [];
+ const userMap = new Map();
+ users.forEach(u => {
+ const pk = String(u.pk || u.id || '');
+ if (pk) userMap.set(pk, { username: u.username || '', fullName: u.full_name || '' });
+ });
+ viewers.forEach(v => {
+ const u = v.user || v;
+ const pk = String(u.pk || u.id || '');
+ if (pk) userMap.set(pk, { username: u.username || '', fullName: u.full_name || '' });
+ });
+ storyViewersData = Array.from(userMap.values());
+ const input = document.getElementById('fg-story-search-input');
+ if (input && input.value.trim()) {
+ filterViewers(input.value.trim().toLowerCase());
+ }
+ }
+ }
+
+ // Listen to main-world fetch interceptor custom events
+ window.addEventListener('focusgram:story-viewers-detected', (e) => {
+ handleStoryViewersDetected(e.detail);
+ });
+ document.addEventListener('focusgram:story-viewers-detected', (e) => {
+ handleStoryViewersDetected(e.detail);
+ });
+
+ // Listen to WebExtension background script messages
+ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.onMessage) {
+ browser.runtime.onMessage.addListener((msg) => {
+ if (msg.action === 'storyViewersDetected') {
+ handleStoryViewersDetected(msg);
+ }
+ });
+ }
+
+ // Continuous autonomous observer for Viewers modal
+ const viewersObserver = new MutationObserver(() => {
+ checkAndMountStorySearch();
+ });
+ viewersObserver.observe(document.documentElement, { childList: true, subtree: true });
+ setInterval(checkAndMountStorySearch, 400);
+
+ // Dismiss search bar on route change away from stories
+ window.addEventListener('focusgram:urlchange', (e) => {
+ const url = (e.detail || window.location.href).toLowerCase();
+ if (!url.includes('/stories/')) {
+ if (storySearchCardEl) {
+ storySearchCardEl.remove();
+ storySearchCardEl = null;
+ }
+ }
+ });
+
})();
+
diff --git a/Ported/app/src/main/assets/scripts/fetch_interceptor.js b/Ported/app/src/main/assets/scripts/fetch_interceptor.js
index 18607fc..91d1ec1 100644
--- a/Ported/app/src/main/assets/scripts/fetch_interceptor.js
+++ b/Ported/app/src/main/assets/scripts/fetch_interceptor.js
@@ -15,6 +15,7 @@
function isAuthPage() {
try {
var path = (window.location.pathname || '').toLowerCase();
+ if (path.includes('/accounts/edit')) return false;
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
var c = document.cookie || '';
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
@@ -231,6 +232,271 @@
}
}
+ let localChatLockMode = 'individual';
+ let localChatLockAll = false;
+ let localLockedChatIds = [];
+ let localUnlockedThreads = [];
+ let localThreadUsernameMap = {};
+
+ function handleChatLockSync(detail) {
+ if (!detail) return;
+ if (typeof detail.chatLockMode !== 'undefined') localChatLockMode = String(detail.chatLockMode).toLowerCase();
+ if (typeof detail.chatLockAll !== 'undefined') localChatLockAll = !!detail.chatLockAll;
+ if (Array.isArray(detail.lockedChatIds)) localLockedChatIds = detail.lockedChatIds;
+ if (Array.isArray(detail.unlockedThreads)) localUnlockedThreads = detail.unlockedThreads;
+ if (detail.threadUsernameMap && typeof detail.threadUsernameMap === 'object') localThreadUsernameMap = detail.threadUsernameMap;
+ }
+
+ window.addEventListener('focusgram:chat-lock-sync', (e) => handleChatLockSync(e.detail));
+ document.addEventListener('focusgram:chat-lock-sync', (e) => handleChatLockSync(e.detail));
+
+ function isDirectThreadLocked(threadId, username) {
+ const mode = window.__fgChatLockMode || localChatLockMode || 'individual';
+ if (mode === 'off') return false;
+
+ const cleanTid = String(threadId || '').toLowerCase().trim();
+ const cleanUser = String(username || '').toLowerCase().trim().replace(/^@/, '');
+
+ // Resolve mapped usernames/IDs from maps
+ const gMap = window.__fgThreadUsernameMap || localThreadUsernameMap || {};
+ const mappedUser = cleanTid ? (gMap.get ? gMap.get(cleanTid) : gMap[cleanTid]) : '';
+ const mappedId = cleanUser ? (gMap.get ? gMap.get(cleanUser) : gMap[cleanUser]) : '';
+
+ const checkInCollection = (collection, val) => {
+ if (!collection || !val) return false;
+ if (typeof collection.has === 'function') return collection.has(val);
+ if (Array.isArray(collection)) return collection.includes(val);
+ return false;
+ };
+
+ // Check if unlocked in current session
+ const unlockedSources = [window.__fgUnlockedThreads, localUnlockedThreads];
+ for (const unl of unlockedSources) {
+ if (unl) {
+ if (checkInCollection(unl, '__all__') ||
+ checkInCollection(unl, cleanTid) ||
+ checkInCollection(unl, cleanUser) ||
+ (mappedUser && checkInCollection(unl, mappedUser)) ||
+ (mappedId && checkInCollection(unl, mappedId))) {
+ return false;
+ }
+ }
+ }
+
+ // Whole DM lock mode
+ if (mode === 'whole_dm' || window.__fgChatLockAll || localChatLockAll) return true;
+
+ // Individual chat lock check
+ const lockedSources = [window.__fgLockedChatIds, localLockedChatIds];
+ for (const lk of lockedSources) {
+ if (lk) {
+ if (checkInCollection(lk, cleanTid) ||
+ checkInCollection(lk, cleanUser) ||
+ (mappedUser && checkInCollection(lk, mappedUser)) ||
+ (mappedId && checkInCollection(lk, mappedId))) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ function maskMessageNode(node) {
+ if (!node || typeof node !== 'object') return false;
+ let didMask = false;
+ if (typeof node.text_body === 'string' && node.text_body.length > 0) {
+ node.text_body = 'โขโขโขโขโขโขโขโขโขโขโขโข';
+ didMask = true;
+ }
+ if (typeof node.text === 'string' && node.text.length > 0) {
+ node.text = 'โขโขโขโขโขโขโขโขโขโขโขโข';
+ didMask = true;
+ }
+ if (typeof node.snippet === 'string' && node.snippet.length > 0) {
+ node.snippet = 'โขโขโขโขโขโขโขโขโขโขโขโข';
+ didMask = true;
+ }
+ if (node.content && typeof node.content === 'object') {
+ if (typeof node.content.text_body === 'string' && node.content.text_body.length > 0) {
+ node.content.text_body = 'โขโขโขโขโขโขโขโขโขโขโขโข';
+ didMask = true;
+ }
+ if (typeof node.content.text === 'string' && node.content.text.length > 0) {
+ node.content.text = 'โขโขโขโขโขโขโขโขโขโขโขโข';
+ didMask = true;
+ }
+ if (typeof node.content.snippet === 'string' && node.content.snippet.length > 0) {
+ node.content.snippet = 'โขโขโขโขโขโขโขโขโขโขโขโข';
+ didMask = true;
+ }
+ }
+ return didMask;
+ }
+
+ function maskThreadMessages(thread) {
+ if (!thread || typeof thread !== 'object') return false;
+ let threadMod = false;
+
+ // 1. slide_messages.edges[].node (HAR / GraphQL IGDThreadDetailQuery format)
+ if (thread.slide_messages && Array.isArray(thread.slide_messages.edges)) {
+ for (const edge of thread.slide_messages.edges) {
+ if (edge && edge.node && maskMessageNode(edge.node)) {
+ threadMod = true;
+ }
+ }
+ }
+
+ // 2. messages.edges or messages.items or messages[]
+ if (thread.messages) {
+ if (Array.isArray(thread.messages.edges)) {
+ for (const edge of thread.messages.edges) {
+ if (edge && edge.node && maskMessageNode(edge.node)) {
+ threadMod = true;
+ }
+ }
+ } else if (Array.isArray(thread.messages.items)) {
+ for (const item of thread.messages.items) {
+ if (maskMessageNode(item)) {
+ threadMod = true;
+ }
+ }
+ } else if (Array.isArray(thread.messages)) {
+ for (const item of thread.messages) {
+ if (maskMessageNode(item)) {
+ threadMod = true;
+ }
+ }
+ }
+ }
+
+ // 3. items[]
+ if (Array.isArray(thread.items)) {
+ for (const item of thread.items) {
+ if (maskMessageNode(item)) {
+ threadMod = true;
+ }
+ }
+ }
+
+ // 4. last_permanent_item
+ if (thread.last_permanent_item && maskMessageNode(thread.last_permanent_item)) {
+ threadMod = true;
+ }
+
+ // 5. snippet
+ if (typeof thread.snippet === 'string') {
+ thread.snippet = 'โขโขโขโขโขโขโขโขโขโขโขโข';
+ threadMod = true;
+ }
+
+ return threadMod;
+ }
+
+ function findThreadsInPayload(root) {
+ const threads = [];
+ if (!root || typeof root !== 'object') return threads;
+
+ if (root.get_slide_thread_nullable?.as_ig_direct_thread) {
+ threads.push(root.get_slide_thread_nullable.as_ig_direct_thread);
+ } else if (root.get_slide_thread_nullable && typeof root.get_slide_thread_nullable === 'object') {
+ threads.push(root.get_slide_thread_nullable);
+ }
+ if (root.message_thread && typeof root.message_thread === 'object') {
+ threads.push(root.message_thread);
+ }
+ if (root.thread && typeof root.thread === 'object') {
+ threads.push(root.thread);
+ }
+
+ const inbox = root.viewer?.inbox || root.inbox;
+ if (inbox && Array.isArray(inbox.threads)) {
+ for (const t of inbox.threads) {
+ if (t && typeof t === 'object') threads.push(t);
+ }
+ }
+
+ if (threads.length === 0) {
+ for (const key of Object.keys(root)) {
+ const val = root[key];
+ if (val && typeof val === 'object') {
+ if (val.slide_messages || val.as_ig_direct_thread || (val.thread_fbid && (val.items || val.messages))) {
+ threads.push(val.as_ig_direct_thread || val);
+ }
+ }
+ }
+ }
+
+ return threads;
+ }
+
+ function processDirectMessagesPayload(data) {
+ if (!data || typeof data !== 'object') return false;
+
+ let modified = false;
+ const root = data.data || data;
+ const threads = findThreadsInPayload(root);
+ if (threads.length === 0) return false;
+
+ for (const thread of threads) {
+ const tid = String(thread.thread_fbid || thread.id || thread.thread_id || '').toLowerCase().trim();
+ const threadKey = String(thread.thread_key || '').toLowerCase().trim();
+ let username = '';
+ let displayName = thread.thread_title || '';
+ const allUsernames = [];
+
+ if (Array.isArray(thread.users)) {
+ for (const u of thread.users) {
+ if (u && u.username) {
+ const uname = String(u.username).toLowerCase().trim().replace(/^@/, '');
+ if (uname) allUsernames.push(uname);
+ }
+ }
+ if (allUsernames.length > 0) {
+ username = allUsernames[0];
+ if (!displayName) displayName = thread.users[0].full_name || username;
+ }
+ }
+
+ const primaryId = tid || threadKey;
+ if (primaryId && (allUsernames.length > 0 || displayName)) {
+ window.dispatchEvent(new CustomEvent('focusgram:thread-matched', {
+ detail: {
+ threadId: primaryId,
+ threadKey: threadKey,
+ usernames: allUsernames,
+ displayName: displayName
+ }
+ }));
+ }
+
+ let isLocked = isDirectThreadLocked(tid, username);
+ if (!isLocked && threadKey) isLocked = isDirectThreadLocked(threadKey, username);
+ if (!isLocked) {
+ for (const u of allUsernames) {
+ if (isDirectThreadLocked(tid || threadKey, u)) {
+ isLocked = true;
+ break;
+ }
+ }
+ }
+
+ if (isLocked) {
+ // Do not mask inbox list previews
+ const isInboxItem = !thread.slide_messages && (thread.last_permanent_item || thread.snippet);
+ if (isInboxItem) {
+ continue;
+ }
+ if (maskThreadMessages(thread)) {
+ modified = true;
+ }
+ }
+ }
+
+ return modified;
+ }
+
+ window.__fgProcessDirectMessagesPayload = processDirectMessagesPayload;
+
// Override fetch in the main world
const _fetch = window.fetch.bind(window);
@@ -241,35 +507,83 @@
? input.href
: input?.url ?? '';
+ // Story viewer request detection: immediately notify UI when request is sent and received
+ if (url.includes('/list_reel_media_viewer/')) {
+ const mediaIdMatch = url.match(/\/api\/v1\/media\/(\d+)\/list_reel_media_viewer\//);
+ const mediaId = mediaIdMatch ? mediaIdMatch[1] : '';
+ const loadingDetail = { phase: 'loading', mediaId: mediaId, url: url };
+
+ // Dispatch to both window & document for maximum compatibility across script compartments
+ window.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: loadingDetail }));
+ document.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: loadingDetail }));
+ window.postMessage({ type: 'FOCUSGRAM_STORY_VIEWERS', detail: loadingDetail }, '*');
+ handleStoryViewersDetected(loadingDetail);
+
+ const response = await _fetch(input, init);
+ try {
+ const cloned = response.clone();
+ cloned.json().then(data => {
+ const readyDetail = { phase: 'ready', mediaId: mediaId, url: url, data: data };
+ window.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: readyDetail }));
+ document.dispatchEvent(new CustomEvent('focusgram:story-viewers-detected', { detail: readyDetail }));
+ window.postMessage({ type: 'FOCUSGRAM_STORY_VIEWERS', detail: readyDetail }, '*');
+ handleStoryViewersDetected(readyDetail);
+ }).catch(() => {});
+ } catch (_) {}
+ return response;
+ }
+
+ // Always execute fetch
const response = await _fetch(input, init);
- // Only inspect GraphQL, feed, and clips endpoints
- if (!url.includes('/graphql') && !url.includes('/api/v1/feed') && !url.includes('/api/v1/clips/')) {
+ // Only inspect GraphQL, feed, clips, and threads endpoints
+ if (!url.includes('/graphql') && !url.includes('/api/v1/feed') && !url.includes('/api/v1/clips/') && !url.includes('/api/v1/direct_v2/threads/')) {
return response;
}
const config = window.__fgFilterConfig;
- if (config && config.blockAds === false) {
- return response;
- }
const cloned = response.clone();
try {
const contentType = response.headers.get('content-type') || '';
- if (!contentType.includes('application/json')) {
+ if (!contentType.includes('json') && !contentType.includes('javascript')) {
return response;
}
- const data = await cloned.json();
- harvestMediaFromData(data);
- walkAndFilter(data);
+ let data;
+ try {
+ data = await cloned.json();
+ } catch (_) {
+ try {
+ let text = await cloned.text();
+ text = text.replace(/^for\s*\(\s*;\s*;\s*\)\s*;\s*/, '');
+ data = JSON.parse(text);
+ } catch (e) {
+ return response;
+ }
+ }
+ harvestMediaFromData(data);
+
+ const directModified = processDirectMessagesPayload(data);
+
+ let adModified = false;
+ if (config && config.blockAds !== false) {
+ walkAndFilter(data);
+ adModified = true;
+ }
+
+ if (!directModified && !adModified) {
+ return response;
+ }
+
+ const originalContentType = response.headers.get('content-type') || 'application/json; charset=utf-8';
const newHeaders = new Headers(response.headers);
// CRITICAL: Must delete content-length and content-encoding so the uncompressed
// JSON body is not truncated or corrupted by the browser HTTP layer
newHeaders.delete('content-length');
newHeaders.delete('content-encoding');
- newHeaders.set('content-type', 'application/json; charset=utf-8');
+ newHeaders.set('content-type', originalContentType);
return new Response(JSON.stringify(data), {
status: response.status,
@@ -287,6 +601,259 @@
configurable: true
});
+ // Hook XMLHttpRequest for complete coverage of background API calls
+ const origXHR = window.XMLHttpRequest;
+ if (origXHR && origXHR.prototype) {
+ const origOpen = origXHR.prototype.open;
+ const origSend = origXHR.prototype.send;
+
+ origXHR.prototype.open = function(method, url) {
+ this._fgUrl = typeof url === 'string' ? url : (url ? url.toString() : '');
+ return origOpen.apply(this, arguments);
+ };
+
+ origXHR.prototype.send = function() {
+ const xhr = this;
+ const url = xhr._fgUrl || '';
+ if (url.includes('/graphql') || url.includes('/api/v1/direct') || url.includes('/direct/')) {
+ const onReady = function() {
+ if (xhr.readyState === 4 && xhr.status === 200) {
+ try {
+ const cType = xhr.getResponseHeader('content-type') || '';
+ if (cType.includes('json') || cType.includes('javascript')) {
+ let text = xhr.responseText;
+ if (text) {
+ text = text.replace(/^for\s*\(\s*;\s*;\s*\)\s*;\s*/, '');
+ const data = JSON.parse(text);
+ if (processDirectMessagesPayload(data)) {
+ const modifiedText = JSON.stringify(data);
+ Object.defineProperty(xhr, 'responseText', { value: modifiedText, writable: true, configurable: true });
+ Object.defineProperty(xhr, 'response', { value: modifiedText, writable: true, configurable: true });
+ }
+ }
+ }
+ } catch (_) {}
+ }
+ };
+ xhr.addEventListener('readystatechange', onReady, false);
+ }
+ return origSend.apply(this, arguments);
+ };
+ }
+
+ /* ==========================================================================
+ STORY VIEWER CLIENT-SIDE SEARCH ENGINE
+ Autonomous DOM detector + Network Interception.
+ Injects a native-style Instagram search bar inside the "Viewers" modal
+ whether opened from cached story data or network requests.
+ ========================================================================== */
+ let storyViewersData = [];
+ let storySearchCardEl = null;
+
+ function findViewersDialog() {
+ const dialogs = document.querySelectorAll('div[aria-modal="true"][role="dialog"], div[role="dialog"]');
+ for (const d of dialogs) {
+ const headings = Array.from(d.querySelectorAll('h2, [role="heading"], div'));
+ const h2 = headings.find(el => el.textContent.trim().toLowerCase() === 'viewers' && el.children.length === 0);
+ if (h2) {
+ // h2.parentElement is the flex row containing Close button, Viewers title, and spacer
+ const headerRow = h2.parentElement;
+ const scrollContainer = d.querySelector('div[style*="overflow"]') ||
+ d.querySelector('div[style*="height"]') ||
+ (headerRow ? headerRow.nextElementSibling : null);
+ return { dialog: d, header: headerRow, scrollContainer: scrollContainer, h2: h2 };
+ }
+ }
+ return null;
+ }
+
+ function createStorySearchCard() {
+ const card = document.createElement('div');
+ card.id = 'fg-story-search-card';
+ card.style.cssText = `
+ margin: 6px 16px 10px 16px !important;
+ padding: 0 10px !important;
+ height: 38px !important;
+ background: #262626 !important;
+ border: 1px solid rgba(255, 255, 255, 0.1) !important;
+ border-radius: 8px !important;
+ display: flex !important;
+ align-items: center !important;
+ gap: 8px !important;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
+ color: #ffffff !important;
+ box-sizing: border-box !important;
+ flex-shrink: 0 !important;
+ `;
+
+ const searchIcon = document.createElement('span');
+ searchIcon.style.cssText = 'display: flex; align-items: center; color: #8e8e8e;';
+ searchIcon.innerHTML = ``;
+
+ const searchInput = document.createElement('input');
+ searchInput.id = 'fg-story-search-input';
+ searchInput.type = 'text';
+ searchInput.placeholder = 'Search viewers';
+ searchInput.autocomplete = 'off';
+ searchInput.style.cssText = `
+ flex: 1 !important;
+ background: transparent !important;
+ border: none !important;
+ color: #FAFAFA !important;
+ font-size: 14px !important;
+ outline: none !important;
+ padding: 0 !important;
+ height: 100% !important;
+ font-family: inherit !important;
+ `;
+
+ const clearBtn = document.createElement('button');
+ clearBtn.type = 'button';
+ clearBtn.textContent = 'โ';
+ clearBtn.style.cssText = `
+ background: transparent !important;
+ border: none !important;
+ color: #8e8e8e !important;
+ font-size: 13px !important;
+ cursor: pointer !important;
+ padding: 4px !important;
+ display: none !important;
+ line-height: 1 !important;
+ `;
+
+ clearBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ searchInput.value = '';
+ clearBtn.style.display = 'none';
+ filterViewers('');
+ searchInput.focus();
+ });
+
+ searchInput.addEventListener('input', () => {
+ const val = searchInput.value;
+ clearBtn.style.display = val.length > 0 ? 'block' : 'none';
+ filterViewers(val.trim().toLowerCase());
+ });
+
+ card.appendChild(searchIcon);
+ card.appendChild(searchInput);
+ card.appendChild(clearBtn);
+
+ return card;
+ }
+
+ function filterViewers(query) {
+ const info = findViewersDialog();
+ if (!info || !info.dialog) return;
+
+ const scrollContainer = info.scrollContainer ||
+ info.dialog.querySelector('div[style*="overflow"]') ||
+ info.dialog.querySelector('div[style*="height"]') ||
+ info.dialog;
+
+ // Target viewer rows: .x1qnrgzn, or rows containing profile links
+ const rows = scrollContainer.querySelectorAll('.x1qnrgzn, div[style*="display: flex"] > div:has(a[href^="/"]), div[style*="padding"] > div:has(a[href^="/"])');
+ let matchedCount = 0;
+ let totalCount = 0;
+
+ rows.forEach(row => {
+ if (row.id === 'fg-story-search-card' || row.contains(document.getElementById('fg-story-search-card'))) return;
+ const links = row.querySelectorAll('a[href^="/"]');
+ if (links.length === 0) return;
+
+ totalCount++;
+ if (!query) {
+ row.style.display = '';
+ matchedCount++;
+ return;
+ }
+
+ const text = (row.textContent || '').toLowerCase();
+ let matched = text.includes(query);
+ for (let i = 0; i < links.length; i++) {
+ const href = (links[i].getAttribute('href') || '').toLowerCase().replace(/\//g, '');
+ if (href.includes(query)) {
+ matched = true;
+ break;
+ }
+ }
+
+ row.style.display = matched ? '' : 'none';
+ if (matched) matchedCount++;
+ });
+
+ // Handle "No results found" placeholder
+ let noResultsEl = scrollContainer.querySelector('#fg-no-viewer-results');
+ if (query && matchedCount === 0 && totalCount > 0) {
+ if (!noResultsEl) {
+ noResultsEl = document.createElement('div');
+ noResultsEl.id = 'fg-no-viewer-results';
+ noResultsEl.style.cssText = 'padding: 24px; text-align: center; color: #8e8e8e; font-size: 14px;';
+ noResultsEl.textContent = 'No viewers found';
+ scrollContainer.appendChild(noResultsEl);
+ }
+ noResultsEl.style.display = 'block';
+ } else if (noResultsEl) {
+ noResultsEl.style.display = 'none';
+ }
+ }
+
+ function checkAndMountStorySearch() {
+ const info = findViewersDialog();
+ if (!info || !info.header) {
+ const existing = document.getElementById('fg-story-search-card');
+ if (existing && !document.querySelector('div[aria-modal="true"][role="dialog"]')) {
+ existing.remove();
+ storySearchCardEl = null;
+ }
+ return;
+ }
+
+ if (info.dialog.querySelector('#fg-story-search-card')) {
+ return;
+ }
+
+ const card = createStorySearchCard();
+ info.header.after(card);
+ storySearchCardEl = card;
+ }
+
+ function handleStoryViewersDetected(detail) {
+ if (!detail) return;
+ checkAndMountStorySearch();
+
+ if (detail.phase === 'ready' && detail.data) {
+ const data = detail.data;
+ const users = data.users || [];
+ const viewers = data.viewers || [];
+ const userMap = new Map();
+ users.forEach(u => {
+ const pk = String(u.pk || u.id || '');
+ if (pk) userMap.set(pk, { username: u.username || '', fullName: u.full_name || '' });
+ });
+ viewers.forEach(v => {
+ const u = v.user || v;
+ const pk = String(u.pk || u.id || '');
+ if (pk) userMap.set(pk, { username: u.username || '', fullName: u.full_name || '' });
+ });
+ storyViewersData = Array.from(userMap.values());
+ const input = document.getElementById('fg-story-search-input');
+ if (input && input.value.trim()) {
+ filterViewers(input.value.trim().toLowerCase());
+ }
+ }
+ }
+
+ window.__fgHandleStoryViewers = handleStoryViewersDetected;
+ window.addEventListener('focusgram:story-viewers-detected', (e) => handleStoryViewersDetected(e.detail));
+ document.addEventListener('focusgram:story-viewers-detected', (e) => handleStoryViewersDetected(e.detail));
+
+ // Continuous autonomous observer for Viewers modal
+ const viewersObserver = new MutationObserver(() => {
+ checkAndMountStorySearch();
+ });
+ viewersObserver.observe(document.documentElement, { childList: true, subtree: true });
+
window.__fgSetFilterConfig = function (cfg) {
if (typeof cfg === 'object' && cfg) {
Object.assign(window.__fgFilterConfig, cfg);
diff --git a/Ported/app/src/main/assets/scripts/native_feel.js b/Ported/app/src/main/assets/scripts/native_feel.js
index 4934ee3..bd25114 100644
--- a/Ported/app/src/main/assets/scripts/native_feel.js
+++ b/Ported/app/src/main/assets/scripts/native_feel.js
@@ -15,6 +15,7 @@
function isAuthPage() {
try {
var path = (window.location.pathname || '').toLowerCase();
+ if (path.includes('/accounts/edit')) return false;
if (path.startsWith('/accounts') || path.startsWith('/login') || path.startsWith('/signup')) return true;
var c = document.cookie || '';
if (!c.includes('sessionid=') && !c.includes('ds_user_id=')) return true;
@@ -206,6 +207,327 @@
} catch (_) {}
}
+ /* ==========================================================================
+ 7. LIVE BIO FONT STYLIST & REAL-TIME INPUT ENGINE
+ Directly mounts a dedicated styling accessory bar above textarea#pepBio,
+ handles bidirectional Unicode mathematical font conversion,
+ and transforms typing in real time.
+ ========================================================================== */
+ const BIO_FONT_MAPS = {
+ normal: null,
+ bold: { u: 0x1D400, l: 0x1D41A, d: 0x1D7CE }, // Mathematical Bold (Zero gaps)
+ sans: { u: 0x1D5D4, l: 0x1D5EE, d: 0x1D7EC }, // Sans-Serif Bold (Zero gaps)
+ sans_italic: { u: 0x1D608, l: 0x1D622, d: null }, // Sans-Serif Italic (Zero gaps)
+ mono: { u: 0x1D670, l: 0x1D68A, d: 0x1D7F6 }, // Monospace (Zero gaps)
+ gothic: { u: 0x1D56C, l: 0x1D586, d: null }, // Bold Fraktur/Gothic (Zero gaps)
+ script: { u: 0x1D4D0, l: 0x1D4EA, d: null } // Bold Script/Cursive (Zero gaps)
+ };
+
+ let activeBioStyle = 'bold';
+ let activeBioTarget = null;
+ let bioInlineBarEl = null;
+
+ function normalizeChar(char) {
+ if (!char) return '';
+ const cp = char.codePointAt(0);
+ for (const [key, map] of Object.entries(BIO_FONT_MAPS)) {
+ if (!map) continue;
+ if (cp >= map.u && cp <= map.u + 25) {
+ return String.fromCharCode(65 + (cp - map.u));
+ }
+ if (cp >= map.l && cp <= map.l + 25) {
+ return String.fromCharCode(97 + (cp - map.l));
+ }
+ if (map.d && cp >= map.d && cp <= map.d + 9) {
+ return String.fromCharCode(48 + (cp - map.d));
+ }
+ }
+ return char;
+ }
+
+ function convertCharToStyle(char, styleKey) {
+ const map = BIO_FONT_MAPS[styleKey];
+ if (!map || styleKey === 'normal') return normalizeChar(char);
+ const norm = normalizeChar(char);
+ const cp = norm.codePointAt(0);
+ if (cp >= 65 && cp <= 90) { // A-Z
+ return String.fromCodePoint(map.u + (cp - 65));
+ }
+ if (cp >= 97 && cp <= 122) { // a-z
+ return String.fromCodePoint(map.l + (cp - 97));
+ }
+ if (map.d && cp >= 48 && cp <= 57) { // 0-9
+ return String.fromCodePoint(map.d + (cp - 48));
+ }
+ return norm;
+ }
+
+ function convertStringToStyle(text, styleKey) {
+ if (!text) return '';
+ return Array.from(text).map(ch => convertCharToStyle(ch, styleKey)).join('');
+ }
+
+ function isBioTarget(el) {
+ if (!el) return false;
+ if (el.id === 'pepBio') return true;
+ const name = (el.name || '').toLowerCase();
+ const placeholder = (el.placeholder || '').toLowerCase();
+ const aria = (el.getAttribute('aria-label') || '').toLowerCase();
+ if (name === 'biography' || name === 'bio' || placeholder === 'bio' || aria === 'bio') return true;
+ const path = (window.location.pathname || '').toLowerCase();
+ if (path.includes('/accounts/edit') && (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT')) return true;
+ return false;
+ }
+
+ function updateBioCharCounter(target) {
+ const el = target || activeBioTarget || document.getElementById('pepBio');
+ const counter = document.getElementById('fg-bio-counter');
+ if (!el || !counter) return;
+
+ const text = el.value || el.textContent || '';
+ const codePoints = Array.from(text).length;
+
+ counter.textContent = `${codePoints} / 150`;
+ if (codePoints > 140) {
+ counter.style.color = '#ef4444';
+ } else if (codePoints > 120) {
+ counter.style.color = '#f59e0b';
+ } else {
+ counter.style.color = '#8e8e8e';
+ }
+ }
+
+ function setReactInputValue(input, val) {
+ if (!input) return;
+ try {
+ const proto = window.HTMLTextAreaElement ? window.HTMLTextAreaElement.prototype : Object.getPrototypeOf(input);
+ const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set;
+ if (setter) {
+ setter.call(input, val);
+ } else {
+ input.value = val;
+ }
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ } catch (_) {
+ input.value = val;
+ }
+ }
+
+ function applyBioStyleToTarget(styleId) {
+ activeBioStyle = styleId;
+ const target = activeBioTarget || document.getElementById('pepBio') || document.querySelector('textarea[aria-label="Bio"]');
+
+ // Update inline chip states (native Instagram Stories pill style)
+ document.querySelectorAll('.fg-bio-chip').forEach(c => {
+ const isSelected = c.dataset.styleId === activeBioStyle;
+ c.style.background = isSelected ? '#ffffff' : '#262626';
+ c.style.borderColor = isSelected ? '#ffffff' : 'rgba(255, 255, 255, 0.15)';
+ c.style.color = isSelected ? '#000000' : '#d1d5db';
+ c.style.fontWeight = isSelected ? '600' : '500';
+ });
+
+ if (!target) return;
+
+ // Convert ONLY selected text if user made an explicit text selection
+ const val = target.value || '';
+ if (val && target.selectionStart !== undefined && target.selectionEnd !== undefined && target.selectionStart !== target.selectionEnd) {
+ const start = target.selectionStart;
+ const end = target.selectionEnd;
+ const before = val.substring(0, start);
+ const selected = val.substring(start, end);
+ const after = val.substring(end);
+ const converted = convertStringToStyle(selected, activeBioStyle);
+ setReactInputValue(target, before + converted + after);
+ target.setSelectionRange(start, start + converted.length);
+ }
+ // If NO text is selected, do NOT alter existing text!
+ // activeBioStyle is set, so only characters typed afterwards will be converted.
+ updateBioCharCounter(target);
+ }
+
+ window.__fgSetBioStyle = applyBioStyleToTarget;
+
+ function notifyBioFocus(focused) {
+ try {
+ window.postMessage({ type: 'FOCUSGRAM_BIO_FOCUSED', focused: focused }, '*');
+ document.dispatchEvent(new CustomEvent('focusgram:bio-focused', { detail: { focused: focused } }));
+ if (typeof browser !== 'undefined' && browser.runtime && browser.runtime.sendMessage) {
+ browser.runtime.sendMessage({ action: 'bioInputFocused', focused: focused }).catch(() => {});
+ }
+ } catch (_) {}
+ }
+
+ function mountBioInlineBar(textarea) {
+ if (!textarea) return null;
+ if (document.getElementById('fg-bio-inline-bar')) {
+ return document.getElementById('fg-bio-inline-bar');
+ }
+
+ const bar = document.createElement('div');
+ bar.id = 'fg-bio-inline-bar';
+ bar.style.cssText = `
+ margin: 10px 0 6px 0 !important;
+ padding: 8px 10px !important;
+ background: #18181c !important;
+ border: 1px solid rgba(255, 255, 255, 0.15) !important;
+ border-radius: 12px !important;
+ display: flex !important;
+ flex-direction: column !important;
+ gap: 6px !important;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
+ box-sizing: border-box !important;
+ user-select: none !important;
+ -webkit-user-select: none !important;
+ `;
+
+ const topRow = document.createElement('div');
+ topRow.style.cssText = `
+ display: flex !important;
+ align-items: center !important;
+ justify-content: space-between !important;
+ font-size: 12px !important;
+ color: #d1d5db !important;
+ `;
+
+ const titleSpan = document.createElement('span');
+ titleSpan.innerHTML = `Bio Font`;
+
+ const counterSpan = document.createElement('span');
+ counterSpan.id = 'fg-bio-counter';
+ counterSpan.style.cssText = `
+ font-size: 11px !important;
+ font-weight: 600 !important;
+ color: #8e8e8e !important;
+ background: rgba(255, 255, 255, 0.06) !important;
+ padding: 2px 8px !important;
+ border-radius: 10px !important;
+ `;
+ counterSpan.textContent = '0 / 150';
+
+ topRow.appendChild(titleSpan);
+ topRow.appendChild(counterSpan);
+
+ const chipsRow = document.createElement('div');
+ chipsRow.style.cssText = `
+ display: flex !important;
+ align-items: center !important;
+ gap: 6px !important;
+ overflow-x: auto !important;
+ scrollbar-width: none !important;
+ -webkit-overflow-scrolling: touch !important;
+ padding: 2px 0 !important;
+ `;
+
+ const STYLES = [
+ { id: 'normal', label: 'Normal' },
+ { id: 'bold', label: 'Bold' },
+ { id: 'sans', label: 'Sans' },
+ { id: 'sans_italic', label: 'Italic' },
+ { id: 'mono', label: 'Mono' },
+ { id: 'gothic', label: 'Gothic' },
+ { id: 'script', label: 'Script' }
+ ];
+
+ STYLES.forEach(st => {
+ const chip = document.createElement('button');
+ chip.type = 'button';
+ chip.className = 'fg-bio-chip';
+ chip.dataset.styleId = st.id;
+ chip.textContent = st.label;
+ const isSelected = st.id === activeBioStyle;
+ chip.style.cssText = `
+ padding: 5px 12px !important;
+ border-radius: 14px !important;
+ border: 1px solid ${isSelected ? '#ffffff' : 'rgba(255, 255, 255, 0.15)'} !important;
+ background: ${isSelected ? '#ffffff' : '#262626'} !important;
+ color: ${isSelected ? '#000000' : '#d1d5db'} !important;
+ font-size: 12px !important;
+ font-weight: ${isSelected ? '600' : '500'} !important;
+ white-space: nowrap !important;
+ cursor: pointer !important;
+ outline: none !important;
+ transition: background 0.15s ease, color 0.15s ease !important;
+ `;
+
+ chip.addEventListener('click', (e) => {
+ e.preventDefault();
+ applyBioStyleToTarget(st.id);
+ if (textarea) textarea.focus();
+ });
+
+ chipsRow.appendChild(chip);
+ });
+
+ bar.appendChild(topRow);
+ bar.appendChild(chipsRow);
+
+ if (textarea.parentElement) {
+ textarea.parentElement.insertBefore(bar, textarea);
+ } else {
+ textarea.before(bar);
+ }
+
+ bioInlineBarEl = bar;
+ updateBioCharCounter(textarea);
+ return bar;
+ }
+
+ function checkAndMountBioStylist() {
+ const bioEl = document.getElementById('pepBio') ||
+ document.querySelector('textarea[aria-label="Bio"]') ||
+ document.querySelector('textarea[placeholder="Bio"]') ||
+ document.querySelector('textarea[name="biography"]');
+
+ if (bioEl) {
+ activeBioTarget = bioEl;
+ mountBioInlineBar(bioEl);
+ }
+ }
+
+ // Intercept keystrokes for real-time live typing transformation
+ document.addEventListener('beforeinput', (e) => {
+ if (!isBioTarget(e.target) || activeBioStyle === 'normal') return;
+ if (e.data && e.inputType === 'insertText') {
+ e.preventDefault();
+ const converted = convertStringToStyle(e.data, activeBioStyle);
+ document.execCommand('insertText', false, converted);
+ updateBioCharCounter(e.target);
+ }
+ }, true);
+
+ document.addEventListener('input', (e) => {
+ if (!isBioTarget(e.target)) return;
+ updateBioCharCounter(e.target);
+ }, true);
+
+ document.addEventListener('focusin', (e) => {
+ if (isBioTarget(e.target)) {
+ activeBioTarget = e.target;
+ mountBioInlineBar(e.target);
+ notifyBioFocus(true);
+ updateBioCharCounter(e.target);
+ }
+ }, true);
+
+ document.addEventListener('focusout', (e) => {
+ if (isBioTarget(e.target)) {
+ setTimeout(() => {
+ const active = document.activeElement;
+ if (!isBioTarget(active)) {
+ notifyBioFocus(false);
+ }
+ }, 200);
+ }
+ }, true);
+
+ // Watch for dynamic rendering of Bio textarea on /accounts/edit/
+ const bioObserver = new MutationObserver(() => {
+ checkAndMountBioStylist();
+ });
+ bioObserver.observe(document.documentElement, { childList: true, subtree: true });
+ setInterval(checkAndMountBioStylist, 500);
+
// Initialize immediately or upon DOM ready
injectNativeStyles();
applyScrollSmoothing();
@@ -213,6 +535,7 @@
setupMediaUploadBridge();
enhanceFileInputsForVideoSupport();
preventLandscapeOrientationLock();
+ checkAndMountBioStylist();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
@@ -221,8 +544,9 @@
setupMediaUploadBridge();
enhanceFileInputsForVideoSupport();
preventLandscapeOrientationLock();
+ checkAndMountBioStylist();
});
}
- console.log('[FocusGram] Native Feel, Touch Smoothing, Media Upload Bridge & Video Support active.');
+ console.log('[FocusGram] Native Feel, Bio Font Stylist & Video Support active.');
})();
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt
index d1d8dce..2d53f0f 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/MainActivity.kt
@@ -27,6 +27,14 @@ class MainActivity : FragmentActivity() {
}
}
+ override fun onResume() {
+ super.onResume()
+ try {
+ val appIcon = com.ujwal.focusgram.core.datastore.SettingsRepository(this).getSettingsSync().appIcon
+ com.ujwal.focusgram.core.utils.AppIconManager.applyTaskDescription(this, appIcon)
+ } catch (_: Exception) {}
+ }
+
private fun applyHighRefreshRate() {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt
index cda8ff9..38b3f9f 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/datastore/SettingsRepository.kt
@@ -8,12 +8,14 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.ujwal.focusgram.core.model.FocusSettings
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.runBlocking
+import org.json.JSONObject
private val Context.dataStore: DataStore by preferencesDataStore(name = "focusgram_settings")
@@ -79,6 +81,15 @@ class SettingsRepository(private val context: Context) {
notifySessionEnd = prefs[KEY_NOTIFY_SESSION_END] ?: false,
notifyPersistent = prefs[KEY_NOTIFY_PERSISTENT] ?: false,
portraitOnly = prefs[KEY_PORTRAIT_ONLY] ?: true,
+ appIcon = prefs[KEY_APP_ICON] ?: "default",
+ customIconsEnabled = prefs[KEY_CUSTOM_ICONS_ENABLED] ?: false,
+
+ chatLockMode = prefs[KEY_CHAT_LOCK_MODE] ?: if (prefs[KEY_CHAT_LOCK_ALL] == true) "whole_dm" else "individual",
+ chatLockAll = (prefs[KEY_CHAT_LOCK_MODE] ?: if (prefs[KEY_CHAT_LOCK_ALL] == true) "whole_dm" else "individual") == "whole_dm",
+ lockedChatIds = prefs[KEY_LOCKED_CHAT_IDS] ?: emptySet(),
+ lockedChatUsernames = parseUsernamesMap(prefs[KEY_LOCKED_CHAT_USERNAMES]),
+ useSeparateChatPin = prefs[KEY_USE_SEPARATE_CHAT_PIN] ?: false,
+ chatBiometricEnabled = prefs[KEY_CHAT_BIOMETRIC_ENABLED] ?: true,
ghostModeEnabled = prefs[KEY_GHOST_MODE_ENABLED] ?: false,
ghostHideStorySeen = prefs[KEY_GHOST_HIDE_STORY_SEEN] ?: true,
@@ -87,6 +98,22 @@ class SettingsRepository(private val context: Context) {
)
}
+ private fun parseUsernamesMap(jsonStr: String?): Map {
+ if (jsonStr.isNullOrBlank()) return emptyMap()
+ return try {
+ val json = JSONObject(jsonStr)
+ val map = mutableMapOf()
+ val keys = json.keys()
+ while (keys.hasNext()) {
+ val k = keys.next()
+ map[k] = json.optString(k)
+ }
+ map
+ } catch (_: Exception) {
+ emptyMap()
+ }
+ }
+
val lastSessionEndTimestampFlow: Flow = context.dataStore.data.map { prefs ->
prefs[KEY_LAST_SESSION_END_TIMESTAMP] ?: 0L
}
@@ -237,6 +264,105 @@ class SettingsRepository(private val context: Context) {
context.dataStore.edit { it[key] = value }
}
+ suspend fun setAppIcon(icon: String) = setString(KEY_APP_ICON, icon)
+ suspend fun setCustomIconsEnabled(enabled: Boolean) = setBoolean(KEY_CUSTOM_ICONS_ENABLED, enabled)
+ suspend fun setChatLockMode(mode: String) {
+ val cleanMode = when (mode.lowercase().trim()) {
+ "whole_dm", "whole", "all" -> "whole_dm"
+ "individual", "individual_dms", "single" -> "individual"
+ else -> "off"
+ }
+ context.dataStore.edit { prefs ->
+ prefs[KEY_CHAT_LOCK_MODE] = cleanMode
+ prefs[KEY_CHAT_LOCK_ALL] = (cleanMode == "whole_dm")
+ }
+ }
+ suspend fun setChatLockAll(enabled: Boolean) {
+ context.dataStore.edit { prefs ->
+ prefs[KEY_CHAT_LOCK_ALL] = enabled
+ prefs[KEY_CHAT_LOCK_MODE] = if (enabled) "whole_dm" else "individual"
+ }
+ }
+ suspend fun setLockedChatIds(ids: Set) {
+ context.dataStore.edit { prefs ->
+ prefs[KEY_LOCKED_CHAT_IDS] = ids
+ }
+ }
+ suspend fun addLockedChatId(id: String) {
+ val clean = id.trim().lowercase().removePrefix("@")
+ if (clean.isBlank()) return
+ context.dataStore.edit { prefs ->
+ val current = prefs[KEY_LOCKED_CHAT_IDS] ?: emptySet()
+ prefs[KEY_LOCKED_CHAT_IDS] = current + clean
+ }
+ }
+ suspend fun removeLockedChatId(id: String) {
+ val clean = id.trim().lowercase().removePrefix("@")
+ context.dataStore.edit { prefs ->
+ val current = prefs[KEY_LOCKED_CHAT_IDS] ?: emptySet()
+ val usernamesMap = parseUsernamesMap(prefs[KEY_LOCKED_CHAT_USERNAMES]).toMutableMap()
+ val mappedUser = usernamesMap[clean]
+ val toRemove = buildSet {
+ add(clean)
+ if (!mappedUser.isNullOrBlank()) add(mappedUser)
+ }
+ prefs[KEY_LOCKED_CHAT_IDS] = current - toRemove
+ usernamesMap.remove(clean)
+ if (!mappedUser.isNullOrBlank()) usernamesMap.remove(mappedUser)
+ prefs[KEY_LOCKED_CHAT_USERNAMES] = JSONObject(usernamesMap as Map<*, *>).toString()
+ }
+ }
+ suspend fun toggleLockedChat(threadId: String, username: String? = null): Boolean {
+ val cleanId = threadId.trim().lowercase()
+ if (cleanId.isBlank()) return false
+ val cleanUser = username?.trim()?.lowercase()?.removePrefix("@")
+ var isNowLocked = false
+ context.dataStore.edit { prefs ->
+ val currentIds = prefs[KEY_LOCKED_CHAT_IDS] ?: emptySet()
+ val usernamesMap = parseUsernamesMap(prefs[KEY_LOCKED_CHAT_USERNAMES]).toMutableMap()
+ val existingUser = usernamesMap[cleanId] ?: cleanUser
+ val matchesExisting = currentIds.contains(cleanId) ||
+ (!cleanUser.isNullOrBlank() && currentIds.contains(cleanUser)) ||
+ (!existingUser.isNullOrBlank() && currentIds.contains(existingUser))
+
+ if (matchesExisting) {
+ val toRemove = buildSet {
+ add(cleanId)
+ if (!cleanUser.isNullOrBlank()) add(cleanUser)
+ if (!existingUser.isNullOrBlank()) add(existingUser)
+ }
+ prefs[KEY_LOCKED_CHAT_IDS] = currentIds - toRemove
+ usernamesMap.remove(cleanId)
+ if (!cleanUser.isNullOrBlank()) usernamesMap.remove(cleanUser)
+ if (!existingUser.isNullOrBlank()) usernamesMap.remove(existingUser)
+ isNowLocked = false
+ } else {
+ prefs[KEY_LOCKED_CHAT_IDS] = currentIds + cleanId
+ val effectiveUser = if (!cleanUser.isNullOrBlank()) cleanUser else existingUser
+ if (!effectiveUser.isNullOrBlank()) {
+ usernamesMap[cleanId] = effectiveUser
+ usernamesMap[effectiveUser] = cleanId
+ }
+ isNowLocked = true
+ }
+ prefs[KEY_LOCKED_CHAT_USERNAMES] = JSONObject(usernamesMap as Map<*, *>).toString()
+ }
+ return isNowLocked
+ }
+ suspend fun mapThreadUsername(threadId: String, username: String) {
+ val cleanId = threadId.trim().lowercase()
+ val cleanUser = username.trim().lowercase().removePrefix("@")
+ if (cleanId.isBlank() || cleanUser.isBlank()) return
+ context.dataStore.edit { prefs ->
+ val usernamesMap = parseUsernamesMap(prefs[KEY_LOCKED_CHAT_USERNAMES]).toMutableMap()
+ usernamesMap[cleanId] = cleanUser
+ usernamesMap[cleanUser] = cleanId
+ prefs[KEY_LOCKED_CHAT_USERNAMES] = JSONObject(usernamesMap as Map<*, *>).toString()
+ }
+ }
+ suspend fun setUseSeparateChatPin(enabled: Boolean) = setBoolean(KEY_USE_SEPARATE_CHAT_PIN, enabled)
+ suspend fun setChatBiometricEnabled(enabled: Boolean) = setBoolean(KEY_CHAT_BIOMETRIC_ENABLED, enabled)
+
companion object {
val KEY_NO_ADS = booleanPreferencesKey("no_ads")
val KEY_NO_STORIES = booleanPreferencesKey("no_stories")
@@ -295,5 +421,14 @@ class SettingsRepository(private val context: Context) {
val KEY_GHOST_HIDE_DIRECT_SEEN = booleanPreferencesKey("ghost_hide_direct_seen")
val KEY_GHOST_HIDE_TYPING = booleanPreferencesKey("ghost_hide_typing")
val KEY_LAST_SESSION_END_TIMESTAMP = longPreferencesKey("last_session_end_timestamp")
+
+ val KEY_APP_ICON = stringPreferencesKey("app_icon")
+ val KEY_CUSTOM_ICONS_ENABLED = booleanPreferencesKey("custom_icons_enabled")
+ val KEY_CHAT_LOCK_MODE = stringPreferencesKey("chat_lock_mode")
+ val KEY_CHAT_LOCK_ALL = booleanPreferencesKey("chat_lock_all")
+ val KEY_LOCKED_CHAT_IDS = stringSetPreferencesKey("locked_chat_ids")
+ val KEY_LOCKED_CHAT_USERNAMES = stringPreferencesKey("locked_chat_usernames")
+ val KEY_USE_SEPARATE_CHAT_PIN = booleanPreferencesKey("use_separate_chat_pin")
+ val KEY_CHAT_BIOMETRIC_ENABLED = booleanPreferencesKey("chat_biometric_enabled")
}
}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt
index cd834e4..d73ae30 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/ExtensionManager.kt
@@ -6,6 +6,7 @@ import com.ujwal.focusgram.core.model.IslandMode
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
+import org.json.JSONArray
import org.json.JSONObject
import org.mozilla.geckoview.GeckoResult
import org.mozilla.geckoview.GeckoRuntime
@@ -28,6 +29,7 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
var onOpenSettings: (() -> Unit)? = null
var onInputFocused: ((Boolean) -> Unit)? = null
+ var onBioInputFocused: ((Boolean) -> Unit)? = null
var onUrlChanged: ((String) -> Unit)? = null
var onModalOrChatActive: ((Boolean) -> Unit)? = null
var onThemeChanged: ((String) -> Unit)? = null
@@ -37,6 +39,9 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
var onDownloadError: ((error: String) -> Unit)? = null
var onDownloadStarted: ((filename: String) -> Unit)? = null
var onAuthStateChanged: ((isAuthenticated: Boolean, isAuthPage: Boolean) -> Unit)? = null
+ var onThreadUsernameMapped: ((threadId: String, usernames: List) -> Unit)? = null
+ var onToggleChatLock: ((threadId: String, username: String?) -> Unit)? = null
+ var onRequestUnlockChat: ((threadId: String, username: String?, targetUrl: String?) -> Unit)? = null
/**
* Installs and activates built-in content blocking and FocusGram controller from APK assets.
@@ -136,6 +141,11 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
Log.d(TAG, "Received inputFocused from Instagram web: $focused")
mainHandler.post { onInputFocused?.invoke(focused) }
}
+ "bioInputFocused" -> {
+ val focused = json.optBoolean("focused", false)
+ Log.d(TAG, "Received bioInputFocused from Instagram web: $focused")
+ mainHandler.post { onBioInputFocused?.invoke(focused) }
+ }
"urlChanged" -> {
val url = json.optString("url")
if (url.isNotEmpty()) {
@@ -215,6 +225,52 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
onAuthStateChanged?.invoke(isAuthenticated, isAuthPage)
}
}
+ "threadUsernameMapped" -> {
+ val threadId = json.optString("threadId")
+ val usernamesArr = json.optJSONArray("usernames")
+ val singleUser = json.optString("username").takeIf { it.isNotBlank() }
+ val displayName = json.optString("displayName").takeIf { it.isNotBlank() }
+ val list = mutableListOf()
+ if (usernamesArr != null) {
+ for (i in 0 until usernamesArr.length()) {
+ val u = usernamesArr.optString(i)
+ if (u.isNotBlank()) list.add(u)
+ }
+ }
+ if (singleUser != null && !list.contains(singleUser)) {
+ list.add(0, singleUser)
+ }
+ if (displayName != null && !list.contains(displayName)) {
+ list.add(displayName)
+ }
+ if (threadId.isNotBlank()) {
+ Log.d(TAG, "Received threadUsernameMapped from web: threadId=$threadId, usernames=$list, displayName=$displayName")
+ mainHandler.post {
+ onThreadUsernameMapped?.invoke(threadId, list)
+ }
+ }
+ }
+ "requestUnlockChat" -> {
+ val threadId = json.optString("threadId")
+ val username = json.optString("username").takeIf { it.isNotBlank() }
+ val targetUrl = json.optString("targetUrl").takeIf { it.isNotBlank() }
+ if (threadId.isNotBlank()) {
+ Log.d(TAG, "Received requestUnlockChat from web: threadId=$threadId, username=$username, targetUrl=$targetUrl")
+ mainHandler.post {
+ onRequestUnlockChat?.invoke(threadId, username, targetUrl)
+ }
+ }
+ }
+ "toggleChatLock" -> {
+ val threadId = json.optString("threadId")
+ val username = json.optString("username").takeIf { it.isNotBlank() }
+ if (threadId.isNotBlank()) {
+ Log.d(TAG, "Received toggleChatLock from web: threadId=$threadId, username=$username")
+ mainHandler.post {
+ onToggleChatLock?.invoke(threadId, username)
+ }
+ }
+ }
"getSettings" -> {
activePort?.let { sendSettingsToPort(it, cachedSettings) }
}
@@ -254,6 +310,11 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
put("ghostHideStorySeen", settings.ghostHideStorySeen)
put("ghostHideDirectSeen", settings.ghostHideDirectSeen)
put("ghostHideTypingIndicator", settings.ghostHideTypingIndicator)
+
+ put("chatLockMode", settings.chatLockMode)
+ put("chatLockAll", settings.chatLockAll)
+ put("lockedChatIds", JSONArray(settings.lockedChatIds.toList()))
+ put("lockedChatUsernames", JSONObject(settings.lockedChatUsernames))
}
val msg = JSONObject().apply {
@@ -262,12 +323,45 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
put("revealMode", settings.unblurMode)
}
port.postMessage(msg)
- Log.d(TAG, "Broadcast settings to WebExtension: noAds=${settings.noAds}, ghost=${settings.ghostModeEnabled}, revealMode=${settings.unblurMode}")
+ Log.d(TAG, "Broadcast settings to WebExtension: noAds=${settings.noAds}, ghost=${settings.ghostModeEnabled}, chatLockAll=${settings.chatLockAll}, lockedChats=${settings.lockedChatIds.size}")
} catch (e: Exception) {
Log.e(TAG, "Failed to send settings to WebExtension port", e)
}
}
+ /**
+ * Informs WebExtension content script that a locked chat has successfully been unlocked.
+ */
+ fun notifyChatUnlocked(threadId: String, isAll: Boolean = false) {
+ try {
+ val msg = JSONObject().apply {
+ put("action", "unlockChat")
+ put("threadId", threadId)
+ put("all", isAll)
+ }
+ activePort?.postMessage(msg)
+ Log.d(TAG, "Sent unlockChat to WebExtension port: threadId=$threadId, all=$isAll")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed sending unlockChat to WebExtension", e)
+ }
+ }
+
+ /**
+ * Informs WebExtension content script that a conversation has been locked.
+ */
+ fun notifyChatLocked(threadId: String) {
+ try {
+ val msg = JSONObject().apply {
+ put("action", "lockChat")
+ put("threadId", threadId)
+ }
+ activePort?.postMessage(msg)
+ Log.d(TAG, "Sent lockChat to WebExtension port: threadId=$threadId")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed sending lockChat to WebExtension", e)
+ }
+ }
+
/**
* Senior Blueprint: Sets feed post reveal mode ("tap" or "hold") dynamically.
*/
@@ -380,6 +474,23 @@ class ExtensionManager(private val runtime: GeckoRuntime) {
}
}
+ /**
+ * Sends the active Unicode font style selection to the WebExtension content script
+ * for real-time live bio transformation.
+ */
+ fun setBioStyle(style: String) {
+ try {
+ val msg = JSONObject().apply {
+ put("action", "setBioStyle")
+ put("style", style)
+ }
+ activePort?.postMessage(msg)
+ Log.d(TAG, "Sent setBioStyle ($style) to WebExtension port")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to send setBioStyle to port: ${e.message}", e)
+ }
+ }
+
companion object {
private const val TAG = "ExtensionManager"
const val CONTENT_BLOCKER_EXTENSION_ID = "uBlock0@raymondhill.net"
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt
index ba57fa7..958ec11 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/FocusGramNavigationDelegate.kt
@@ -118,6 +118,25 @@ class FocusGramNavigationDelegate(
}
}
Log.d(TAG, "Location changed to: $it")
+ val lower = it.lowercase()
+ if (lower.contains("/direct/t/")) {
+ val path = try { Uri.parse(it).path?.lowercase() ?: "" } catch (_: Exception) { "" }
+ val tid = path.removePrefix("/direct/t/").trim('/').split('/').firstOrNull()?.trim() ?: ""
+ val settings = sessionManager.currentSettings
+ val isLocked = if (settings.chatLockMode == "off") {
+ false
+ } else if (settings.chatLockMode == "whole_dm" || settings.chatLockAll) {
+ true
+ } else {
+ settings.lockedChatIds.any { lockedId ->
+ val clean = lockedId.trim().lowercase().removePrefix("@")
+ clean.equals(tid, ignoreCase = true) || lower.contains(clean)
+ }
+ }
+ if (isLocked) {
+ session.loadUri("javascript:(function(){if(typeof window.__fgApplyPrivacyVeil==='function'){window.__fgApplyPrivacyVeil();}})()")
+ }
+ }
sessionManager.updateCurrentUrl(it)
onRouteChanged?.invoke(it)
sessionManager.injectBottomBarTrimmer()
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt
index d010c77..d2fef14 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/GeckoSessionManager.kt
@@ -52,7 +52,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
isAuthenticated = auth
if (auth && changed) {
val url = _currentUrl.value.lowercase()
- val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup")
+ val isAuth = (url.contains("/accounts") && !url.contains("/accounts/edit")) || url.contains("/login") || url.contains("/signup")
if (!isAuth) {
injectBottomBarTrimmer()
userScriptManager?.injectAll(session, currentSettings)
@@ -91,7 +91,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
_progress.value = 100
if (success) {
val url = _currentUrl.value.lowercase()
- val isAuth = url.contains("/accounts") || url.contains("/login") || url.contains("/signup")
+ val isAuth = (url.contains("/accounts") && !url.contains("/accounts/edit")) || url.contains("/login") || url.contains("/signup")
if (isAuthenticated && !isAuth) {
injectBottomBarTrimmer()
userScriptManager?.injectAll(session, currentSettings)
@@ -212,11 +212,15 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
target = document.querySelector('svg[aria-label="Reels"]')?.closest('a') ||
document.querySelector('a[href*="/reels"]') ||
document.querySelector('a[href*="/reel/"]');
- } else if (p.indexOf('direct') !== -1) {
+ } else if (p == "/direct" || p == "/direct/" || p == "/direct/inbox" || p == "/direct/inbox/") {
target = document.querySelector('svg[aria-label="Messages"]')?.closest('a') ||
document.querySelector('svg[aria-label="Direct"]')?.closest('a') ||
- document.querySelector('a[href*="/direct/inbox"]') ||
- document.querySelector('a[href*="/direct"]');
+ document.querySelector('a[href="/direct/inbox/"]') ||
+ document.querySelector('a[href="/direct/inbox"]') ||
+ document.querySelector('a[href="/direct/"]');
+ } else if (p.indexOf('/direct/t/') !== -1) {
+ target = document.querySelector('a[href*="' + p + '"]') ||
+ document.querySelector('a[href*="' + p.replace(/\/$/, '') + '"]');
}
if (!target) {
target = document.querySelector('a[href="' + p + '"]') ||
@@ -300,7 +304,7 @@ class GeckoSessionManager(private val runtime: GeckoRuntime) {
val js = """javascript:(function() {
try {
var p = (window.location.pathname || '').toLowerCase();
- if (p.startsWith('/direct') || p.startsWith('/accounts') || p.startsWith('/login') || p.startsWith('/signup')) return;
+ if (p.startsWith('/direct') || (p.startsWith('/accounts') && !p.startsWith('/accounts/edit')) || p.startsWith('/login') || p.startsWith('/signup')) return;
var c = document.cookie || '';
if ((p === '/' || p === '') && !c.includes('sessionid=') && !c.includes('ds_user_id=')) return;
if (document.getElementById('loginForm') || document.querySelector('form[action*="login"], input[name="password"]')) return;
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt
index 663ac36..0847e46 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/engine/UserScriptManager.kt
@@ -30,6 +30,8 @@ class UserScriptManager(private val context: Context) {
* and broadcasts updates to active scripts.
*/
fun injectConfig(session: GeckoSession, settings: FocusSettings) {
+ val lockedIdsJson = org.json.JSONArray(settings.lockedChatIds.map { it.lowercase().trim().removePrefix("@") }).toString()
+ val lockedUsersJson = org.json.JSONObject(settings.lockedChatUsernames).toString()
val configJs = """
(function() {
window.__FOCUSGRAM_CONFIG__ = {
@@ -59,6 +61,24 @@ class UserScriptManager(private val context: Context) {
igSeen: ${settings.ghostHideDirectSeen},
igTyping: ${settings.ghostHideTypingIndicator}
};
+ window.__fgChatLockMode = '${settings.chatLockMode}';
+ window.__fgChatLockAll = ${settings.chatLockAll || settings.chatLockMode == "whole_dm"};
+ window.__fgLockedChatIds = $lockedIdsJson;
+ window.__fgLockedChatUsernames = $lockedUsersJson;
+ window.__fgThreadUsernameMap = $lockedUsersJson;
+
+ const syncDetail = {
+ chatLockMode: '${settings.chatLockMode}',
+ chatLockAll: ${settings.chatLockAll || settings.chatLockMode == "whole_dm"},
+ lockedChatIds: $lockedIdsJson,
+ lockedChatUsernames: $lockedUsersJson,
+ threadUsernameMap: $lockedUsersJson
+ };
+ try {
+ window.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { detail: syncDetail }));
+ document.dispatchEvent(new CustomEvent('focusgram:chat-lock-sync', { detail: syncDetail }));
+ } catch(_) {}
+
if (window.__focusgramUpdateStyles) {
window.__focusgramUpdateStyles();
}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt
index 85d7f01..10df1cc 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/FocusSettings.kt
@@ -63,6 +63,16 @@ data class FocusSettings(
val notifySessionEnd: Boolean = false,
val notifyPersistent: Boolean = false,
val portraitOnly: Boolean = true,
+ val appIcon: String = "default",
+ val customIconsEnabled: Boolean = false,
+
+ // Chat Lock & Direct Message Privacy
+ val chatLockMode: String = "individual", // "off", "individual", "whole_dm"
+ val chatLockAll: Boolean = false,
+ val lockedChatIds: Set = emptySet(),
+ val lockedChatUsernames: Map = emptyMap(),
+ val useSeparateChatPin: Boolean = false,
+ val chatBiometricEnabled: Boolean = true,
// Ghost Mode (Privacy & Stealth)
val ghostModeEnabled: Boolean = false,
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt
index 3a001c8..af03788 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/model/IslandMode.kt
@@ -44,7 +44,7 @@ enum class IslandMode {
val path = extractPath(url)
return when {
- path.startsWith("/accounts/") || path.startsWith("/login") || path.startsWith("/signup") -> HIDDEN
+ (path.startsWith("/accounts/") && !path.startsWith("/accounts/edit")) || path.startsWith("/login") || path.startsWith("/signup") -> HIDDEN
path.startsWith("/direct") -> HIDDEN
path.contains("/comments") -> HIDDEN
path.startsWith("/stories/") -> HIDDEN
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt
index a6d4d13..2efafd8 100644
--- a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/security/EncryptedStorage.kt
@@ -82,6 +82,46 @@ class EncryptedStorage(context: Context) {
Log.i(TAG, "App Lock PIN removed from vault.")
}
+ // --- Chat Lock PIN Management ---
+
+ fun saveChatPin(pin: String) {
+ val hashedPin = hashPin("chat_$pin")
+ securePrefs.edit()
+ .putString(KEY_CHAT_PIN_HASH, hashedPin)
+ .putBoolean(KEY_HAS_CHAT_PIN, true)
+ .apply()
+ resetFailedAttempts()
+ Log.i(TAG, "Chat Lock PIN securely stored in MasterKey AES-GCM vault.")
+ }
+
+ fun verifyChatPin(enteredPin: String): Boolean {
+ if (isLockedOut()) {
+ Log.w(TAG, "Chat PIN verification rejected: Vault is currently locked out.")
+ return false
+ }
+ val storedHash = securePrefs.getString(KEY_CHAT_PIN_HASH, null) ?: return false
+ val enteredHash = hashPin("chat_$enteredPin")
+ val isValid = storedHash == enteredHash
+ if (isValid) {
+ resetFailedAttempts()
+ } else {
+ recordFailedAttempt()
+ }
+ return isValid
+ }
+
+ fun hasChatPin(): Boolean {
+ return securePrefs.getBoolean(KEY_HAS_CHAT_PIN, false) && securePrefs.getString(KEY_CHAT_PIN_HASH, null) != null
+ }
+
+ fun clearChatPin() {
+ securePrefs.edit()
+ .remove(KEY_CHAT_PIN_HASH)
+ .putBoolean(KEY_HAS_CHAT_PIN, false)
+ .apply()
+ Log.i(TAG, "Chat Lock PIN removed from vault.")
+ }
+
// --- Brute Force Protection ---
fun isLockedOut(): Boolean {
@@ -138,6 +178,8 @@ class EncryptedStorage(context: Context) {
private const val KEY_PIN_HASH = "vault_pin_hash"
private const val KEY_HAS_PIN = "vault_has_pin"
+ private const val KEY_CHAT_PIN_HASH = "vault_chat_pin_hash"
+ private const val KEY_HAS_CHAT_PIN = "vault_has_chat_pin"
private const val KEY_FAILED_ATTEMPTS = "vault_failed_attempts"
private const val KEY_LOCKOUT_UNTIL = "vault_lockout_until"
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt
new file mode 100644
index 0000000..38d738b
--- /dev/null
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/AppIconManager.kt
@@ -0,0 +1,285 @@
+package com.ujwal.focusgram.core.utils
+
+import android.app.Activity
+import android.app.ActivityManager
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.os.Build
+import android.util.Log
+import androidx.annotation.DrawableRes
+import androidx.core.content.pm.ShortcutInfoCompat
+import androidx.core.content.pm.ShortcutManagerCompat
+import androidx.core.graphics.drawable.IconCompat
+import com.ujwal.focusgram.MainActivity
+import com.ujwal.focusgram.R
+
+/**
+ * AppIconManager
+ * Controls dynamic launcher icon switching via Android PackageManager activity-alias toggles.
+ * LITERALLY replaces the app icon across Home Screen, App Drawer, Recents Page, and App Info page.
+ * Safe, purely client-side, with DONT_KILL_APP flag to avoid killing running tasks.
+ */
+object AppIconManager {
+ private const val TAG = "AppIconManager"
+
+ enum class IconOption(
+ val id: String,
+ val aliasSuffix: String,
+ val displayName: String,
+ val subtitle: String,
+ val category: String,
+ @DrawableRes val iconRes: Int
+ ) {
+ // --- Core Icons ---
+ DEFAULT(
+ id = "default",
+ aliasSuffix = "MainActivityDefault",
+ displayName = "FocusGram Classic",
+ subtitle = "Signature focus gradient on dark",
+ category = "Core",
+ iconRes = R.mipmap.ic_launcher
+ ),
+ PITCH_BLACK(
+ id = "pitch_black",
+ aliasSuffix = "MainActivityPitchBlack",
+ displayName = "Pitch Black",
+ subtitle = "True OLED stealth black aesthetic",
+ category = "Core",
+ iconRes = R.mipmap.ic_launcher_pitch_black
+ ),
+ ALL_WHITE(
+ id = "all_white",
+ aliasSuffix = "MainActivityAllWhite",
+ displayName = "All White",
+ subtitle = "Minimalist bright white with clean camera",
+ category = "Core",
+ iconRes = R.mipmap.ic_launcher_all_white
+ ),
+
+ // --- Custom Redesigns (alt-logos) ---
+ REDESIGN(
+ id = "redesign",
+ aliasSuffix = "MainActivityRedesign",
+ displayName = "FocusGram Redesign",
+ subtitle = "Official modern brand redesign",
+ category = "Redesigns",
+ iconRes = R.mipmap.ic_launcher_redesign
+ ),
+ ABSTRACT(
+ id = "abstract",
+ aliasSuffix = "MainActivityAbstract",
+ displayName = "Abstract Art",
+ subtitle = "Geometric gradient art redesign",
+ category = "Redesigns",
+ iconRes = R.mipmap.ic_launcher_abstract
+ ),
+ FOREST(
+ id = "forest",
+ aliasSuffix = "MainActivityForest",
+ displayName = "Forest Nature",
+ subtitle = "Earthy organic botanical tones",
+ category = "Redesigns",
+ iconRes = R.mipmap.ic_launcher_forest
+ ),
+ GOLDEN(
+ id = "golden",
+ aliasSuffix = "MainActivityGolden",
+ displayName = "Golden Luxury",
+ subtitle = "Deep gold emblem on obsidian",
+ category = "Redesigns",
+ iconRes = R.mipmap.ic_launcher_golden
+ ),
+ METAL(
+ id = "metal",
+ aliasSuffix = "MainActivityMetal",
+ displayName = "Titanium Metal",
+ subtitle = "Brushed industrial steel & chrome",
+ category = "Redesigns",
+ iconRes = R.mipmap.ic_launcher_metal
+ ),
+ SCRIBBLE(
+ id = "scribble",
+ aliasSuffix = "MainActivityScribble",
+ displayName = "Scribble Sketch",
+ subtitle = "Handcrafted playful illustration",
+ category = "Redesigns",
+ iconRes = R.mipmap.ic_launcher_scribble
+ ),
+
+ // --- Originals (Authentic Brand History) ---
+ POLAROID(
+ id = "polaroid",
+ aliasSuffix = "MainActivityPolaroid",
+ displayName = "Polaroid Camera",
+ subtitle = "Iconic 2010 debut with rainbow stripe",
+ category = "Originals",
+ iconRes = R.mipmap.ic_launcher_polaroid
+ ),
+ VIEWFINDER(
+ id = "viewfinder",
+ aliasSuffix = "MainActivityViewfinder",
+ displayName = "Viewfinder",
+ subtitle = "2011 classic retro leather camera",
+ category = "Originals",
+ iconRes = R.mipmap.ic_launcher_viewfinder
+ ),
+ MINIMALIST(
+ id = "minimalist",
+ aliasSuffix = "MainActivityMinimalist",
+ displayName = "Minimalist Colors",
+ subtitle = "2016 breakthrough flat sunset gradient",
+ category = "Originals",
+ iconRes = R.mipmap.ic_launcher_minimalist
+ ),
+ VIBRANT(
+ id = "vibrant",
+ aliasSuffix = "MainActivityVibrant",
+ displayName = "Vibrant Gradient",
+ subtitle = "2022 radiant illuminated brand design",
+ category = "Originals",
+ iconRes = R.mipmap.ic_launcher_vibrant
+ );
+
+ companion object {
+ fun fromId(id: String): IconOption {
+ return values().firstOrNull { it.id.equals(id, ignoreCase = true) } ?: DEFAULT
+ }
+ }
+ }
+
+ /**
+ * Atomically switches active launcher icon by enabling the chosen alias and disabling others.
+ * Replaces the app icon on Home Screen, App Drawer (App Screen), and App Info (System Settings).
+ * Also updates the running Activity's Recents overview TaskDescription immediately.
+ */
+ fun setAppIcon(context: Context, iconId: String) {
+ val targetOption = IconOption.fromId(iconId)
+ val packageManager = context.packageManager
+ val packageName = context.packageName
+
+ Log.i(TAG, "Switching app icon to: ${targetOption.displayName} (${targetOption.aliasSuffix})")
+
+ // 1. Enable target alias so there's always at least one enabled launcher entry point
+ try {
+ val targetComponent = ComponentName(packageName, "$packageName.${targetOption.aliasSuffix}")
+ packageManager.setComponentEnabledSetting(
+ targetComponent,
+ PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+ PackageManager.DONT_KILL_APP
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed enabling target launcher alias ${targetOption.aliasSuffix}: ${e.message}", e)
+ }
+
+ // 2. Disable all other aliases
+ for (option in IconOption.values()) {
+ if (option == targetOption) continue
+ try {
+ val comp = ComponentName(packageName, "$packageName.${option.aliasSuffix}")
+ packageManager.setComponentEnabledSetting(
+ comp,
+ PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
+ PackageManager.DONT_KILL_APP
+ )
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed disabling launcher alias ${option.aliasSuffix}: ${e.message}")
+ }
+ }
+
+ // 3. Immediately update TaskDescription for the Recents screen
+ if (context is Activity) {
+ applyTaskDescription(context, iconId)
+ }
+ }
+
+ /**
+ * Updates the Activity's TaskDescription so the Recents / Multitasking overview screen
+ * reflects the exact chosen icon and label immediately.
+ */
+ fun applyTaskDescription(activity: Activity, iconId: String) {
+ try {
+ val option = IconOption.fromId(iconId)
+ val label = activity.getString(R.string.app_name)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ val taskDesc = ActivityManager.TaskDescription.Builder()
+ .setLabel(label)
+ .setIcon(option.iconRes)
+ .build()
+ activity.setTaskDescription(taskDesc)
+ } else {
+ val bmp = BitmapFactory.decodeResource(activity.resources, option.iconRes)
+ @Suppress("DEPRECATION")
+ val taskDesc = ActivityManager.TaskDescription(label, bmp)
+ activity.setTaskDescription(taskDesc)
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed applying TaskDescription for Recents screen: ${e.message}")
+ }
+ }
+
+ /**
+ * Pins a custom home screen shortcut with a user-provided or dynamically downloaded bitmap.
+ * Uses ShortcutManagerCompat to support pinning on supported Android launchers.
+ */
+ fun pinCustomShortcut(context: Context, bitmap: Bitmap, label: String = "Instagram"): Boolean {
+ if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) {
+ Log.w(TAG, "Pin shortcut is not supported by current launcher")
+ return false
+ }
+
+ return try {
+ val launchIntent = Intent(context, MainActivity::class.java).apply {
+ action = Intent.ACTION_MAIN
+ addCategory(Intent.CATEGORY_LAUNCHER)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_RESET_TASK_IF_NEEDED
+ }
+
+ val iconCompat = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ IconCompat.createWithAdaptiveBitmap(bitmap)
+ } else {
+ IconCompat.createWithBitmap(bitmap)
+ }
+
+ val shortcutId = "focusgram_icon_${System.currentTimeMillis()}"
+ val shortcut = ShortcutInfoCompat.Builder(context, shortcutId)
+ .setShortLabel(label)
+ .setIcon(iconCompat)
+ .setIntent(launchIntent)
+ .build()
+
+ ShortcutManagerCompat.requestPinShortcut(context, shortcut, null)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to pin custom shortcut: ${e.message}", e)
+ false
+ }
+ }
+
+ private val iconCache = mutableMapOf()
+
+ /**
+ * Safely rasterizes any Android drawable (AdaptiveIconDrawable, VectorDrawable, BitmapDrawable)
+ * onto an in-memory ARGB_8888 Bitmap for Compose rendering without crashing on adaptive icons.
+ */
+ fun getIconBitmap(context: Context, @DrawableRes resId: Int): Bitmap? {
+ iconCache[resId]?.let { return it }
+ return try {
+ val drawable = androidx.core.content.ContextCompat.getDrawable(context, resId) ?: return null
+ val width = if (drawable.intrinsicWidth > 0) drawable.intrinsicWidth else 192
+ val height = if (drawable.intrinsicHeight > 0) drawable.intrinsicHeight else 192
+ val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
+ val canvas = android.graphics.Canvas(bitmap)
+ drawable.setBounds(0, 0, canvas.width, canvas.height)
+ drawable.draw(canvas)
+ iconCache[resId] = bitmap
+ bitmap
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed creating icon bitmap for resId $resId: ${e.message}", e)
+ null
+ }
+ }
+}
diff --git a/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt
new file mode 100644
index 0000000..185cc4f
--- /dev/null
+++ b/Ported/app/src/main/kotlin/com/ujwal/focusgram/core/utils/HistoricalIconManager.kt
@@ -0,0 +1,156 @@
+package com.ujwal.focusgram.core.utils
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Log
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.io.FileOutputStream
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * OriginalIcon (Historical Instagram Brand Icon)
+ * Represents an authentic Instagram brand era logo downloaded dynamically on demand.
+ */
+data class OriginalIcon(
+ val id: String,
+ val title: String,
+ val subtitle: String,
+ val downloadUrl: String,
+ val year: String = ""
+)
+
+typealias HistoricalIcon = OriginalIcon
+
+/**
+ * HistoricalIconManager
+ * Dynamically downloads authentic original Instagram brand logos on demand,
+ * preventing any APK size bloat.
+ */
+object HistoricalIconManager {
+ private const val TAG = "HistoricalIconManager"
+
+ val ORIGINAL_ICONS = listOf(
+ OriginalIcon(
+ id = "polaroid_camera",
+ title = "Polaroid Camera",
+ subtitle = "Iconic debut Polaroid camera with rainbow stripe",
+ downloadUrl = "https://logovent.com/blog/wp-content/uploads/2024/06/Polaroid-Camera-Instagram-Logo.png"
+ ),
+ OriginalIcon(
+ id = "viewfinder",
+ title = "Viewfinder",
+ subtitle = "Retro brown leather camera with viewfinder banner",
+ downloadUrl = "https://logovent.com/blog/wp-content/uploads/2024/06/Viewfinder-Logo.png"
+ ),
+ OriginalIcon(
+ id = "minimalist_colors",
+ title = "Minimalist Colors",
+ subtitle = "Flat minimalist neon sunset camera outline",
+ downloadUrl = "https://logovent.com/blog/wp-content/uploads/2024/06/Minimalist-with-Colors.png"
+ ),
+ OriginalIcon(
+ id = "vibrant_version",
+ title = "Vibrant Gradient",
+ subtitle = "Refined brand identity with vibrant illumination",
+ downloadUrl = "https://logovent.com/blog/wp-content/uploads/2024/06/Vibrant-Version.png"
+ )
+ )
+
+ val HISTORICAL_ICONS = ORIGINAL_ICONS
+
+ private val _downloadedIcons = MutableStateFlow