• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

AJGranowski / reddit-expanded-community-filter-userscript / 10765121853

09 Sep 2024 01:52AM UTC coverage: 90.951%. Remained the same
10765121853

push

github

web-flow
Bump rollup-plugin-userscript from 0.3.2 to 0.3.4 (#348)

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

78 of 96 branches covered (81.25%)

Branch coverage included in aggregate %.

314 of 335 relevant lines covered (93.73%)

5.21 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

80.3
/src/RedditExpandedCommunityFilter.ts
1
import { AccessToken } from "./reddit/AccessToken";
1✔
2
import { AsyncMutationObserver } from "./utilities/AsyncMutationObserver";
1✔
3
import { Fetch } from "./web/Fetch";
1✔
4
import { RedditFeed } from "./reddit/@types/RedditFeed";
5
import { RedditFeedFactory } from "./reddit/RedditFeedFactory";
1✔
6
import { RedditPostItem } from "./reddit/@types/RedditPostItem";
7
import { RedditSession } from "./reddit/RedditSession";
1✔
8
import { Storage, STORAGE_KEY } from "./userscript/Storage";
1✔
9

10
const DEBUG_CLASSNAME = "muted-subreddit-post";
1✔
11

12
/**
13
 * Top level application for Reddit Expanded Community Filter.
14
 * Just call start() and wait.
15
 */
16
class RedditExpandedCommunityFilter {
17
    private readonly asyncMutationObserver: AsyncMutationObserver;
18
    private readonly reddit: RedditFeed;
19
    private readonly redditSession: RedditSession;
20
    private readonly storage: Storage;
21

22
    private startObservingPromise: Promise<void> | null;
23
    private startPromise: Promise<void> | null;
24
    private styleElement: HTMLStyleElement | null;
25

26
    constructor() {
27
        this.asyncMutationObserver = this.asyncMutationObserverSupplier(this.mutationCallback);
10✔
28
        this.redditSession = this.redditSessionSupplier();
10✔
29
        this.reddit = this.redditSupplier(this.redditSession);
10✔
30
        this.storage = this.storageSupplier();
10✔
31

32
        this.startObservingPromise = null;
10✔
33
        this.startPromise = null;
10✔
34
        this.styleElement = null;
10✔
35
    }
36

37
    start(): Promise<void> {
38
        if (this.startPromise != null) {
11✔
39
            return this.startPromise;
1✔
40
        }
41

42
        if (this.styleElement != null) {
10✔
43
            this.styleElement.remove();
1✔
44
            this.styleElement = null;
1✔
45
        }
46

47
        this.asyncMutationObserver.disconnect();
10✔
48
        this.styleElement = this.addStyle(`.${DEBUG_CLASSNAME} {border: dashed red;}`);
10✔
49

50
        let resolveStartObservingPromise: true | (() => void) | null = null;
10✔
51
        this.startObservingPromise = new Promise<void>((resolve) => {
10✔
52
            if (resolveStartObservingPromise == null) {
10!
53
                resolveStartObservingPromise = resolve;
10✔
54
            } else {
55
                resolve();
×
56
            }
57
        });
58

59
        const startObserving = (): Promise<any> => {
10✔
60
            return Promise.resolve()
10✔
61
                .then(this.debugPrintCallback)
62
                .then(() => this.refresh())
10✔
63
                .then(() => {
64
                    const feedContainerElement = this.reddit.getFeedContainer();
10✔
65
                    if (this.storage.get(STORAGE_KEY.DEBUG)) {
10!
66
                        console.log("Feed container", feedContainerElement);
×
67
                    }
68

69
                    const options = { attributes: false, childList: true, subtree: true };
10✔
70
                    const observePromise = this.asyncMutationObserver.observe(feedContainerElement, options);
10✔
71
                    if (resolveStartObservingPromise != null && resolveStartObservingPromise !== true) {
10!
72
                        resolveStartObservingPromise();
10✔
73
                    } else {
74
                        resolveStartObservingPromise = true;
×
75
                    }
76

77
                    return observePromise;
10✔
78
                });
79
        };
80

81
        this.startPromise = Promise.all([this.redditSession.updateAccessToken(), this.redditSession.updateMutedSubreddits()])
10✔
82
            .then(() => startObserving)
10✔
83
            .catch((e) => {
84
                if (this.storage.get(STORAGE_KEY.DEBUG)) {
×
85
                    console.warn(e);
×
86
                } else if (e instanceof Error) {
×
87
                    console.log(`${e.name}:`, e.message);
×
88
                } else {
89
                    console.warn(e);
×
90
                }
91
            })
92
            .then((func: void | (() => any)) => {
93
                if (func != null) {
10✔
94
                    return func();
10✔
95
                }
96
            })
97
            .finally(() => {
98
                this.startPromise = null;
3✔
99
                if (this.styleElement != null) {
3✔
100
                    this.styleElement.remove();
3✔
101
                }
102
            });
103

104
        return this.startPromise;
10✔
105
    }
106

107
    stop(): Promise<void> {
108
        if (this.startObservingPromise == null) {
4✔
109
            return Promise.resolve();
1✔
110
        }
111

112
        return this.startObservingPromise
3✔
113
            .then(() => {
114
                if (this.startPromise == null) {
3!
115
                    return;
×
116
                }
117

118
                this.asyncMutationObserver.disconnect();
3✔
119
                return this.startPromise;
3✔
120
            });
121
    }
122

123
    /**
124
     * Manually trigger an update.
125
     */
126
    refresh(): Promise<void> {
127
        return this.reddit.getMutedPosts()
10✔
128
            .then((redditPosts: Iterable<RedditPostItem>) => {
129
                for (const redditPost of redditPosts) {
10✔
130
                    this.mutePost(redditPost);
×
131
                }
132
            });
133
    }
134

135
    /**
136
     * Determine if this node contains text.
137
     *
138
     * `<a></a>` === `false`, `<a>text</a>` === `true`
139
     */
140
    private containsText(node: Node): boolean {
141
        return node.childNodes.length === 1 && node.childNodes[0].nodeType === Node.TEXT_NODE;
3!
142
    }
143

144
    /**
145
     * Print the muted subreddits if debug mode is enabled.
146
     */
147
    private readonly debugPrintCallback: () => void | Promise<void> = () => {
10✔
148
        if (this.storage.get(STORAGE_KEY.DEBUG)) {
10!
149
            return this.redditSession.getMutedSubreddits()
×
150
                .then((mutedSubreddits: string[]) => {
151
                    console.log("Muted subreddits:", mutedSubreddits);
×
152
                });
153
        }
154
    };
155

156
    /**
157
     * Mutation observer callback after filtering. Properties of these nodes include:
158
     * 1. Has a parent.
159
     * 2. Is not a text element.
160
     * 3. Is visible.
161
     */
162
    private filteredMutationCallback(addedNodes: ParentNode[]): Promise<any> {
163
        if (addedNodes.length === 0) {
3!
164
            return Promise.resolve();
×
165
        }
166

167
        if (this.storage.get(STORAGE_KEY.DEBUG)) {
3✔
168
            console.debug("Added nodes:", addedNodes);
1✔
169
        }
170

171
        return this.reddit.getMutedPosts(addedNodes)
3✔
172
            .then((redditPosts: Iterable<RedditPostItem>) => {
173
                for (const redditPost of redditPosts) {
3✔
174
                    this.mutePost(redditPost);
2✔
175
                }
176
            });
177
    }
178

179
    /**
180
     * Duck typing HTMLElement objects from Node objects.
181
     */
182
    private isHTMLElement(node: Node): boolean {
183
        return "offsetHeight" in node &&
3✔
184
            "offsetLeft" in node &&
185
            "offsetTop" in node &&
186
            "offsetWidth" in node &&
187
            "querySelectorAll" in node;
188
    }
189

190
    private isVisible(element: HTMLElement): boolean {
191
        if ("checkVisibility" in element) {
3!
192
            return element.checkVisibility();
×
193
        }
194

195
        return true;
3✔
196
    }
197

198
    private readonly mutationCallback: MutationCallback = (mutations: MutationRecord[]) => {
10✔
199
        const addedElementNodes = mutations
3✔
200
            .filter((mutation) => {
201
                // Filter mutations to added elements
202
                return mutation.type === "childList" && mutation.addedNodes.length > 0;
3✔
203
            })
204
            .flatMap((mutation) => Array.from(mutation.addedNodes))
3✔
205
            .filter((addedNode) => {
206
                // Filter added nodes to added HTMLElements
207
                const hasParent = addedNode.parentElement != null && addedNode.parentNode != null;
3✔
208
                return hasParent && this.isHTMLElement(addedNode) && !this.containsText(addedNode) && this.isVisible(addedNode as HTMLElement);
3✔
209
            }) as HTMLElement[];
210

211
        return this.filteredMutationCallback(addedElementNodes);
3✔
212
    };
213

214
    private mutePost(redditPost: RedditPostItem): void {
215
        for (const element of redditPost.elements) {
2✔
216
            if (this.storage.get(STORAGE_KEY.DEBUG)) {
2✔
217
                if (!element.classList.contains(DEBUG_CLASSNAME)) {
1✔
218
                    element.classList.add(DEBUG_CLASSNAME);
1✔
219
                    console.log(`Highlighted ${redditPost.subreddit} post (muted subreddit):`, redditPost.elements);
1✔
220
                }
221
            } else {
222
                element.remove();
1✔
223
                const newTotalMutedPosts = Math.max(0, this.storage.get(STORAGE_KEY.TOTAL_MUTED_POSTS)) + 1;
1✔
224
                this.storage.set(STORAGE_KEY.TOTAL_MUTED_POSTS, newTotalMutedPosts);
1✔
225
            }
226
        }
227
    }
228

229
    /* istanbul ignore next */
230
    protected addStyle(css: string): HTMLStyleElement {
231
        return GM_addStyle(css);
232
    }
233

234
    /* istanbul ignore next */
235
    protected asyncMutationObserverSupplier(callback: MutationCallback): AsyncMutationObserver {
236
        return new AsyncMutationObserver(callback);
237
    }
238

239
    /* istanbul ignore next */
240
    protected redditSupplier(redditSession: RedditSession): RedditFeed {
241
        return (new RedditFeedFactory(redditSession)).getRedditFeed(document);
242
    }
243

244
    /* istanbul ignore next */
245
    protected redditSessionSupplier(): RedditSession {
246
        return new RedditSession(new AccessToken(), new Fetch());
247
    }
248

249
    /* istanbul ignore next */
250
    protected storageSupplier(): Storage {
251
        return new Storage();
252
    }
253
}
254

255
export { RedditExpandedCommunityFilter };
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc