this repo has no description
1import { api } from './api';
2import { extractTagsFromStatus, getFollowedTags } from './followed-tags';
3import pmem from './pmem';
4import { fetchRelationships } from './relationships';
5import states, { saveStatus, statusKey } from './states';
6import store from './store';
7import supports from './supports';
8
9export function groupBoosts(values) {
10 let newValues = [];
11 let boostStash = [];
12 let serialBoosts = 0;
13 for (let i = 0; i < values.length; i++) {
14 const item = values[i];
15 if (item.reblog && !item.account?.group) {
16 boostStash.push(item);
17 serialBoosts++;
18 } else {
19 newValues.push(item);
20 if (serialBoosts < 3) {
21 serialBoosts = 0;
22 }
23 }
24 }
25 // if boostStash is more than quarter of values
26 // or if there are 3 or more boosts in a row
27 if (
28 values.length > 10 &&
29 (boostStash.length > values.length / 4 || serialBoosts >= 3)
30 ) {
31 // if boostStash is more than 3 quarter of values
32 const boostStashID = boostStash.map((status) => status.id);
33 if (boostStash.length > (values.length * 3) / 4) {
34 // insert boost array at the end of specialHome list
35 newValues = [
36 ...newValues,
37 { id: boostStashID, items: boostStash, type: 'boosts' },
38 ];
39 } else {
40 // insert boosts array in the middle of specialHome list
41 const half = Math.floor(newValues.length / 2);
42 newValues = [
43 ...newValues.slice(0, half),
44 {
45 id: boostStashID,
46 items: boostStash,
47 type: 'boosts',
48 },
49 ...newValues.slice(half),
50 ];
51 }
52 return newValues;
53 } else {
54 return values;
55 }
56}
57
58export function dedupeBoosts(items, instance) {
59 const boostedStatusIDs = store.account.get('boostedStatusIDs') || {};
60 const filteredItems = items.filter((item) => {
61 if (!item.reblog) return true;
62 const statusKey = `${instance}-${item.reblog.id}`;
63 const boosterID = boostedStatusIDs[statusKey];
64 if (boosterID && boosterID !== item.id) {
65 console.warn(
66 `🚫 Duplicate boost by ${item.account.displayName}`,
67 item,
68 item.reblog,
69 );
70 return false;
71 } else {
72 boostedStatusIDs[statusKey] = item.id;
73 }
74 return true;
75 });
76 // Limit to 50
77 const keys = Object.keys(boostedStatusIDs);
78 if (keys.length > 50) {
79 keys.slice(0, keys.length - 50).forEach((key) => {
80 delete boostedStatusIDs[key];
81 });
82 }
83 store.account.set('boostedStatusIDs', boostedStatusIDs);
84 return filteredItems;
85}
86
87export function groupContext(items, instance) {
88 const contexts = [];
89 let contextIndex = 0;
90 items.forEach((item) => {
91 for (let i = 0; i < contexts.length; i++) {
92 if (contexts[i].find((t) => t.id === item.id)) return;
93 if (
94 contexts[i].find((t) => t.id === item.inReplyToId) ||
95 contexts[i].find((t) => t.inReplyToId === item.id)
96 ) {
97 contexts[i].push(item);
98 return;
99 }
100 }
101 const repliedItem = items.find((i) => i.id === item.inReplyToId);
102 if (repliedItem) {
103 contexts[contextIndex++] = [item, repliedItem];
104 }
105 });
106
107 // Check for cross-item contexts
108 // Merge contexts into one if they have a common item (same id)
109 for (let i = 0; i < contexts.length; i++) {
110 for (let j = i + 1; j < contexts.length; j++) {
111 const commonItem = contexts[i].find((t) => contexts[j].includes(t));
112 if (commonItem) {
113 contexts[i] = [...contexts[i], ...contexts[j]];
114 // Remove duplicate items
115 contexts[i] = contexts[i].filter(
116 (item, index, self) =>
117 self.findIndex((t) => t.id === item.id) === index,
118 );
119 contexts.splice(j, 1);
120 j--;
121 }
122 }
123 }
124
125 // Sort items by checking inReplyToId
126 contexts.forEach((context) => {
127 context.sort((a, b) => {
128 if (!a.inReplyToId && !b.inReplyToId) {
129 return Date.parse(a.createdAt) - Date.parse(b.createdAt);
130 }
131 if (a.inReplyToId === b.id) return 1;
132 if (b.inReplyToId === a.id) return -1;
133 if (!a.inReplyToId) return -1;
134 if (!b.inReplyToId) return 1;
135 return Date.parse(a.createdAt) - Date.parse(b.createdAt);
136 });
137 });
138
139 // Tag items that has different author than first post's author
140 contexts.forEach((context) => {
141 const firstItemAccountID = context[0].account.id;
142 context.forEach((item) => {
143 if (item.account.id !== firstItemAccountID) {
144 item._differentAuthor = true;
145 }
146 });
147 });
148
149 if (contexts.length) console.log('🧵 Contexts', contexts);
150
151 const newItems = [];
152 const appliedContextIndices = [];
153 const inReplyToIds = [];
154 items.forEach((item) => {
155 if (item.reblog) {
156 newItems.push(item);
157 return;
158 }
159 for (let i = 0; i < contexts.length; i++) {
160 if (contexts[i].find((t) => t.id === item.id)) {
161 if (appliedContextIndices.includes(i)) return;
162 const contextItems = contexts[i];
163 contextItems.sort((a, b) => {
164 return Date.parse(a.createdAt) - Date.parse(b.createdAt);
165 });
166 const firstItemAccountID = contextItems[0].account.id;
167 newItems.push({
168 id: contextItems.map((i) => i.id),
169 items: contextItems,
170 type: contextItems.every((it) => it.account.id === firstItemAccountID)
171 ? 'thread'
172 : 'conversation',
173 });
174 appliedContextIndices.push(i);
175 return;
176 }
177 }
178
179 // PREPARE FOR REPLY HINTS
180 if (item.inReplyToId && item.inReplyToAccountId !== item.account.id) {
181 const sKey = statusKey(item.id, instance);
182 if (!states.statusReply[sKey]) {
183 // If it's a reply and not a thread
184 inReplyToIds.push({
185 sKey,
186 inReplyToId: item.inReplyToId,
187 });
188 // queueMicrotask(async () => {
189 // try {
190 // const { masto } = api({ instance });
191 // // const replyToStatus = await masto.v1.statuses
192 // // .$select(item.inReplyToId)
193 // // .fetch();
194 // const replyToStatus = await fetchStatus(item.inReplyToId, masto);
195 // saveStatus(replyToStatus, instance, {
196 // skipThreading: true,
197 // skipUnfurling: true,
198 // });
199 // states.statusReply[sKey] = {
200 // id: replyToStatus.id,
201 // instance,
202 // };
203 // } catch (e) {
204 // // Silently fail
205 // console.error(e);
206 // }
207 // });
208 }
209 }
210
211 newItems.push(item);
212 });
213
214 // FETCH AND SHOW REPLY HINTS
215 if (inReplyToIds?.length) {
216 queueMicrotask(() => {
217 const { masto } = api({ instance });
218 console.log('REPLYHINT', inReplyToIds);
219
220 // Fallback if batch fetch fails or returns nothing or not supported
221 async function fallbackFetch() {
222 for (let i = 0; i < inReplyToIds.length; i++) {
223 const { sKey, inReplyToId } = inReplyToIds[i];
224 try {
225 const replyToStatus = await fetchStatus(inReplyToId, masto);
226 saveStatus(replyToStatus, instance, {
227 skipThreading: true,
228 });
229 states.statusReply[sKey] = {
230 id: replyToStatus.id,
231 instance,
232 };
233 // Pause 1s
234 await new Promise((resolve) => setTimeout(resolve, 1000));
235 } catch (e) {
236 // Silently fail
237 console.error(e);
238 }
239 }
240 }
241
242 if (supports('@mastodon/fetch-multiple-statuses')) {
243 // This is batch fetching yooo, woot
244 // Limit 20, returns 422 if exceeded https://github.com/mastodon/mastodon/pull/27871
245 const ids = inReplyToIds.map(({ inReplyToId }) => inReplyToId);
246 (async () => {
247 try {
248 const replyToStatuses = await masto.v1.statuses.list({ id: ids });
249 if (replyToStatuses?.length) {
250 for (const replyToStatus of replyToStatuses) {
251 saveStatus(replyToStatus, instance, {
252 skipThreading: true,
253 });
254 const sKey = inReplyToIds.find(
255 ({ inReplyToId }) => inReplyToId === replyToStatus.id,
256 )?.sKey;
257 if (sKey) {
258 states.statusReply[sKey] = {
259 id: replyToStatus.id,
260 instance,
261 };
262 }
263 }
264 } else {
265 fallbackFetch();
266 }
267 } catch (e) {
268 // Silently fail
269 console.error(e);
270 fallbackFetch();
271 }
272 })();
273 } else {
274 fallbackFetch();
275 }
276 });
277 }
278
279 return newItems;
280}
281
282const fetchStatus = pmem((statusID, masto) => {
283 return masto.v1.statuses.$select(statusID).fetch();
284});
285
286export async function assignFollowedTags(items, instance) {
287 const followedTags = await getFollowedTags(); // [{name: 'tag'}, {...}]
288 if (!followedTags.length) return;
289 const { statusFollowedTags } = states;
290 console.log('statusFollowedTags', statusFollowedTags);
291 const statusWithFollowedTags = [];
292 items.forEach((item) => {
293 if (item.reblog) return;
294 const { id, content, tags = [] } = item;
295 const sKey = statusKey(id, instance);
296 if (statusFollowedTags[sKey]?.length) return;
297 const extractedTags = extractTagsFromStatus(content);
298 if (!extractedTags.length && !tags.length) return;
299 const itemFollowedTags = followedTags.reduce((acc, tag) => {
300 if (
301 extractedTags.some((t) => t.toLowerCase() === tag.name.toLowerCase()) ||
302 tags.some((t) => t.name.toLowerCase() === tag.name.toLowerCase())
303 ) {
304 acc.push(tag.name);
305 }
306 return acc;
307 }, []);
308 if (itemFollowedTags.length) {
309 // statusFollowedTags[sKey] = itemFollowedTags;
310 statusWithFollowedTags.push({
311 item,
312 sKey,
313 followedTags: itemFollowedTags,
314 });
315 }
316 });
317
318 if (statusWithFollowedTags.length) {
319 const accounts = statusWithFollowedTags.map((s) => s.item.account);
320 const relationships = await fetchRelationships(accounts);
321 if (!relationships) return;
322
323 statusWithFollowedTags.forEach((s) => {
324 const { item, sKey, followedTags } = s;
325 const r = relationships[item.account.id];
326 if (r && !r.following) {
327 statusFollowedTags[sKey] = followedTags;
328 }
329 });
330 }
331}
332
333export function clearFollowedTagsState() {
334 states.statusFollowedTags = {};
335}