-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcontent.js
More file actions
844 lines (782 loc) · 33.2 KB
/
Copy pathcontent.js
File metadata and controls
844 lines (782 loc) · 33.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
// LinkedIn Defluffer — content script.
// Finds feed posts, sends their text to the background worker for a one-line
// summary, then swaps the post body for that summary. Everything else on the
// card (author, avatar, reactions, comments) stays exactly as it was.
const PROCESSED = "data-defluffed";
const MIN_CHARS = 180; // don't bother summarizing already-short posts
// Ads never leave the browser — we just slap an animated label on them: a live
// nod to Claude Code's spinner (braille frame ticking, gerund cycling), aimed at
// the AI-tooling crowd. Always keeps the word "ad" so it stays honest.
const SPIN_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const AD_VERBS = [
"Monetizing",
"Upselling",
"Sponsoring",
"Synergizing",
"Retargeting",
"Converting",
"Influencing",
"Optimizing",
];
// Drive the spinner on an ad's line: braille frame every 80ms, verb every ~3s.
// Self-cleans the moment LinkedIn recycles the node out of the DOM, so we never
// leak intervals as the feed virtualizes.
function startAdSpinner(line) {
stopAdSpinner(line);
let frame = 0;
let ticks = 0;
let verb = Math.floor(Math.random() * AD_VERBS.length);
const render = () => {
if (!line.isConnected) return stopAdSpinner(line);
line.textContent = `${SPIN_FRAMES[frame]} ad · ${AD_VERBS[verb]}…`;
frame = (frame + 1) % SPIN_FRAMES.length;
if (++ticks % 38 === 0) verb = (verb + 1) % AD_VERBS.length;
};
render();
line.__spinId = setInterval(render, 80);
}
function stopAdSpinner(line) {
if (line && line.__spinId) {
clearInterval(line.__spinId);
line.__spinId = null;
}
}
// Detect a post written in a non-Latin script we want to pin the output language
// for. Today that's Hebrew only (the validated bug). Rule: Hebrew letters exist
// AND are at least as common as Latin letters — survives a stray English word in
// a Hebrew post, and a lone Hebrew name in an English post. Returns "" otherwise
// so the model just matches the language on its own (e.g. English, Spanish).
function detectLang(text) {
const heb = (text.match(/[-]/g) || []).length;
const lat = (text.match(/[A-Za-z]/g) || []).length;
return heb > 0 && heb >= lat ? "Hebrew" : "";
}
let enabled = true;
// After the extension is reloaded/updated, this already-injected script keeps
// running in the open tab but its chrome.* APIs are dead. Detect that so we go
// quiet instead of throwing "Extension context invalidated" on every mutation.
function contextAlive() {
try {
return !!chrome.runtime?.id;
} catch {
return false;
}
}
chrome.storage.sync.get({ enabled: true }, (s) => {
enabled = s.enabled;
if (enabled) scanAll();
});
chrome.storage.onChanged.addListener((changes) => {
if (changes.enabled) {
enabled = changes.enabled.newValue;
if (enabled) scanAll();
else restoreAll();
}
});
// --- DOM helpers -----------------------------------------------------------
// A post card in the main feed. LinkedIn's current feed uses a managed
// component framework with hashed class names, so we anchor on stable
// attributes (role, data-testid, componentkey) instead of CSS classes.
function findPosts() {
// Two coexisting LinkedIn renderers: the new obfuscated one wraps posts in
// div[role="listitem"]; some pages (e.g. certain /posts/ permalinks) still
// serve the legacy renderer where a post is div.feed-shared-update-v2 with
// role="article". The class+role pair keeps comments/articles out.
return document.querySelectorAll(
'div[role="listitem"], div.feed-shared-update-v2[role="article"]'
);
}
// The element holding the actual post text inside a card.
function findTextEl(post) {
return (
post.querySelector('p[componentkey^="feed-commentary"]') ||
post.querySelector('span[data-testid="expandable-text-box"]') ||
// Legacy fallbacks in case LinkedIn serves an older layout.
post.querySelector(".update-components-text") ||
post.querySelector(".feed-shared-inline-show-more-text")
);
}
function cleanText(s) {
// Strip the trailing "…more" / "... more" affordance the feed appends.
return s.replace(/[…\.]{1,3}\s*more\s*$/i, "").trim();
}
// The element(s) that actually carry LinkedIn's line-clamp. Two feed layouts
// exist: sometimes findTextEl's element IS the clamped `expandable-text-box`
// span; other times it's a `p[componentkey=feed-commentary]` wrapper whose CHILD
// span holds the clamp. Override both so the un-clamp never misses.
function clampTargets(textEl) {
const t = [textEl];
textEl.querySelectorAll('span[data-testid="expandable-text-box"]').forEach((s) => t.push(s));
return t;
}
// Force a truncated post fully open. LinkedIn clamps long text with
// `-webkit-line-clamp` + an inline "…more" toggle; a synthetic click on that
// toggle doesn't fire its React handler, so we override the clamp directly
// (!important beats LinkedIn's class) and hide the now-dead native toggle.
function showFullText(textEl) {
clampTargets(textEl).forEach((el) => {
el.style.setProperty("-webkit-line-clamp", "unset", "important");
el.style.setProperty("max-height", "none", "important");
el.style.setProperty("overflow", "visible", "important");
});
textEl.querySelectorAll("button").forEach((b) => {
if (/^…?\s*(see\s+)?(more|less)\s*$/i.test((b.innerText || "").trim())) {
b.dataset.defluffHid = "1";
b.style.display = "none";
}
});
}
// Undo showFullText — return the element(s) to LinkedIn's own clamped state.
function restoreClamp(textEl) {
clampTargets(textEl).forEach((el) => {
el.style.removeProperty("-webkit-line-clamp");
el.style.removeProperty("max-height");
el.style.removeProperty("overflow");
});
textEl.querySelectorAll('button[data-defluff-hid="1"]').forEach((b) => {
b.style.display = "";
delete b.dataset.defluffHid;
});
}
function findAuthor(post, textEl) {
// The actor's name shows up in screen-reader strings like
// "View Mansi Bhamu’s profile" on the avatar image alt or an aria-label.
// A post can carry several: a "X commented/reposted" header on top, then the
// actual poster just above the post text. We want the poster — i.e. the last
// actor label that appears before the commentary in document order.
const labels = [...post.querySelectorAll('img[alt^="View "], [aria-label^="View "]')];
let pick = labels[0];
if (textEl) {
const before = labels.filter(
(el) => el.compareDocumentPosition(textEl) & Node.DOCUMENT_POSITION_FOLLOWING
);
if (before.length) pick = before[before.length - 1];
}
const label = pick?.getAttribute("alt") || pick?.getAttribute("aria-label") || "";
const m = label.match(/View (.+?)[’'`]s profile/);
if (m) return m[1].trim();
// Fallback: actor link (closest before the text) with real visible text.
const links = [...post.querySelectorAll('a[href*="/in/"], a[href*="/company/"]')];
const ordered = textEl
? links.filter((a) => a.compareDocumentPosition(textEl) & Node.DOCUMENT_POSITION_FOLLOWING).reverse()
: links;
for (const a of ordered) {
const t = a.innerText.trim().split("\n")[0];
if (t && !/^View /.test(t)) return t.slice(0, 60);
}
return "";
}
// --- Core ------------------------------------------------------------------
// Only defluff on real feed surfaces — the home feed and single-post pages.
// The content script is injected on all of linkedin.com, but we must NOT touch
// text on other pages (invitation manager, messaging, notifications, jobs),
// where "posts" like a connection-request note would get wrongly summarized.
// LinkedIn is an SPA, so this is re-checked on every scan, not just at load.
function onFeedPage() {
const p = location.pathname;
return (
p === "/feed/" ||
p === "/feed" ||
p.startsWith("/feed/update/") ||
p.startsWith("/posts/") ||
// Profile pages (incl. /in/<user>/recent-activity/): users asked for their
// activity feeds defluffed too. Messaging/invitations/jobs stay excluded.
p.startsWith("/in/") ||
// When searching for posts
p.startsWith("/search/results/content/")
);
}
function scanAll() {
if (!contextAlive() || !onFeedPage()) return;
let blind = 0;
findPosts().forEach((post) => {
// Guard each post so one unexpected DOM shape can't abort the whole sweep
// and leave every post after it unprocessed.
try {
// Selector-drift canary: a container with real text that findTextEl
// can't parse means LinkedIn moved the furniture.
if (!findTextEl(post) && (post.innerText || "").length > 300) blind++;
maybeDefluff(post);
} catch {}
});
// Three unparseable posts and not a single success on the page = we're blind.
// Say so instead of dying silently ("looks fake, does nothing" is the review
// that kills extensions), and ping home so the fix ships before the reviews.
if (blind >= 3 && !document.querySelector(`[${PROCESSED}]`)) reportBreakage("feed");
}
let breakageReported = false;
function reportBreakage(surface) {
if (breakageReported) return;
breakageReported = true;
try {
chrome.runtime.sendMessage({ type: "breakage", surface });
} catch {}
const toast = document.createElement("div");
toast.className = "defluff-toast";
toast.setAttribute("dir", "ltr");
const msg = document.createElement("span");
msg.textContent = "defluffer can't read the feed — LinkedIn changed something. A fix usually ships within a day.";
const close = document.createElement("button");
close.type = "button";
close.className = "defluff-toast-close";
close.setAttribute("aria-label", "Dismiss");
close.textContent = "×";
close.addEventListener("click", () => toast.remove());
toast.appendChild(msg);
toast.appendChild(close);
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 15000);
}
function restoreAll() {
document.querySelectorAll(`[${PROCESSED}]`).forEach((textEl) => {
// The original text was never overwritten — just un-hide it and drop the
// animation styles + the summary block.
textEl.style.display = "";
textEl.style.maxHeight = "";
textEl.style.opacity = "";
textEl.style.overflow = "";
textEl.style.transition = "";
restoreClamp(textEl); // clear any full-text override + un-hide native toggles
stopAdSpinner(textEl.__defluffSummaryEl?.querySelector(".defluff-line"));
textEl.__defluffSummaryEl?.remove();
revealObserver.unobserve(textEl);
delete textEl.__pendingSummary;
delete textEl.__defluffIsAd;
delete textEl.__defluffOrigLen;
delete textEl.__defluffLang;
delete textEl.__defluffFluff;
const badge = textEl.parentElement?.querySelector(".defluff-badge");
badge?.remove();
textEl.removeAttribute(PROCESSED);
});
// Composer fluff-check UI lives inside shadow roots — clear it there too.
document.querySelectorAll('div[class*="theme--"]').forEach((host) => {
host.shadowRoot?.querySelectorAll(".dfc-wrap").forEach((n) => n.remove());
});
}
// A post is an ad if a "Promoted" / "Sponsored" label sits in its header (where
// the timestamp normally goes), above the post text. The label can be bare
// ("Promoted") or attributed ("Promoted by Acme"). We deliberately do NOT match
// "Promoted to VP…" so genuine promotion humblebrags still get summarized.
function isPromoted(post, textEl) {
for (const el of post.querySelectorAll("span, div")) {
const t = el.textContent.trim();
if (t.length > 60) continue;
if (!/^(Promoted|Sponsored)( by\b|$)/.test(t)) continue;
if (!textEl) return true;
if (el.compareDocumentPosition(textEl) & Node.DOCUMENT_POSITION_FOLLOWING) return true;
}
return false;
}
function maybeDefluff(post) {
const textEl = findTextEl(post);
if (!textEl || textEl.hasAttribute(PROCESSED)) return;
// Ads don't get summarized or sent anywhere — just slapped with a local,
// rotating spinner-verb label (regardless of length).
if (isPromoted(post, textEl)) {
textEl.setAttribute(PROCESSED, "pending");
textEl.__defluffOriginal = textEl.innerHTML;
textEl.__defluffIsAd = true;
textEl.__pendingSummary = "ad"; // sentinel; the line is animated, not static
revealObserver.observe(textEl);
return;
}
const text = cleanText(textEl.innerText);
if (text.length < MIN_CHARS) {
// Already short = already defluffed by its author. Reward it with a local
// badge — no network, nothing hidden. Skip trivial posts (a few words) so
// the feed doesn't fill with badges. Clicking it is an easter egg: each
// click escalates a deadpan confirmation, then it settles back down.
if (text.length >= 40) {
textEl.setAttribute(PROCESSED, "clean");
const clean = document.createElement("button");
clean.type = "button";
clean.className = "defluff-badge defluff-badge--clean";
clean.setAttribute("dir", "ltr"); // English label, even on RTL posts
clean.textContent = "fluff not found";
const RETORTS = ["we checked twice.", "still nothing.", "not even a buzzword.", "go read it, it's short."];
let clicks = 0;
let settleTimer = null;
clean.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const i = Math.min(clicks++, RETORTS.length - 1);
clean.textContent = RETORTS[i];
// Last retort drops the pill background — the visual cue that the
// bit is over and there's nothing further to click for.
clean.classList.toggle("defluff-badge--spent", i === RETORTS.length - 1);
clearTimeout(settleTimer);
settleTimer = setTimeout(() => {
clean.textContent = "fluff not found";
clean.classList.remove("defluff-badge--spent");
clicks = 0;
}, 2500);
});
textEl.insertAdjacentElement("afterend", clean);
}
return;
}
textEl.setAttribute(PROCESSED, "pending");
textEl.__defluffOriginal = textEl.innerHTML;
textEl.__defluffOrigLen = text.length; // for the badge's "defluffed NN%" stat
textEl.__defluffText = text; // kept for the haiku easter egg
const author = findAuthor(post, textEl);
const lang = detectLang(text);
textEl.__defluffLang = lang; // the summary line's base direction follows the post
try {
chrome.runtime.sendMessage(
{ type: "defluff", text, authorName: author, lang },
(res) => {
if (chrome.runtime.lastError) {
textEl.removeAttribute(PROCESSED);
return;
}
if (!res || res.error) {
textEl.removeAttribute(PROCESSED); // rate-limited / network / upstream — skip quietly
return;
}
// Summary is ready, but hold the reveal until the post is scrolled into
// view so it fades in right as the reader reaches it.
textEl.__pendingSummary = res.summary;
textEl.__defluffFluff = res.fluff || ""; // model's LOW/MEDIUM/HIGH/PURE grade
revealObserver.observe(textEl);
}
);
} catch {
// Extension context went away (reloaded/updated) — drop it quietly.
textEl.removeAttribute(PROCESSED);
}
}
// Fades the summary in when its post enters the viewport. The negative bottom
// margin means it triggers once the post is comfortably in view, not the
// instant its bottom edge peeks in.
const revealObserver = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const textEl = e.target;
revealObserver.unobserve(textEl);
if (textEl.getAttribute(PROCESSED) === "done") continue;
if (textEl.__pendingSummary) revealSummary(textEl, textEl.__pendingSummary);
}
},
{ rootMargin: "0px 0px -120px 0px", threshold: 0 }
);
function revealSummary(textEl, summary) {
textEl.setAttribute(PROCESSED, "done");
// Build the summary block as a sibling so the original and the summary can
// cross-fade. The summary line copies the post's own computed text styling,
// so it matches LinkedIn exactly (and survives light/dark themes).
const cs = getComputedStyle(textEl);
const summaryEl = document.createElement("div");
summaryEl.className = "defluff-summary";
// Match the post's own horizontal insets — on LinkedIn the side padding
// lives on the text element itself, so the summary must mirror it or it
// runs flush to the card edges.
summaryEl.style.paddingLeft = cs.paddingLeft;
summaryEl.style.paddingRight = cs.paddingRight;
// For ads "the fluff" doesn't fit, so the ad just toggles show/hide — and its
// line is an animated spinner, not static text.
const isAd = textEl.__defluffIsAd;
const line = document.createElement("div");
line.className = "defluff-line";
if (isAd) {
// Seed a frame so the height measures right; startAdSpinner takes over once
// the node is in the DOM. Force LTR — the label is English even on RTL ads.
line.textContent = `${SPIN_FRAMES[0]} ad · ${AD_VERBS[0]}…`;
line.style.direction = "ltr";
line.style.textAlign = "left";
} else {
line.textContent = summary;
// A Hebrew summary often embeds Latin runs ("...כ-Senior Product Designer").
// Without a declared base direction the line inherits the page's LTR and the
// bidi algorithm scrambles the pieces. dir="auto" isn't enough either: it
// reads the FIRST strong character, so a Hebrew summary that opens with a
// Latin name ("OpenWiki Brains בונה...") still resolves LTR and scrambles.
// We know the post's language from the request, so set the base direction
// explicitly; text-align:start then aligns to the reading side.
line.setAttribute("dir", textEl.__defluffLang === "Hebrew" ? "rtl" : "auto");
line.style.textAlign = "start";
}
line.style.color = cs.color;
line.style.fontSize = cs.fontSize;
line.style.fontWeight = cs.fontWeight;
line.style.lineHeight = cs.lineHeight;
line.style.fontFamily = cs.fontFamily;
// Badge = stat + verdict. The NUMBER is how much text the summary cut (an
// honest stat, hidden below 40% where it's more embarrassing than
// impressive). The COLOR is the model's own fluff grade — a long-but-dense
// post compresses a lot yet stays blue; red means the model read it and
// found nothing.
let defluffedLabel = isAd ? "show the ad" : "defluffed · show fluff";
let badgeTier = "";
if (!isAd) {
const pct =
textEl.__defluffOrigLen > 0
? Math.round((1 - summary.length / textEl.__defluffOrigLen) * 100)
: 0;
const stat = pct >= 40 ? `defluffed ${pct}%` : "defluffed";
const fluff = textEl.__defluffFluff;
if (fluff === "PURE") {
defluffedLabel = `${stat} · see for yourself · really?`;
badgeTier = "defluff-badge--hot";
} else if (fluff === "HIGH") {
defluffedLabel = `${stat} · show fluff`;
badgeTier = "defluff-badge--warm";
} else {
defluffedLabel = `${stat} · show fluff`;
}
}
const originalLabel = isAd ? "hide the ad" : "fluff · defluff it";
const badge = document.createElement("button");
badge.className = badgeTier ? `defluff-badge ${badgeTier}` : "defluff-badge";
badge.type = "button";
// The label is always English. On RTL posts the badge inherits an RTL base
// direction and bidi shuffles the trailing "?" to the far side — pin it LTR.
badge.setAttribute("dir", "ltr");
badge.textContent = defluffedLabel;
summaryEl.appendChild(line);
summaryEl.appendChild(badge);
// Easter egg: haiku-ify. A barely-there button that rewrites the summary as
// a deadpan haiku (one worker call, cached; toggles back). Not for ads.
let haikuBtn = null;
if (!isAd && textEl.__defluffText) {
haikuBtn = document.createElement("button");
haikuBtn.type = "button";
haikuBtn.className = "defluff-haiku";
haikuBtn.setAttribute("dir", "ltr");
haikuBtn.textContent = "haiku";
summaryEl.appendChild(haikuBtn);
let haikuText = null;
let poetry = false;
const summaryText = summary;
const showHaiku = () => {
line.textContent = haikuText;
line.style.whiteSpace = "pre-line";
line.style.fontStyle = "italic";
haikuBtn.textContent = "enough poetry";
haikuBtn.classList.add("defluff-haiku--on"); // stays visible while poetry is up
};
haikuBtn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
if (poetry) {
poetry = false;
line.textContent = summaryText;
line.style.whiteSpace = "";
line.style.fontStyle = "";
haikuBtn.textContent = "haiku";
haikuBtn.classList.remove("defluff-haiku--on");
return;
}
poetry = true;
if (haikuText) return showHaiku();
haikuBtn.textContent = "composing…";
try {
chrome.runtime.sendMessage(
{ type: "defluff", text: textEl.__defluffText, authorName: "", lang: textEl.__defluffLang, mode: "haiku" },
(res) => {
if (chrome.runtime.lastError || !res || res.error || !res.summary) {
poetry = false;
haikuBtn.textContent = "haiku";
return;
}
haikuText = res.summary;
if (poetry) showHaiku(); // user may have toggled back while waiting
}
);
} catch {
poetry = false;
haikuBtn.textContent = "haiku";
}
});
}
textEl.__defluffSummaryEl = summaryEl;
// Measure both heights before animating.
const origH = textEl.offsetHeight;
textEl.insertAdjacentElement("afterend", summaryEl);
const sumH = summaryEl.offsetHeight;
if (isAd) startAdSpinner(line); // node is in the DOM now — animate the label
// Initial state: original fully shown, summary collapsed + hidden.
textEl.style.overflow = "hidden";
textEl.style.maxHeight = origH + "px";
textEl.style.opacity = "1";
summaryEl.style.overflow = "hidden";
summaryEl.style.maxHeight = "0px";
summaryEl.style.opacity = "0";
void textEl.offsetHeight; // force a reflow so the transition runs
// Cross-fade: original fades + collapses out, summary fades + expands in.
textEl.style.transition = "max-height .45s ease, opacity .3s ease";
summaryEl.style.transition = "max-height .45s ease, opacity .45s ease .08s";
requestAnimationFrame(() => {
textEl.style.maxHeight = "0px";
textEl.style.opacity = "0";
summaryEl.style.maxHeight = sumH + "px";
summaryEl.style.opacity = "1";
summaryEl.classList.add("defluff-flash"); // brief blue "marking" glow
});
// After the transition: hide the original outright and release the clamp so
// the summary can reflow naturally (e.g. if the window resizes).
setTimeout(() => {
if (textEl.getAttribute(PROCESSED) !== "done") return;
textEl.style.display = "none";
summaryEl.style.maxHeight = "none";
summaryEl.style.transition = "";
summaryEl.classList.remove("defluff-flash");
}, 700);
let showing = false;
badge.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
showing = !showing;
textEl.style.transition = "";
if (showing) {
// Show original: bring the post text back (the reveal timeout parked it at
// display:none), and hide ONLY the summary line — keep summaryEl in flow so
// the badge stays clickable. This is the fix for the old one-way door.
textEl.style.display = "";
textEl.style.opacity = "1";
// Long posts are truncated by LinkedIn with `-webkit-line-clamp` + a native
// "…more" toggle *inside* the text element. Hiding the element during the
// defluff animation makes LinkedIn strip that toggle, so restoring it would
// leave a clamped post with no way to expand. Override the clamp ourselves
// (a synthetic click doesn't fire LinkedIn's React handler) and hide the now
// dead native toggle so the full post is always readable here.
showFullText(textEl);
line.style.display = "none";
if (haikuBtn) haikuBtn.style.display = "none"; // no poetry over the original
if (isAd) stopAdSpinner(line); // no need to animate a hidden label
} else {
// Back to defluffed: hide the original, show the summary line again.
textEl.style.display = "none";
line.style.display = "";
if (haikuBtn) haikuBtn.style.display = "";
if (isAd) startAdSpinner(line);
}
badge.textContent = showing ? originalLabel : defluffedLabel;
});
}
// --- Composer fluff check ("am I the fluff?") -------------------------------
//
// LinkedIn's post composer lives inside an OPEN shadow root on a div.theme--*
// host (a Quill editor: div.ql-editor[contenteditable]). Our stylesheet can't
// reach into shadow DOM, so the UI carries its own <style> per root. The check
// is user-invoked: one click = one worker call (same endpoint as the feed),
// and the result shows exactly what defluffer readers would see.
const COMPOSE_STYLE = `
.dfc-wrap { margin: 6px 12px 4px; font-family: -apple-system, system-ui, sans-serif; }
.dfc-btn { display: inline-block; font-size: 12px; font-weight: 600; padding: 3px 11px;
border: none; border-radius: 12px; cursor: pointer;
color: #0a66c2; background: rgba(10, 102, 194, .08); }
.dfc-btn:hover { background: rgba(10, 102, 194, .16); }
.dfc-btn:disabled { opacity: .6; cursor: default; }
.dfc-out { margin-top: 6px; font-size: 13px; line-height: 1.45; }
.dfc-pill { display: inline-block; font-size: 12px; font-weight: 700; padding: 2px 10px;
border-radius: 12px; }
.dfc-blue { color: #0a66c2; background: rgba(10, 102, 194, .08); }
.dfc-warm { color: #b26a05; background: rgba(239, 159, 39, .14); }
.dfc-hot { color: #c93a38; background: rgba(226, 75, 74, .12); }
.dfc-clean { color: #1f8a45; background: rgba(31, 138, 69, .1); }
.dfc-muted { color: #808080; font-size: 12px; }
.dfc-line { margin: 3px 0 0; }
.dfc-card { margin-top: 5px; padding: 10px 12px; border: 1px solid rgba(128, 128, 128, .3);
border-radius: 10px; color: inherit; }
.dfc-card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.dfc-ava { width: 32px; height: 32px; border-radius: 50%; object-fit: cover; flex: none; }
.dfc-ava-fallback { width: 32px; height: 32px; border-radius: 50%; flex: none;
background: rgba(10, 102, 194, .15); color: #0a66c2; font-weight: 700; font-size: 14px;
display: flex; align-items: center; justify-content: center; }
.dfc-name { font-size: 13px; font-weight: 600; line-height: 1.2; }
.dfc-meta { font-size: 11px; color: #808080; }
.dfc-postline { font-size: 14px; line-height: 1.45; margin: 0 0 6px; }
`;
function composeResult(out, kind, data) {
out.textContent = "";
const pill = document.createElement("span");
pill.className = "dfc-pill";
if (kind === "tooShort") {
out.className = "dfc-out dfc-muted";
out.textContent = "write a bit more first.";
return;
}
out.className = "dfc-out";
if (kind === "error") {
out.className = "dfc-out dfc-muted";
out.textContent = "couldn't check — try again.";
return;
}
if (kind === "clean") {
pill.classList.add("dfc-clean");
pill.textContent = "no fluff · post it";
out.appendChild(pill);
return;
}
// Render the verdict as a miniature feed post — the reader's-eye view,
// avatar and all, with the badge exactly as the feed would label it.
const cap = document.createElement("div");
cap.className = "dfc-muted";
cap.textContent = "readers with defluffer will see:";
out.appendChild(cap);
const card = document.createElement("div");
card.className = "dfc-card";
const head = document.createElement("div");
head.className = "dfc-card-head";
if (data.avatar) {
const img = document.createElement("img");
img.className = "dfc-ava";
img.src = data.avatar;
img.alt = "";
head.appendChild(img);
} else {
const fb = document.createElement("div");
fb.className = "dfc-ava-fallback";
fb.textContent = (data.name || "?").trim().charAt(0).toUpperCase();
head.appendChild(fb);
}
const who = document.createElement("div");
const nm = document.createElement("div");
nm.className = "dfc-name";
nm.textContent = data.name || "You";
const meta = document.createElement("div");
meta.className = "dfc-meta";
meta.textContent = "Just now · defluffed";
who.appendChild(nm);
who.appendChild(meta);
head.appendChild(who);
card.appendChild(head);
const line = document.createElement("div");
line.className = "dfc-postline";
line.setAttribute("dir", data.lang === "Hebrew" ? "rtl" : "auto");
line.textContent = data.summary;
card.appendChild(line);
// Badge labeled exactly like the feed badge would be.
const grade = data.fluff || "";
pill.classList.add(grade === "PURE" ? "dfc-hot" : grade === "HIGH" ? "dfc-warm" : "dfc-blue");
const stat = data.pct >= 40 ? `defluffed ${data.pct}%` : "defluffed";
pill.textContent =
grade === "PURE" ? `${stat} · see for yourself · really?` : `${stat} · show fluff`;
pill.setAttribute("dir", "ltr");
card.appendChild(pill);
out.appendChild(card);
}
function scanComposer() {
document.querySelectorAll('div[class*="theme--"]').forEach((host) => {
const sr = host.shadowRoot;
if (!sr || sr.querySelector(".dfc-wrap")) return;
const editor = sr.querySelector('div.ql-editor[contenteditable="true"]');
if (!editor) return;
const anchor = sr.querySelector(".editor-content.ql-container");
if (!anchor) return reportBreakage("composer"); // editor found, mount point gone
const style = document.createElement("style");
style.textContent = COMPOSE_STYLE;
sr.appendChild(style);
// The poster's identity for the preview card: the composer header shows
// the author (avatar img + "Name / Post to Anyone" button).
let avatar = "";
let name = "";
const headBtn = [...sr.querySelectorAll("button")].find((b) =>
/post to/i.test(b.innerText || "")
);
if (headBtn) {
name = (headBtn.innerText || "").split("\n")[0].trim();
avatar = headBtn.querySelector("img")?.src || "";
}
if (!avatar) avatar = sr.querySelector('img[class*="avatar"], img[alt*="photo" i]')?.src || "";
const wrap = document.createElement("div");
wrap.className = "dfc-wrap";
wrap.setAttribute("dir", "ltr");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "dfc-btn";
btn.textContent = "check fluff";
const out = document.createElement("div");
out.className = "dfc-out";
wrap.appendChild(btn);
wrap.appendChild(out);
anchor.insertAdjacentElement("afterend", wrap);
// After a verdict the button hides — it has nothing more to say about this
// draft. Editing the text brings it back (the verdict may be stale).
let checkedText = null;
editor.addEventListener("input", () => {
if (checkedText !== null && cleanText(editor.innerText) !== checkedText) {
btn.style.display = "";
checkedText = null;
}
});
btn.addEventListener("click", () => {
const text = cleanText(editor.innerText);
if (text.length < 40) return composeResult(out, "tooShort");
// Same threshold as the feed: short enough not to summarize = clean.
if (text.length < MIN_CHARS) {
checkedText = text;
btn.style.display = "none";
return composeResult(out, "clean");
}
btn.disabled = true;
btn.textContent = "checking…";
const lang = detectLang(text);
const done = () => {
btn.disabled = false;
btn.textContent = "check fluff";
};
try {
chrome.runtime.sendMessage({ type: "defluff", text, authorName: "", lang }, (res) => {
done();
if (chrome.runtime.lastError || !res || res.error || !res.summary) {
return composeResult(out, "error"); // button stays — retry is the next move
}
checkedText = text;
btn.style.display = "none";
const pct = Math.round((1 - res.summary.length / text.length) * 100);
composeResult(out, "result", { summary: res.summary, fluff: res.fluff, pct, lang, avatar, name });
});
} catch {
done();
composeResult(out, "error");
}
});
});
}
// --- Watch the feed (infinite scroll) --------------------------------------
//
// A plain debounce is the wrong tool here: LinkedIn mutates the feed constantly
// while you scroll (lazy media, live UI, virtualization), which kept resetting
// the timer so posts that loaded mid-scroll were never scanned. Instead we
// THROTTLE — scan on the leading edge, then at most every 500ms — so a steady
// mutation stream can't starve it. A periodic safety-net scan guarantees any
// post the observer missed still gets picked up within ~1.5s.
let scanTimer = null;
let lastScan = 0;
function scheduleScan() {
const since = Date.now() - lastScan;
clearTimeout(scanTimer);
if (since >= 500) {
lastScan = Date.now();
scanAll();
scanComposer();
} else {
scanTimer = setTimeout(() => {
lastScan = Date.now();
scanAll();
scanComposer();
}, 500 - since);
}
}
function teardown() {
observer.disconnect();
clearInterval(safetyNet);
}
const observer = new MutationObserver(() => {
if (!contextAlive()) return teardown(); // stale script from a previous version
if (enabled) scheduleScan();
});
observer.observe(document.body, { childList: true, subtree: true });
// Safety net: even if the observer misses a mutation batch, sweep the whole
// feed every 1.5s (cheap — scanAll dedupes on the data-defluffed attribute).
const safetyNet = setInterval(() => {
if (!contextAlive()) return teardown();
if (enabled) {
scanAll();
scanComposer(); // the composer modal can open on any linkedin.com page
}
}, 1500);