+69
-18
api.js
+69
-18
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('/');
···
197
197
this.cacheProfile(profile);
198
198
return profile;
199
199
}
200
+
}
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;
200
207
}
201
208
202
209
/** @returns {Promise<json | undefined>} */
···
306
313
307
314
/**
308
315
* @param {number} days
309
-
* @param {{ onPageLoad?: FetchAllOnPageLoad }} [options]
316
+
* @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options]
310
317
* @returns {Promise<json[]>}
311
318
*/
312
319
···
315
322
let timeLimit = now.getTime() - days * 86400 * 1000;
316
323
317
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', {
318
358
params: {
359
+
actor: did,
360
+
filter: options.filter,
319
361
limit: 100
320
362
},
321
363
field: 'feed',
322
-
breakWhen: (x) => {
323
-
let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
324
-
return Date.parse(timestamp) < timeLimit;
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
325
377
},
326
-
onPageLoad: options.onPageLoad
378
+
field: 'lists'
327
379
});
380
+
381
+
return lists.filter(x => x.purpose == "app.bsky.graph.defs#curatelist");
328
382
}
329
383
330
384
/**
331
-
* @param {string} did
385
+
* @param {string} list
332
386
* @param {number} days
333
-
* @param {{ onPageLoad?: FetchAllOnPageLoad }} [options]
387
+
* @param {{ onPageLoad?: FetchAllOnPageLoad, keepLastPage?: boolean }} [options]
334
388
* @returns {Promise<json[]>}
335
389
*/
336
390
337
-
async loadUserTimeline(did, days, options = {}) {
391
+
async loadListTimeline(list, days, options = {}) {
338
392
let now = new Date();
339
393
let timeLimit = now.getTime() - days * 86400 * 1000;
340
394
341
-
return await this.fetchAll('app.bsky.feed.getAuthorFeed', {
395
+
return await this.fetchAll('app.bsky.feed.getListFeed', {
342
396
params: {
343
-
actor: did,
344
-
filter: 'posts_no_replies',
397
+
list: list,
345
398
limit: 100
346
399
},
347
400
field: 'feed',
348
-
breakWhen: (x) => {
349
-
let timestamp = x.reason ? x.reason.indexedAt : x.post.record.createdAt;
350
-
return Date.parse(timestamp) < timeLimit;
351
-
},
352
-
onPageLoad: options.onPageLoad
401
+
breakWhen: (x) => (feedPostTime(x) < timeLimit),
402
+
onPageLoad: options.onPageLoad,
403
+
keepLastPage: options.keepLastPage
353
404
});
354
405
}
355
406
+78
-28
index.html
+78
-28
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
···
94
100
<form>
95
101
<p>
96
102
Scan posts from:
97
-
<input type="radio" name="scan_type" id="scan_type_timeline" value="timeline" checked>
98
-
<label for="scan_type_timeline">Your timeline</label>
99
-
<input type="radio" name="scan_type" id="scan_type_users" value="users" disabled>
100
-
<label for="scan_type_users">Selected users (coming soon)</label>
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>
101
114
</p>
102
115
103
116
<p>
104
117
Time range: <input type="range" min="1" max="60" value="7"> <label>7 days</label>
105
118
</p>
106
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
+
107
131
<p>
108
132
<input type="submit" value="Start scan"> <progress></progress>
109
133
</p>
···
112
136
<p class="scan-info"></p>
113
137
114
138
<table class="scan-result">
115
-
<thead>
116
-
<tr>
117
-
<th>#</th>
118
-
<th>Handle</th>
119
-
<th>All posts /d</th>
120
-
<th>Own posts /d</th>
121
-
<th>Reposts /d</th>
122
-
<th>% of all</th>
123
-
</tr>
124
-
</thead>
125
-
<tbody>
126
-
</tbody>
139
+
<thead></thead>
140
+
<tbody></tbody>
127
141
</table>
128
142
</div>
129
143
···
156
170
</div>
157
171
158
172
<div id="private_search_page">
159
-
<h2>Archive search *Beta*</h2>
173
+
<h2>Archive search</h2>
160
174
161
-
<form>
162
-
<p>
163
-
Fetch timeline posts: <input type="range" min="1" max="60" value="7"> <label>7 days</label>
164
-
</p>
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>
165
193
166
-
<p>
167
-
<input type="submit" value="Fetch timeline"> <progress></progress>
168
-
</p>
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>
169
200
</form>
170
201
171
-
<p class="archive-status"></p>
202
+
<div class="lycan-import">
203
+
<form>
204
+
<h4>Data not imported yet</h4>
172
205
173
-
<hr>
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>
174
218
175
-
<p class="search">Search: <input type="text" class="search-query"></p>
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>
176
226
177
227
<div class="results">
178
228
</div>
+11
-9
like_stats_page.js
+11
-9
like_stats_page.js
···
27
27
e.preventDefault();
28
28
29
29
if (!this.scanStartTime) {
30
-
this.findLikes();
30
+
this.findLikes();
31
31
} else {
32
32
this.stopScan();
33
33
}
···
110
110
field: 'records',
111
111
breakWhen: (x) => Date.parse(x['value']['createdAt']) < startTime - 86400 * requestedDays * 1000,
112
112
onPageLoad: (data) => {
113
-
if (data.length == 0) { return }
113
+
let last = data.at(-1);
114
+
115
+
if (!last) { return }
114
116
115
-
let last = data.at(-1);
116
117
let lastDate = Date.parse(last.value.createdAt);
118
+
let daysBack = (startTime - lastDate) / 86400 / 1000;
117
119
118
-
let daysBack = (startTime - lastDate) / 86400 / 1000;
119
120
this.updateProgress({ likeRecords: Math.min(1.0, daysBack / requestedDays) });
120
121
}
121
122
});
···
127
128
let startTime = /** @type {number} */ (this.scanStartTime);
128
129
129
130
let myPosts = await this.appView.loadUserTimeline(accountAPI.user.did, requestedDays, {
131
+
filter: 'posts_with_replies',
130
132
onPageLoad: (data) => {
131
-
if (data.length == 0) { return }
132
-
133
133
let last = data.at(-1);
134
-
let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
135
-
let lastDate = Date.parse(lastTimestamp);
136
134
135
+
if (!last) { return }
136
+
137
+
let lastDate = feedPostTime(last);
137
138
let daysBack = (startTime - lastDate) / 86400 / 1000;
139
+
138
140
this.updateProgress({ posts: Math.min(1.0, daysBack / requestedDays) });
139
141
}
140
142
});
···
226
228
tr.append(
227
229
$tag('td.no', { text: i + 1 }),
228
230
$tag('td.handle', {
229
-
html: `<img class="avatar" src="${user.avatar}"> ` +
231
+
html: `<img class="avatar" src="${user.avatar}"> ` +
230
232
`<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
231
233
}),
232
234
$tag('td.count', { text: user.count })
+6
-2
minisky.js
+6
-2
minisky.js
···
186
186
* field: string,
187
187
* params?: json,
188
188
* breakWhen?: (obj: json) => boolean,
189
+
* keepLastPage?: boolean | undefined,
189
190
* onPageLoad?: FetchAllOnPageLoad | undefined
190
191
* }} FetchAllOptions
191
192
*
···
213
214
let test = options.breakWhen;
214
215
215
216
if (items.some(x => test(x))) {
216
-
items = items.filter(x => !test(x));
217
+
if (!options.keepLastPage) {
218
+
items = items.filter(x => !test(x));
219
+
}
220
+
217
221
cursor = null;
218
222
}
219
223
}
···
297
301
let text = await response.text();
298
302
let json = text.trim().length > 0 ? JSON.parse(text) : undefined;
299
303
300
-
if (response.status == 200) {
304
+
if (response.status >= 200 && response.status < 300) {
301
305
return json;
302
306
} else {
303
307
throw new APIError(response.status, json);
+5
models.js
+5
models.js
+1
-1
notifications_page.js
+1
-1
notifications_page.js
+73
post_component.js
+73
post_component.js
···
162
162
if (this.post.embed) {
163
163
let embed = new EmbedComponent(this.post, this.post.embed).buildElement();
164
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
+
}
165
178
}
166
179
167
180
if (this.post.likeCount !== undefined && this.post.repostCount !== undefined) {
···
301
314
return p;
302
315
}
303
316
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
+
304
361
/** @param {string[]} tags, @returns {HTMLElement} */
305
362
306
363
buildTagsRow(tags) {
···
433
490
loadHiddenReplies(loadMoreButton) {
434
491
loadMoreButton.innerHTML = `<img class="loader" src="icons/sunny.png">`;
435
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
+
}
436
509
}
437
510
438
511
/** @param {HTMLLinkElement} authorLink */
+478
-67
posting_stats_page.js
+478
-67
posting_stats_page.js
···
7
7
/** @type {number | undefined} */
8
8
scanStartTime;
9
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
+
10
25
constructor() {
11
26
this.pageElement = $id('posting_stats_page');
27
+
this.form = $(this.pageElement.querySelector('form'), HTMLFormElement);
12
28
13
29
this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
14
30
this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
15
31
this.progressBar = $(this.pageElement.querySelector('input[type=submit] + progress'), HTMLProgressElement);
16
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);
17
45
18
46
this.setupEvents();
19
47
}
20
48
21
49
setupEvents() {
22
-
$(this.pageElement.querySelector('form')).addEventListener('submit', (e) => {
50
+
let html = $(document.body.parentNode);
51
+
52
+
html.addEventListener('click', (e) => {
53
+
this.hideAutocomplete();
54
+
});
55
+
56
+
this.form.addEventListener('submit', (e) => {
23
57
e.preventDefault();
24
58
25
59
if (!this.scanStartTime) {
26
-
this.scanPostingStats();
60
+
this.scanPostingStats();
27
61
} else {
28
62
this.stopScan();
29
63
}
···
31
65
32
66
this.rangeInput.addEventListener('input', (e) => {
33
67
let days = parseInt(this.rangeInput.value, 10);
34
-
this.configurePostingStats({ days });
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);
35
93
});
36
94
}
37
95
38
96
show() {
39
97
this.pageElement.style.display = 'block';
98
+
this.fetchLists();
40
99
}
41
100
42
101
/** @returns {number} */
···
45
104
return parseInt(this.rangeInput.value, 10);
46
105
}
47
106
48
-
/** @param {{ days: number }} args */
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
+
});
49
190
50
-
configurePostingStats(args) {
51
-
if (args.days) {
52
-
let label = $(this.pageElement.querySelector('input[type=range] + label'));
53
-
label.innerText = (args.days == 1) ? '1 day' : `${args.days} days`;
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;
54
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;
55
282
}
56
283
57
284
/** @returns {Promise<void>} */
58
285
59
286
async scanPostingStats() {
60
-
this.submitButton.value = 'Cancel';
61
-
287
+
let startTime = new Date().getTime();
62
288
let requestedDays = this.selectedDaysRange();
289
+
let scanType = this.scanType.value;
63
290
64
-
this.progressBar.max = requestedDays;
65
-
this.progressBar.value = 0;
66
-
this.progressBar.style.display = 'inline';
291
+
/** @type {FetchAllOnPageLoad} */
292
+
let onPageLoad = (data) => {
293
+
if (this.scanStartTime != startTime) {
294
+
return { cancel: true };
295
+
}
67
296
68
-
this.table.style.display = 'none';
297
+
this.updateProgress(data, startTime);
298
+
};
69
299
70
-
let tbody = $(this.table.querySelector('tbody'));
71
-
tbody.innerHTML = '';
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
+
}
72
315
73
-
let startTime = new Date().getTime();
74
-
this.scanStartTime = startTime;
316
+
this.startScan(startTime, requestedDays);
75
317
76
-
let scanInfo = $(this.pageElement.querySelector('.scan-info'));
77
-
scanInfo.style.display = 'none';
318
+
let posts = await accountAPI.loadListTimeline(list, requestedDays, {
319
+
onPageLoad: onPageLoad,
320
+
keepLastPage: true
321
+
});
78
322
79
-
let items = await accountAPI.loadHomeTimeline(requestedDays, {
80
-
onPageLoad: (data) => {
81
-
if (this.scanStartTime != startTime) {
82
-
return { cancel: true };
83
-
}
323
+
this.updateResultsTable(posts, startTime, requestedDays, { showReposts: false });
324
+
} else if (scanType == 'users') {
325
+
let dids = Object.keys(this.selectedUsers);
84
326
85
-
this.updateProgress(data, startTime);
327
+
if (dids.length == 0) {
328
+
return;
86
329
}
87
-
});
88
330
89
-
if (this.scanStartTime != startTime) {
90
-
return;
91
-
}
331
+
this.startScan(startTime, requestedDays);
332
+
this.resetUserProgress(dids);
92
333
93
-
this.updateResultsTable(items, startTime, requestedDays);
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
+
}
94
366
}
95
367
96
368
/** @param {json[]} dataPage, @param {number} startTime */
97
369
98
370
updateProgress(dataPage, startTime) {
99
-
if (dataPage.length == 0) { return }
371
+
let last = dataPage.at(-1);
372
+
373
+
if (!last) { return }
374
+
375
+
let lastDate = feedPostTime(last);
376
+
let daysBack = (startTime - lastDate) / 86400 / 1000;
100
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) {
101
394
let last = dataPage.at(-1);
102
-
let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
103
-
let lastDate = Date.parse(lastTimestamp);
104
395
396
+
if (!last) { return }
397
+
398
+
let lastDate = feedPostTime(last);
105
399
let daysBack = (startTime - lastDate) / 86400 / 1000;
106
-
this.progressBar.value = daysBack;
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);
107
410
}
108
411
109
412
/** @param {json} a, @param {json} b, @returns {number} */
···
121
424
}
122
425
}
123
426
124
-
/** @param {json[]} items, @param {number} startTime, @param {number} requestedDays */
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
+
*/
125
440
126
-
updateResultsTable(items, startTime, requestedDays) {
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
+
127
453
let users = {};
128
454
let total = 0;
129
455
let allReposts = 0;
130
456
let allNormalPosts = 0;
131
457
132
-
let last = items.at(-1);
133
-
let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
134
-
let lastDate = Date.parse(lastTimestamp);
135
-
let daysBack = (startTime - lastDate) / 86400 / 1000;
458
+
let last = posts.at(-1);
136
459
137
-
for (let item of items) {
138
-
if (item.reply) { continue; }
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
+
}
139
499
140
500
let user = item.reason ? item.reason.by : item.post.author;
141
501
let handle = user.handle;
···
148
508
} else {
149
509
users[handle].own += 1;
150
510
allNormalPosts += 1;
511
+
ownThreads.add(item.post.uri);
151
512
}
152
513
}
153
514
154
-
let tbody = $(this.table.querySelector('tbody'));
155
-
let tr = $tag('tr.total');
515
+
let headRow = $tag('tr');
156
516
157
-
tr.append(
158
-
$tag('td.no', { text: '' }),
159
-
$tag('td.handle', { text: 'Total:' }),
160
-
$tag('td', { text: (total / daysBack).toFixed(1) }),
161
-
$tag('td', { text: (allNormalPosts / daysBack).toFixed(1) }),
162
-
$tag('td', { text: (allReposts / daysBack).toFixed(1) }),
163
-
$tag('td.percent', { text: '' })
164
-
);
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
+
}
165
532
166
-
tbody.append(tr);
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
+
}
167
561
168
562
let sorted = Object.values(users).sort(this.sortUserRows);
169
563
···
174
568
tr.append(
175
569
$tag('td.no', { text: i + 1 }),
176
570
$tag('td.handle', {
177
-
html: `<img class="avatar" src="${user.avatar}"> ` +
571
+
html: `<img class="avatar" src="${user.avatar}"> ` +
178
572
`<a href="https://bsky.app/profile/${user.handle}" target="_blank">${user.handle}</a>`
179
573
}),
180
-
$tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }),
574
+
575
+
(options.showReposts !== false) ?
576
+
$tag('td', { text: ((user.own + user.reposts) / daysBack).toFixed(1) }) : '',
577
+
181
578
$tag('td', { text: user.own > 0 ? (user.own / daysBack).toFixed(1) : 'โ' }),
182
-
$tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : 'โ' }),
183
-
$tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' })
579
+
580
+
(options.showReposts !== false) ?
581
+
$tag('td', { text: user.reposts > 0 ? (user.reposts / daysBack).toFixed(1) : 'โ' }) : ''
184
582
);
185
583
186
-
tbody.append(tr);
187
-
}
584
+
if (options.showPercentages !== false) {
585
+
tr.append($tag('td.percent', { text: ((user.own + user.reposts) * 100 / total).toFixed(1) + '%' }));
586
+
}
188
587
189
-
if (Math.ceil(daysBack) < requestedDays) {
190
-
let scanInfo = $(this.pageElement.querySelector('.scan-info'));
191
-
scanInfo.innerText = `๐ Showing data from ${Math.round(daysBack)} days (your timeline only goes that far):`;
192
-
scanInfo.style.display = 'block';
588
+
this.tableBody.append(tr);
193
589
}
194
590
195
591
this.table.style.display = 'table';
196
-
this.submitButton.value = 'Start scan';
197
-
this.progressBar.style.display = 'none';
198
-
this.scanStartTime = undefined;
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';
199
610
}
200
611
201
612
stopScan() {
+296
-16
private_search_page.js
+296
-16
private_search_page.js
···
3
3
/** @type {number | undefined} */
4
4
fetchStartTime;
5
5
6
+
/** @type {number | undefined} */
7
+
importTimer;
8
+
9
+
/** @type {string | undefined} */
10
+
lycanImportStatus;
11
+
6
12
constructor() {
7
13
this.pageElement = $id('private_search_page');
14
+
15
+
this.header = $(this.pageElement.querySelector('h2'));
8
16
9
17
this.rangeInput = $(this.pageElement.querySelector('input[type="range"]'), HTMLInputElement);
10
18
this.submitButton = $(this.pageElement.querySelector('input[type="submit"]'), HTMLInputElement);
···
13
21
14
22
this.searchLine = $(this.pageElement.querySelector('.search'));
15
23
this.searchField = $(this.pageElement.querySelector('.search-query'), HTMLInputElement);
24
+
this.searchForm = $(this.pageElement.querySelector('.search-form'), HTMLFormElement);
16
25
this.results = $(this.pageElement.querySelector('.results'));
17
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;
18
39
this.timelinePosts = [];
19
40
20
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
+
}
21
54
}
22
55
23
56
setupEvents() {
24
-
$(this.pageElement.querySelector('form')).addEventListener('submit', (e) => {
57
+
this.timelineSearchForm.addEventListener('submit', (e) => {
25
58
e.preventDefault();
26
59
27
60
if (!this.fetchStartTime) {
28
-
this.fetchTimeline();
61
+
this.fetchTimeline();
29
62
} else {
30
63
this.stopFetch();
31
64
}
···
37
70
label.innerText = (days == 1) ? '1 day' : `${days} days`;
38
71
});
39
72
40
-
this.searchField.addEventListener('input', (e) => {
41
-
let query = this.searchField.value.trim().toLowerCase();
73
+
this.searchField.addEventListener('keydown', (e) => {
74
+
if (e.key == 'Enter') {
75
+
e.preventDefault();
42
76
43
-
if (this.searchTimer) {
44
-
clearTimeout(this.searchTimer);
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
+
}
45
84
}
85
+
});
46
86
47
-
this.searchTimer = setTimeout(() => this.searchInTimeline(query), 100);
87
+
this.lycanImportForm.addEventListener('submit', (e) => {
88
+
e.preventDefault();
89
+
this.startLycanImport();
48
90
});
49
91
}
50
92
···
56
98
57
99
show() {
58
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
+
}
59
260
}
60
261
61
262
/** @returns {Promise<void>} */
···
90
291
let daysBack;
91
292
92
293
if (last) {
93
-
let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
94
-
let lastDate = Date.parse(lastTimestamp);
294
+
let lastDate = feedPostTime(last);
95
295
daysBack = Math.round((startTime - lastDate) / 86400 / 1000);
96
296
} else {
97
297
daysBack = 0;
98
298
}
99
299
100
-
this.timelinePosts = timeline.map(x => Post.parseFeedPost(x));
300
+
this.timelinePosts = timeline;
101
301
102
302
this.archiveStatus.innerText = "Timeline archive fetched: " + ((daysBack == 1) ? '1 day' : `${daysBack} days`);
103
303
this.searchLine.style.display = 'block';
···
107
307
this.fetchStartTime = undefined;
108
308
}
109
309
310
+
/** @param {string} query */
311
+
110
312
searchInTimeline(query) {
111
313
this.results.innerHTML = '';
112
314
···
114
316
return;
115
317
}
116
318
117
-
let matching = this.timelinePosts.filter(x => x.lowercaseText.includes(query));
319
+
let matching = this.timelinePosts
320
+
.filter(x => x.post.record.text.toLowerCase().includes(query))
321
+
.map(x => Post.parseFeedPost(x));
118
322
119
323
for (let post of matching) {
120
324
let postView = new PostComponent(post, 'feed').buildElement();
···
122
326
}
123
327
}
124
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
+
125
405
/** @param {json[]} dataPage, @param {number} startTime */
126
406
127
407
updateProgress(dataPage, startTime) {
128
-
if (dataPage.length == 0) { return }
408
+
let last = dataPage.at(-1);
129
409
130
-
let last = dataPage.at(-1);
131
-
let lastTimestamp = last.reason ? last.reason.indexedAt : last.post.record.createdAt;
132
-
let lastDate = Date.parse(lastTimestamp);
410
+
if (!last) { return }
133
411
412
+
let lastDate = feedPostTime(last);
134
413
let daysBack = (startTime - lastDate) / 86400 / 1000;
135
-
this.progressBar.value = daysBack;
414
+
415
+
this.progressBar.value = daysBack;
136
416
}
137
417
138
418
stopFetch() {
+5
-19
skythread.js
+5
-19
skythread.js
···
188
188
}
189
189
}
190
190
191
-
function toggleLoginInfo(event) {
191
+
function toggleLoginInfo() {
192
192
$id('login').classList.toggle('expanded');
193
193
}
194
194
···
304
304
}
305
305
}
306
306
307
+
/** @param {string} page */
308
+
307
309
function openPage(page) {
308
310
if (!accountAPI.isLoggedIn) {
309
311
showDialog(loginDialog);
···
339
341
let finished = false;
340
342
let cursor;
341
343
342
-
loadInPages(() => {
344
+
Paginator.loadInPages(() => {
343
345
if (isLoading || finished) { return; }
344
346
isLoading = true;
345
347
···
387
389
let cursor;
388
390
let finished = false;
389
391
390
-
loadInPages(() => {
392
+
Paginator.loadInPages(() => {
391
393
if (isLoading || finished) { return; }
392
394
isLoading = true;
393
395
···
438
440
});
439
441
});
440
442
}
441
-
442
-
/** @param {Function} callback */
443
-
444
-
function loadInPages(callback) {
445
-
let loadIfNeeded = () => {
446
-
if (window.pageYOffset + window.innerHeight > document.body.offsetHeight - 500) {
447
-
callback(loadIfNeeded);
448
-
}
449
-
};
450
-
451
-
callback(loadIfNeeded);
452
-
453
-
document.addEventListener('scroll', loadIfNeeded);
454
-
const resizeObserver = new ResizeObserver(loadIfNeeded);
455
-
resizeObserver.observe(document.body);
456
-
}
+298
-8
style.css
+298
-8
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;
···
640
663
color: #aaa;
641
664
}
642
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
+
643
693
.post div.gif img {
644
694
user-select: none;
645
695
-webkit-user-select: none;
···
739
789
#posting_stats_page input[type="radio"] {
740
790
position: relative;
741
791
top: -1px;
792
+
margin-left: 5px;
742
793
}
743
794
744
-
#posting_stats_page label {
795
+
#posting_stats_page input[type="radio"] + label {
745
796
user-select: none;
746
797
-webkit-user-select: none;
747
798
}
748
799
749
-
#posting_stats_page input:disabled + label {
800
+
#posting_stats_page input[type="radio"]:disabled + label {
750
801
color: #999;
751
802
}
752
803
···
761
812
padding: 5px 10px;
762
813
}
763
814
815
+
#posting_stats_page select {
816
+
font-size: 12pt;
817
+
margin-left: 5px;
818
+
}
819
+
764
820
#posting_stats_page progress {
765
821
width: 300px;
766
822
margin-left: 10px;
···
768
824
display: none;
769
825
}
770
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
+
771
923
#posting_stats_page .scan-info {
772
924
display: none;
773
925
font-weight: 600;
···
817
969
818
970
#posting_stats_page .scan-result .avatar {
819
971
width: 24px;
972
+
height: 24px;
820
973
border-radius: 14px;
821
974
vertical-align: middle;
822
975
margin-right: 2px;
823
-
padding: 2px;
976
+
padding: 2px;
824
977
}
825
978
826
979
#posting_stats_page .scan-result td.no {
···
892
1045
893
1046
#like_stats_page .scan-result .avatar {
894
1047
width: 24px;
1048
+
height: 24px;
895
1049
border-radius: 14px;
896
1050
vertical-align: middle;
897
1051
margin-right: 2px;
898
-
padding: 2px;
1052
+
padding: 2px;
899
1053
}
900
1054
901
1055
#private_search_page {
···
932
1086
margin-left: 8px;
933
1087
}
934
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
+
935
1135
@media (prefers-color-scheme: dark) {
936
1136
body {
937
1137
background-color: rgb(39, 39, 37);
···
958
1158
background-color: transparent;
959
1159
}
960
1160
1161
+
#account.active {
1162
+
color: #333;
1163
+
}
1164
+
961
1165
#account_menu {
962
1166
background: hsl(210, 33.33%, 94.0%);
963
1167
border-color: #ccc;
964
1168
}
965
1169
966
-
#account_menu li a {
1170
+
#account_menu li a[data-action] {
967
1171
color: #333;
968
1172
border-color: #bbb;
969
1173
background-color: hsla(210, 100%, 4%, 0.12);
970
1174
}
971
1175
972
-
#account_menu li a:hover {
1176
+
#account_menu li a[data-action]:hover {
973
1177
background-color: hsla(210, 100%, 4%, 0.2);
974
1178
}
975
1179
···
1051
1255
color: #888;
1052
1256
}
1053
1257
1258
+
.post .body .highlight {
1259
+
background-color: rgba(255, 255, 0, 0.35);
1260
+
}
1261
+
1054
1262
.post .quote-embed {
1055
1263
background-color: #303030;
1056
1264
border-color: #606060;
···
1100
1308
color: #ff7070;
1101
1309
}
1102
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
+
1103
1355
#posting_stats_page input:disabled + label {
1104
1356
color: #777;
1105
1357
}
1106
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
+
1107
1385
#posting_stats_page .scan-result, #posting_stats_page .scan-result td, #posting_stats_page .scan-result th {
1108
1386
border-color: #888;
1109
1387
}
···
1126
1404
1127
1405
#private_search_page .search-query {
1128
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;
1129
1419
}
1130
1420
}
+1
-1
test/ts_test.js
+1
-1
test/ts_test.js
+10
-1
thread_page.js
+10
-1
thread_page.js
···
23
23
}
24
24
25
25
return p;
26
-
}
26
+
}
27
27
28
28
/** @param {string} url, @returns {Promise<void>} */
29
29
···
64
64
65
65
if (root.parent) {
66
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>` });
67
76
$id('thread').appendChild(p);
68
77
}
69
78
}
+2
types.d.ts
+2
types.d.ts
+41
utils.js
+41
utils.js
···
18
18
}
19
19
20
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
+
/**
21
55
* @template T
22
56
* @param {string} tag
23
57
* @param {string | object} params
···
96
130
return html.replace(/&/g, '&')
97
131
.replace(/</g, '<')
98
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);
99
140
}
100
141
101
142
/** @param {string} html, @returns {string} */