From c9f2259180425c60547fba2d9fcc66bb5c7fc3b6 Mon Sep 17 00:00:00 2001 From: Meik Date: Thu, 26 Feb 2026 09:11:04 +0100 Subject: [PATCH] Stabilize tracker action bar placement across Facebook layout variants --- .gitignore | 3 +- extension/content.js | 894 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 869 insertions(+), 28 deletions(-) diff --git a/.gitignore b/.gitignore index a17ba5e..df3a85f 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ backend/data/*.db-shm backend/data/*.db-wal .DS_Store *.log -.env \ No newline at end of file +.env +screenshots/ diff --git a/extension/content.js b/extension/content.js index bdc5fb6..39ad54e 100644 --- a/extension/content.js +++ b/extension/content.js @@ -29,6 +29,7 @@ function isOnSearchResultsPage() { } const trackerElementsByPost = new WeakMap(); +const trackerAnchorsByPost = new WeakMap(); const postAdditionalNotes = new WeakMap(); const REELS_PATH_PREFIX = '/reel/'; @@ -189,6 +190,772 @@ function clearTrackerElementForPost(postElement, trackerElement = null) { trackerElementsByPost.delete(postElement); } +function getTrackerAnchorForPost(postElement) { + if (!postElement) { + return null; + } + + const anchor = trackerAnchorsByPost.get(postElement); + if (anchor && anchor.isConnected) { + return anchor; + } + + if (anchor) { + trackerAnchorsByPost.delete(postElement); + } + + return null; +} + +function setTrackerAnchorForPost(postElement, anchorElement) { + if (!postElement) { + return; + } + + if (anchorElement && anchorElement.isConnected) { + trackerAnchorsByPost.set(postElement, anchorElement); + } else { + trackerAnchorsByPost.delete(postElement); + } +} + +function clearTrackerAnchorForPost(postElement, anchorElement = null) { + if (!postElement) { + return; + } + + if (!trackerAnchorsByPost.has(postElement)) { + return; + } + + const current = trackerAnchorsByPost.get(postElement); + if (anchorElement && current && current !== anchorElement) { + return; + } + + trackerAnchorsByPost.delete(postElement); +} + +function getTrackerScopeRoot(element) { + return element ? element.closest(DIALOG_ROOT_SELECTOR) : null; +} + +function getTrackersForUrlInScope(encodedUrl, scopeRoot) { + if (!encodedUrl) { + return []; + } + + const selector = `.fb-tracker-ui[data-post-url="${encodedUrl}"]`; + const trackers = Array.from(document.querySelectorAll(selector)).filter((tracker) => tracker && tracker.isConnected); + + return trackers.filter((tracker) => { + const trackerScopeRoot = getTrackerScopeRoot(tracker); + if (scopeRoot) { + return trackerScopeRoot === scopeRoot; + } + return !trackerScopeRoot; + }); +} + +function dedupeTrackersForUrlInScope(encodedUrl, scopeRoot, preferredTracker = null) { + const trackers = getTrackersForUrlInScope(encodedUrl, scopeRoot); + if (!trackers.length) { + return null; + } + + const keep = preferredTracker && trackers.includes(preferredTracker) + ? preferredTracker + : trackers[0]; + + trackers.forEach((tracker) => { + if (tracker !== keep) { + tracker.remove(); + } + }); + + return keep; +} + +function getTrackerHostElement(trackerElement) { + if (!trackerElement) { + return null; + } + + const rawHost = trackerElement.closest('div[aria-posinset], article[role="article"], article, div[role="complementary"]'); + return rawHost ? ensurePrimaryPostElement(rawHost) : null; +} + +function isTrackerInsertionMarkerLocked(marker) { + return Boolean(marker && marker.dataset && marker.dataset.anchorLocked === '1'); +} + +function setTrackerInsertionMarkerLocked(marker, locked) { + if (!marker || !marker.dataset) { + return; + } + marker.dataset.anchorLocked = locked ? '1' : '0'; +} + +function isNodeAfter(node, referenceNode) { + if (!node || !referenceNode || node === referenceNode) { + return false; + } + return Boolean(node.compareDocumentPosition(referenceNode) & Node.DOCUMENT_POSITION_PRECEDING); +} + +function hasCommentComposerSignal(node) { + if (!node || !node.querySelector) { + return false; + } + + return Boolean( + node.querySelector( + 'div[role="textbox"][contenteditable="true"], [role="textbox"][aria-label*="komment" i], [role="textbox"][aria-label*="comment" i], [role="textbox"][aria-placeholder*="komment" i], [role="textbox"][aria-placeholder*="comment" i]' + ) + ); +} + +function findCommentComposerAnchorInPost(postElement) { + if (!postElement) { + return null; + } + + const composerCandidates = Array.from( + postElement.querySelectorAll( + 'div[role="textbox"][contenteditable="true"], [role="textbox"][aria-label*="komment" i], [role="textbox"][aria-label*="comment" i], [role="textbox"][aria-placeholder*="komment" i], [role="textbox"][aria-placeholder*="comment" i]' + ) + ); + + for (const composer of composerCandidates) { + if (!composer || composer.closest('.fb-tracker-ui')) { + continue; + } + + const composerRoot = composer.closest('form[role="presentation"]') + || composer.closest('form') + || composer; + const topLevelAnchor = getTopLevelAnchorWithinPost(postElement, composerRoot) || composerRoot; + if (topLevelAnchor && topLevelAnchor.parentElement && postElement.contains(topLevelAnchor)) { + return topLevelAnchor; + } + } + + return null; +} + +function countActionRoleSignals(node) { + if (!node || !node.querySelector) { + return 0; + } + + const hasLike = Boolean(node.querySelector('[data-ad-rendering-role="like_button"], [data-ad-rendering-role*="gefällt" i]')); + const hasComment = Boolean(node.querySelector('[data-ad-rendering-role="comment_button"]')); + const hasShare = Boolean(node.querySelector('[data-ad-rendering-role="share_button"]')); + + return [hasLike, hasComment, hasShare].filter(Boolean).length; +} + +function hasCommentSortSignal(node) { + if (!node || !node.querySelector) { + return false; + } + + return Boolean( + node.querySelector( + '[aria-label*="relevantes zuerst" i], [aria-label*="most relevant" i], [aria-label*="neueste" i], [aria-label*="newest" i]' + ) + ); +} + +function isActionRowShapeCandidate(postElement, candidate) { + if (!postElement || !candidate || !candidate.isConnected || !postElement.contains(candidate)) { + return false; + } + + if (candidate.closest('.fb-tracker-ui')) { + return false; + } + + if ( + hasCommentComposerSignal(candidate) + || hasCommentContentSignal(candidate) + || hasCommentSortSignal(candidate) + ) { + return false; + } + + const buttonCount = candidate.querySelectorAll('[role="button"], button').length; + if (buttonCount < 3 || buttonCount > 24) { + return false; + } + + return true; +} + +function isCompactActionBarCandidate(postElement, candidate) { + if (!isActionRowShapeCandidate(postElement, candidate)) { + return false; + } + + const actionRoleSignals = countActionRoleSignals(candidate); + if (actionRoleSignals >= 2) { + return true; + } + + return hasInteractionButtons(candidate); +} + +function hasMainPostContentSignal(node) { + if (!node || !node.querySelector) { + return false; + } + + return Boolean( + node.querySelector( + '[data-ad-preview*="message" i], [data-ad-comet-preview*="message" i], [data-ad-preview*="story" i], [data-ad-comet-preview*="story" i]' + ) + ); +} + +function normalizeActionBarCandidate(postElement, candidate) { + if (!postElement || !candidate || !candidate.isConnected || !postElement.contains(candidate)) { + return null; + } + + let fallback = null; + let current = candidate; + for (let depth = 0; depth < 12 && current && current !== postElement; depth++) { + if (isCompactActionBarCandidate(postElement, current)) { + return current; + } + + if (!fallback && isActionRowShapeCandidate(postElement, current)) { + fallback = current; + } + + current = current.parentElement; + } + + if (fallback) { + return fallback; + } + + return isReliableActionBarCandidate(postElement, candidate) ? candidate : null; +} + +function findFeedbackSectionAnchor(postElement, actionAnchor = null) { + if (!postElement) { + return null; + } + + const resolvedAction = actionAnchor && postElement.contains(actionAnchor) + ? actionAnchor + : resolveActionButtonBar(postElement, actionAnchor); + if (!resolvedAction || !resolvedAction.isConnected || !postElement.contains(resolvedAction)) { + return null; + } + + const commentBoundary = findCommentBoundaryAnchorInPost(postElement, resolvedAction); + let candidate = resolvedAction; + let current = resolvedAction; + + for (let depth = 0; depth < 12 && current && current !== postElement; depth++) { + const parent = current.parentElement; + if (!parent || !postElement.contains(parent) || parent === postElement) { + break; + } + + if (hasMainPostContentSignal(parent)) { + break; + } + + const parentLooksFeedback = + (commentBoundary && parent.contains(commentBoundary)) + || hasCommentComposerSignal(parent) + || hasCommentSortSignal(parent) + || hasCommentContentSignal(parent); + + if (!parentLooksFeedback) { + // Keep climbing through transparent wrappers that only wrap this subtree. + if (parent.childElementCount === 1) { + candidate = parent; + current = parent; + continue; + } + break; + } + + candidate = parent; + current = parent; + } + + return candidate; +} + +function isReliableActionBarCandidate(postElement, candidate) { + if (!postElement || !candidate || !candidate.isConnected || !postElement.contains(candidate)) { + return false; + } + + if (candidate.closest('.fb-tracker-ui')) { + return false; + } + + if ( + hasCommentComposerSignal(candidate) + || hasCommentContentSignal(candidate) + || hasCommentSortSignal(candidate) + ) { + return false; + } + + const buttonCount = candidate.querySelectorAll('[role="button"], button').length; + if (buttonCount > 80) { + return false; + } + + const actionRoleSignals = countActionRoleSignals(candidate); + if (actionRoleSignals >= 2) { + return true; + } + + return hasInteractionButtons(candidate); +} + +function findActionBarByDataRoles(postElement) { + if (!postElement) { + return null; + } + + const commentBoundary = findCommentComposerAnchorInPost(postElement) + || findCommentSortAnchorInPost(postElement); + const markers = Array.from(postElement.querySelectorAll( + '[data-ad-rendering-role="like_button"], [data-ad-rendering-role*="gefällt" i], [data-ad-rendering-role="comment_button"], [data-ad-rendering-role="share_button"]' + )); + + for (const marker of markers) { + if ( + marker.closest('[aria-roledescription*="comment" i], [aria-roledescription*="kommentar" i], [data-testid*="comment" i], [data-scope="comment"]') + ) { + continue; + } + if (commentBoundary && isNodeAfter(marker, commentBoundary)) { + continue; + } + + let current = marker; + for (let depth = 0; depth < 8 && current && current !== postElement; depth++) { + if (commentBoundary && isNodeAfter(current, commentBoundary)) { + current = current.parentElement; + continue; + } + if (isReliableActionBarCandidate(postElement, current)) { + return current; + } + current = current.parentElement; + } + } + + return null; +} + +function resolveActionButtonBar(postElement, buttonBar) { + if (!postElement) { + return null; + } + + const candidates = [ + findActionBarByDataRoles(postElement), + buttonBar, + findButtonBar(postElement) + ]; + + for (const candidate of candidates) { + if (!candidate) { + continue; + } + const normalized = normalizeActionBarCandidate(postElement, candidate); + if (normalized) { + return normalized; + } + } + + return null; +} + +function getDirectActionAnchorInPost(postElement, buttonBar) { + if (!postElement) { + return null; + } + + const resolvedButtonBar = resolveActionButtonBar(postElement, buttonBar); + if (!resolvedButtonBar) { + return null; + } + + // Use the local action bar container directly. + // Promoting to post-level wrappers can place the marker below the entire comments block. + return resolvedButtonBar; +} + +function getTopLevelAnchorWithinPost(postElement, element) { + if (!postElement || !element || !postElement.contains(element)) { + return null; + } + + let anchor = element; + while (anchor.parentElement && anchor.parentElement !== postElement) { + anchor = anchor.parentElement; + } + + return anchor && anchor.parentElement === postElement ? anchor : null; +} + +function isLikelyCommentSortNode(node) { + if (!node || !isElementVisible(node) || node.closest('.fb-tracker-ui')) { + return false; + } + + const keywords = ['relevantes zuerst', 'most relevant', 'neueste', 'newest']; + const text = (node.textContent || '').trim().toLowerCase(); + const ariaLabel = (node.getAttribute('aria-label') || '').trim().toLowerCase(); + + if (ariaLabel && keywords.some((keyword) => ariaLabel.includes(keyword))) { + return true; + } + + if (text && text.length <= 120 && keywords.some((keyword) => text.includes(keyword))) { + return true; + } + + return false; +} + +function hasCommentContentSignal(node) { + if (!node) { + return false; + } + + if (node.matches && node.matches('[data-testid*="comment" i], [data-scope="comment"]')) { + return true; + } + + return Boolean( + node.querySelector( + '[data-testid*="comment" i], [data-scope="comment"], [aria-roledescription*="comment" i], [aria-roledescription*="kommentar" i], div[role="textbox"][contenteditable="true"]' + ) + ); +} + +function findAdjacentCommentSortAnchor(postElement, actionAnchor = null) { + if (!postElement || !actionAnchor || !postElement.contains(actionAnchor)) { + return null; + } + + let sibling = actionAnchor.nextElementSibling; + for (let i = 0; i < 4 && sibling; i++) { + if (sibling.classList && sibling.classList.contains('fb-tracker-ui')) { + sibling = sibling.nextElementSibling; + continue; + } + + if (isLikelyCommentSortNode(sibling)) { + return sibling; + } + + const innerSortNode = sibling.querySelector && sibling.querySelector([ + '[aria-label*="relevantes zuerst" i]', + '[aria-label*="most relevant" i]', + '[aria-label*="neueste" i]', + '[aria-label*="newest" i]', + '[role="button"]', + 'button' + ].join(', ')); + if (innerSortNode && isLikelyCommentSortNode(innerSortNode)) { + return sibling; + } + + if (hasCommentContentSignal(sibling)) { + break; + } + + sibling = sibling.nextElementSibling; + } + + return null; +} + +function findCommentSortAnchorInPost(postElement) { + if (!postElement) { + return null; + } + + const sortCandidates = Array.from( + postElement.querySelectorAll('[aria-label*="relevantes zuerst" i], [aria-label*="most relevant" i], [aria-label*="neueste" i], [aria-label*="newest" i], [role="button"], button') + ); + + for (const candidate of sortCandidates) { + if (!isLikelyCommentSortNode(candidate)) { + continue; + } + + const topLevel = getTopLevelAnchorWithinPost(postElement, candidate) || candidate; + if (topLevel && topLevel.parentElement && postElement.contains(topLevel)) { + return topLevel; + } + } + + return null; +} + +function findCommentBoundaryAnchorInPost(postElement, actionAnchor = null) { + if (!postElement) { + return null; + } + + const adjacentSortAnchor = actionAnchor + ? findAdjacentCommentSortAnchor(postElement, actionAnchor) + : null; + if (adjacentSortAnchor) { + return adjacentSortAnchor; + } + + return findCommentSortAnchorInPost(postElement) || findCommentComposerAnchorInPost(postElement); +} + +function createTrackerInsertionMarker() { + const marker = document.createElement('div'); + marker.className = 'fb-tracker-insertion-anchor'; + marker.setAttribute('aria-hidden', 'true'); + setTrackerInsertionMarkerLocked(marker, false); + marker.style.cssText = ` + display: block; + width: 100%; + height: 0; + margin: 0; + padding: 0; + border: 0; + pointer-events: none; + `; + return marker; +} + +function isInteractiveContainer(element) { + return Boolean( + element + && element.matches + && element.matches('a, button, [role="button"]') + ); +} + +function normalizeTrackerInsertionAnchor(postElement, anchor) { + if (!postElement || !anchor || !anchor.isConnected || !postElement.contains(anchor)) { + return null; + } + + let current = anchor; + while (current && current !== postElement) { + const parent = current.parentElement; + if (!parent || !postElement.contains(parent)) { + break; + } + + if (isInteractiveContainer(current) || isInteractiveContainer(parent)) { + current = parent; + continue; + } + + break; + } + + return current; +} + +function insertMarkerBeforeAnchor(postElement, marker, anchor) { + const target = normalizeTrackerInsertionAnchor(postElement, anchor); + if (!target || !target.parentElement) { + return false; + } + + target.parentElement.insertBefore(marker, target); + return true; +} + +function getNextNonTrackerSibling(node) { + if (!node) { + return null; + } + + let current = node.nextElementSibling; + while (current) { + if ( + !current.classList + || ( + !current.classList.contains('fb-tracker-ui') + && !current.classList.contains('fb-tracker-insertion-anchor') + ) + ) { + return current; + } + current = current.nextElementSibling; + } + + return null; +} + +function positionTrackerInsertionMarker(postElement, marker, buttonBar, postNum = '?') { + if (!postElement || !marker) { + return false; + } + + const resolvedButtonBar = resolveActionButtonBar(postElement, buttonBar); + const actionAnchor = getDirectActionAnchorInPost(postElement, resolvedButtonBar); + const feedbackAnchor = findFeedbackSectionAnchor(postElement, actionAnchor || resolvedButtonBar); + + if (feedbackAnchor && insertMarkerBeforeAnchor(postElement, marker, feedbackAnchor)) { + setTrackerInsertionMarkerLocked(marker, true); + console.log('[FB Tracker] Post #' + postNum + ' - Marker inserted above feedback section'); + return true; + } + + if (actionAnchor && insertMarkerBeforeAnchor(postElement, marker, actionAnchor)) { + setTrackerInsertionMarkerLocked(marker, true); + console.log('[FB Tracker] Post #' + postNum + ' - Marker inserted above action bar'); + return true; + } + + if (resolvedButtonBar && insertMarkerBeforeAnchor(postElement, marker, resolvedButtonBar)) { + setTrackerInsertionMarkerLocked(marker, true); + console.log('[FB Tracker] Post #' + postNum + ' - Marker inserted above resolved button bar'); + return true; + } + + if (postElement.firstChild) { + postElement.insertBefore(marker, postElement.firstChild); + } else { + postElement.appendChild(marker); + } + setTrackerInsertionMarkerLocked(marker, false); + console.log('[FB Tracker] Post #' + postNum + ' - Marker fallback prepended to post'); + return true; +} + +function isTrackerInsertionMarkerPlacementValid(postElement, marker, buttonBar) { + if (!postElement || !marker || !marker.isConnected || !postElement.contains(marker)) { + return false; + } + + if (marker.closest('a, button, [role="button"]')) { + return false; + } + + const actionAnchor = getDirectActionAnchorInPost(postElement, buttonBar); + const feedbackAnchor = findFeedbackSectionAnchor(postElement, actionAnchor || buttonBar); + if (feedbackAnchor && isNodeAfter(marker, feedbackAnchor)) { + return false; + } + + if (feedbackAnchor && marker.parentElement === feedbackAnchor.parentElement) { + const nextSibling = getNextNonTrackerSibling(marker); + if (nextSibling && nextSibling !== feedbackAnchor) { + return false; + } + } + + if (actionAnchor) { + if (isNodeAfter(marker, actionAnchor)) { + return false; + } + + if (actionAnchor.contains(marker) || marker.contains(actionAnchor)) { + return false; + } + + if (marker.parentElement === actionAnchor.parentElement) { + const nextSibling = getNextNonTrackerSibling(marker); + if (nextSibling && nextSibling !== actionAnchor) { + return false; + } + } + } + + const commentBoundary = findCommentBoundaryAnchorInPost(postElement); + + if (commentBoundary && isNodeAfter(marker, commentBoundary)) { + return false; + } + + return true; +} + +function ensureTrackerInsertionMarker(postElement, buttonBar, postNum = '?') { + if (!postElement) { + return null; + } + + let marker = getTrackerAnchorForPost(postElement); + + if (!marker) { + const existingMarkers = Array.from(postElement.querySelectorAll('.fb-tracker-insertion-anchor')); + if (existingMarkers.length > 0) { + marker = existingMarkers[0]; + existingMarkers.slice(1).forEach((duplicate) => duplicate.remove()); + setTrackerAnchorForPost(postElement, marker); + } + } + + if (!marker || !marker.isConnected) { + marker = createTrackerInsertionMarker(); + } + + if (marker.isConnected && postElement.contains(marker) && isTrackerInsertionMarkerLocked(marker)) { + if (!isTrackerInsertionMarkerPlacementValid(postElement, marker, buttonBar)) { + setTrackerInsertionMarkerLocked(marker, false); + } else { + setTrackerAnchorForPost(postElement, marker); + return marker; + } + } + + if (marker.isConnected && postElement.contains(marker) && !isTrackerInsertionMarkerLocked(marker)) { + if (isTrackerInsertionMarkerPlacementValid(postElement, marker, buttonBar)) { + setTrackerAnchorForPost(postElement, marker); + return marker; + } + } + + const positioned = positionTrackerInsertionMarker(postElement, marker, buttonBar, postNum); + if (!positioned || !marker.isConnected) { + clearTrackerAnchorForPost(postElement, marker); + return null; + } + + setTrackerAnchorForPost(postElement, marker); + return marker; +} + +function insertTrackerAtMarker(marker, trackerElement) { + if (!marker || !trackerElement || !marker.parentElement) { + return false; + } + + marker.parentElement.insertBefore(trackerElement, marker.nextSibling); + return true; +} + +function realignExistingTrackerUI(postElement, buttonBar, trackerElement, postNum = '?') { + if (!postElement || !trackerElement || !trackerElement.isConnected) { + return false; + } + + const marker = ensureTrackerInsertionMarker(postElement, buttonBar, postNum); + if (!marker) { + return false; + } + + return insertTrackerAtMarker(marker, trackerElement); +} + const AI_CREDENTIAL_CACHE_TTL = 60 * 1000; // 1 minute cache const aiCredentialCache = { data: null, @@ -252,20 +1019,52 @@ function ensureTrackerActionsContainer(container) { return null; } - let actionsContainer = container.querySelector('.fb-tracker-actions-end'); + const existingContainers = Array.from(container.querySelectorAll('.fb-tracker-actions-end')) + .filter((candidate) => candidate && candidate.closest('.fb-tracker-ui') === container); + + let actionsContainer = existingContainers.length > 0 ? existingContainers[0] : null; if (actionsContainer && actionsContainer.isConnected) { - return actionsContainer; + existingContainers.slice(1).forEach((duplicate) => { + while (duplicate.firstChild) { + actionsContainer.appendChild(duplicate.firstChild); + } + duplicate.remove(); + }); + } else { + actionsContainer = null; } - actionsContainer = document.createElement('div'); - actionsContainer.className = 'fb-tracker-actions-end'; + if (!actionsContainer) { + actionsContainer = document.createElement('div'); + actionsContainer.className = 'fb-tracker-actions-end'; + container.appendChild(actionsContainer); + } + + // Pull any legacy/orphan action elements into the single canonical actions container. + const orphanActions = Array.from( + container.querySelectorAll('.fb-tracker-ai-wrapper, .fb-tracker-webapp-link') + ).filter((node) => node && node.parentElement && node.parentElement !== actionsContainer); + orphanActions.forEach((node) => actionsContainer.appendChild(node)); + + const aiWrappers = Array.from(actionsContainer.querySelectorAll('.fb-tracker-ai-wrapper')); + aiWrappers.slice(1).forEach((duplicate) => duplicate.remove()); + + const webAppLinks = Array.from(actionsContainer.querySelectorAll('.fb-tracker-webapp-link')); + webAppLinks.slice(1).forEach((duplicate) => duplicate.remove()); + actionsContainer.style.cssText = ` margin-left: auto; + margin-top: 0; display: inline-flex; align-items: center; + justify-content: flex-end; gap: 8px; + flex-wrap: nowrap; + min-width: 0; + max-width: 100%; + flex: 0 1 auto; `; - container.appendChild(actionsContainer); + return actionsContainer; } @@ -3051,6 +3850,7 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = } if (existingUI) { + realignExistingTrackerUI(postElement, buttonBar, existingUI, postNum); console.log('[FB Tracker] Post #' + postNum + ' - UI already exists on normalized element'); postElement.setAttribute(PROCESSED_ATTR, '1'); return; @@ -3074,6 +3874,25 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = console.log('[FB Tracker] Post #' + postNum + ' - Creating tracker UI for:', postUrlData.url, postElement); const encodedUrl = encodeURIComponent(postUrlData.url); + const scopeRoot = getTrackerScopeRoot(postElement); + + const existingScopedTrackers = getTrackersForUrlInScope(encodedUrl, scopeRoot); + if (existingScopedTrackers.length > 0) { + const scopedTracker = dedupeTrackersForUrlInScope(encodedUrl, scopeRoot, existingScopedTrackers[0]); + if (scopedTracker) { + const trackerHost = getTrackerHostElement(scopedTracker); + if (trackerHost && trackerHost !== postElement) { + console.log('[FB Tracker] Post #' + postNum + ' - Reusing tracker skipped (different host container)'); + postElement.setAttribute(PROCESSED_ATTR, '1'); + return; + } + realignExistingTrackerUI(postElement, buttonBar, scopedTracker, postNum); + console.log('[FB Tracker] Post #' + postNum + ' - Reusing existing tracker UI in same scope'); + setTrackerElementForPost(postElement, scopedTracker); + postElement.setAttribute(PROCESSED_ATTR, '1'); + return; + } + } const existingEntry = processedPostUrls.get(encodedUrl); if (existingEntry && existingEntry.element && existingEntry.element !== postElement) { @@ -3083,10 +3902,7 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = } else { processedPostUrls.delete(encodedUrl); } - const otherUI = document.querySelector(`.fb-tracker-ui[data-post-url="${encodedUrl}"]`); - if (otherUI) { - otherUI.remove(); - } + getTrackersForUrlInScope(encodedUrl, scopeRoot).forEach((tracker) => tracker.remove()); } const { likeButton: sourceLikeButton = null, isSearchResult = false, isDialogContext = false } = options; @@ -3106,14 +3922,19 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = padding: 6px 12px; background-color: #f0f2f5; border-top: 1px solid #e4e6eb; + margin-bottom: 6px; display: flex; flex-wrap: wrap; flex-direction: row; align-items: center; gap: 8px; row-gap: 6px; + flex: 0 0 100%; width: 100%; box-sizing: border-box; + position: static; + z-index: auto; + pointer-events: auto; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; font-size: 13px; `; @@ -3726,20 +4547,19 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = inserted = tryInsertBeforeReelsCommentComposer(); } - // Strategy 1: After button bar's parent (more stable) - if (!inserted && buttonBar && buttonBar.parentElement && buttonBar.parentElement.parentElement) { - const grandParent = buttonBar.parentElement.parentElement; - grandParent.insertBefore(container, buttonBar.parentElement.nextSibling); - console.log('[FB Tracker] Post #' + postNum + ' - UI inserted after button bar parent. ID: #' + container.id); - inserted = true; + const insertionMarker = !inserted + ? ensureTrackerInsertionMarker(postElement, buttonBar, postNum) + : null; + + // Strategy 1: Insert at stable marker that is locked below the action bar + if (!inserted && insertionMarker) { + inserted = insertTrackerAtMarker(insertionMarker, container); + if (inserted) { + console.log('[FB Tracker] Post #' + postNum + ' - UI inserted at stable marker. ID: #' + container.id); + } } - // Strategy 2: After button bar directly - if (!inserted && buttonBar && buttonBar.parentElement) { - buttonBar.parentElement.insertBefore(container, buttonBar.nextSibling); - console.log('[FB Tracker] Post #' + postNum + ' - UI inserted after button bar. ID: #' + container.id); - inserted = true; - } - // Strategy 3: Append to post element + + // Strategy 2: Last fallback append to post element if (!inserted) { postElement.appendChild(container); console.log('[FB Tracker] Post #' + postNum + ' - UI inserted into article (fallback). ID: #' + container.id); @@ -3747,6 +4567,11 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = } if (inserted) { + const dedupedTracker = dedupeTrackersForUrlInScope(encodedUrl, scopeRoot, container); + if (dedupedTracker && dedupedTracker !== container) { + clearTrackerElementForPost(postElement, container); + } + processedPostUrls.set(encodedUrl, { element: postElement, createdAt: Date.now(), @@ -3755,7 +4580,7 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = : null, hidden: false }); - setTrackerElementForPost(postElement, container); + setTrackerElementForPost(postElement, dedupedTracker || container); } // Monitor if the UI gets removed and re-insert it @@ -3765,12 +4590,12 @@ async function createTrackerUI(postElement, buttonBar, postNum = '?', options = console.log('[FB Tracker] Post #' + postNum + ' - UI was removed, re-inserting...'); observer.disconnect(); // Try to re-insert - if (buttonBar && buttonBar.parentElement && buttonBar.parentElement.parentElement) { - buttonBar.parentElement.parentElement.insertBefore(container, buttonBar.parentElement.nextSibling); - } else if (postElement.parentElement) { - postElement.parentElement.appendChild(container); + const currentMarker = ensureTrackerInsertionMarker(postElement, buttonBar, postNum); + if (!currentMarker || !insertTrackerAtMarker(currentMarker, container)) { + postElement.appendChild(container); } if (container.isConnected) { + dedupeTrackersForUrlInScope(encodedUrl, scopeRoot, container); setTrackerElementForPost(postElement, container); } } @@ -3918,6 +4743,7 @@ function findPosts() { if (isInDialog) { if (trackerInSameDialog) { + realignExistingTrackerUI(container, precomputedButtonBar || null, existingTracker, 'existing'); seenSet.add(container); continue; } @@ -3932,6 +4758,12 @@ function findPosts() { } } else { if (alreadyProcessed || (existingTracker && existingTracker.isConnected)) { + if (existingTracker && existingTracker.isConnected) { + realignExistingTrackerUI(container, precomputedButtonBar || null, existingTracker, 'existing'); + setTrackerElementForPost(container, existingTracker); + } else if (alreadyProcessed) { + container.removeAttribute(PROCESSED_ATTR); + } seenSet.add(container); continue; } @@ -5612,6 +6444,11 @@ async function addAICommentButton(container, postElement) { return; } + const existingWrapper = actionsContainer.querySelector('.fb-tracker-ai-wrapper'); + if (existingWrapper && existingWrapper.isConnected) { + return; + } + const encodedPostUrl = container && container.getAttribute('data-post-url') ? container.getAttribute('data-post-url') : null; @@ -5627,6 +6464,9 @@ async function addAICommentButton(container, postElement) { box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); transition: transform 0.2s ease, box-shadow 0.2s ease; + flex: 0 1 auto; + max-width: 100%; + white-space: nowrap; `; const button = document.createElement('button');