this repo has no description
at main 335 lines 10 kB view raw
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}