+119
-17
api.js
+119
-17
api.js
···
130
130
throw new URLError(`${error}`);
131
131
}
132
132
133
-
if (url.protocol != 'https:') {
134
-
throw new URLError('URL must start with https://');
133
+
if (url.protocol != 'https:' && url.protocol != 'http:') {
134
+
throw new URLError('URL must start with http(s)://');
135
135
}
136
136
137
137
let parts = url.pathname.split('/');
···
199
199
}
200
200
}
201
201
202
+
/** @param {string} query, @returns {Promise<json[]>} */
203
+
204
+
async autocompleteUsers(query) {
205
+
let json = await this.getRequest('app.bsky.actor.searchActorsTypeahead', { q: query });
206
+
return json.actors;
207
+
}
208
+
202
209
/** @returns {Promise<json | undefined>} */
203
210
204
211
async getCurrentUserAvatar() {
···
230
237
}
231
238
}
232
239
233
-
/** @param {string} uri, @returns {Promise<json[]>} */
240
+
/** @param {string} uri, @returns {Promise<string[]>} */
234
241
235
242
async getReplies(uri) {
236
243
let json = await this.getRequest('blue.feeds.post.getReplies', { uri });
···
278
285
return await this.getRequest('app.bsky.feed.searchPosts', params);
279
286
}
280
287
281
-
async loadNotifications(cursor) {
282
-
let params = { limit: 100 };
283
-
284
-
if (cursor) {
285
-
params.cursor = cursor;
286
-
}
288
+
/** @param {json} [params], @returns {Promise<json>} */
287
289
288
-
return await this.getRequest('app.bsky.notification.listNotifications', params);
290
+
async loadNotifications(params) {
291
+
return await this.getRequest('app.bsky.notification.listNotifications', params || {});
289
292
}
290
293
294
+
/**
295
+
* @param {string} [cursor]
296
+
* @returns {Promise<{ cursor: string | undefined, posts: json[] }>}
297
+
*/
298
+
291
299
async loadMentions(cursor) {
292
-
let response = await this.loadNotifications(cursor);
293
-
let mentions = response.notifications.filter(x => ['reply', 'mention'].includes(x.reason));
294
-
let uris = mentions.map(x => x['uri']);
295
-
let posts = [];
300
+
let response = await this.loadNotifications({ cursor: cursor ?? '', limit: 100, reasons: ['reply', 'mention'] });
301
+
let uris = response.notifications.map(x => x.uri);
302
+
let batches = [];
296
303
297
304
for (let i = 0; i < uris.length; i += 25) {
298
-
let batch = await this.loadPosts(uris.slice(i, i + 25));
299
-
posts = posts.concat(batch);
305
+
let batch = this.loadPosts(uris.slice(i, i + 25));
306
+
batches.push(batch);
300
307
}
301
308
302
-
return { cursor: response.cursor, posts };
309
+
let postGroups = await Promise.all(batches);
310
+
311
+
return { cursor: response.cursor, posts: postGroups.flat() };
312
+
}
313
+
314
+
/**
315
+
* @param {number} days
316
+
* @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options]
317
+
* @returns {Promise<json[]>}
318
+
*/
319
+
320
+
async loadHomeTimeline(days, options = {}) {
321
+
let now = new Date();
322
+
let timeLimit = now.getTime() - days * 86400 * 1000;
323
+
324
+
return await this.fetchAll('app.bsky.feed.getTimeline', {
325
+
params: { limit: 100 },
326
+
field: 'feed',
327
+
breakWhen: (x) => (feedPostTime(x) < timeLimit),
328
+
onPageLoad: options.onPageLoad,
329
+
keepLastPage: options.keepLastPage
330
+
});
331
+
}
332
+
333
+
/**
334
+
@typedef
335
+
{'posts_with_replies' | 'posts_no_replies' | 'posts_and_author_threads' | 'posts_with_media' | 'posts_with_video'}
336
+
AuthorFeedFilter
337
+
338
+
Filters:
339
+
- posts_with_replies: posts, replies and reposts (default)
340
+
- posts_no_replies: posts and reposts (no replies)
341
+
- posts_and_author_threads: posts, reposts, and replies in your own threads
342
+
- posts_with_media: posts and replies, but only with images (no reposts)
343
+
- posts_with_video: posts and replies, but only with videos (no reposts)
344
+
*/
345
+
346
+
/**
347
+
* @param {string} did
348
+
* @param {number} days
349
+
* @param {{ filter: AuthorFeedFilter, onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} options
350
+
* @returns {Promise<json[]>}
351
+
*/
352
+
353
+
async loadUserTimeline(did, days, options) {
354
+
let now = new Date();
355
+
let timeLimit = now.getTime() - days * 86400 * 1000;
356
+
357
+
return await this.fetchAll('app.bsky.feed.getAuthorFeed', {
358
+
params: {
359
+
actor: did,
360
+
filter: options.filter,
361
+
limit: 100
362
+
},
363
+
field: 'feed',
364
+
breakWhen: (x) => (feedPostTime(x) < timeLimit),
365
+
onPageLoad: options.onPageLoad,
366
+
keepLastPage: options.keepLastPage
367
+
});
368
+
}
369
+
370
+
/** @returns {Promise<json[]>} */
371
+
372
+
async loadUserLists() {
373
+
let lists = await this.fetchAll('app.bsky.graph.getLists', {
374
+
params: {
375
+
actor: this.user.did,
376
+
limit: 100
377
+
},
378
+
field: 'lists'
379
+
});
380
+
381
+
return lists.filter(x => x.purpose == "app.bsky.graph.defs#curatelist");
382
+
}
383
+
384
+
/**
385
+
* @param {string} list
386
+
* @param {number} days
387
+
* @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options]
388
+
* @returns {Promise<json[]>}
389
+
*/
390
+
391
+
async loadListTimeline(list, days, options = {}) {
392
+
let now = new Date();
393
+
let timeLimit = now.getTime() - days * 86400 * 1000;
394
+
395
+
return await this.fetchAll('app.bsky.feed.getListFeed', {
396
+
params: {
397
+
list: list,
398
+
limit: 100
399
+
},
400
+
field: 'feed',
401
+
breakWhen: (x) => (feedPostTime(x) < timeLimit),
402
+
onPageLoad: options.onPageLoad,
403
+
keepLastPage: options.keepLastPage
404
+
});
303
405
}
304
406
305
407
/** @param {string} postURI, @returns {Promise<json>} */
+14
async_lint.sh
+14
async_lint.sh
···
1
+
#!/bin/bash
2
+
3
+
scan() {
4
+
local identifier=$1
5
+
grep "\b$identifier(" *.js | grep -Ev "await |async |return |\.then\(|\.map"
6
+
}
7
+
8
+
for name in $(grep -oE "async \w+\(" *.js | grep -oE "\w+\(" | sed -e "s/(//"); do
9
+
scan $name
10
+
done
11
+
12
+
for name in $(grep -oE "async function \w+\(" *.js | grep -oE "\w+\(" | sed -e "s/(//"); do
13
+
scan $name
14
+
done
+62
-15
embed_component.js
+62
-15
embed_component.js
···
10
10
this.embed = embed;
11
11
}
12
12
13
-
/** @returns {AnyElement} */
13
+
/** @returns {HTMLElement} */
14
14
15
15
buildElement() {
16
16
if (this.embed instanceof RawRecordEmbed) {
···
54
54
}
55
55
}
56
56
57
-
/** @returns {AnyElement} */
57
+
/** @returns {HTMLElement} */
58
58
59
59
quotedPostPlaceholder() {
60
60
return $tag('div.quote-embed', {
···
62
62
});
63
63
}
64
64
65
-
/** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {AnyElement} */
65
+
/** @param {InlineRecordEmbed | InlineRecordWithMediaEmbed} embed, @returns {HTMLElement} */
66
66
67
67
buildQuotedPostElement(embed) {
68
68
let div = $tag('div.quote-embed');
···
88
88
return div;
89
89
}
90
90
91
-
/** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {AnyElement} */
91
+
/** @params {RawLinkEmbed | InlineLinkEmbed} embed, @returns {HTMLElement} */
92
92
93
93
buildLinkComponent(embed) {
94
94
let hostname;
···
108
108
let box = $tag('div');
109
109
110
110
let domain = $tag('p.domain', { text: hostname });
111
-
let title = $tag('h2', { text: embed.title });
111
+
let title = $tag('h2', { text: embed.title || embed.url });
112
112
box.append(domain, title);
113
113
114
114
if (embed.description) {
···
125
125
126
126
a.append(box);
127
127
128
+
if (hostname == 'media.tenor.com') {
129
+
a.addEventListener('click', (e) => {
130
+
e.preventDefault();
131
+
this.displayGIFInline(a, embed);
132
+
});
133
+
}
134
+
128
135
return a;
129
136
}
130
137
131
-
/** @param {FeedGeneratorRecord} feedgen, @returns {AnyElement} */
138
+
/** @param {HTMLElement} a, @param {RawLinkEmbed | InlineLinkEmbed} embed */
139
+
140
+
displayGIFInline(a, embed) {
141
+
let gifDiv = $tag('div.gif');
142
+
let img = $tag('img', { src: embed.url }, HTMLImageElement);
143
+
img.style.opacity = '0';
144
+
img.style.maxHeight = '200px';
145
+
gifDiv.append(img);
146
+
a.replaceWith(gifDiv);
147
+
148
+
img.addEventListener('load', (e) => {
149
+
if (img.naturalWidth > img.naturalHeight) {
150
+
img.style.maxHeight = '200px';
151
+
} else {
152
+
img.style.maxWidth = '200px';
153
+
img.style.maxHeight = '400px';
154
+
}
155
+
156
+
img.style.opacity = '';
157
+
});
158
+
159
+
let staticPic;
160
+
161
+
if (typeof embed.thumb == 'string') {
162
+
staticPic = embed.thumb;
163
+
} else {
164
+
staticPic = `https://cdn.bsky.app/img/avatar/plain/${this.post.author.did}/${embed.thumb.ref.$link}@jpeg`;
165
+
}
166
+
167
+
img.addEventListener('click', (e) => {
168
+
if (img.classList.contains('static')) {
169
+
img.src = embed.url;
170
+
img.classList.remove('static');
171
+
} else {
172
+
img.src = staticPic;
173
+
img.classList.add('static');
174
+
}
175
+
});
176
+
}
177
+
178
+
/** @param {FeedGeneratorRecord} feedgen, @returns {HTMLElement} */
132
179
133
180
buildFeedGeneratorView(feedgen) {
134
181
let link = this.linkToFeedGenerator(feedgen);
···
137
184
let box = $tag('div');
138
185
139
186
if (feedgen.avatar) {
140
-
let avatar = $tag('img.avatar');
187
+
let avatar = $tag('img.avatar', HTMLImageElement);
141
188
avatar.src = feedgen.avatar;
142
189
box.append(avatar);
143
190
}
···
167
214
return `https://bsky.app/profile/${repo}/feed/${rkey}`;
168
215
}
169
216
170
-
/** @param {UserListRecord} list, @returns {AnyElement} */
217
+
/** @param {UserListRecord} list, @returns {HTMLElement} */
171
218
172
219
buildUserListView(list) {
173
220
let link = this.linkToUserList(list);
···
176
223
let box = $tag('div');
177
224
178
225
if (list.avatar) {
179
-
let avatar = $tag('img.avatar');
226
+
let avatar = $tag('img.avatar', HTMLImageElement);
180
227
avatar.src = list.avatar;
181
228
box.append(avatar);
182
229
}
···
207
254
return a;
208
255
}
209
256
210
-
/** @param {StarterPackRecord} pack, @returns {AnyElement} */
257
+
/** @param {StarterPackRecord} pack, @returns {HTMLElement} */
211
258
212
259
buildStarterPackView(pack) {
213
260
let { repo, rkey } = atURI(pack.uri);
···
236
283
return `https://bsky.app/profile/${repo}/lists/${rkey}`;
237
284
}
238
285
239
-
/** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {AnyElement} */
286
+
/** @params {RawImageEmbed | InlineImageEmbed} embed, @returns {HTMLElement} */
240
287
241
288
buildImagesComponent(embed) {
242
289
let wrapper = $tag('div');
···
246
293
p.append('[');
247
294
248
295
// TODO: load image
249
-
let a = $tag('a', { text: "Image" });
296
+
let a = $tag('a', { text: "Image" }, HTMLLinkElement);
250
297
251
298
if (image.fullsize) {
252
299
a.href = image.fullsize;
···
272
319
return wrapper;
273
320
}
274
321
275
-
/** @params {RawVideoEmbed | InlineVideoEmbed} embed, @returns {AnyElement} */
322
+
/** @params {RawVideoEmbed | InlineVideoEmbed} embed, @returns {HTMLElement} */
276
323
277
324
buildVideoComponent(embed) {
278
325
let wrapper = $tag('div');
279
326
280
327
// TODO: load thumbnail
281
-
let a = $tag('a', { text: "Video" });
328
+
let a = $tag('a', { text: "Video" }, HTMLLinkElement);
282
329
283
330
if (embed.playlistURL) {
284
331
a.href = embed.playlistURL;
···
303
350
return wrapper;
304
351
}
305
352
306
-
/** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */
353
+
/** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */
307
354
308
355
async loadQuotedPost(uri, div) {
309
356
let record = await api.loadPostIfExists(uri);
+147
-1
index.html
+147
-1
index.html
···
10
10
font-src 'self';
11
11
script-src-attr 'none';
12
12
style-src-attr 'none';
13
-
connect-src https:;
13
+
connect-src https: http://localhost:3000;
14
14
base-uri 'none';
15
15
form-action 'none';">
16
16
···
49
49
50
50
<li><a href="#" data-action="login">Log in</a></li>
51
51
<li><a href="#" data-action="logout">Log out</a></li>
52
+
53
+
<li class="link"><a href="?">Home</a></li>
54
+
<li class="link"><a href="?page=posting_stats">Posting stats</a></li>
55
+
<li class="link"><a href="?page=like_stats">Like stats</a></li>
56
+
<li class="link"><a href="?page=search">Timeline search</a></li>
57
+
<li class="link"><a href="?page=search&mode=likes">Archive search</a></li>
52
58
</ul>
53
59
</div>
54
60
···
88
94
</form>
89
95
</div>
90
96
97
+
<div id="posting_stats_page">
98
+
<h2>Bluesky posting statistics</h2>
99
+
100
+
<form>
101
+
<p>
102
+
Scan posts from:
103
+
<input type="radio" name="scan_type" id="scan_type_timeline" value="home" checked>
104
+
<label for="scan_type_timeline">Home timeline</label>
105
+
106
+
<input type="radio" name="scan_type" id="scan_type_list" value="list">
107
+
<label for="scan_type_list">List feed</label>
108
+
109
+
<input type="radio" name="scan_type" id="scan_type_users" value="users">
110
+
<label for="scan_type_users">Selected users</label>
111
+
112
+
<input type="radio" name="scan_type" id="scan_type_you" value="you">
113
+
<label for="scan_type_you">Your profile</label>
114
+
</p>
115
+
116
+
<p>
117
+
Time range: <input type="range" min="1" max="60" value="7"> <label>7 days</label>
118
+
</p>
119
+
120
+
<p class="list-choice">
121
+
<label>Select list:</label>
122
+
<select name="scan_list"></select>
123
+
</p>
124
+
125
+
<div class="user-choice">
126
+
<input type="text" placeholder="Add user" autocomplete="off">
127
+
<div class="autocomplete"></div>
128
+
<div class="selected-users"></div>
129
+
</div>
130
+
131
+
<p>
132
+
<input type="submit" value="Start scan"> <progress></progress>
133
+
</p>
134
+
</form>
135
+
136
+
<p class="scan-info"></p>
137
+
138
+
<table class="scan-result">
139
+
<thead></thead>
140
+
<tbody></tbody>
141
+
</table>
142
+
</div>
143
+
144
+
<div id="like_stats_page">
145
+
<h2>Like statistics</h2>
146
+
147
+
<form>
148
+
<p>
149
+
Time range: <input type="range" min="1" max="60" value="7"> <label>7 days</label>
150
+
</p>
151
+
152
+
<p>
153
+
<input type="submit" value="Start scan"> <progress></progress>
154
+
</p>
155
+
</form>
156
+
157
+
<table class="scan-result given-likes">
158
+
<thead>
159
+
<tr><th colspan="3">โค๏ธ Likes from you:</th></tr>
160
+
</thead>
161
+
<tbody></tbody>
162
+
</table>
163
+
164
+
<table class="scan-result received-likes">
165
+
<thead>
166
+
<tr><th colspan="3">๐ Likes on your posts:</th></tr>
167
+
</thead>
168
+
<tbody></tbody>
169
+
</table>
170
+
</div>
171
+
172
+
<div id="private_search_page">
173
+
<h2>Archive search</h2>
174
+
175
+
<div class="timeline-search">
176
+
<form>
177
+
<p>
178
+
Fetch timeline posts: <input type="range" min="1" max="60" value="7"> <label>7 days</label>
179
+
</p>
180
+
181
+
<p>
182
+
<input type="submit" value="Fetch timeline"> <progress></progress>
183
+
</p>
184
+
</form>
185
+
186
+
<p class="archive-status"></p>
187
+
188
+
<hr>
189
+
</div>
190
+
191
+
<form class="search-form">
192
+
<p class="search">Search: <input type="text" class="search-query" autocomplete="off"></p>
193
+
194
+
<div class="search-collections">
195
+
<input type="radio" name="collection" value="likes" id="collection-likes" checked> <label for="collection-likes">Likes</label>
196
+
<input type="radio" name="collection" value="reposts" id="collection-reposts"> <label for="collection-reposts">Reposts</label>
197
+
<input type="radio" name="collection" value="quotes" id="collection-quotes"> <label for="collection-quotes">Quotes</label>
198
+
<input type="radio" name="collection" value="pins" id="collection-pins"> <label for="collection-pins">Pins</label>
199
+
</div>
200
+
</form>
201
+
202
+
<div class="lycan-import">
203
+
<form>
204
+
<h4>Data not imported yet</h4>
205
+
206
+
<p>
207
+
In order to search within your likes and bookmarks, the posts you've liked or saved need to be imported into a database.
208
+
This is a one-time process, but it can take several minutes or more, depending on the age of your account.
209
+
</p>
210
+
<p>
211
+
To start the import, press the button below. You can then wait until it finishes, or close this tab and come back a bit later.
212
+
After the import is complete, the database will be kept up to date automatically going forward.
213
+
</p>
214
+
<p>
215
+
<input type="submit" value="Start import">
216
+
</p>
217
+
</form>
218
+
219
+
<div class="import-progress">
220
+
<h4>Import in progress</h4>
221
+
222
+
<p class="import-status"></p>
223
+
<p><progress></progress> <output></output></p>
224
+
</div>
225
+
</div>
226
+
227
+
<div class="results">
228
+
</div>
229
+
</div>
230
+
91
231
<script src="lib/purify.min.js"></script>
92
232
<script src="minisky.js"></script>
93
233
<script src="api.js"></script>
94
234
<script src="utils.js"></script>
95
235
<script src="rich_text_lite.js"></script>
96
236
<script src="models.js"></script>
237
+
<script src="menu.js"></script>
238
+
<script src="thread_page.js"></script>
239
+
<script src="posting_stats_page.js"></script>
240
+
<script src="like_stats_page.js"></script>
241
+
<script src="notifications_page.js"></script>
242
+
<script src="private_search_page.js"></script>
97
243
<script src="embed_component.js"></script>
98
244
<script src="post_component.js"></script>
99
245
<script src="skythread.js"></script>
+1
-1
jsconfig.json
+1
-1
jsconfig.json
+289
like_stats_page.js
+289
like_stats_page.js
···
1
+
class LikeStatsPage {
2
+
3
+
/** @type {number | undefined} */
4
+
scanStartTime;
5
+
6
+
constructor() {
7
+
this.pageElement = $id('like_stats_page');
8
+
9
+
this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
10
+
this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
11
+
this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement);
12
+
13
+
this.receivedTable = $(this.pageElement.querySelector('.received-likes'), HTMLTableElement);
14
+
this.givenTable = $(this.pageElement.querySelector('.given-likes'), HTMLTableElement);
15
+
16
+
this.appView = new BlueskyAPI('public.api.bsky.app', false);
17
+
18
+
this.setupEvents();
19
+
20
+
this.progressPosts = 0;
21
+
this.progressLikeRecords = 0;
22
+
this.progressPostLikes = 0;
23
+
}
24
+
25
+
setupEvents() {
26
+
$(this.pageElement.querySelector('form')).addEventListener('submit', (e) => {
27
+
e.preventDefault();
28
+
29
+
if (!this.scanStartTime) {
30
+
this.findLikes();
31
+
} else {
32
+
this.stopScan();
33
+
}
34
+
});
35
+
36
+
this.rangeInput.addEventListener('input', (e) => {
37
+
let days = parseInt(this.rangeInput.value, 10);
38
+
let label = $(this.pageElement.querySelector('input[type=range] + label'));
39
+
label.innerText = (days == 1) ? '1 day' : `${days} days`;
40
+
});
41
+
}
42
+
43
+
/** @returns {number} */
44
+
45
+
selectedDaysRange() {
46
+
return parseInt(this.rangeInput.value, 10);
47
+
}
48
+
49
+
show() {
50
+
this.pageElement.style.display = 'block';
51
+
}
52
+
53
+
/** @returns {Promise<void>} */
54
+
55
+
async findLikes() {
56
+
this.submitButton.value = 'Cancel';
57
+
58
+
let requestedDays = this.selectedDaysRange();
59
+
60
+
this.resetProgress();
61
+
this.progressBar.style.display = 'inline';
62
+
63
+
let startTime = new Date().getTime();
64
+
this.scanStartTime = startTime;
65
+
66
+
this.receivedTable.style.display = 'none';
67
+
this.givenTable.style.display = 'none';
68
+
69
+
let fetchGivenLikes = this.fetchGivenLikes(requestedDays);
70
+
71
+
let receivedLikes = await this.fetchReceivedLikes(requestedDays);
72
+
let receivedStats = this.sumUpReceivedLikes(receivedLikes);
73
+
let topReceived = this.getTopEntries(receivedStats);
74
+
75
+
await this.renderResults(topReceived, this.receivedTable);
76
+
77
+
let givenLikes = await fetchGivenLikes;
78
+
let givenStats = this.sumUpGivenLikes(givenLikes);
79
+
let topGiven = this.getTopEntries(givenStats);
80
+
81
+
let profileInfo = await appView.getRequest('app.bsky.actor.getProfiles', { actors: topGiven.map(x => x.did) });
82
+
83
+
for (let profile of profileInfo.profiles) {
84
+
let user = /** @type {LikeStat} */ (topGiven.find(x => x.did == profile.did));
85
+
user.handle = profile.handle;
86
+
user.avatar = profile.avatar;
87
+
}
88
+
89
+
await this.renderResults(topGiven, this.givenTable);
90
+
91
+
this.receivedTable.style.display = 'table';
92
+
this.givenTable.style.display = 'table';
93
+
94
+
this.submitButton.value = 'Start scan';
95
+
this.progressBar.style.display = 'none';
96
+
this.scanStartTime = undefined;
97
+
}
98
+
99
+
/** @param {number} requestedDays, @returns {Promise<json[]>} */
100
+
101
+
async fetchGivenLikes(requestedDays) {
102
+
let startTime = /** @type {number} */ (this.scanStartTime);
103
+
104
+
return await accountAPI.fetchAll('com.atproto.repo.listRecords', {
105
+
params: {
106
+
repo: accountAPI.user.did,
107
+
collection: 'app.bsky.feed.like',
108
+
limit: 100
109
+
},
110
+
field: 'records',
111
+
breakWhen: (x) => Date.parse(x['value']['createdAt']) < startTime - 86400 * requestedDays * 1000,
112
+
onPageLoad: (data) => {
113
+
let last = data.at(-1);
114
+
115
+
if (!last) { return }
116
+
117
+
let lastDate = Date.parse(last.value.createdAt);
118
+
let daysBack = (startTime - lastDate) / 86400 / 1000;
119
+
120
+
this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) });
121
+
}
122
+
});
123
+
}
124
+
125
+
/** @param {number} requestedDays, @returns {Promise<json[]>} */
126
+
127
+
async fetchReceivedLikes(requestedDays) {
128
+
let startTime = /** @type {number} */ (this.scanStartTime);
129
+
130
+
let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, {
131
+
filter: 'posts_with_replies',
132
+
onPageLoad: (data) => {
133
+
let last = data.at(-1);
134
+
135
+
if (!last) { return }
136
+
137
+
let lastDate = feedPostTime(last);
138
+
let daysBack = (startTime - lastDate) / 86400 / 1000;
139
+
140
+
this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) });
141
+
}
142
+
});
143
+
144
+
let likedPosts = myPosts.filter(x => !x['reason'] && x['post']['likeCount'] > 0);
145
+
146
+
let results = [];
147
+
148
+
for (let i = 0; i < likedPosts.length; i += 10) {
149
+
let batch = likedPosts.slice(i, i + 10);
150
+
this.updateProgress({ postLikes: i / likedPosts.length });
151
+
152
+
let fetchBatch = batch.map(x => {
153
+
return this.appView.fetchAll('app.bsky.feed.getLikes', {
154
+
params: {
155
+
uri: x['post']['uri'],
156
+
limit: 100
157
+
},
158
+
field: 'likes'
159
+
});
160
+
});
161
+
162
+
let batchResults = await Promise.all(fetchBatch);
163
+
results = results.concat(batchResults);
164
+
}
165
+
166
+
this.updateProgress({ postLikes: 1.0 });
167
+
168
+
return results.flat();
169
+
}
170
+
171
+
/**
172
+
* @typedef {{ handle?: string, did?: string, avatar?: string, count: number }} LikeStat
173
+
* @typedef {Record<string, LikeStat>} LikeStatHash
174
+
*/
175
+
176
+
/** @param {json[]} likes, @returns {LikeStatHash} */
177
+
178
+
sumUpReceivedLikes(likes) {
179
+
/** @type {LikeStatHash} */
180
+
let stats = {};
181
+
182
+
for (let like of likes) {
183
+
let handle = like.actor.handle;
184
+
185
+
if (!stats[handle]) {
186
+
stats[handle] = { handle: handle, count: 0, avatar: like.actor.avatar };
187
+
}
188
+
189
+
stats[handle].count += 1;
190
+
}
191
+
192
+
return stats;
193
+
}
194
+
195
+
/** @param {json[]} likes, @returns {LikeStatHash} */
196
+
197
+
sumUpGivenLikes(likes) {
198
+
/** @type {LikeStatHash} */
199
+
let stats = {};
200
+
201
+
for (let like of likes) {
202
+
let did = atURI(like.value.subject.uri).repo;
203
+
204
+
if (!stats[did]) {
205
+
stats[did] = { did: did, count: 0 };
206
+
}
207
+
208
+
stats[did].count += 1;
209
+
}
210
+
211
+
return stats;
212
+
}
213
+
214
+
/** @param {LikeStatHash} counts, @returns {LikeStat[]} */
215
+
216
+
getTopEntries(counts) {
217
+
return Object.entries(counts).sort(this.sortResults).map(x => x[1]).slice(0, 25);
218
+
}
219
+
220
+
/** @param {LikeStat[]} topUsers, @param {HTMLTableElement} table, @returns {Promise<void>} */
221
+
222
+
async renderResults(topUsers, table) {
223
+
let tableBody = $(table.querySelector('tbody'));
224
+
tableBody.innerHTML = '';
225
+
226
+
for (let [i, user] of topUsers.entries()) {
227
+
let tr = $tag('tr');
228
+
tr.append(
229
+
$tag('td.no', { text: i + 1 }),
230
+
$tag('td.handle', {
231
+
html: `<img class="avatar" src="${user.avatar}"> ` +
232
+
`<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
233
+
}),
234
+
$tag('td.count', { text: user.count })
235
+
);
236
+
237
+
tableBody.append(tr);
238
+
};
239
+
}
240
+
241
+
resetProgress() {
242
+
this.progressBar.value = 0;
243
+
this.progressPosts = 0;
244
+
this.progressLikeRecords = 0;
245
+
this.progressPostLikes = 0;
246
+
}
247
+
248
+
/** @param {{ posts?: number, likeRecords?: number, postLikes?: number }} data */
249
+
250
+
updateProgress(data) {
251
+
if (data.posts) {
252
+
this.progressPosts = data.posts;
253
+
}
254
+
255
+
if (data.likeRecords) {
256
+
this.progressLikeRecords = data.likeRecords;
257
+
}
258
+
259
+
if (data.postLikes) {
260
+
this.progressPostLikes = data.postLikes;
261
+
}
262
+
263
+
let totalProgress = (
264
+
0.1 * this.progressPosts +
265
+
0.65 * this.progressLikeRecords +
266
+
0.25 * this.progressPostLikes
267
+
);
268
+
269
+
this.progressBar.value = totalProgress;
270
+
}
271
+
272
+
/** @param {[string, LikeStat]} a, @param {[string, LikeStat]} b, @returns {-1|1|0} */
273
+
274
+
sortResults(a, b) {
275
+
if (a[1].count < b[1].count) {
276
+
return 1;
277
+
} else if (a[1].count > b[1].count) {
278
+
return -1;
279
+
} else {
280
+
return 0;
281
+
}
282
+
}
283
+
284
+
stopScan() {
285
+
this.submitButton.value = 'Start scan';
286
+
this.progressBar.style.display = 'none';
287
+
this.scanStartTime = undefined;
288
+
}
289
+
}
+85
-2
minisky.js
+85
-2
minisky.js
···
14
14
15
15
16
16
/**
17
+
* Thrown when passed arguments/options are invalid or missing.
18
+
*/
19
+
20
+
class RequestError extends Error {}
21
+
22
+
23
+
/**
17
24
* Thrown when authentication is needed, but access token is invalid or missing.
18
25
*/
19
26
···
97
104
let host = (this.host.includes('://')) ? this.host : `https://${this.host}`;
98
105
return host + '/xrpc';
99
106
} else {
100
-
throw new AuthError('Hostname not set');
107
+
throw new RequestError('Hostname not set');
101
108
}
102
109
}
103
110
···
172
179
return await this.parseResponse(response);
173
180
}
174
181
182
+
/**
183
+
* @typedef {(obj: json[]) => { cancel: true } | void} FetchAllOnPageLoad
184
+
*
185
+
* @typedef {MiniskyOptions & {
186
+
* field: string,
187
+
* params?: json,
188
+
* breakWhen?: (obj: json) => boolean,
189
+
* keepLastPage?: boolean | undefined,
190
+
* onPageLoad?: FetchAllOnPageLoad | undefined
191
+
* }} FetchAllOptions
192
+
*
193
+
* @param {string} method
194
+
* @param {FetchAllOptions} [options]
195
+
* @returns {Promise<json[]>}
196
+
*/
197
+
198
+
async fetchAll(method, options) {
199
+
if (!options || !options.field) {
200
+
throw new RequestError("'field' option is required");
201
+
}
202
+
203
+
let data = [];
204
+
let reqParams = options.params ?? {};
205
+
let reqOptions = this.sliceOptions(options, ['auth', 'headers']);
206
+
207
+
for (;;) {
208
+
let response = await this.getRequest(method, reqParams, reqOptions);
209
+
210
+
let items = response[options.field];
211
+
let cursor = response.cursor;
212
+
213
+
if (options.breakWhen) {
214
+
let test = options.breakWhen;
215
+
216
+
if (items.some(x => test(x))) {
217
+
if (!options.keepLastPage) {
218
+
items = items.filter(x => !test(x));
219
+
}
220
+
221
+
cursor = null;
222
+
}
223
+
}
224
+
225
+
data = data.concat(items);
226
+
reqParams.cursor = cursor;
227
+
228
+
if (options.onPageLoad) {
229
+
let result = options.onPageLoad(items);
230
+
231
+
if (result?.cancel) {
232
+
break;
233
+
}
234
+
}
235
+
236
+
if (!cursor) {
237
+
break;
238
+
}
239
+
}
240
+
241
+
return data;
242
+
}
243
+
175
244
/** @param {string | boolean} auth, @returns {Record<string, string>} */
176
245
177
246
authHeaders(auth) {
···
188
257
}
189
258
}
190
259
260
+
/** @param {json} options, @param {string[]} list, @returns {json} */
261
+
262
+
sliceOptions(options, list) {
263
+
let newOptions = {};
264
+
265
+
for (let i of list) {
266
+
if (i in options) {
267
+
newOptions[i] = options[i];
268
+
}
269
+
}
270
+
271
+
return newOptions;
272
+
}
273
+
191
274
/** @param {string} token, @returns {number} */
192
275
193
276
tokenExpirationTimestamp(token) {
···
218
301
let text = await response.text();
219
302
let json = text.trim().length > 0 ? JSON.parse(text) : undefined;
220
303
221
-
if (response.status == 200) {
304
+
if (response.status >= 200 && response.status < 300) {
222
305
return json;
223
306
} else {
224
307
throw new APIError(response.status, json);
+25
models.js
+25
models.js
···
287
287
return -1;
288
288
} else if (a.author.did != this.author.did && b.author.did == this.author.did) {
289
289
return 1;
290
+
} else if (a.text != "๐" && b.text == "๐") {
291
+
return -1;
292
+
} else if (a.text == "๐" && b.text != "๐") {
293
+
return 1;
290
294
} else if (a.createdAt.getTime() < b.createdAt.getTime()) {
291
295
return -1;
292
296
} else if (a.createdAt.getTime() > b.createdAt.getTime()) {
···
318
322
return this.record.bridgyOriginalText;
319
323
}
320
324
325
+
/** @returns {string | undefined} */
326
+
get originalFediURL() {
327
+
return this.record.bridgyOriginalUrl;
328
+
}
329
+
321
330
/** @returns {boolean} */
322
331
get isRoot() {
323
332
// I AM ROOOT
···
338
347
return this.record.text;
339
348
}
340
349
350
+
/** @returns {string} */
351
+
get lowercaseText() {
352
+
if (!this._lowercaseText) {
353
+
this._lowercaseText = this.record.text.toLowerCase();
354
+
}
355
+
356
+
return this._lowercaseText;
357
+
}
358
+
341
359
/** @returns {json} */
342
360
get facets() {
343
361
return this.record.facets;
···
380
398
let shouldHaveMoreReplies = (this.replyCount !== undefined && this.replyCount > this.replies.length);
381
399
382
400
return shouldHaveMoreReplies && (this.replies.length > 0 || (this.level !== undefined && this.level <= 4));
401
+
}
402
+
403
+
/** @returns {boolean} */
404
+
get isRestrictingReplies() {
405
+
return !!(this.data.threadgate && this.data.threadgate.record.allow);
383
406
}
384
407
385
408
/** @returns {number} */
···
650
673
651
674
this.url = json.external.uri;
652
675
this.title = json.external.title;
676
+
this.thumb = json.external.thumb;
653
677
}
654
678
}
655
679
···
718
742
this.url = json.external.uri;
719
743
this.title = json.external.title;
720
744
this.description = json.external.description;
745
+
this.thumb = json.external.thumb;
721
746
}
722
747
}
723
748
+78
notifications_page.js
+78
notifications_page.js
···
1
+
class NotificationsPage {
2
+
3
+
constructor() {
4
+
this.pageElement = $id('thread');
5
+
}
6
+
7
+
show() {
8
+
document.title = `Notifications - Skythread`;
9
+
showLoader();
10
+
11
+
let isLoading = false;
12
+
let firstPageLoaded = false;
13
+
let finished = false;
14
+
let cursor;
15
+
16
+
Paginator.loadInPages((next) => {
17
+
if (isLoading || finished) { return; }
18
+
isLoading = true;
19
+
20
+
accountAPI.loadMentions(cursor).then(data => {
21
+
let posts = data.posts.map(x => new Post(x));
22
+
23
+
if (posts.length > 0) {
24
+
if (!firstPageLoaded) {
25
+
hideLoader();
26
+
firstPageLoaded = true;
27
+
28
+
let header = $tag('header');
29
+
let h2 = $tag('h2', { text: "Replies & Mentions:" });
30
+
header.append(h2);
31
+
32
+
this.pageElement.appendChild(header);
33
+
this.pageElement.classList.add('notifications');
34
+
}
35
+
36
+
for (let post of posts) {
37
+
if (post.parentReference) {
38
+
let p = $tag('p.back');
39
+
p.innerHTML = `<i class="fa-solid fa-reply"></i> `;
40
+
41
+
let { repo, rkey } = atURI(post.parentReference.uri);
42
+
let url = linkToPostById(repo, rkey);
43
+
let parentLink = $tag('a', { href: url });
44
+
p.append(parentLink);
45
+
46
+
if (repo == accountAPI.user.did) {
47
+
parentLink.innerText = 'Reply to you';
48
+
} else {
49
+
parentLink.innerText = 'Reply';
50
+
api.fetchHandleForDid(repo).then(handle => {
51
+
parentLink.innerText = `Reply to @${handle}`;
52
+
});
53
+
}
54
+
55
+
this.pageElement.appendChild(p);
56
+
}
57
+
58
+
let postView = new PostComponent(post, 'feed').buildElement();
59
+
this.pageElement.appendChild(postView);
60
+
}
61
+
}
62
+
63
+
isLoading = false;
64
+
cursor = data.cursor;
65
+
66
+
if (!cursor) {
67
+
finished = true;
68
+
} else if (posts.length == 0) {
69
+
next();
70
+
}
71
+
}).catch(error => {
72
+
hideLoader();
73
+
console.log(error);
74
+
isLoading = false;
75
+
});
76
+
});
77
+
}
78
+
}
+266
-70
post_component.js
+266
-70
post_component.js
···
5
5
class PostComponent {
6
6
/**
7
7
* Post component's root HTML element, if built.
8
-
* @type {AnyElement | undefined}
8
+
* @type {HTMLElement | undefined}
9
9
*/
10
10
_rootElement;
11
11
···
26
26
}
27
27
28
28
/**
29
-
* @returns {AnyElement}
29
+
* @returns {HTMLElement}
30
30
*/
31
31
get rootElement() {
32
32
if (!this._rootElement) {
···
91
91
}
92
92
}
93
93
94
-
/** @returns {AnyElement} */
94
+
/** @param {HTMLElement} nodeToUpdate */
95
+
installIntoElement(nodeToUpdate) {
96
+
let view = this.buildElement();
97
+
98
+
let oldContent = $(nodeToUpdate.querySelector('.content'));
99
+
let newContent = $(view.querySelector('.content'));
100
+
oldContent.replaceWith(newContent);
101
+
102
+
this._rootElement = nodeToUpdate;
103
+
}
104
+
105
+
/** @returns {HTMLElement} */
95
106
buildElement() {
96
107
if (this._rootElement) {
97
108
return this._rootElement;
98
109
}
99
110
100
-
let div = $tag('div.post');
111
+
let div = $tag('div.post', `post-${this.context}`);
101
112
this._rootElement = div;
102
113
103
114
if (this.post.muted) {
···
151
162
if (this.post.embed) {
152
163
let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
153
164
wrapper.appendChild(embed);
165
+
166
+
if (this.post.originalFediURL) {
167
+
if (this.post.embed instanceof InlineLinkEmbed && this.post.embed.title.startsWith('Original post on ')) {
168
+
embed.remove();
169
+
}
170
+
}
171
+
}
172
+
173
+
if (this.post.originalFediURL) {
174
+
let link = this.buildFediSourceLink(this.post.originalFediURL);
175
+
if (link) {
176
+
wrapper.appendChild(link);
177
+
}
154
178
}
155
179
156
180
if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
···
158
182
wrapper.appendChild(stats);
159
183
}
160
184
161
-
if (this.post.replies.length == 1 && this.post.replies[0].author?.did == this.post.author.did) {
185
+
if (this.post.replyCount == 1 && this.post.replies[0]?.author?.did == this.post.author.did) {
162
186
let component = new PostComponent(this.post.replies[0], 'thread');
163
187
let element = component.buildElement();
164
188
element.classList.add('flat');
···
188
212
return div;
189
213
}
190
214
191
-
/** @returns {AnyElement} */
215
+
/** @returns {HTMLElement} */
192
216
193
217
buildPostHeader() {
194
218
let timeFormat = this.timeFormatForTimestamp;
···
200
224
h.innerHTML = `${escapeHTML(this.authorName)} `;
201
225
202
226
if (this.post.isFediPost) {
203
-
let handle = this.post.authorFediHandle;
204
-
h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${handle}</a> ` +
227
+
let handle = `@${this.post.authorFediHandle}`;
228
+
h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> ` +
205
229
`<img src="icons/mastodon.svg" class="mastodon"> `;
206
230
} else {
207
-
h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">@${this.post.author.handle}</a> `;
231
+
let handle = (this.post.author.handle != 'handle.invalid') ? `@${this.post.author.handle}` : '[invalid handle]';
232
+
h.innerHTML += `<a class="handle" href="${this.linkToAuthor}" target="_blank">${handle}</a> `;
208
233
}
209
234
210
235
h.innerHTML += `<span class="separator">•</span> ` +
···
252
277
/** @param {string} url, @returns {HTMLImageElement} */
253
278
254
279
buildUserAvatar(url) {
255
-
let avatar = $tag('img.avatar', { src: url });
256
-
let tries = 0;
257
-
258
-
let errorHandler = function(e) {
259
-
if (tries < 3) {
260
-
tries++;
261
-
setTimeout(() => { avatar.src = url }, Math.random() * 5 * tries);
262
-
} else {
263
-
avatar.removeEventListener('error', errorHandler);
264
-
}
265
-
};
266
-
267
-
avatar.addEventListener('error', errorHandler);
280
+
let avatar = $tag('img.avatar', { loading: 'lazy' }, HTMLImageElement); // needs to be set before src!
281
+
avatar.src = url;
282
+
window.avatarPreloader.observe(avatar);
268
283
return avatar;
269
284
}
270
285
271
-
/** @returns {AnyElement} */
286
+
/** @returns {HTMLElement} */
272
287
273
288
buildPostBody() {
274
289
if (this.post.originalFediContent) {
···
299
314
return p;
300
315
}
301
316
302
-
/** @param {string[]} tags, @returns {AnyElement} */
317
+
/** @param {string[]} terms */
318
+
319
+
highlightSearchResults(terms) {
320
+
let regexp = new RegExp(`\\b(${terms.join('|')})\\b`, 'gi');
321
+
322
+
let root = this.rootElement;
323
+
let body = $(root.querySelector(':scope > .content > .body, :scope > .content > details .body'));
324
+
let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
325
+
let textNodes = [];
326
+
327
+
while (walker.nextNode()) {
328
+
textNodes.push(walker.currentNode);
329
+
}
330
+
331
+
for (let node of textNodes) {
332
+
if (!node.textContent) { continue; }
333
+
334
+
let markedText = document.createDocumentFragment();
335
+
let currentPosition = 0;
336
+
337
+
for (;;) {
338
+
let match = regexp.exec(node.textContent);
339
+
if (match === null) break;
340
+
341
+
if (match.index > currentPosition) {
342
+
let earlierText = node.textContent.slice(currentPosition, match.index);
343
+
markedText.appendChild(document.createTextNode(earlierText));
344
+
}
345
+
346
+
let span = $tag('span.highlight', { text: match[0] });
347
+
markedText.appendChild(span);
348
+
349
+
currentPosition = match.index + match[0].length;
350
+
}
351
+
352
+
if (currentPosition < node.textContent.length) {
353
+
let remainingText = node.textContent.slice(currentPosition);
354
+
markedText.appendChild(document.createTextNode(remainingText));
355
+
}
356
+
357
+
$(node.parentNode).replaceChild(markedText, node);
358
+
}
359
+
}
360
+
361
+
/** @param {string[]} tags, @returns {HTMLElement} */
303
362
304
363
buildTagsRow(tags) {
305
364
let p = $tag('p.tags');
···
315
374
return p;
316
375
}
317
376
318
-
/** @returns {AnyElement} */
377
+
/** @returns {HTMLElement} */
319
378
320
379
buildStatsFooter() {
321
380
let stats = $tag('p.stats');
···
332
391
stats.append(span);
333
392
}
334
393
394
+
if (this.post.replyCount > 0 && (this.context == 'quotes' || this.context == 'feed')) {
395
+
let pluralizedCount = (this.post.replyCount > 1) ? `${this.post.replyCount} replies` : '1 reply';
396
+
let span = $tag('span', {
397
+
html: `<i class="fa-regular fa-message"></i> <a href="${linkToPostThread(this.post)}">${pluralizedCount}</a>`
398
+
});
399
+
stats.append(span);
400
+
}
401
+
335
402
if (!this.isRoot && this.context != 'quote' && this.post.quoteCount) {
336
-
let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, false);
403
+
let expanded = this.context == 'quotes' || this.context == 'feed';
404
+
let quotesLink = this.buildQuotesIconLink(this.post.quoteCount, expanded);
337
405
stats.append(quotesLink);
338
406
}
339
407
408
+
if (this.context == 'thread' && this.post.isRestrictingReplies) {
409
+
let span = $tag('span', { html: `<i class="fa-solid fa-ban"></i> Limited replies` });
410
+
stats.append(span);
411
+
}
412
+
340
413
return stats;
341
414
}
342
415
343
-
/** @param {number} count, @param {boolean} expanded, @returns {AnyElement} */
416
+
/** @param {number} count, @param {boolean} expanded, @returns {HTMLElement} */
344
417
345
418
buildQuotesIconLink(count, expanded) {
346
419
let q = new URL(getLocation());
347
420
q.searchParams.set('quotes', this.linkToPost);
348
421
349
422
let url = q.toString();
350
-
let icon = `<i class="fa-regular ${count > 1 ? 'fa-comments' : 'fa-comment'}"></i>`;
423
+
let icon = `<i class="fa-regular fa-comments"></i>`;
351
424
352
425
if (expanded) {
353
426
let span = $tag('span', { html: `${icon} ` });
···
362
435
/** @param {number} quoteCount, @param {boolean} expanded */
363
436
364
437
appendQuotesIconLink(quoteCount, expanded) {
365
-
let stats = this.rootElement.querySelector(':scope > .content > p.stats');
438
+
let stats = $(this.rootElement.querySelector(':scope > .content > p.stats'));
366
439
let quotesLink = this.buildQuotesIconLink(quoteCount, expanded);
367
440
stats.append(quotesLink);
368
441
}
369
442
370
-
/** @returns {AnyElement} */
443
+
/** @returns {HTMLElement} */
371
444
372
445
buildLoadMoreLink() {
373
446
let loadMore = $tag('p');
···
380
453
link.addEventListener('click', (e) => {
381
454
e.preventDefault();
382
455
loadMore.innerHTML = `<img class="loader" src="icons/sunny.png">`;
383
-
loadSubtree(this.post, this.rootElement);
456
+
this.loadSubtree(this.post, this.rootElement);
384
457
});
385
458
386
459
loadMore.appendChild(link);
387
460
return loadMore;
388
461
}
389
462
390
-
/** @returns {AnyElement} */
463
+
/** @returns {HTMLElement} */
391
464
392
465
buildHiddenRepliesLink() {
393
466
let loadMore = $tag('p.hidden-replies');
···
412
485
return loadMore;
413
486
}
414
487
415
-
/** @param {HTMLLinkElement} loadMoreButton */
488
+
/** @param {HTMLElement} loadMoreButton */
416
489
417
490
loadHiddenReplies(loadMoreButton) {
418
491
loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`;
419
-
loadHiddenSubtree(this.post, this.rootElement);
492
+
this.loadHiddenSubtree(this.post, this.rootElement);
493
+
}
494
+
495
+
/** @param {string} url, @returns {HTMLElement | undefined} */
496
+
497
+
buildFediSourceLink(url) {
498
+
try {
499
+
let hostname = new URL(url).hostname;
500
+
let a = $tag('a.fedi-link', { href: url, target: '_blank' });
501
+
502
+
let box = $tag('div', { html: `<i class="fa-solid fa-arrow-up-right-from-square fa-sm"></i> View on ${hostname}` });
503
+
a.append(box);
504
+
return a;
505
+
} catch (error) {
506
+
console.log("Invalid Fedi URL:" + error);
507
+
return undefined;
508
+
}
420
509
}
421
510
422
511
/** @param {HTMLLinkElement} authorLink */
···
436
525
});
437
526
}
438
527
439
-
/** @param {AnyElement} div, @returns {AnyElement} */
528
+
/** @param {HTMLElement} div, @returns {HTMLElement} */
440
529
441
530
buildBlockedPostElement(div) {
442
531
let p = $tag('p.blocked-header');
···
451
540
let blockStatus = this.post.blockedByUser ? 'has blocked you' : this.post.blocksUser ? "you've blocked them" : '';
452
541
blockStatus = blockStatus ? `, ${blockStatus}` : '';
453
542
454
-
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' });
543
+
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
455
544
p.append(' (', authorLink, blockStatus, ') ');
456
545
div.appendChild(p);
457
546
···
472
561
return div;
473
562
}
474
563
475
-
/** @param {AnyElement} div, @returns {AnyElement} */
564
+
/** @param {HTMLElement} div, @returns {HTMLElement} */
476
565
477
566
buildDetachedQuoteElement(div) {
478
567
let p = $tag('p.blocked-header');
···
484
573
return p;
485
574
}
486
575
487
-
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' });
576
+
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
488
577
p.append(' (', authorLink, ') ');
489
578
div.appendChild(p);
490
579
···
505
594
return div;
506
595
}
507
596
508
-
/** @param {AnyElement} div, @returns {AnyElement} */
597
+
/** @param {HTMLElement} div, @returns {HTMLElement} */
509
598
510
599
buildMissingPostElement(div) {
511
600
let p = $tag('p.blocked-header');
512
601
p.innerHTML = `<i class="fa-solid fa-ban"></i> <span>Deleted post</span>`;
513
602
514
-
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' });
603
+
let authorLink = $tag('a', { href: this.didLinkToAuthor, target: '_blank', text: 'see author' }, HTMLLinkElement);
515
604
p.append(' (', authorLink, ') ');
516
605
517
606
this.loadReferencedPostAuthor(authorLink);
···
521
610
return div;
522
611
}
523
612
524
-
/** @param {string} uri, @param {AnyElement} div, @returns Promise<void> */
613
+
/** @param {string} uri, @param {HTMLElement} div, @returns Promise<void> */
525
614
526
615
async loadBlockedPost(uri, div) {
527
616
let record = await appView.loadPostIfExists(this.post.uri);
···
547
636
html: `<i class="fa-solid fa-arrows-split-up-and-left fa-rotate-180"></i>`
548
637
});
549
638
550
-
let header = div.querySelector('p.blocked-header');
639
+
let header = $(div.querySelector('p.blocked-header'));
551
640
let separator = $tag('span.separator', { html: '•' });
552
641
header.append(separator, ' ', a);
553
642
}
554
643
555
-
div.querySelector('p.load-post').remove();
644
+
let loadPost = $(div.querySelector('p.load-post'));
645
+
loadPost.remove();
556
646
557
647
if (this.isRoot && this.post.parentReference) {
558
648
let { repo, rkey } = atURI(this.post.parentReference.uri);
···
577
667
}
578
668
}
579
669
670
+
/** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */
671
+
672
+
async loadSubtree(post, nodeToUpdate) {
673
+
try {
674
+
let json = await api.loadThreadByAtURI(post.uri);
675
+
676
+
let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel);
677
+
post.updateDataFromPost(root);
678
+
window.subtreeRoot = post;
679
+
680
+
let component = new PostComponent(post, 'thread');
681
+
component.installIntoElement(nodeToUpdate);
682
+
} catch (error) {
683
+
showError(error);
684
+
}
685
+
}
686
+
687
+
688
+
/** @param {Post} post, @param {HTMLElement} nodeToUpdate, @returns {Promise<void>} */
689
+
690
+
async loadHiddenSubtree(post, nodeToUpdate) {
691
+
let content = $(nodeToUpdate.querySelector('.content'));
692
+
let hiddenRepliesDiv = $(content.querySelector(':scope > .hidden-replies'));
693
+
694
+
try {
695
+
var expectedReplyURIs = await blueAPI.getReplies(post.uri);
696
+
} catch (error) {
697
+
hiddenRepliesDiv.remove();
698
+
699
+
if (error instanceof APIError && error.code == 404) {
700
+
let info = $tag('p.missing-replies-info', {
701
+
html: `<i class="fa-solid fa-ban"></i> Hidden replies not available (post too old)`
702
+
});
703
+
content.append(info);
704
+
} else {
705
+
setTimeout(() => showError(error), 1);
706
+
}
707
+
708
+
return;
709
+
}
710
+
711
+
let missingReplyURIs = expectedReplyURIs.filter(r => !post.replies.some(x => x.uri === r));
712
+
let promises = missingReplyURIs.map(uri => api.loadThreadByAtURI(uri));
713
+
714
+
try {
715
+
// TODO
716
+
var responses = await Promise.allSettled(promises);
717
+
} catch (error) {
718
+
hiddenRepliesDiv.remove();
719
+
setTimeout(() => showError(error), 1);
720
+
return;
721
+
}
722
+
723
+
let replies = responses
724
+
.map(r => r.status == 'fulfilled' ? r.value : undefined)
725
+
.filter(v => v)
726
+
.map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1));
727
+
728
+
post.setReplies(replies);
729
+
hiddenRepliesDiv.remove();
730
+
731
+
for (let reply of post.replies) {
732
+
let component = new PostComponent(reply, 'thread');
733
+
let view = component.buildElement();
734
+
content.append(view);
735
+
}
736
+
737
+
if (replies.length < responses.length) {
738
+
let notFoundCount = responses.length - replies.length;
739
+
let pluralizedCount = (notFoundCount > 1) ? `${notFoundCount} replies are` : '1 reply is';
740
+
741
+
let info = $tag('p.missing-replies-info', {
742
+
html: `<i class="fa-solid fa-ban"></i> ${pluralizedCount} missing (likely taken down by moderation)`
743
+
});
744
+
content.append(info);
745
+
}
746
+
}
747
+
580
748
/** @returns {boolean} */
581
749
isCollapsed() {
582
750
return this.rootElement.classList.contains('collapsed');
583
751
}
584
752
585
753
toggleSectionFold() {
586
-
let plus = this.rootElement.querySelector(':scope > .margin .plus');
754
+
let plus = $(this.rootElement.querySelector(':scope > .margin .plus'), HTMLImageElement);
587
755
588
756
if (this.isCollapsed()) {
589
757
this.rootElement.classList.remove('collapsed');
···
594
762
}
595
763
}
596
764
597
-
/** @param {AnyElement} heart */
765
+
/** @param {HTMLElement} heart, @returns {Promise<void>} */
598
766
599
-
onHeartClick(heart) {
600
-
if (!this.post.hasViewerInfo) {
601
-
if (accountAPI.isLoggedIn) {
602
-
accountAPI.loadPost(this.post.uri).then(data => {
603
-
this.post = new Post(data);
767
+
async onHeartClick(heart) {
768
+
try {
769
+
if (!this.post.hasViewerInfo) {
770
+
if (accountAPI.isLoggedIn) {
771
+
let data = await this.loadViewerInfo();
604
772
605
-
if (this.post.liked) {
606
-
heart.classList.add('liked');
773
+
if (data) {
774
+
if (this.post.liked) {
775
+
heart.classList.add('liked');
776
+
return;
777
+
} else {
778
+
// continue down
779
+
}
607
780
} else {
608
-
this.onHeartClick(heart);
781
+
this.showPostAsBlocked();
782
+
return;
609
783
}
610
-
}).catch(error => {
611
-
console.log(error);
612
-
alert("Sorry, this post is blocked.");
613
-
});
614
-
} else {
615
-
showDialog(loginDialog);
784
+
} else {
785
+
// not logged in
786
+
showDialog(loginDialog);
787
+
return;
788
+
}
616
789
}
617
-
return;
618
-
}
619
790
620
-
let count = heart.nextElementSibling;
791
+
let countField = $(heart.nextElementSibling);
792
+
let likeCount = parseInt(countField.innerText, 10);
621
793
622
-
if (!heart.classList.contains('liked')) {
623
-
accountAPI.likePost(this.post).then((like) => {
794
+
if (!heart.classList.contains('liked')) {
795
+
let like = await accountAPI.likePost(this.post);
624
796
this.post.viewerLike = like.uri;
625
797
heart.classList.add('liked');
626
-
count.innerText = String(parseInt(count.innerText, 10) + 1);
627
-
}).catch(showError);
628
-
} else {
629
-
accountAPI.removeLike(this.post.viewerLike).then(() => {
798
+
countField.innerText = String(likeCount + 1);
799
+
} else {
800
+
await accountAPI.removeLike(this.post.viewerLike);
630
801
this.post.viewerLike = undefined;
631
802
heart.classList.remove('liked');
632
-
count.innerText = String(parseInt(count.innerText, 10) - 1);
633
-
}).catch(showError);
803
+
countField.innerText = String(likeCount - 1);
804
+
}
805
+
} catch (error) {
806
+
showError(error);
634
807
}
808
+
}
809
+
810
+
showPostAsBlocked() {
811
+
let stats = $(this.rootElement.querySelector(':scope > .content > p.stats'));
812
+
813
+
if (!stats.querySelector('.blocked-info')) {
814
+
let span = $tag('span.blocked-info', { text: '๐ซ Post unavailable' });
815
+
stats.append(span);
816
+
}
817
+
}
818
+
819
+
/** @returns {Promise<json | undefined>} */
820
+
821
+
async loadViewerInfo() {
822
+
let data = await accountAPI.loadPostIfExists(this.post.uri);
823
+
824
+
if (data) {
825
+
this.post.author = data.author;
826
+
this.post.viewerData = data.viewer;
827
+
this.post.viewerLike = data.viewer?.like;
828
+
}
829
+
830
+
return data;
635
831
}
636
832
}
+617
posting_stats_page.js
+617
posting_stats_page.js
···
1
+
/**
2
+
* Manages the Posting Stats page.
3
+
*/
4
+
5
+
class PostingStatsPage {
6
+
7
+
/** @type {number | undefined} */
8
+
scanStartTime;
9
+
10
+
/** @type {Record<string, { pages: number, progress: number }>} */
11
+
userProgress;
12
+
13
+
/** @type {number | undefined} */
14
+
autocompleteTimer;
15
+
16
+
/** @type {number} */
17
+
autocompleteIndex = -1;
18
+
19
+
/** @type {json[]} */
20
+
autocompleteResults = [];
21
+
22
+
/** @type {Record<string, json>} */
23
+
selectedUsers = {};
24
+
25
+
constructor() {
26
+
this.pageElement = $id('posting_stats_page');
27
+
this.form = $(this.pageElement.querySelector('form'), HTMLFormElement);
28
+
29
+
this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
30
+
this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
31
+
this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement);
32
+
this.table = $(this.pageElement.querySelector('table.scan-result'));
33
+
this.tableHead = $(this.table.querySelector('thead'));
34
+
this.tableBody = $(this.table.querySelector('tbody'));
35
+
this.listSelect = $(this.pageElement.querySelector('.list-choice select'), HTMLSelectElement);
36
+
this.scanInfo = $(this.pageElement.querySelector('.scan-info'));
37
+
this.scanType = this.form.elements['scan_type'];
38
+
39
+
this.userField = $(this.pageElement.querySelector('.user-choice input'), HTMLInputElement);
40
+
this.userList = $(this.pageElement.querySelector('.selected-users'));
41
+
this.autocomplete = $(this.pageElement.querySelector('.autocomplete'));
42
+
43
+
this.userProgress = {};
44
+
this.appView = new BlueskyAPI('public.api.bsky.app', false);
45
+
46
+
this.setupEvents();
47
+
}
48
+
49
+
setupEvents() {
50
+
let html = $(document.body.parentNode);
51
+
52
+
html.addEventListener('click', (e) => {
53
+
this.hideAutocomplete();
54
+
});
55
+
56
+
this.form.addEventListener('submit', (e) => {
57
+
e.preventDefault();
58
+
59
+
if (!this.scanStartTime) {
60
+
this.scanPostingStats();
61
+
} else {
62
+
this.stopScan();
63
+
}
64
+
});
65
+
66
+
this.rangeInput.addEventListener('input', (e) => {
67
+
let days = parseInt(this.rangeInput.value, 10);
68
+
let label = $(this.pageElement.querySelector('input[type=range] + label'));
69
+
label.innerText = (days == 1) ? '1 day' : `${days} days`;
70
+
});
71
+
72
+
this.scanType.forEach(r => {
73
+
r.addEventListener('click', (e) => {
74
+
let value = $(r, HTMLInputElement).value;
75
+
76
+
$(this.pageElement.querySelector('.list-choice')).style.display = (value == 'list') ? 'block' : 'none';
77
+
$(this.pageElement.querySelector('.user-choice')).style.display = (value == 'users') ? 'block' : 'none';
78
+
79
+
if (value == 'users') {
80
+
this.userField.focus();
81
+
}
82
+
83
+
this.table.style.display = 'none';
84
+
});
85
+
});
86
+
87
+
this.userField.addEventListener('input', () => {
88
+
this.onUserInput();
89
+
});
90
+
91
+
this.userField.addEventListener('keydown', (e) => {
92
+
this.onUserKeyDown(e);
93
+
});
94
+
}
95
+
96
+
show() {
97
+
this.pageElement.style.display = 'block';
98
+
this.fetchLists();
99
+
}
100
+
101
+
/** @returns {number} */
102
+
103
+
selectedDaysRange() {
104
+
return parseInt(this.rangeInput.value, 10);
105
+
}
106
+
107
+
/** @returns {Promise<void>} */
108
+
109
+
async fetchLists() {
110
+
let lists = await accountAPI.loadUserLists();
111
+
112
+
let sorted = lists.sort((a, b) => {
113
+
let aName = a.name.toLocaleLowerCase();
114
+
let bName = b.name.toLocaleLowerCase();
115
+
116
+
return aName.localeCompare(bName);
117
+
});
118
+
119
+
for (let list of lists) {
120
+
this.listSelect.append(
121
+
$tag('option', { value: list.uri, text: list.name + 'ย ' })
122
+
);
123
+
}
124
+
}
125
+
126
+
onUserInput() {
127
+
if (this.autocompleteTimer) {
128
+
clearTimeout(this.autocompleteTimer);
129
+
}
130
+
131
+
let query = this.userField.value.trim();
132
+
133
+
if (query.length == 0) {
134
+
this.hideAutocomplete();
135
+
this.autocompleteTimer = undefined;
136
+
return;
137
+
}
138
+
139
+
this.autocompleteTimer = setTimeout(() => this.fetchAutocomplete(query), 100);
140
+
}
141
+
142
+
/** @param {KeyboardEvent} e */
143
+
144
+
onUserKeyDown(e) {
145
+
if (e.key == 'Enter') {
146
+
e.preventDefault();
147
+
148
+
if (this.autocompleteIndex >= 0) {
149
+
this.selectUser(this.autocompleteIndex);
150
+
}
151
+
} else if (e.key == 'Escape') {
152
+
this.hideAutocomplete();
153
+
} else if (e.key == 'ArrowDown' && this.autocompleteResults.length > 0) {
154
+
e.preventDefault();
155
+
this.moveAutocomplete(1);
156
+
} else if (e.key == 'ArrowUp' && this.autocompleteResults.length > 0) {
157
+
e.preventDefault();
158
+
this.moveAutocomplete(-1);
159
+
}
160
+
}
161
+
162
+
/** @param {string} query, @returns {Promise<void>} */
163
+
164
+
async fetchAutocomplete(query) {
165
+
let users = await accountAPI.autocompleteUsers(query);
166
+
167
+
let selectedDIDs = new Set(Object.keys(this.selectedUsers));
168
+
users = users.filter(u => !selectedDIDs.has(u.did));
169
+
170
+
this.autocompleteResults = users;
171
+
this.autocompleteIndex = -1;
172
+
this.showAutocomplete();
173
+
}
174
+
175
+
showAutocomplete() {
176
+
this.autocomplete.innerHTML = '';
177
+
this.autocomplete.scrollTop = 0;
178
+
179
+
if (this.autocompleteResults.length == 0) {
180
+
this.hideAutocomplete();
181
+
return;
182
+
}
183
+
184
+
for (let [i, user] of this.autocompleteResults.entries()) {
185
+
let row = this.makeUserRow(user);
186
+
187
+
row.addEventListener('mouseenter', () => {
188
+
this.highlightAutocomplete(i);
189
+
});
190
+
191
+
row.addEventListener('mousedown', (e) => {
192
+
e.preventDefault();
193
+
this.selectUser(i);
194
+
});
195
+
196
+
this.autocomplete.append(row);
197
+
};
198
+
199
+
this.autocomplete.style.top = this.userField.offsetHeight + 'px';
200
+
this.autocomplete.style.display = 'block';
201
+
this.highlightAutocomplete(0);
202
+
}
203
+
204
+
hideAutocomplete() {
205
+
this.autocomplete.style.display = 'none';
206
+
this.autocompleteResults = [];
207
+
this.autocompleteIndex = -1;
208
+
}
209
+
210
+
/** @param {number} change */
211
+
212
+
moveAutocomplete(change) {
213
+
if (this.autocompleteResults.length == 0) {
214
+
return;
215
+
}
216
+
217
+
let newIndex = this.autocompleteIndex + change;
218
+
219
+
if (newIndex < 0) {
220
+
newIndex = this.autocompleteResults.length - 1;
221
+
} else if (newIndex >= this.autocompleteResults.length) {
222
+
newIndex = 0;
223
+
}
224
+
225
+
this.highlightAutocomplete(newIndex);
226
+
}
227
+
228
+
/** @param {number} index */
229
+
230
+
highlightAutocomplete(index) {
231
+
this.autocompleteIndex = index;
232
+
233
+
let rows = this.autocomplete.querySelectorAll('.user-row');
234
+
235
+
rows.forEach((row, i) => {
236
+
row.classList.toggle('hover', i == index);
237
+
});
238
+
}
239
+
240
+
/** @param {number} index */
241
+
242
+
selectUser(index) {
243
+
let user = this.autocompleteResults[index];
244
+
245
+
if (!user) {
246
+
return;
247
+
}
248
+
249
+
this.selectedUsers[user.did] = user;
250
+
251
+
let row = this.makeUserRow(user, true);
252
+
this.userList.append(row);
253
+
254
+
this.userField.value = '';
255
+
this.hideAutocomplete();
256
+
}
257
+
258
+
/** @param {json} user, @param {boolean} [withRemove], @returns HTMLElement */
259
+
260
+
makeUserRow(user, withRemove = false) {
261
+
let row = $tag('div.user-row');
262
+
row.dataset.did = user.did;
263
+
row.append(
264
+
$tag('img.avatar', { src: user.avatar }),
265
+
$tag('span.name', { text: user.displayName || 'โ' }),
266
+
$tag('span.handle', { text: user.handle })
267
+
);
268
+
269
+
if (withRemove) {
270
+
let remove = $tag('a.remove', { href: '#', text: 'โ' });
271
+
272
+
remove.addEventListener('click', (e) => {
273
+
e.preventDefault();
274
+
row.remove();
275
+
delete this.selectedUsers[user.did];
276
+
});
277
+
278
+
row.append(remove);
279
+
}
280
+
281
+
return row;
282
+
}
283
+
284
+
/** @returns {Promise<void>} */
285
+
286
+
async scanPostingStats() {
287
+
let startTime = new Date().getTime();
288
+
let requestedDays = this.selectedDaysRange();
289
+
let scanType = this.scanType.value;
290
+
291
+
/** @type {FetchAllOnPageLoad} */
292
+
let onPageLoad = (data) => {
293
+
if (this.scanStartTime != startTime) {
294
+
return { cancel: true };
295
+
}
296
+
297
+
this.updateProgress(data, startTime);
298
+
};
299
+
300
+
if (scanType == 'home') {
301
+
this.startScan(startTime, requestedDays);
302
+
303
+
let posts = await accountAPI.loadHomeTimeline(requestedDays, {
304
+
onPageLoad: onPageLoad,
305
+
keepLastPage: true
306
+
});
307
+
308
+
this.updateResultsTable(posts, startTime, requestedDays);
309
+
} else if (scanType == 'list') {
310
+
let list = this.listSelect.value;
311
+
312
+
if (!list) {
313
+
return;
314
+
}
315
+
316
+
this.startScan(startTime, requestedDays);
317
+
318
+
let posts = await accountAPI.loadListTimeline(list, requestedDays, {
319
+
onPageLoad: onPageLoad,
320
+
keepLastPage: true
321
+
});
322
+
323
+
this.updateResultsTable(posts, startTime, requestedDays, { showReposts: false });
324
+
} else if (scanType == 'users') {
325
+
let dids = Object.keys(this.selectedUsers);
326
+
327
+
if (dids.length == 0) {
328
+
return;
329
+
}
330
+
331
+
this.startScan(startTime, requestedDays);
332
+
this.resetUserProgress(dids);
333
+
334
+
let requests = dids.map(did => this.appView.loadUserTimeline(did, requestedDays, {
335
+
filter: 'posts_and_author_threads',
336
+
onPageLoad: (data) => {
337
+
if (this.scanStartTime != startTime) {
338
+
return { cancel: true };
339
+
}
340
+
341
+
this.updateUserProgress(did, data, startTime, requestedDays);
342
+
},
343
+
keepLastPage: true
344
+
}));
345
+
346
+
let datasets = await Promise.all(requests);
347
+
let posts = datasets.flat();
348
+
349
+
this.updateResultsTable(posts, startTime, requestedDays, {
350
+
showTotal: false,
351
+
showPercentages: false,
352
+
countFetchedDays: false,
353
+
users: Object.values(this.selectedUsers)
354
+
});
355
+
} else {
356
+
this.startScan(startTime, requestedDays);
357
+
358
+
let posts = await accountAPI.loadUserTimeline(accountAPI.user.did, requestedDays, {
359
+
filter: 'posts_no_replies',
360
+
onPageLoad: onPageLoad,
361
+
keepLastPage: true
362
+
});
363
+
364
+
this.updateResultsTable(posts, startTime, requestedDays, { showTotal: false, showPercentages: false });
365
+
}
366
+
}
367
+
368
+
/** @param {json[]} dataPage, @param {number} startTime */
369
+
370
+
updateProgress(dataPage, startTime) {
371
+
let last = dataPage.at(-1);
372
+
373
+
if (!last) { return }
374
+
375
+
let lastDate = feedPostTime(last);
376
+
let daysBack = (startTime - lastDate) / 86400 / 1000;
377
+
378
+
this.progressBar.value = daysBack;
379
+
}
380
+
381
+
/** @param {string[]} dids */
382
+
383
+
resetUserProgress(dids) {
384
+
this.userProgress = {};
385
+
386
+
for (let did of dids) {
387
+
this.userProgress[did] = { pages: 0, progress: 0 };
388
+
}
389
+
}
390
+
391
+
/** @param {string} did, @param {json[]} dataPage, @param {number} startTime, @param {number} requestedDays */
392
+
393
+
updateUserProgress(did, dataPage, startTime, requestedDays) {
394
+
let last = dataPage.at(-1);
395
+
396
+
if (!last) { return }
397
+
398
+
let lastDate = feedPostTime(last);
399
+
let daysBack = (startTime - lastDate) / 86400 / 1000;
400
+
401
+
this.userProgress[did].pages += 1;
402
+
this.userProgress[did].progress = Math.min(daysBack / requestedDays, 1.0);
403
+
404
+
let expectedPages = Object.values(this.userProgress).map(x => x.pages / x.progress);
405
+
let known = expectedPages.filter(x => !isNaN(x));
406
+
let expectedTotalPages = known.reduce((a, b) => a + b) / known.length * expectedPages.length;
407
+
let fetchedPages = Object.values(this.userProgress).map(x => x.pages).reduce((a, b) => a + b);
408
+
409
+
this.progressBar.value = Math.max(this.progressBar.value, (fetchedPages / expectedTotalPages) * requestedDays);
410
+
}
411
+
412
+
/** @param {json} a, @param {json} b, @returns {number} */
413
+
414
+
sortUserRows(a, b) {
415
+
let asum = a.own + a.reposts;
416
+
let bsum = b.own + b.reposts;
417
+
418
+
if (asum < bsum) {
419
+
return 1;
420
+
} else if (asum > bsum) {
421
+
return -1;
422
+
} else {
423
+
return 0;
424
+
}
425
+
}
426
+
427
+
/**
428
+
* @param {json[]} posts
429
+
* @param {number} startTime
430
+
* @param {number} requestedDays
431
+
* @param {{
432
+
* showTotal?: boolean,
433
+
* showPercentages?: boolean,
434
+
* showReposts?: boolean,
435
+
* countFetchedDays?: boolean,
436
+
* users?: json[]
437
+
* }} [options]
438
+
* @returns {Promise<void>}
439
+
*/
440
+
441
+
async updateResultsTable(posts, startTime, requestedDays, options = {}) {
442
+
if (this.scanStartTime != startTime) {
443
+
return;
444
+
}
445
+
446
+
let now = new Date().getTime();
447
+
448
+
if (now - startTime < 100) {
449
+
// artificial UI delay in case scan finishes immediately
450
+
await new Promise(resolve => setTimeout(resolve, 100));
451
+
}
452
+
453
+
let users = {};
454
+
let total = 0;
455
+
let allReposts = 0;
456
+
let allNormalPosts = 0;
457
+
458
+
let last = posts.at(-1);
459
+
460
+
if (!last) {
461
+
this.stopScan();
462
+
return;
463
+
}
464
+
465
+
let daysBack;
466
+
467
+
if (options.countFetchedDays !== false) {
468
+
let lastDate = feedPostTime(last);
469
+
let fetchedDays = (startTime - lastDate) / 86400 / 1000;
470
+
471
+
if (Math.ceil(fetchedDays) < requestedDays) {
472
+
this.scanInfo.innerText = `๐ Showing data from ${Math.round(fetchedDays)} days (the timeline only goes that far):`;
473
+
this.scanInfo.style.display = 'block';
474
+
}
475
+
476
+
daysBack = Math.min(requestedDays, fetchedDays);
477
+
} else {
478
+
daysBack = requestedDays;
479
+
}
480
+
481
+
let timeLimit = startTime - requestedDays * 86400 * 1000;
482
+
posts = posts.filter(x => (feedPostTime(x) > timeLimit));
483
+
posts.reverse();
484
+
485
+
if (options.users) {
486
+
for (let user of options.users) {
487
+
users[user.handle] = { handle: user.handle, own: 0, reposts: 0, avatar: user.avatar };
488
+
}
489
+
}
490
+
491
+
let ownThreads = new Set();
492
+
493
+
for (let item of posts) {
494
+
if (item.reply) {
495
+
if (!ownThreads.has(item.reply.parent.uri)) {
496
+
continue;
497
+
}
498
+
}
499
+
500
+
let user = item.reason ? item.reason.by : item.post.author;
501
+
let handle = user.handle;
502
+
users[handle] = users[handle] ?? { handle: handle, own: 0, reposts: 0, avatar: user.avatar };
503
+
total += 1;
504
+
505
+
if (item.reason) {
506
+
users[handle].reposts += 1;
507
+
allReposts += 1;
508
+
} else {
509
+
users[handle].own += 1;
510
+
allNormalPosts += 1;
511
+
ownThreads.add(item.post.uri);
512
+
}
513
+
}
514
+
515
+
let headRow = $tag('tr');
516
+
517
+
if (options.showReposts !== false) {
518
+
headRow.append(
519
+
$tag('th', { text: '#' }),
520
+
$tag('th', { text: 'Handle' }),
521
+
$tag('th', { text: 'All posts /d' }),
522
+
$tag('th', { text: 'Own posts /d' }),
523
+
$tag('th', { text: 'Reposts /d' })
524
+
);
525
+
} else {
526
+
headRow.append(
527
+
$tag('th', { text: '#' }),
528
+
$tag('th', { text: 'Handle' }),
529
+
$tag('th', { text: 'Posts /d' }),
530
+
);
531
+
}
532
+
533
+
if (options.showPercentages !== false) {
534
+
headRow.append($tag('th', { text: '% of timeline' }));
535
+
}
536
+
537
+
this.tableHead.append(headRow);
538
+
539
+
if (options.showTotal !== false) {
540
+
let tr = $tag('tr.total');
541
+
542
+
tr.append(
543
+
$tag('td.no', { text: '' }),
544
+
$tag('td.handle', { text: 'Total:' }),
545
+
546
+
(options.showReposts !== false) ?
547
+
$tag('td', { text: (total / daysBack).toFixed(1) }) : '',
548
+
549
+
$tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
550
+
551
+
(options.showReposts !== false) ?
552
+
$tag('td', { text: (allReposts / daysBack).toFixed(1) }) : ''
553
+
);
554
+
555
+
if (options.showPercentages !== false) {
556
+
tr.append($tag('td.percent', { text: '' }));
557
+
}
558
+
559
+
this.tableBody.append(tr);
560
+
}
561
+
562
+
let sorted = Object.values(users).sort(this.sortUserRows);
563
+
564
+
for (let i = 0; i < sorted.length; i++) {
565
+
let user = sorted[i];
566
+
let tr = $tag('tr');
567
+
568
+
tr.append(
569
+
$tag('td.no', { text: i + 1 }),
570
+
$tag('td.handle', {
571
+
html: `<img class="avatar" src="${user.avatar}"> ` +
572
+
`<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
573
+
}),
574
+
575
+
(options.showReposts !== false) ?
576
+
$tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
577
+
578
+
$tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : 'โ' }),
579
+
580
+
(options.showReposts !== false) ?
581
+
$tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : 'โ' }) : ''
582
+
);
583
+
584
+
if (options.showPercentages !== false) {
585
+
tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
586
+
}
587
+
588
+
this.tableBody.append(tr);
589
+
}
590
+
591
+
this.table.style.display = 'table';
592
+
this.stopScan();
593
+
}
594
+
595
+
/** @param {number} startTime, @param {number} requestedDays */
596
+
597
+
startScan(startTime, requestedDays) {
598
+
this.submitButton.value = 'Cancel';
599
+
600
+
this.progressBar.max = requestedDays;
601
+
this.progressBar.value = 0;
602
+
this.progressBar.style.display = 'inline';
603
+
604
+
this.table.style.display = 'none';
605
+
this.tableHead.innerHTML = '';
606
+
this.tableBody.innerHTML = '';
607
+
608
+
this.scanStartTime = startTime;
609
+
this.scanInfo.style.display = 'none';
610
+
}
611
+
612
+
stopScan() {
613
+
this.submitButton.value = 'Start scan';
614
+
this.scanStartTime = undefined;
615
+
this.progressBar.style.display = 'none';
616
+
}
617
+
}
+423
private_search_page.js
+423
private_search_page.js
···
1
+
class PrivateSearchPage {
2
+
3
+
/** @type {number | undefined} */
4
+
fetchStartTime;
5
+
6
+
/** @type {number | undefined} */
7
+
importTimer;
8
+
9
+
/** @type {string | undefined} */
10
+
lycanImportStatus;
11
+
12
+
constructor() {
13
+
this.pageElement = $id('private_search_page');
14
+
15
+
this.header = $(this.pageElement.querySelector('h2'));
16
+
17
+
this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
18
+
this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
19
+
this.progressBar = $(this.pageElement.querySelector('input[type="submit"] + progress'), HTMLProgressElement);
20
+
this.archiveStatus = $(this.pageElement.querySelector('.archive-status'));
21
+
22
+
this.searchLine = $(this.pageElement.querySelector('.search'));
23
+
this.searchField = $(this.pageElement.querySelector('.search-query'), HTMLInputElement);
24
+
this.searchForm = $(this.pageElement.querySelector('.search-form'), HTMLFormElement);
25
+
this.results = $(this.pageElement.querySelector('.results'));
26
+
27
+
this.timelineSearch = $(this.pageElement.querySelector('.timeline-search'));
28
+
this.timelineSearchForm = $(this.pageElement.querySelector('.timeline-search form'), HTMLFormElement);
29
+
this.searchCollections = $(this.pageElement.querySelector('.search-collections'));
30
+
31
+
this.lycanImportSection = $(this.pageElement.querySelector('.lycan-import'));
32
+
this.lycanImportForm = $(this.pageElement.querySelector('.lycan-import form'), HTMLFormElement);
33
+
this.importProgress = $(this.pageElement.querySelector('.import-progress'));
34
+
this.importProgressBar = $(this.pageElement.querySelector('.import-progress progress'), HTMLProgressElement);
35
+
this.importStatusLabel = $(this.pageElement.querySelector('.import-status'));
36
+
this.importStatusPosition = $(this.pageElement.querySelector('.import-progress output'));
37
+
38
+
this.isCheckingStatus = false;
39
+
this.timelinePosts = [];
40
+
41
+
this.setupEvents();
42
+
43
+
let params = new URLSearchParams(location.search);
44
+
this.mode = params.get('mode');
45
+
let lycan = params.get('lycan');
46
+
47
+
if (lycan == 'local') {
48
+
this.localLycan = new BlueskyAPI('http://localhost:3000', false);
49
+
} else if (lycan) {
50
+
this.lycanAddress = `did:web:${lycan}#lycan`;
51
+
} else {
52
+
this.lycanAddress = 'did:web:lycan.feeds.blue#lycan';
53
+
}
54
+
}
55
+
56
+
setupEvents() {
57
+
this.timelineSearchForm.addEventListener('submit', (e) => {
58
+
e.preventDefault();
59
+
60
+
if (!this.fetchStartTime) {
61
+
this.fetchTimeline();
62
+
} else {
63
+
this.stopFetch();
64
+
}
65
+
});
66
+
67
+
this.rangeInput.addEventListener('input', (e) => {
68
+
let days = parseInt(this.rangeInput.value, 10);
69
+
let label = $(this.pageElement.querySelector('input[type=range] + label'));
70
+
label.innerText = (days == 1) ? '1 day' : `${days} days`;
71
+
});
72
+
73
+
this.searchField.addEventListener('keydown', (e) => {
74
+
if (e.key == 'Enter') {
75
+
e.preventDefault();
76
+
77
+
let query = this.searchField.value.trim().toLowerCase();
78
+
79
+
if (this.mode == 'likes') {
80
+
this.searchInLycan(query);
81
+
} else {
82
+
this.searchInTimeline(query);
83
+
}
84
+
}
85
+
});
86
+
87
+
this.lycanImportForm.addEventListener('submit', (e) => {
88
+
e.preventDefault();
89
+
this.startLycanImport();
90
+
});
91
+
}
92
+
93
+
/** @returns {number} */
94
+
95
+
selectedDaysRange() {
96
+
return parseInt(this.rangeInput.value, 10);
97
+
}
98
+
99
+
show() {
100
+
this.pageElement.style.display = 'block';
101
+
102
+
if (this.mode == 'likes') {
103
+
this.header.innerText = 'Archive search';
104
+
this.timelineSearch.style.display = 'none';
105
+
this.searchCollections.style.display = 'block';
106
+
this.searchLine.style.display = 'block';
107
+
this.lycanImportSection.style.display = 'none';
108
+
this.checkLycanImportStatus();
109
+
} else {
110
+
this.header.innerText = 'Timeline search';
111
+
this.timelineSearch.style.display = 'block';
112
+
this.searchCollections.style.display = 'none';
113
+
this.lycanImportSection.style.display = 'none';
114
+
}
115
+
}
116
+
117
+
/** @returns {Promise<void>} */
118
+
119
+
async checkLycanImportStatus() {
120
+
if (this.isCheckingStatus) {
121
+
return;
122
+
}
123
+
124
+
this.isCheckingStatus = true;
125
+
126
+
try {
127
+
let response = await this.getImportStatus();
128
+
this.showImportStatus(response);
129
+
} catch (error) {
130
+
this.showImportError(`Couldn't check import status: ${error}`);
131
+
} finally {
132
+
this.isCheckingStatus = false;
133
+
}
134
+
}
135
+
136
+
/** @returns {Promise<json>} */
137
+
138
+
async getImportStatus() {
139
+
if (this.localLycan) {
140
+
return await this.localLycan.getRequest('blue.feeds.lycan.getImportStatus', { user: accountAPI.user.did });
141
+
} else {
142
+
return await accountAPI.getRequest('blue.feeds.lycan.getImportStatus', null, {
143
+
headers: { 'atproto-proxy': this.lycanAddress }
144
+
});
145
+
}
146
+
}
147
+
148
+
/** @param {json} info */
149
+
150
+
showImportStatus(info) {
151
+
console.log(info);
152
+
153
+
if (!info.status) {
154
+
this.showImportError("Error checking import status");
155
+
return;
156
+
}
157
+
158
+
this.lycanImportStatus = info.status;
159
+
160
+
if (info.status == 'not_started') {
161
+
this.lycanImportSection.style.display = 'block';
162
+
this.lycanImportForm.style.display = 'block';
163
+
this.importProgress.style.display = 'none';
164
+
this.searchField.disabled = true;
165
+
166
+
this.stopImportTimer();
167
+
} else if (info.status == 'in_progress' || info.status == 'scheduled' || info.status == 'requested') {
168
+
this.lycanImportSection.style.display = 'block';
169
+
this.lycanImportForm.style.display = 'none';
170
+
this.importProgress.style.display = 'block';
171
+
this.searchField.disabled = true;
172
+
173
+
this.showImportProgress(info);
174
+
this.startImportTimer();
175
+
} else if (info.status == 'finished') {
176
+
this.lycanImportForm.style.display = 'none';
177
+
this.importProgress.style.display = 'block';
178
+
this.searchField.disabled = false;
179
+
180
+
this.showImportProgress({ status: 'finished', progress: 1.0 });
181
+
this.stopImportTimer();
182
+
} else {
183
+
this.showImportError("Error checking import status");
184
+
this.stopImportTimer();
185
+
}
186
+
}
187
+
188
+
/** @param {json} info */
189
+
190
+
showImportProgress(info) {
191
+
let progress = Math.max(0, Math.min(info.progress || 0));
192
+
this.importProgressBar.value = progress;
193
+
this.importProgressBar.style.display = 'inline';
194
+
195
+
let percent = Math.round(progress * 100);
196
+
this.importStatusPosition.innerText = `${percent}%`;
197
+
198
+
if (info.progress == 1.0) {
199
+
this.importStatusLabel.innerText = `Import complete โ`;
200
+
} else if (info.position) {
201
+
let date = new Date(info.position).toLocaleString(window.dateLocale, { day: 'numeric', month: 'short', year: 'numeric' });
202
+
this.importStatusLabel.innerText = `Downloaded data until: ${date}`;
203
+
} else if (info.status == 'requested') {
204
+
this.importStatusLabel.innerText = 'Requesting importโฆ';
205
+
} else {
206
+
this.importStatusLabel.innerText = 'Import startedโฆ';
207
+
}
208
+
}
209
+
210
+
/** @param {string} message */
211
+
212
+
showImportError(message) {
213
+
this.lycanImportSection.style.display = 'block';
214
+
this.lycanImportForm.style.display = 'none';
215
+
this.importProgress.style.display = 'block';
216
+
this.searchField.disabled = true;
217
+
218
+
this.importStatusLabel.innerText = message;
219
+
this.stopImportTimer();
220
+
}
221
+
222
+
startImportTimer() {
223
+
if (this.importTimer) {
224
+
return;
225
+
}
226
+
227
+
this.importTimer = setInterval(() => {
228
+
this.checkLycanImportStatus();
229
+
}, 3000);
230
+
}
231
+
232
+
stopImportTimer() {
233
+
if (this.importTimer) {
234
+
clearInterval(this.importTimer);
235
+
this.importTimer = undefined;
236
+
}
237
+
}
238
+
239
+
/** @returns {Promise<void>} */
240
+
241
+
async startLycanImport() {
242
+
this.showImportStatus({ status: 'requested' });
243
+
244
+
try {
245
+
if (this.localLycan) {
246
+
await this.localLycan.postRequest('blue.feeds.lycan.startImport', {
247
+
user: accountAPI.user.did
248
+
});
249
+
} else {
250
+
await accountAPI.postRequest('blue.feeds.lycan.startImport', null, {
251
+
headers: { 'atproto-proxy': this.lycanAddress }
252
+
});
253
+
}
254
+
255
+
this.startImportTimer();
256
+
} catch (err) {
257
+
console.error('Failed to start Lycan import', err);
258
+
this.showImportError(`Import failed: ${err}`);
259
+
}
260
+
}
261
+
262
+
/** @returns {Promise<void>} */
263
+
264
+
async fetchTimeline() {
265
+
this.submitButton.value = 'Cancel';
266
+
267
+
let requestedDays = this.selectedDaysRange();
268
+
269
+
this.progressBar.max = requestedDays;
270
+
this.progressBar.value = 0;
271
+
this.progressBar.style.display = 'inline';
272
+
273
+
let startTime = new Date().getTime();
274
+
this.fetchStartTime = startTime;
275
+
276
+
let timeline = await accountAPI.loadHomeTimeline(requestedDays, {
277
+
onPageLoad: (data) => {
278
+
if (this.fetchStartTime != startTime) {
279
+
return { cancel: true };
280
+
}
281
+
282
+
this.updateProgress(data, startTime);
283
+
}
284
+
});
285
+
286
+
if (this.fetchStartTime != startTime) {
287
+
return;
288
+
}
289
+
290
+
let last = timeline.at(-1);
291
+
let daysBack;
292
+
293
+
if (last) {
294
+
let lastDate = feedPostTime(last);
295
+
daysBack = Math.round((startTime - lastDate) / 86400 / 1000);
296
+
} else {
297
+
daysBack = 0;
298
+
}
299
+
300
+
this.timelinePosts = timeline;
301
+
302
+
this.archiveStatus.innerText = "Timeline archive fetched: " + ((daysBack == 1) ? '1 day' : `${daysBack} days`);
303
+
this.searchLine.style.display = 'block';
304
+
305
+
this.submitButton.value = 'Fetch timeline';
306
+
this.progressBar.style.display = 'none';
307
+
this.fetchStartTime = undefined;
308
+
}
309
+
310
+
/** @param {string} query */
311
+
312
+
searchInTimeline(query) {
313
+
this.results.innerHTML = '';
314
+
315
+
if (query.length == 0) {
316
+
return;
317
+
}
318
+
319
+
let matching = this.timelinePosts
320
+
.filter(x => x.post.record.text.toLowerCase().includes(query))
321
+
.map(x => Post.parseFeedPost(x));
322
+
323
+
for (let post of matching) {
324
+
let postView = new PostComponent(post, 'feed').buildElement();
325
+
this.results.appendChild(postView);
326
+
}
327
+
}
328
+
329
+
/** @param {string} query */
330
+
331
+
searchInLycan(query) {
332
+
if (query.length == 0 || this.lycanImportStatus != 'finished') {
333
+
return;
334
+
}
335
+
336
+
this.results.innerHTML = '';
337
+
this.lycanImportSection.style.display = 'none';
338
+
339
+
let collection = this.searchForm.elements['collection'].value;
340
+
341
+
let loading = $tag('p', { text: "..." });
342
+
this.results.append(loading);
343
+
344
+
let isLoading = false;
345
+
let firstPageLoaded = false;
346
+
let cursor;
347
+
let finished = false;
348
+
349
+
Paginator.loadInPages(async () => {
350
+
if (isLoading || finished) { return; }
351
+
isLoading = true;
352
+
353
+
let response;
354
+
355
+
if (this.localLycan) {
356
+
let params = { collection, query, user: accountAPI.user.did };
357
+
if (cursor) params.cursor = cursor;
358
+
359
+
response = await this.localLycan.getRequest('blue.feeds.lycan.searchPosts', params);
360
+
} else {
361
+
let params = { collection, query };
362
+
if (cursor) params.cursor = cursor;
363
+
364
+
response = await accountAPI.getRequest('blue.feeds.lycan.searchPosts', params, {
365
+
headers: { 'atproto-proxy': this.lycanAddress }
366
+
});
367
+
}
368
+
369
+
if (response.posts.length == 0) {
370
+
let p = $tag('p.results-end', { text: firstPageLoaded ? "No more results." : "No results." });
371
+
loading.remove();
372
+
this.results.append(p);
373
+
374
+
isLoading = false;
375
+
finished = true;
376
+
return;
377
+
}
378
+
379
+
let records = await accountAPI.loadPosts(response.posts);
380
+
let posts = records.map(x => new Post(x));
381
+
382
+
if (!firstPageLoaded) {
383
+
loading.remove();
384
+
firstPageLoaded = true;
385
+
}
386
+
387
+
for (let post of posts) {
388
+
let component = new PostComponent(post, 'feed');
389
+
let postView = component.buildElement();
390
+
this.results.appendChild(postView);
391
+
392
+
component.highlightSearchResults(response.terms);
393
+
}
394
+
395
+
isLoading = false;
396
+
cursor = response.cursor;
397
+
398
+
if (!cursor) {
399
+
finished = true;
400
+
this.results.append("No more results.");
401
+
}
402
+
});
403
+
}
404
+
405
+
/** @param {json[]} dataPage, @param {number} startTime */
406
+
407
+
updateProgress(dataPage, startTime) {
408
+
let last = dataPage.at(-1);
409
+
410
+
if (!last) { return }
411
+
412
+
let lastDate = feedPostTime(last);
413
+
let daysBack = (startTime - lastDate) / 86400 / 1000;
414
+
415
+
this.progressBar.value = daysBack;
416
+
}
417
+
418
+
stopFetch() {
419
+
this.submitButton.value = 'Fetch timeline';
420
+
this.progressBar.style.display = 'none';
421
+
this.fetchStartTime = undefined;
422
+
}
423
+
}
+83
-367
skythread.js
+83
-367
skythread.js
···
1
1
function init() {
2
-
let document = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document));
3
-
let html = /** @type {AnyElement} */ (/** @type {unknown} */ (window.document.body.parentNode));
4
-
5
2
window.dateLocale = localStorage.getItem('locale') || undefined;
6
3
window.isIncognito = !!localStorage.getItem('incognito');
7
4
window.biohazardEnabled = JSON.parse(localStorage.getItem('biohazard') ?? 'null');
8
5
9
-
window.loginDialog = document.querySelector('#login');
10
-
window.accountMenu = document.querySelector('#account_menu');
6
+
window.loginDialog = $(document.querySelector('#login'));
7
+
8
+
window.avatarPreloader = buildAvatarPreloader();
11
9
12
-
html.addEventListener('click', (e) => {
13
-
$id('account_menu').style.visibility = 'hidden';
14
-
});
10
+
window.accountMenu = new Menu();
11
+
window.threadPage = new ThreadPage();
12
+
window.postingStatsPage = new PostingStatsPage();
13
+
window.likeStatsPage = new LikeStatsPage();
14
+
window.notificationsPage = new NotificationsPage();
15
+
window.privateSearchPage = new PrivateSearchPage();
15
16
16
-
document.querySelector('#search form').addEventListener('submit', (e) => {
17
+
$(document.querySelector('#search form')).addEventListener('submit', (e) => {
17
18
e.preventDefault();
18
19
submitSearch();
19
20
});
20
21
21
22
for (let dialog of document.querySelectorAll('.dialog')) {
23
+
let close = $(dialog.querySelector('.close'));
24
+
22
25
dialog.addEventListener('click', (e) => {
23
-
if (e.target === e.currentTarget) {
26
+
if (e.target === e.currentTarget && close && close.offsetHeight > 0) {
24
27
hideDialog(dialog);
25
28
} else {
26
29
e.stopPropagation();
27
30
}
28
31
});
29
32
30
-
dialog.querySelector('.close')?.addEventListener('click', (e) => {
33
+
close?.addEventListener('click', (e) => {
31
34
hideDialog(dialog);
32
35
});
33
36
}
34
37
35
-
document.querySelector('#login .info a').addEventListener('click', (e) => {
38
+
$(document.querySelector('#login .info a')).addEventListener('click', (e) => {
36
39
e.preventDefault();
37
40
toggleLoginInfo();
38
41
});
39
42
40
-
document.querySelector('#login form').addEventListener('submit', (e) => {
43
+
$(document.querySelector('#login form')).addEventListener('submit', (e) => {
41
44
e.preventDefault();
42
45
submitLogin();
43
46
});
44
47
45
-
document.querySelector('#biohazard_show').addEventListener('click', (e) => {
48
+
$(document.querySelector('#biohazard_show')).addEventListener('click', (e) => {
46
49
e.preventDefault();
47
50
48
51
window.biohazardEnabled = true;
···
53
56
window.loadInfohazard = undefined;
54
57
}
55
58
56
-
let target = /** @type {AnyElement} */ (/** @type {unknown} */ (e.target));
59
+
let target = $(e.target);
57
60
58
61
hideDialog(target.closest('.dialog'));
59
62
});
60
63
61
-
document.querySelector('#biohazard_hide').addEventListener('click', (e) => {
64
+
$(document.querySelector('#biohazard_hide')).addEventListener('click', (e) => {
62
65
e.preventDefault();
63
66
64
67
window.biohazardEnabled = false;
65
68
localStorage.setItem('biohazard', 'false');
66
-
toggleMenuButton('biohazard', false);
69
+
accountMenu.toggleMenuButtonCheck('biohazard', false);
67
70
68
71
for (let p of document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post')) {
69
-
p.style.display = 'none';
72
+
$(p).style.display = 'none';
70
73
}
71
74
72
-
let target = /** @type {AnyElement} */ (/** @type {unknown} */ (e.target));
75
+
let target = $(e.target);
73
76
74
77
hideDialog(target.closest('.dialog'));
75
78
});
76
79
77
-
document.querySelector('#account').addEventListener('click', (e) => {
78
-
toggleAccountMenu();
79
-
e.stopPropagation();
80
-
});
81
-
82
-
accountMenu.addEventListener('click', (e) => {
83
-
e.stopPropagation();
84
-
});
85
-
86
-
accountMenu.querySelector('a[data-action=biohazard]').addEventListener('click', (e) => {
87
-
e.preventDefault();
88
-
89
-
let hazards = document.querySelectorAll('p.hidden-replies, .content > .post.blocked, .blocked > .load-post');
90
-
91
-
if (window.biohazardEnabled === false) {
92
-
window.biohazardEnabled = true;
93
-
localStorage.setItem('biohazard', 'true');
94
-
toggleMenuButton('biohazard', true);
95
-
Array.from(hazards).forEach(p => { p.style.display = 'block' });
96
-
} else {
97
-
window.biohazardEnabled = false;
98
-
localStorage.setItem('biohazard', 'false');
99
-
toggleMenuButton('biohazard', false);
100
-
Array.from(hazards).forEach(p => { p.style.display = 'none' });
101
-
}
102
-
});
103
-
104
-
accountMenu.querySelector('a[data-action=incognito]').addEventListener('click', (e) => {
105
-
e.preventDefault();
106
-
107
-
if (isIncognito) {
108
-
localStorage.removeItem('incognito');
109
-
} else {
110
-
localStorage.setItem('incognito', '1');
111
-
}
112
-
113
-
location.reload();
114
-
});
115
-
116
-
accountMenu.querySelector('a[data-action=login]').addEventListener('click', (e) => {
117
-
e.preventDefault();
118
-
toggleDialog(loginDialog);
119
-
$id('account_menu').style.visibility = 'hidden';
120
-
});
121
-
122
-
accountMenu.querySelector('a[data-action=logout]').addEventListener('click', (e) => {
123
-
e.preventDefault();
124
-
logOut();
125
-
});
126
-
127
80
window.appView = new BlueskyAPI('api.bsky.app', false);
128
81
window.blueAPI = new BlueskyAPI('blue.mackuba.eu', false);
129
82
window.accountAPI = new BlueskyAPI(undefined, true);
130
83
131
84
if (accountAPI.isLoggedIn) {
132
85
accountAPI.host = accountAPI.user.pdsEndpoint;
133
-
hideMenuButton('login');
86
+
accountMenu.hideMenuButton('login');
134
87
135
88
if (!isIncognito) {
136
89
window.api = accountAPI;
137
-
showLoggedInStatus(true, api.user.avatar);
90
+
accountMenu.showLoggedInStatus(true, api.user.avatar);
138
91
} else {
139
92
window.api = appView;
140
-
showLoggedInStatus('incognito');
141
-
toggleMenuButton('incognito', true);
93
+
accountMenu.showLoggedInStatus('incognito');
94
+
accountMenu.toggleMenuButtonCheck('incognito', true);
142
95
}
143
96
} else {
144
97
window.api = appView;
145
-
hideMenuButton('logout');
146
-
hideMenuButton('incognito');
98
+
accountMenu.hideMenuButton('logout');
99
+
accountMenu.hideMenuButton('incognito');
147
100
}
148
101
149
-
toggleMenuButton('biohazard', window.biohazardEnabled !== false);
102
+
accountMenu.toggleMenuButtonCheck('biohazard', window.biohazardEnabled !== false);
150
103
151
104
parseQueryParams();
152
105
}
153
106
154
107
function parseQueryParams() {
155
108
let params = new URLSearchParams(location.search);
156
-
let query = params.get('q');
157
-
let author = params.get('author');
158
-
let post = params.get('post');
159
-
let quotes = params.get('quotes');
160
-
let hash = params.get('hash');
161
-
let page = params.get('page');
109
+
let { q, author, post, quotes, hash, page } = Object.fromEntries(params);
162
110
163
111
if (quotes) {
164
112
showLoader();
···
166
114
} else if (hash) {
167
115
showLoader();
168
116
loadHashtagPage(decodeURIComponent(hash));
169
-
} else if (query) {
117
+
} else if (q) {
170
118
showLoader();
171
-
loadThreadByURL(decodeURIComponent(query));
119
+
threadPage.loadThreadByURL(decodeURIComponent(q));
172
120
} else if (author && post) {
173
121
showLoader();
174
-
loadThreadById(decodeURIComponent(author), decodeURIComponent(post));
122
+
threadPage.loadThreadById(decodeURIComponent(author), decodeURIComponent(post));
175
123
} else if (page) {
176
124
openPage(page);
177
125
} else {
···
179
127
}
180
128
}
181
129
182
-
/** @param {AnyPost} post, @returns {AnyElement} */
183
-
184
-
function buildParentLink(post) {
185
-
let p = $tag('p.back');
186
-
187
-
if (post instanceof BlockedPost) {
188
-
let element = new PostComponent(post, 'parent').buildElement();
189
-
element.className = 'back';
190
-
element.querySelector('p.blocked-header span').innerText = 'Parent post blocked';
191
-
return element;
192
-
} else if (post instanceof MissingPost) {
193
-
p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`;
194
-
} else {
195
-
let url = linkToPostThread(post);
196
-
p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`;
197
-
}
130
+
/** @returns {IntersectionObserver} */
198
131
199
-
return p;
132
+
function buildAvatarPreloader() {
133
+
return new IntersectionObserver((entries, observer) => {
134
+
for (const entry of entries) {
135
+
if (entry.isIntersecting) {
136
+
const img = entry.target;
137
+
img.removeAttribute('lazy');
138
+
observer.unobserve(img);
139
+
}
140
+
}
141
+
}, {
142
+
rootMargin: '1000px 0px'
143
+
});
200
144
}
201
145
202
146
function showLoader() {
···
208
152
}
209
153
210
154
function showSearch() {
211
-
$id('search').style.visibility = 'visible';
212
-
$id('search').querySelector('input[type=text]').focus();
155
+
let search = $id('search');
156
+
let searchField = $(search.querySelector('input[type=text]'));
157
+
158
+
search.style.visibility = 'visible';
159
+
searchField.focus();
213
160
}
214
161
215
162
function hideSearch() {
···
241
188
}
242
189
}
243
190
244
-
function toggleLoginInfo(event) {
191
+
function toggleLoginInfo() {
245
192
$id('login').classList.toggle('expanded');
246
193
}
247
194
248
-
function toggleAccountMenu() {
249
-
let menu = $id('account_menu');
250
-
menu.style.visibility = (menu.style.visibility == 'visible') ? 'hidden' : 'visible';
251
-
}
252
-
253
-
/** @param {string} buttonName */
254
-
255
-
function showMenuButton(buttonName) {
256
-
let button = accountMenu.querySelector(`a[data-action=${buttonName}]`);
257
-
button.parentNode.style.display = 'list-item';
258
-
}
259
-
260
-
/** @param {string} buttonName */
261
-
262
-
function hideMenuButton(buttonName) {
263
-
let button = accountMenu.querySelector(`a[data-action=${buttonName}]`);
264
-
button.parentNode.style.display = 'none';
265
-
}
266
-
267
-
/** @param {string} buttonName, @param {boolean} state */
268
-
269
-
function toggleMenuButton(buttonName, state) {
270
-
let button = accountMenu.querySelector(`a[data-action=${buttonName}]`);
271
-
button.querySelector('.check').style.display = (state) ? 'inline' : 'none';
272
-
}
273
-
274
-
/** @param {boolean | 'incognito'} loggedIn, @param {string | undefined | null} [avatar] */
275
-
276
-
function showLoggedInStatus(loggedIn, avatar) {
277
-
let account = $id('account');
278
-
279
-
if (loggedIn === true && avatar) {
280
-
let button = account.querySelector('i');
281
-
282
-
let img = $tag('img.avatar', { src: avatar });
283
-
img.style.display = 'none';
284
-
img.addEventListener('load', () => {
285
-
button.remove();
286
-
img.style.display = 'inline';
287
-
});
288
-
img.addEventListener('error', () => {
289
-
showLoggedInStatus(true, null);
290
-
})
291
-
292
-
account.append(img);
293
-
} else if (loggedIn === false) {
294
-
$id('account').innerHTML = `<i class="fa-regular fa-user-circle fa-xl"></i>`;
295
-
} else if (loggedIn === 'incognito') {
296
-
$id('account').innerHTML = `<i class="fa-solid fa-user-secret fa-lg"></i>`;
297
-
} else {
298
-
account.innerHTML = `<i class="fa-solid fa-user-circle fa-xl"></i>`;
299
-
}
300
-
}
301
-
302
195
function submitLogin() {
303
-
let handle = $id('login_handle');
304
-
let password = $id('login_password');
196
+
let handleField = $id('login_handle', HTMLInputElement);
197
+
let passwordField = $id('login_password', HTMLInputElement);
305
198
let submit = $id('login_submit');
306
199
let cloudy = $id('cloudy');
200
+
let close = $(loginDialog.querySelector('.close'));
307
201
308
202
if (submit.style.display == 'none') { return }
309
203
310
-
handle.blur();
311
-
password.blur();
204
+
handleField.blur();
205
+
passwordField.blur();
312
206
313
207
submit.style.display = 'none';
314
208
cloudy.style.display = 'inline-block';
315
209
316
-
logIn(handle.value, password.value).then((pds) => {
210
+
let handle = handleField.value.trim();
211
+
let password = passwordField.value.trim();
212
+
213
+
logIn(handle, password).then((pds) => {
317
214
window.api = pds;
318
215
window.accountAPI = pds;
319
216
320
217
hideDialog(loginDialog);
321
218
submit.style.display = 'inline';
322
219
cloudy.style.display = 'none';
220
+
close.style.display = 'inline';
323
221
324
-
loadCurrentUserAvatar();
325
-
showMenuButton('logout');
326
-
showMenuButton('incognito');
327
-
hideMenuButton('login');
222
+
accountMenu.loadCurrentUserAvatar();
223
+
224
+
accountMenu.showMenuButton('logout');
225
+
accountMenu.showMenuButton('incognito');
226
+
accountMenu.hideMenuButton('login');
328
227
329
228
let params = new URLSearchParams(location.search);
330
229
let page = params.get('page');
···
367
266
return pds;
368
267
}
369
268
370
-
function loadCurrentUserAvatar() {
371
-
api.loadCurrentUserAvatar().then((url) => {
372
-
showLoggedInStatus(true, url);
373
-
}).catch((error) => {
374
-
console.log(error);
375
-
showLoggedInStatus(true, null);
376
-
});
377
-
}
378
-
379
269
function logOut() {
380
270
accountAPI.resetTokens();
381
271
localStorage.removeItem('incognito');
···
383
273
}
384
274
385
275
function submitSearch() {
386
-
let url = $id('search').querySelector('input[name=q]').value.trim();
276
+
let search = $id('search');
277
+
let searchField = $(search.querySelector('input[name=q]'), HTMLInputElement);
278
+
let url = searchField.value.trim();
387
279
388
280
if (!url) { return }
389
281
···
412
304
}
413
305
}
414
306
307
+
/** @param {string} page */
308
+
415
309
function openPage(page) {
416
310
if (!accountAPI.isLoggedIn) {
417
-
toggleDialog(loginDialog);
311
+
showDialog(loginDialog);
312
+
$(loginDialog.querySelector('.close')).style.display = 'none';
418
313
return;
419
314
}
420
315
421
316
if (page == 'notif') {
422
-
showLoader();
423
-
showNotificationsPage();
317
+
window.notificationsPage.show();
318
+
} else if (page == 'posting_stats') {
319
+
window.postingStatsPage.show();
320
+
} else if (page == 'like_stats') {
321
+
window.likeStatsPage.show();
322
+
} else if (page == 'search') {
323
+
window.privateSearchPage.show();
424
324
}
425
325
}
426
326
427
-
function showNotificationsPage() {
428
-
document.title = `Notifications - Skythread`;
429
-
430
-
let isLoading = false;
431
-
let firstPageLoaded = false;
432
-
let finished = false;
433
-
let cursor;
434
-
435
-
loadInPages((next) => {
436
-
if (isLoading || finished) { return; }
437
-
isLoading = true;
438
-
439
-
accountAPI.loadMentions(cursor).then(data => {
440
-
let posts = data.posts.map(x => new Post(x));
441
-
442
-
if (posts.length > 0) {
443
-
if (!firstPageLoaded) {
444
-
hideLoader();
445
-
firstPageLoaded = true;
446
-
447
-
let header = $tag('header');
448
-
let h2 = $tag('h2', { text: "Replies & Mentions:" });
449
-
header.append(h2);
450
-
$id('thread').appendChild(header);
451
-
$id('thread').classList.add('notifications');
452
-
}
453
-
454
-
for (let post of posts) {
455
-
if (post.parentReference) {
456
-
let p = $tag('p.back');
457
-
p.innerHTML = `<i class="fa-solid fa-reply"></i> `;
458
-
459
-
let { repo, rkey } = atURI(post.parentReference.uri);
460
-
let url = linkToPostById(repo, rkey);
461
-
let parentLink = $tag('a', { href: url });
462
-
p.append(parentLink);
463
-
464
-
if (repo == api.user.did) {
465
-
parentLink.innerText = 'Reply to you';
466
-
} else {
467
-
parentLink.innerText = 'Reply';
468
-
api.fetchHandleForDid(repo).then(handle => {
469
-
parentLink.innerText = `Reply to @${handle}`;
470
-
});
471
-
}
472
-
473
-
$id('thread').appendChild(p);
474
-
}
475
-
476
-
let postView = new PostComponent(post, 'feed').buildElement();
477
-
$id('thread').appendChild(postView);
478
-
}
479
-
}
480
-
481
-
isLoading = false;
482
-
cursor = data.cursor;
483
-
484
-
if (!cursor) {
485
-
finished = true;
486
-
} else if (posts.length == 0) {
487
-
next();
488
-
}
489
-
}).catch(error => {
490
-
hideLoader();
491
-
console.log(error);
492
-
isLoading = false;
493
-
});
494
-
});
495
-
}
496
-
497
327
/** @param {Post} post */
498
328
499
329
function setPageTitle(post) {
···
511
341
let finished = false;
512
342
let cursor;
513
343
514
-
loadInPages(() => {
344
+
Paginator.loadInPages(() => {
515
345
if (isLoading || finished) { return; }
516
346
isLoading = true;
517
347
···
559
389
let cursor;
560
390
let finished = false;
561
391
562
-
loadInPages(() => {
392
+
Paginator.loadInPages(() => {
563
393
if (isLoading || finished) { return; }
564
394
isLoading = true;
565
395
···
610
440
});
611
441
});
612
442
}
613
-
614
-
/** @param {Function} callback */
615
-
616
-
function loadInPages(callback) {
617
-
let loadIfNeeded = () => {
618
-
if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) {
619
-
callback(loadIfNeeded);
620
-
}
621
-
};
622
-
623
-
callback(loadIfNeeded);
624
-
625
-
document.addEventListener('scroll', loadIfNeeded);
626
-
const resizeObserver = new ResizeObserver(loadIfNeeded);
627
-
resizeObserver.observe(document.body);
628
-
}
629
-
630
-
/** @param {string} url */
631
-
632
-
function loadThreadByURL(url) {
633
-
let loadThread = url.startsWith('at://') ? api.loadThreadByAtURI(url) : api.loadThreadByURL(url);
634
-
635
-
loadThread.then(json => {
636
-
displayThread(json);
637
-
}).catch(error => {
638
-
hideLoader();
639
-
showError(error);
640
-
});
641
-
}
642
-
643
-
/** @param {string} author, @param {string} rkey */
644
-
645
-
function loadThreadById(author, rkey) {
646
-
api.loadThreadById(author, rkey).then(json => {
647
-
displayThread(json);
648
-
}).catch(error => {
649
-
hideLoader();
650
-
showError(error);
651
-
});
652
-
}
653
-
654
-
/** @param {json} json */
655
-
656
-
function displayThread(json) {
657
-
let root = Post.parseThreadPost(json.thread);
658
-
window.root = root;
659
-
window.subtreeRoot = root;
660
-
661
-
let loadQuoteCount;
662
-
663
-
if (root instanceof Post) {
664
-
setPageTitle(root);
665
-
loadQuoteCount = blueAPI.getQuoteCount(root.uri);
666
-
667
-
if (root.parent) {
668
-
let p = buildParentLink(root.parent);
669
-
$id('thread').appendChild(p);
670
-
}
671
-
}
672
-
673
-
let component = new PostComponent(root, 'thread');
674
-
let view = component.buildElement();
675
-
hideLoader();
676
-
$id('thread').appendChild(view);
677
-
678
-
loadQuoteCount?.then(count => {
679
-
if (count > 0) {
680
-
component.appendQuotesIconLink(count, true);
681
-
}
682
-
}).catch(error => {
683
-
console.warn("Couldn't load quote count: " + error);
684
-
});
685
-
}
686
-
687
-
/** @param {Post} post, @param {AnyElement} nodeToUpdate */
688
-
689
-
function loadSubtree(post, nodeToUpdate) {
690
-
api.loadThreadByAtURI(post.uri).then(json => {
691
-
let root = Post.parseThreadPost(json.thread, post.pageRoot, 0, post.absoluteLevel);
692
-
post.updateDataFromPost(root);
693
-
window.subtreeRoot = post;
694
-
695
-
let component = new PostComponent(post, 'thread');
696
-
let view = component.buildElement();
697
-
698
-
nodeToUpdate.querySelector('.content').replaceWith(view.querySelector('.content'));
699
-
}).catch(showError);
700
-
}
701
-
702
-
/** @param {Post} post, @param {AnyElement} nodeToUpdate */
703
-
704
-
function loadHiddenSubtree(post, nodeToUpdate) {
705
-
blueAPI.getReplies(post.uri).then(replies => {
706
-
let missingReplies = replies.filter(r => !post.replies.some(x => x.uri === r));
707
-
708
-
Promise.allSettled(missingReplies.map(uri => api.loadThreadByAtURI(uri))).then(responses => {
709
-
let replies = responses
710
-
.map(r => r.status == 'fulfilled' ? r.value : undefined)
711
-
.filter(v => v)
712
-
.map(json => Post.parseThreadPost(json.thread, post.pageRoot, 1, post.absoluteLevel + 1));
713
-
714
-
post.setReplies(replies);
715
-
716
-
let content = nodeToUpdate.querySelector('.content');
717
-
content.querySelector(':scope > .hidden-replies').remove();
718
-
719
-
for (let reply of post.replies) {
720
-
let component = new PostComponent(reply, 'thread');
721
-
let view = component.buildElement();
722
-
content.append(view);
723
-
}
724
-
}).catch(showError);
725
-
}).catch(showError);
726
-
}
+551
-4
style.css
+551
-4
style.css
···
127
127
padding: 6px 11px;
128
128
}
129
129
130
-
#account_menu li a {
130
+
#account_menu li a[data-action] {
131
131
display: inline-block;
132
132
color: #333;
133
133
font-size: 11pt;
···
138
138
background-color: hsla(210, 100%, 4%, 0.12);
139
139
}
140
140
141
-
#account_menu li a:hover {
141
+
#account_menu li a[data-action]:hover {
142
142
background-color: hsla(210, 100%, 4%, 0.2);
143
143
text-decoration: none;
144
+
}
145
+
146
+
#account_menu li:not(.link) + li.link {
147
+
margin-top: 16px;
148
+
padding-top: 10px;
149
+
border-top: 1px solid #ccc;
150
+
}
151
+
152
+
#account_menu li.link {
153
+
margin-top: 8px;
154
+
margin-left: 2px;
155
+
}
156
+
157
+
#account_menu li.link a {
158
+
font-size: 11pt;
159
+
color: #333;
144
160
}
145
161
146
162
#account_menu li .check {
···
486
502
margin-top: 18px;
487
503
}
488
504
505
+
.post .body .highlight {
506
+
background-color: rgba(255, 255, 0, 0.75);
507
+
padding: 1px 2px;
508
+
margin-left: -1px;
509
+
margin-right: -1px;
510
+
}
511
+
489
512
.post .quote-embed {
490
513
border: 1px solid #ddd;
491
514
border-radius: 8px;
···
507
530
font-style: italic;
508
531
font-size: 11pt;
509
532
color: #888;
533
+
}
534
+
535
+
.post-quotes .post-quote .quote-embed {
536
+
display: none;
537
+
}
538
+
539
+
.post-quotes .post-quote p.stats {
540
+
display: none;
510
541
}
511
542
512
543
.post .image-alt {
···
632
663
color: #aaa;
633
664
}
634
665
666
+
.post a.fedi-link {
667
+
display: inline-block;
668
+
margin-bottom: 6px;
669
+
margin-top: 2px;
670
+
}
671
+
672
+
.post a.fedi-link:hover {
673
+
text-decoration: none;
674
+
}
675
+
676
+
.post a.fedi-link > div {
677
+
border: 1px solid #d0d0d0;
678
+
border-radius: 8px;
679
+
padding: 5px 9px;
680
+
color: #555;
681
+
font-size: 10pt;
682
+
}
683
+
684
+
.post a.fedi-link i {
685
+
margin-right: 3px;
686
+
}
687
+
688
+
.post a.fedi-link:hover > div {
689
+
background-color: #f6f7f8;
690
+
border: 1px solid #c8c8c8;
691
+
}
692
+
693
+
.post div.gif img {
694
+
user-select: none;
695
+
-webkit-user-select: none;
696
+
}
697
+
698
+
.post div.gif img.static {
699
+
opacity: 0.75;
700
+
}
701
+
635
702
.post .stats {
636
703
font-size: 10pt;
637
704
color: #666;
···
672
739
margin-right: 10px;
673
740
}
674
741
742
+
.post .stats .blocked-info {
743
+
color: #a02020;
744
+
font-weight: bold;
745
+
margin-left: 5px;
746
+
}
747
+
675
748
.post img.loader {
676
749
width: 24px;
677
750
animation: rotation 3s infinite linear;
···
703
776
color: saddlebrown;
704
777
}
705
778
779
+
.post p.missing-replies-info {
780
+
font-size: 11pt;
781
+
color: darkred;
782
+
margin-top: 25px;
783
+
}
784
+
785
+
#posting_stats_page {
786
+
display: none;
787
+
}
788
+
789
+
#posting_stats_page input[type="radio"] {
790
+
position: relative;
791
+
top: -1px;
792
+
margin-left: 5px;
793
+
}
794
+
795
+
#posting_stats_page input[type="radio"] + label {
796
+
user-select: none;
797
+
-webkit-user-select: none;
798
+
}
799
+
800
+
#posting_stats_page input[type="radio"]:disabled + label {
801
+
color: #999;
802
+
}
803
+
804
+
#posting_stats_page input[type="range"] {
805
+
width: 250px;
806
+
vertical-align: middle;
807
+
}
808
+
809
+
#posting_stats_page input[type="submit"] {
810
+
font-size: 12pt;
811
+
margin: 5px 0px;
812
+
padding: 5px 10px;
813
+
}
814
+
815
+
#posting_stats_page select {
816
+
font-size: 12pt;
817
+
margin-left: 5px;
818
+
}
819
+
820
+
#posting_stats_page progress {
821
+
width: 300px;
822
+
margin-left: 10px;
823
+
vertical-align: middle;
824
+
display: none;
825
+
}
826
+
827
+
#posting_stats_page .list-choice {
828
+
display: none;
829
+
}
830
+
831
+
#posting_stats_page .user-choice {
832
+
display: none;
833
+
position: relative;
834
+
}
835
+
836
+
#posting_stats_page .user-choice input {
837
+
width: 260px;
838
+
font-size: 11pt;
839
+
}
840
+
841
+
#posting_stats_page .user-choice .autocomplete {
842
+
display: none;
843
+
position: absolute;
844
+
left: 0;
845
+
top: 0;
846
+
margin-top: 4px;
847
+
width: 350px;
848
+
max-height: 250px;
849
+
overflow-y: auto;
850
+
background-color: white;
851
+
border: 1px solid #ccc;
852
+
z-index: 10;
853
+
}
854
+
855
+
#posting_stats_page .user-choice .selected-users {
856
+
width: 275px;
857
+
height: 150px;
858
+
overflow-y: auto;
859
+
border: 1px solid #aaa;
860
+
padding: 4px;
861
+
margin-top: 20px;
862
+
}
863
+
864
+
#posting_stats_page .user-choice .user-row {
865
+
position: relative;
866
+
padding: 2px 4px 2px 37px;
867
+
cursor: pointer;
868
+
}
869
+
870
+
#posting_stats_page .user-choice .user-row .avatar {
871
+
position: absolute;
872
+
left: 6px;
873
+
top: 8px;
874
+
width: 24px;
875
+
border-radius: 12px;
876
+
}
877
+
878
+
#posting_stats_page .user-choice .user-row span {
879
+
display: block;
880
+
overflow-x: hidden;
881
+
text-overflow: ellipsis;
882
+
}
883
+
884
+
#posting_stats_page .user-choice .user-row .name {
885
+
font-size: 11pt;
886
+
margin-top: 1px;
887
+
margin-bottom: 1px;
888
+
}
889
+
890
+
#posting_stats_page .user-choice .user-row .handle {
891
+
font-size: 10pt;
892
+
margin-bottom: 2px;
893
+
color: #666;
894
+
}
895
+
896
+
#posting_stats_page .user-choice .autocomplete .user-row {
897
+
cursor: pointer;
898
+
}
899
+
900
+
#posting_stats_page .user-choice .autocomplete .user-row.hover {
901
+
background-color: hsl(207, 100%, 85%);
902
+
}
903
+
904
+
#posting_stats_page .user-choice .selected-users .user-row span {
905
+
padding-right: 14px;
906
+
}
907
+
908
+
#posting_stats_page .user-choice .selected-users .user-row .remove {
909
+
position: absolute;
910
+
right: 4px;
911
+
top: 11px;
912
+
padding: 0px 4px;
913
+
color: #333;
914
+
line-height: 17px;
915
+
}
916
+
917
+
#posting_stats_page .user-choice .selected-users .user-row .remove:hover {
918
+
text-decoration: none;
919
+
background-color: #ddd;
920
+
border-radius: 8px;
921
+
}
922
+
923
+
#posting_stats_page .scan-info {
924
+
display: none;
925
+
font-weight: 600;
926
+
line-height: 125%;
927
+
margin: 20px 0px;
928
+
}
929
+
930
+
#posting_stats_page .scan-result {
931
+
border: 1px solid #333;
932
+
border-collapse: collapse;
933
+
display: none;
934
+
}
935
+
936
+
#posting_stats_page .scan-result td, #posting_stats_page .scan-result th {
937
+
border: 1px solid #333;
938
+
}
939
+
940
+
#posting_stats_page .scan-result td {
941
+
text-align: right;
942
+
padding: 5px 8px;
943
+
}
944
+
945
+
#posting_stats_page .scan-result th {
946
+
text-align: center;
947
+
background-color: hsl(207, 100%, 86%);
948
+
padding: 7px 10px;
949
+
}
950
+
951
+
#posting_stats_page .scan-result td.handle {
952
+
text-align: left;
953
+
max-width: 450px;
954
+
overflow: hidden;
955
+
text-overflow: ellipsis;
956
+
white-space: nowrap;
957
+
}
958
+
959
+
#posting_stats_page .scan-result tr.total td {
960
+
font-weight: bold;
961
+
font-size: 11pt;
962
+
background-color: hsla(207, 100%, 86%, 0.4);
963
+
}
964
+
965
+
#posting_stats_page .scan-result tr.total td.handle {
966
+
text-align: left;
967
+
padding: 10px 12px;
968
+
}
969
+
970
+
#posting_stats_page .scan-result .avatar {
971
+
width: 24px;
972
+
height: 24px;
973
+
border-radius: 14px;
974
+
vertical-align: middle;
975
+
margin-right: 2px;
976
+
padding: 2px;
977
+
}
978
+
979
+
#posting_stats_page .scan-result td.no {
980
+
font-weight: bold;
981
+
}
982
+
983
+
#posting_stats_page .scan-result td.percent {
984
+
min-width: 70px;
985
+
}
986
+
987
+
#like_stats_page {
988
+
display: none;
989
+
}
990
+
991
+
#like_stats_page input[type="range"] {
992
+
width: 250px;
993
+
vertical-align: middle;
994
+
}
995
+
996
+
#like_stats_page input[type="submit"] {
997
+
font-size: 12pt;
998
+
margin: 5px 0px;
999
+
padding: 5px 10px;
1000
+
}
1001
+
1002
+
#like_stats_page progress {
1003
+
width: 300px;
1004
+
margin-left: 10px;
1005
+
vertical-align: middle;
1006
+
display: none;
1007
+
}
1008
+
1009
+
#like_stats_page .scan-result {
1010
+
border: 1px solid #333;
1011
+
border-collapse: collapse;
1012
+
display: none;
1013
+
float: left;
1014
+
margin-top: 20px;
1015
+
margin-bottom: 40px;
1016
+
}
1017
+
1018
+
#like_stats_page .given-likes {
1019
+
margin-right: 100px;
1020
+
}
1021
+
1022
+
#like_stats_page .scan-result td, #like_stats_page .scan-result th {
1023
+
border: 1px solid #333;
1024
+
padding: 5px 10px;
1025
+
}
1026
+
1027
+
#like_stats_page .scan-result th {
1028
+
text-align: center;
1029
+
background-color: hsl(207, 100%, 86%);
1030
+
padding: 12px 10px;
1031
+
}
1032
+
1033
+
#like_stats_page .scan-result td.no {
1034
+
font-weight: bold;
1035
+
text-align: right;
1036
+
}
1037
+
1038
+
#like_stats_page .scan-result td.handle {
1039
+
width: 280px;
1040
+
}
1041
+
1042
+
#like_stats_page .scan-result td.count {
1043
+
padding: 5px 15px;
1044
+
}
1045
+
1046
+
#like_stats_page .scan-result .avatar {
1047
+
width: 24px;
1048
+
height: 24px;
1049
+
border-radius: 14px;
1050
+
vertical-align: middle;
1051
+
margin-right: 2px;
1052
+
padding: 2px;
1053
+
}
1054
+
1055
+
#private_search_page {
1056
+
display: none;
1057
+
}
1058
+
1059
+
#private_search_page input[type="range"] {
1060
+
width: 250px;
1061
+
vertical-align: middle;
1062
+
}
1063
+
1064
+
#private_search_page input[type="submit"] {
1065
+
font-size: 12pt;
1066
+
margin: 5px 0px;
1067
+
padding: 5px 10px;
1068
+
}
1069
+
1070
+
#private_search_page progress {
1071
+
width: 300px;
1072
+
margin-left: 10px;
1073
+
vertical-align: middle;
1074
+
display: none;
1075
+
}
1076
+
1077
+
#private_search_page .search {
1078
+
display: none;
1079
+
}
1080
+
1081
+
#private_search_page .search-query {
1082
+
font-size: 12pt;
1083
+
border: 1px solid #ccc;
1084
+
border-radius: 6px;
1085
+
padding: 5px 6px;
1086
+
margin-left: 8px;
1087
+
}
1088
+
1089
+
#private_search_page .search-collections label {
1090
+
vertical-align: middle;
1091
+
}
1092
+
1093
+
#private_search_page .lycan-import {
1094
+
display: none;
1095
+
1096
+
margin-top: 30px;
1097
+
border-top: 1px solid #ccc;
1098
+
padding-top: 5px;
1099
+
}
1100
+
1101
+
#private_search_page .lycan-import form p {
1102
+
line-height: 135%;
1103
+
}
1104
+
1105
+
#private_search_page .lycan-import .import-progress progress {
1106
+
margin-left: 0;
1107
+
margin-right: 6px;
1108
+
}
1109
+
1110
+
#private_search_page .lycan-import .import-progress progress + output {
1111
+
font-size: 11pt;
1112
+
}
1113
+
1114
+
#private_search_page .results {
1115
+
margin-top: 30px;
1116
+
}
1117
+
1118
+
#private_search_page .results > .post {
1119
+
margin-left: -15px;
1120
+
padding-left: 15px;
1121
+
border-bottom: 1px solid #ddd;
1122
+
padding-bottom: 10px;
1123
+
margin-top: 24px;
1124
+
}
1125
+
1126
+
#private_search_page .results-end {
1127
+
font-size: 12pt;
1128
+
color: #333;
1129
+
}
1130
+
1131
+
#private_search_page .post + .results-end {
1132
+
font-size: 11pt;
1133
+
}
1134
+
706
1135
@media (prefers-color-scheme: dark) {
707
1136
body {
708
1137
background-color: rgb(39, 39, 37);
···
729
1158
background-color: transparent;
730
1159
}
731
1160
1161
+
#account.active {
1162
+
color: #333;
1163
+
}
1164
+
732
1165
#account_menu {
733
1166
background: hsl(210, 33.33%, 94.0%);
734
1167
border-color: #ccc;
735
1168
}
736
1169
737
-
#account_menu li a {
1170
+
#account_menu li a[data-action] {
738
1171
color: #333;
739
1172
border-color: #bbb;
740
1173
background-color: hsla(210, 100%, 4%, 0.12);
741
1174
}
742
1175
743
-
#account_menu li a:hover {
1176
+
#account_menu li a[data-action]:hover {
744
1177
background-color: hsla(210, 100%, 4%, 0.2);
745
1178
}
746
1179
···
822
1255
color: #888;
823
1256
}
824
1257
1258
+
.post .body .highlight {
1259
+
background-color: rgba(255, 255, 0, 0.35);
1260
+
}
1261
+
825
1262
.post .quote-embed {
826
1263
background-color: #303030;
827
1264
border-color: #606060;
···
869
1306
870
1307
.post .stats i.fa-heart.liked:hover {
871
1308
color: #ff7070;
1309
+
}
1310
+
1311
+
.post a.link-card > div {
1312
+
background-color: #303030;
1313
+
border-color: #606060;
1314
+
}
1315
+
1316
+
.post a.link-card:hover > div {
1317
+
background-color: #383838;
1318
+
border-color: #707070;
1319
+
}
1320
+
1321
+
.post a.link-card p.domain {
1322
+
color: #666;
1323
+
}
1324
+
1325
+
.post a.link-card h2 {
1326
+
color: #ccc;
1327
+
}
1328
+
1329
+
.post a.link-card p.description {
1330
+
color: #888;
1331
+
}
1332
+
1333
+
.post a.link-card.record .handle {
1334
+
color: #666;
1335
+
}
1336
+
1337
+
.post a.link-card.record .avatar {
1338
+
border-color: #888;
1339
+
}
1340
+
1341
+
.post a.link-card.record .stats i.fa-heart:hover {
1342
+
color: #eee;
1343
+
}
1344
+
1345
+
.post a.fedi-link > div {
1346
+
border-color: #606060;
1347
+
color: #909090;
1348
+
}
1349
+
1350
+
.post a.fedi-link:hover > div {
1351
+
background-color: #444;
1352
+
border-color: #909090;
1353
+
}
1354
+
1355
+
#posting_stats_page input:disabled + label {
1356
+
color: #777;
1357
+
}
1358
+
1359
+
#posting_stats_page .user-choice .autocomplete {
1360
+
background-color: hsl(210, 5%, 18%);
1361
+
border-color: #4b4b4b;
1362
+
}
1363
+
1364
+
#posting_stats_page .user-choice .selected-users {
1365
+
border-color: #666;
1366
+
}
1367
+
1368
+
#posting_stats_page .user-choice .user-row .handle {
1369
+
color: #888;
1370
+
}
1371
+
1372
+
#posting_stats_page .user-choice .autocomplete .user-row.hover {
1373
+
background-color: hsl(207, 90%, 25%);
1374
+
}
1375
+
1376
+
#posting_stats_page .user-choice .selected-users .user-row .remove {
1377
+
color: #aaa;
1378
+
}
1379
+
1380
+
#posting_stats_page .user-choice .selected-users .user-row .remove:hover {
1381
+
background-color: #555;
1382
+
color: #bbb;
1383
+
}
1384
+
1385
+
#posting_stats_page .scan-result, #posting_stats_page .scan-result td, #posting_stats_page .scan-result th {
1386
+
border-color: #888;
1387
+
}
1388
+
1389
+
#posting_stats_page .scan-result th {
1390
+
background-color: hsl(207, 90%, 25%);
1391
+
}
1392
+
1393
+
#posting_stats_page .scan-result tr.total td {
1394
+
background-color: hsla(207, 90%, 25%, 0.4);
1395
+
}
1396
+
1397
+
#like_stats_page .scan-result, #like_stats_page .scan-result td, #like_stats_page .scan-result th {
1398
+
border-color: #888;
1399
+
}
1400
+
1401
+
#like_stats_page .scan-result th {
1402
+
background-color: hsl(207, 90%, 25%);
1403
+
}
1404
+
1405
+
#private_search_page .search-query {
1406
+
border: 1px solid #666;
1407
+
}
1408
+
1409
+
#private_search_page .lycan-import {
1410
+
border-top-color: #888;
1411
+
}
1412
+
1413
+
#private_search_page .results-end {
1414
+
color: #888;
1415
+
}
1416
+
1417
+
#private_search_page .results > .post {
1418
+
border-bottom: 1px solid #555;
872
1419
}
873
1420
}
+60
test/ts_test.js
+60
test/ts_test.js
···
1
+
// @ts-nocheck
2
+
3
+
// "Test suite" for TypeScript checking in $(), $id() and $tag()
4
+
5
+
function test() {
6
+
7
+
let panel = $(document.querySelector('.panel')); // HTMLElement
8
+
panel.style.display = 'none';
9
+
10
+
/** @type {never} */ let x1 = panel;
11
+
12
+
let link = $(document.querySelector('a.more'), HTMLLinkElement); // HTMLLinkElement
13
+
link.href = 'about:blank';
14
+
15
+
/** @type {never} */ let x2 = link;
16
+
17
+
let html = $(document.parentNode);
18
+
19
+
/** @type {never} */ let x3 = html;
20
+
21
+
document.addEventListener('click', (e) => {
22
+
let target = $(e.target);
23
+
/** @type {never} */ let x4 = target;
24
+
});
25
+
26
+
let text = $(link.innerText);
27
+
28
+
/** @type {never} */ let x5 = text;
29
+
30
+
let login = $id('login'); // HTMLElement
31
+
login.remove();
32
+
33
+
/** @type {never} */ let x6 = login;
34
+
35
+
let loginField = $id('login_field', HTMLInputElement); // HTMLInputElement
36
+
loginField.value = '';
37
+
38
+
/** @type {never} */ let x7 = loginField;
39
+
40
+
let p = $tag('p.details'); // HTMLElement
41
+
p.innerText = 'About';
42
+
43
+
/** @type {never} */ let x8 = p;
44
+
45
+
let p2 = $tag('p.details', { text: 'Info' }); // HTMLElement
46
+
p2.innerText = 'About';
47
+
48
+
/** @type {never} */ let x9 = p2;
49
+
50
+
let img = $tag('img.icon', HTMLImageElement); // HTMLImageElement
51
+
img.loading = 'lazy';
52
+
53
+
/** @type {never} */ let x10 = img;
54
+
55
+
let img2 = $tag('img.icon', { src: accountAPI.user.avatar }, HTMLImageElement); // HTMLImageElement
56
+
img2.loading = 'lazy';
57
+
58
+
/** @type {never} */ let x11 = img2;
59
+
60
+
}
+93
thread_page.js
+93
thread_page.js
···
1
+
/**
2
+
* Manages the page that displays a thread, as a whole.
3
+
*/
4
+
5
+
class ThreadPage {
6
+
7
+
/** @param {AnyPost} post, @returns {HTMLElement} */
8
+
9
+
buildParentLink(post) {
10
+
let p = $tag('p.back');
11
+
12
+
if (post instanceof BlockedPost) {
13
+
let element = new PostComponent(post, 'parent').buildElement();
14
+
element.className = 'back';
15
+
let span = $(element.querySelector('p.blocked-header span'));
16
+
span.innerText = 'Parent post blocked';
17
+
return element;
18
+
} else if (post instanceof MissingPost) {
19
+
p.innerHTML = `<i class="fa-solid fa-ban"></i> parent post has been deleted`;
20
+
} else {
21
+
let url = linkToPostThread(post);
22
+
p.innerHTML = `<i class="fa-solid fa-reply"></i><a href="${url}">See parent post (@${post.author.handle})</a>`;
23
+
}
24
+
25
+
return p;
26
+
}
27
+
28
+
/** @param {string} url, @returns {Promise<void>} */
29
+
30
+
async loadThreadByURL(url) {
31
+
try {
32
+
let json = url.startsWith('at://') ? await api.loadThreadByAtURI(url) : await api.loadThreadByURL(url);
33
+
this.displayThread(json);
34
+
} catch (error) {
35
+
hideLoader();
36
+
showError(error);
37
+
}
38
+
}
39
+
40
+
/** @param {string} author, @param {string} rkey, @returns {Promise<void>} */
41
+
42
+
async loadThreadById(author, rkey) {
43
+
try {
44
+
let json = await api.loadThreadById(author, rkey);
45
+
this.displayThread(json);
46
+
} catch (error) {
47
+
hideLoader();
48
+
showError(error);
49
+
}
50
+
}
51
+
52
+
/** @param {json} json */
53
+
54
+
displayThread(json) {
55
+
let root = Post.parseThreadPost(json.thread);
56
+
window.root = root;
57
+
window.subtreeRoot = root;
58
+
59
+
let loadQuoteCount;
60
+
61
+
if (root instanceof Post) {
62
+
setPageTitle(root);
63
+
loadQuoteCount = blueAPI.getQuoteCount(root.uri);
64
+
65
+
if (root.parent) {
66
+
let p = this.buildParentLink(root.parent);
67
+
$id('thread').appendChild(p);
68
+
} else if (root.parentReference) {
69
+
let { repo, rkey } = atURI(root.parentReference.uri);
70
+
let url = linkToPostById(repo, rkey);
71
+
72
+
let handle = api.findHandleByDid(repo);
73
+
let link = handle ? `See parent post (@${handle})` : "See parent post";
74
+
75
+
let p = $tag('p.back', { html: `<i class="fa-solid fa-reply"></i><a href="${url}">${link}</a>` });
76
+
$id('thread').appendChild(p);
77
+
}
78
+
}
79
+
80
+
let component = new PostComponent(root, 'thread');
81
+
let view = component.buildElement();
82
+
hideLoader();
83
+
$id('thread').appendChild(view);
84
+
85
+
loadQuoteCount?.then(count => {
86
+
if (count > 0) {
87
+
component.appendQuotesIconLink(count, true);
88
+
}
89
+
}).catch(error => {
90
+
console.warn("Couldn't load quote count: " + error);
91
+
});
92
+
}
93
+
}
+14
-27
types.d.ts
+14
-27
types.d.ts
···
11
11
declare var api: BlueskyAPI;
12
12
declare var isIncognito: boolean;
13
13
declare var biohazardEnabled: boolean;
14
-
declare var loginDialog: AnyElement;
15
-
declare var accountMenu: AnyElement;
14
+
declare var loginDialog: HTMLElement;
15
+
declare var accountMenu: Menu;
16
+
declare var avatarPreloader: IntersectionObserver;
17
+
declare var threadPage: ThreadPage;
18
+
declare var postingStatsPage: PostingStatsPage;
19
+
declare var likeStatsPage: LikeStatsPage;
20
+
declare var notificationsPage: NotificationsPage;
21
+
declare var privateSearchPage: PrivateSearchPage;
22
+
23
+
declare var Paginator: PaginatorType;
16
24
17
-
type SomeElement = Element | HTMLElement | AnyElement;
18
25
type json = Record<string, any>;
19
26
20
-
interface AnyElement {
21
-
classList: CSSClassList;
22
-
className: string;
23
-
innerText: string;
24
-
innerHTML: string;
25
-
nextElementSibling: AnyElement;
26
-
parentNode: AnyElement;
27
-
src: string;
28
-
style: CSSStyleDeclaration;
29
-
30
-
addEventListener<K extends keyof DocumentEventMap>(
31
-
type: K, listener: EventListenerOrEventListenerObject
32
-
): void;
33
-
34
-
append(...e: Array<string | SomeElement>): void;
35
-
appendChild(e: SomeElement): void;
36
-
closest(q: string): AnyElement;
37
-
querySelector(q: string): AnyElement;
38
-
querySelectorAll(q: string): AnyElement[];
39
-
prepend(...e: Array<string | SomeElement>): void;
40
-
remove(): void;
41
-
replaceChildren(e: SomeElement): void;
42
-
replaceWith(e: SomeElement): void;
43
-
}
27
+
function $tag(tag: string): HTMLElement;
28
+
function $tag<T extends HTMLElement>(tag: string, type: new (...args: any[]) => T): T;
29
+
function $tag(tag: string, params: string | object): HTMLElement;
30
+
function $tag<T extends HTMLElement>(tag: string, params: string | object, type: new (...args: any[]) => T): T;
+69
-6
utils.js
+69
-6
utils.js
···
17
17
}
18
18
}
19
19
20
-
/** @param {string} tag, @param {string | object} [params], @returns {any} */
20
+
/**
21
+
* @typedef {object} PaginatorType
22
+
* @property {(callback: (boolean) => void) => void} loadInPages
23
+
* @property {(() => void)=} scrollHandler
24
+
* @property {ResizeObserver=} resizeObserver
25
+
*/
26
+
27
+
window.Paginator = {
28
+
loadInPages(callback) {
29
+
if (this.scrollHandler) {
30
+
document.removeEventListener('scroll', this.scrollHandler);
31
+
}
32
+
33
+
if (this.resizeObserver) {
34
+
this.resizeObserver.disconnect();
35
+
}
36
+
37
+
let loadIfNeeded = () => {
38
+
if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) {
39
+
callback(loadIfNeeded);
40
+
}
41
+
};
42
+
43
+
callback(loadIfNeeded);
44
+
45
+
document.addEventListener('scroll', loadIfNeeded);
46
+
const resizeObserver = new ResizeObserver(loadIfNeeded);
47
+
resizeObserver.observe(document.body);
48
+
49
+
this.scrollHandler = loadIfNeeded;
50
+
this.resizeObserver = resizeObserver;
51
+
}
52
+
};
53
+
54
+
/**
55
+
* @template T
56
+
* @param {string} tag
57
+
* @param {string | object} params
58
+
* @param {new (...args: any[]) => T} type
59
+
* @returns {T}
60
+
*/
21
61
22
-
function $tag(tag, params) {
62
+
function $tag(tag, params, type) {
23
63
let element;
24
64
let parts = tag.split('.');
25
65
···
45
85
}
46
86
}
47
87
48
-
return element;
88
+
return /** @type {T} */ (element);
89
+
}
90
+
91
+
/**
92
+
* @template {HTMLElement} T
93
+
* @param {string} name
94
+
* @param {new (...args: any[]) => T} [type]
95
+
* @returns {T}
96
+
*/
97
+
98
+
function $id(name, type) {
99
+
return /** @type {T} */ (document.getElementById(name));
49
100
}
50
101
51
-
/** @param {string} name, @returns {any} */
102
+
/**
103
+
* @template {HTMLElement} T
104
+
* @param {Node | EventTarget | null} element
105
+
* @param {new (...args: any[]) => T} [type]
106
+
* @returns {T}
107
+
*/
52
108
53
-
function $id(name) {
54
-
return document.getElementById(name);
109
+
function $(element, type) {
110
+
return /** @type {T} */ (element);
55
111
}
56
112
57
113
/** @param {string} uri, @returns {AtURI} */
···
74
130
return html.replace(/&/g, '&')
75
131
.replace(/</g, '<')
76
132
.replace(/>/g,'>');
133
+
}
134
+
135
+
/** @param {json} feedPost, @returns {number} */
136
+
137
+
function feedPostTime(feedPost) {
138
+
let timestamp = feedPost.reason ? feedPost.reason.indexedAt : feedPost.post.record.createdAt;
139
+
return Date.parse(timestamp);
77
140
}
78
141
79
142
/** @param {string} html, @returns {string} */