Skip to content

Commit 941eb97

Browse files
committed
feat(instagram): add full Instagram support (Reels and Feed modes)
- implement InstagramAdapter with dual-mode tracking: - URL-based detection for Reels - IntersectionObserver-based tracking for Feed - add InstagramDomEnforcer for media blocking and input suppression - add parser with pure URL-based detection (isInstagramUrl, getVideoId, getPlatformType) - register InstagramAdapter in content bootstrap - extend PlatformId and PLATFORMS_CONFIG with instagram_reels and instagram_feed - add manifest permissions and content script matches for Instagram - integrate Instagram into popup UI: - add platform selector options - use parser-based platform detection - wire i18n labels in updateUITexts - add i18n keys for instagramReels and instagramFeed (en, ar) - add unit tests for parser and adapter: - cover URL parsing logic - verify adapter mode detection and hot-swap behavior
1 parent 9384cc2 commit 941eb97

13 files changed

Lines changed: 483 additions & 6 deletions

File tree

manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
"description": "If willpower worked, you wouldn't be here. Set hard limits. Limitra enforces them.",
66
"default_locale": "en",
77
"permissions": ["storage", "activeTab", "scripting", "alarms"],
8-
"host_permissions": ["*://*.youtube.com/*"],
8+
"host_permissions": ["*://*.youtube.com/*", "*://*.instagram.com/*"],
99
"background": {
1010
"service_worker": "background.js",
1111
"type": "module"
@@ -31,7 +31,7 @@
3131
},
3232
"content_scripts": [
3333
{
34-
"matches": ["*://*.youtube.com/*"],
34+
"matches": ["*://*.youtube.com/*", "*://*.instagram.com/*"],
3535
"js": ["content.js"],
3636
"css": ["styles.css"]
3737
}

src/content.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { YouTubeAdapter } from './platforms/youtube/adapter';
2+
import { InstagramAdapter } from './platforms/instagram/adapter';
23
import { AppOrchestrator } from './app/orchestrator';
34
import { ChromeMessageBus } from './adapters/chrome/message-bus';
45
import { ChromeConnectionManager } from './adapters/chrome/connection-manager';
@@ -48,6 +49,9 @@ if (!window.__LIMITRA_INJECTED__) {
4849
new YouTubeAdapter(currentUrl, () => {
4950
void bootstrap();
5051
}),
52+
new InstagramAdapter(currentUrl, () => {
53+
void bootstrap();
54+
}),
5155
];
5256

5357
const activeAdapter = adapters.find((adapter) => adapter.isCurrentPlatform(currentUrl));

src/i18n/locales/ar.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const ar: LocaleStrings = {
2121
selectPlatform: 'اختر المنصة...',
2222
youtubeShorts: 'يوتيوب شورتس',
2323
youtubeWatch: 'يوتيوب العادي',
24+
instagramReels: 'إنستغرام ريلز',
25+
instagramFeed: 'إنستغرام تصفُّح',
2426
},
2527
dashboard: {
2628
title: 'مركز قيادة ليمترا',

src/i18n/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const en: LocaleStrings = {
2121
selectPlatform: 'Select Platform...',
2222
youtubeShorts: 'YouTube Shorts',
2323
youtubeWatch: 'YouTube Watch',
24+
instagramReels: 'Instagram Reels',
25+
instagramFeed: 'Instagram Feed',
2426
},
2527
dashboard: {
2628
title: 'Limitra Command Center',

src/i18n/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ export interface LocaleStrings {
2121
selectPlatform: string;
2222
youtubeShorts: string;
2323
youtubeWatch: string;
24+
instagramReels: string;
25+
instagramFeed: string;
2426
};
2527
dashboard: {
2628
title: string;

src/platforms/instagram/adapter.ts

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { PlatformAdapter, ItemChangeCallback } from '../../core/interfaces/platform-adapter';
2+
import { PlatformId } from '../../types';
3+
import { isInstagramUrl, getVideoId, getPlatformType } from './parser';
4+
import { InstagramDomEnforcer } from './enforcer';
5+
6+
export class InstagramAdapter implements PlatformAdapter {
7+
public readonly id: PlatformId;
8+
public name: string;
9+
10+
private readonly WATCH_THRESHOLD = 1500;
11+
12+
private callback: ItemChangeCallback | null = null;
13+
private lastVideoId: string | null = null;
14+
private pendingVideoId: string | null = null;
15+
16+
private popstateHandler: (() => void) | null = null;
17+
private visibilityHandler: (() => void) | null = null;
18+
private urlCheckInterval: number | null = null;
19+
20+
private feedObserver: IntersectionObserver | null = null;
21+
private domObserver: MutationObserver | null = null;
22+
23+
private watchTimer: number | null = null;
24+
private isWatching: boolean = false;
25+
private countedItems = new Set<string>();
26+
27+
private enforcer = new InstagramDomEnforcer();
28+
29+
constructor(
30+
currentUrl: string,
31+
private onModeChange: () => void,
32+
) {
33+
this.id = getPlatformType(currentUrl);
34+
this.name = this.id === 'instagram_reels' ? 'Instagram Reels' : 'Instagram Feed';
35+
}
36+
37+
private startOrResumeWatchTimer(id: string) {
38+
if (this.isWatching) return;
39+
if (this.countedItems.has(id)) return;
40+
41+
this.isWatching = true;
42+
this.watchTimer = window.setTimeout(() => {
43+
const isReels = this.id === 'instagram_reels';
44+
const isValid = isReels
45+
? getVideoId(window.location.href) === id
46+
: this.pendingVideoId === id;
47+
48+
if (!document.hidden && isValid) {
49+
if (!this.countedItems.has(id)) {
50+
this.countedItems.add(id);
51+
if (this.callback) this.callback(id);
52+
}
53+
}
54+
this.isWatching = false;
55+
this.watchTimer = null;
56+
}, this.WATCH_THRESHOLD);
57+
}
58+
59+
private pauseWatchTimer() {
60+
if (this.watchTimer) {
61+
window.clearTimeout(this.watchTimer);
62+
this.watchTimer = null;
63+
}
64+
this.isWatching = false;
65+
}
66+
67+
private handleItemChange(id: string) {
68+
this.pendingVideoId = id;
69+
this.pauseWatchTimer();
70+
if (!document.hidden) {
71+
this.startOrResumeWatchTimer(id);
72+
}
73+
}
74+
75+
private setupFeedObservation() {
76+
if (this.feedObserver) return;
77+
78+
this.feedObserver = new IntersectionObserver(
79+
(entries) => {
80+
entries.forEach((entry) => {
81+
const link = entry.target.querySelector('a[href*="/p/"], a[href*="/reel/"]');
82+
const postId = link ? link.getAttribute('href') : null;
83+
84+
if (entry.isIntersecting && postId) {
85+
this.pendingVideoId = postId;
86+
this.startOrResumeWatchTimer(postId);
87+
} else if (!entry.isIntersecting && postId === this.pendingVideoId) {
88+
this.pauseWatchTimer();
89+
this.pendingVideoId = null;
90+
}
91+
});
92+
},
93+
{ threshold: 0.6 },
94+
);
95+
96+
this.domObserver = new MutationObserver(() => {
97+
const articles = document.querySelectorAll('article');
98+
articles.forEach((article) => {
99+
if (!article.hasAttribute('data-limitra-observed')) {
100+
article.setAttribute('data-limitra-observed', 'true');
101+
this.feedObserver?.observe(article);
102+
}
103+
});
104+
});
105+
106+
this.domObserver.observe(document.body, { childList: true, subtree: true });
107+
}
108+
109+
private stopFeedObservation() {
110+
if (this.feedObserver) {
111+
this.feedObserver.disconnect();
112+
this.feedObserver = null;
113+
}
114+
if (this.domObserver) {
115+
this.domObserver.disconnect();
116+
this.domObserver = null;
117+
}
118+
}
119+
120+
public isCurrentPlatform(url: string): boolean {
121+
return isInstagramUrl(url);
122+
}
123+
124+
public observe(onItemChange: ItemChangeCallback): void {
125+
this.callback = onItemChange;
126+
this.lastVideoId = null;
127+
128+
this.visibilityHandler = () => {
129+
if (document.hidden) {
130+
this.pauseWatchTimer();
131+
} else {
132+
if (this.pendingVideoId) {
133+
this.startOrResumeWatchTimer(this.pendingVideoId);
134+
}
135+
}
136+
};
137+
document.addEventListener('visibilitychange', this.visibilityHandler);
138+
139+
this.observeUrlChanges();
140+
141+
if (this.id === 'instagram_feed') {
142+
this.setupFeedObservation();
143+
}
144+
}
145+
146+
public disconnect(): void {
147+
this.pauseWatchTimer();
148+
this.stopFeedObservation();
149+
this.countedItems.clear();
150+
151+
if (this.urlCheckInterval !== null) {
152+
window.clearInterval(this.urlCheckInterval);
153+
this.urlCheckInterval = null;
154+
}
155+
if (this.popstateHandler) {
156+
window.removeEventListener('popstate', this.popstateHandler);
157+
this.popstateHandler = null;
158+
}
159+
if (this.visibilityHandler) {
160+
document.removeEventListener('visibilitychange', this.visibilityHandler);
161+
this.visibilityHandler = null;
162+
}
163+
164+
this.enforcer.stop();
165+
166+
this.callback = null;
167+
this.pendingVideoId = null;
168+
this.lastVideoId = null;
169+
}
170+
171+
public executePunishment(): void {
172+
this.enforcer.enforce();
173+
}
174+
175+
private checkUrl() {
176+
const currentUrl = window.location.href;
177+
const evaluatedMode = getPlatformType(currentUrl);
178+
179+
if (evaluatedMode !== this.id) {
180+
this.onModeChange();
181+
return;
182+
}
183+
184+
if (this.id === 'instagram_reels') {
185+
const currentId = getVideoId(currentUrl);
186+
if (currentId !== this.lastVideoId) {
187+
this.lastVideoId = currentId;
188+
if (currentId) {
189+
void this.handleItemChange(currentId);
190+
} else {
191+
this.pauseWatchTimer();
192+
this.pendingVideoId = null;
193+
}
194+
}
195+
}
196+
}
197+
198+
private observeUrlChanges() {
199+
this.popstateHandler = () => this.checkUrl();
200+
window.addEventListener('popstate', this.popstateHandler);
201+
202+
this.urlCheckInterval = window.setInterval(() => {
203+
this.checkUrl();
204+
if (
205+
this.id === 'instagram_reels' &&
206+
!document.hidden &&
207+
!this.isWatching &&
208+
this.pendingVideoId
209+
) {
210+
if (getVideoId(window.location.href) === this.pendingVideoId) {
211+
this.startOrResumeWatchTimer(this.pendingVideoId);
212+
}
213+
}
214+
}, 500);
215+
216+
this.checkUrl();
217+
}
218+
219+
public isVideoPlaying(): boolean {
220+
const video = document.querySelector('video') as HTMLVideoElement | null;
221+
return !!video && !video.paused;
222+
}
223+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
export class InstagramDomEnforcer {
2+
private punishmentObserver: MutationObserver | null = null;
3+
private keydownHandler: ((e: KeyboardEvent) => void) | null = null;
4+
5+
private readonly BLOCKED_KEYS = [' ', 'ArrowUp', 'ArrowDown'];
6+
7+
public enforce(): void {
8+
if (this.punishmentObserver) return;
9+
10+
document.body.classList.add('limitra-global-punishment');
11+
12+
const silenceMedia = () => {
13+
const videos = document.querySelectorAll<HTMLVideoElement>('video');
14+
videos.forEach((video) => {
15+
if (!video.paused || !video.muted) {
16+
video.muted = true;
17+
video.pause();
18+
}
19+
});
20+
};
21+
22+
silenceMedia();
23+
24+
let scheduled = false;
25+
this.punishmentObserver = new MutationObserver(() => {
26+
if (scheduled) return;
27+
scheduled = true;
28+
requestAnimationFrame(() => {
29+
silenceMedia();
30+
scheduled = false;
31+
});
32+
});
33+
34+
this.punishmentObserver.observe(document.body, {
35+
childList: true,
36+
subtree: true,
37+
});
38+
39+
this.keydownHandler = (e: KeyboardEvent) => {
40+
if (this.BLOCKED_KEYS.includes(e.key)) {
41+
e.preventDefault();
42+
e.stopPropagation();
43+
}
44+
};
45+
window.addEventListener('keydown', this.keydownHandler, true);
46+
}
47+
48+
public stop(): void {
49+
if (this.punishmentObserver) {
50+
this.punishmentObserver.disconnect();
51+
this.punishmentObserver = null;
52+
}
53+
if (this.keydownHandler) {
54+
window.removeEventListener('keydown', this.keydownHandler, true);
55+
this.keydownHandler = null;
56+
}
57+
document.body.classList.remove('limitra-global-punishment');
58+
}
59+
}

0 commit comments

Comments
 (0)