this repo has no description

New experiment: followed tag indicator

+248 -31
+2 -7
src/components/shortcuts-settings.jsx
··· 13 13 import tabMenuBarUrl from '../assets/tab-menu-bar.svg'; 14 14 15 15 import { api } from '../utils/api'; 16 + import { fetchFollowedTags } from '../utils/followed-tags'; 16 17 import pmem from '../utils/pmem'; 17 18 import showToast from '../utils/show-toast'; 18 19 import states from '../utils/states'; ··· 500 501 (async () => { 501 502 if (currentType !== 'hashtag') return; 502 503 try { 503 - const iterator = masto.v1.followedTags.list(); 504 - const tags = []; 505 - do { 506 - const { value, done } = await iterator.next(); 507 - if (done || value?.length === 0) break; 508 - tags.push(...value); 509 - } while (true); 504 + const tags = await fetchFollowedTags(); 510 505 setFollowedHashtags(tags); 511 506 } catch (e) { 512 507 console.error(e);
+51 -1
src/components/status.css
··· 14 14 transparent min(160px, 50%) 15 15 ); 16 16 } 17 + .status-followed-tags { 18 + background: linear-gradient( 19 + 160deg, 20 + var(--hashtag-faded-color), 21 + transparent min(160px, 50%) 22 + ); 23 + } 17 24 .status-reply-to { 18 25 background: linear-gradient( 19 26 160deg, ··· 21 28 transparent min(160px, 50%) 22 29 ); 23 30 } 24 - :is(.status-reblog, .status-group) .status-reply-to { 31 + :is(.status-reblog, .status-group, .status-followed-tags) .status-reply-to { 25 32 background: linear-gradient( 26 33 -20deg, 27 34 var(--reply-to-faded-color), ··· 62 69 color: var(--group-color); 63 70 margin-right: 4px; 64 71 vertical-align: text-bottom; 72 + } 73 + .status-followed-tags { 74 + .status-pre-meta { 75 + position: relative; 76 + z-index: 1; 77 + display: flex; 78 + flex-wrap: wrap; 79 + gap: 4px; 80 + align-items: center; 81 + 82 + .icon { 83 + color: var(--hashtag-color); 84 + margin-right: 4px; 85 + vertical-align: text-bottom; 86 + } 87 + a { 88 + color: var(--hashtag-text-color); 89 + font-weight: bold; 90 + font-size: 12px; 91 + text-decoration-color: var(--hashtag-faded-color); 92 + text-underline-offset: 2px; 93 + text-decoration-thickness: 2px; 94 + display: inline-block; 95 + padding: 2px; 96 + vertical-align: top; 97 + text-transform: uppercase; 98 + text-shadow: 0 1px var(--bg-color); 99 + 100 + &:hover { 101 + color: var(--text-color); 102 + text-decoration-color: var(--hashtag-color); 103 + } 104 + } 105 + } 106 + 107 + .status-followed-tag-item { 108 + color: var(--hashtag-text-color); 109 + padding: 2px; 110 + font-weight: bold; 111 + font-size: 12px; 112 + text-transform: uppercase; 113 + margin-inline-end: 0.5em; 114 + } 65 115 } 66 116 67 117 /* STATUS */
+70 -8
src/components/status.jsx
··· 92 92 statusID, 93 93 status, 94 94 instance: propInstance, 95 + size = 'm', 96 + contentTextWeight, 97 + readOnly, 98 + enableCommentHint, 95 99 withinContext, 96 - size = 'm', 97 100 skeleton, 98 - readOnly, 99 - contentTextWeight, 100 101 enableTranslate, 101 102 forceTranslate: _forceTranslate, 102 103 previewMode, ··· 104 105 onMediaClick, 105 106 quoted, 106 107 onStatusLinkClick = () => {}, 107 - enableCommentHint, 108 + showFollowedTags, 108 109 }) { 109 110 if (skeleton) { 110 111 return ( ··· 174 175 uri, 175 176 url, 176 177 emojis, 178 + tags, 177 179 // Non-API props 178 180 _deleted, 179 181 _pinned, ··· 214 216 containerProps={{ 215 217 onMouseEnter: debugHover, 216 218 }} 219 + showFollowedTags 217 220 /> 218 221 ); 219 222 } ··· 292 295 <Status 293 296 status={statusID ? null : reblog} 294 297 statusID={statusID ? reblog.id : null} 298 + instance={instance} 299 + size={size} 300 + contentTextWeight={contentTextWeight} 301 + readOnly={readOnly} 302 + enableCommentHint 303 + /> 304 + </div> 305 + ); 306 + } 307 + 308 + // Check followedTags 309 + if (showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length) { 310 + return ( 311 + <div 312 + data-state-post-id={sKey} 313 + class="status-followed-tags" 314 + onMouseEnter={debugHover} 315 + > 316 + <div class="status-pre-meta"> 317 + <Icon icon="hashtag" size="l" />{' '} 318 + {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => ( 319 + <Link 320 + key={tag} 321 + to={instance ? `/${instance}/t/${tag}` : `/t/${tag}`} 322 + class="status-followed-tag-item" 323 + > 324 + {tag} 325 + </Link> 326 + ))} 327 + </div> 328 + <Status 329 + status={statusID ? null : status} 330 + statusID={statusID ? status.id : null} 295 331 instance={instance} 296 332 size={size} 297 333 contentTextWeight={contentTextWeight} ··· 2372 2408 2373 2409 const unfurlMastodonLink = throttle(_unfurlMastodonLink); 2374 2410 2375 - function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { 2411 + function FilteredStatus({ 2412 + status, 2413 + filterInfo, 2414 + instance, 2415 + containerProps = {}, 2416 + showFollowedTags, 2417 + }) { 2418 + const snapStates = useSnapshot(states); 2376 2419 const { 2377 2420 id: statusID, 2378 2421 account: { avatar, avatarStatic, bot, group }, ··· 2399 2442 ); 2400 2443 2401 2444 const statusPeekRef = useTruncated(); 2402 - const sKey = 2445 + const sKey = statusKey(status.id, instance); 2446 + const ssKey = 2403 2447 statusKey(status.id, instance) + 2404 2448 ' ' + 2405 2449 (statusKey(reblog?.id, instance) || ''); ··· 2408 2452 const url = instance 2409 2453 ? `/${instance}/s/${actualStatusID}` 2410 2454 : `/s/${actualStatusID}`; 2455 + const isFollowedTags = 2456 + showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length; 2411 2457 2412 2458 return ( 2413 2459 <div 2414 - class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''} 2460 + class={ 2461 + isReblog 2462 + ? group 2463 + ? 'status-group' 2464 + : 'status-reblog' 2465 + : isFollowedTags 2466 + ? 'status-followed-tags' 2467 + : '' 2468 + } 2415 2469 {...containerProps} 2416 2470 title={statusPeekText} 2417 2471 onContextMenu={(e) => { ··· 2420 2474 }} 2421 2475 {...bindLongPressPeek()} 2422 2476 > 2423 - <article data-state-post-id={sKey} class="status filtered" tabindex="-1"> 2477 + <article data-state-post-id={ssKey} class="status filtered" tabindex="-1"> 2424 2478 <b 2425 2479 class="status-filtered-badge clickable badge-meta" 2426 2480 title={filterTitleStr} ··· 2443 2497 />{' '} 2444 2498 {isReblog ? ( 2445 2499 'boosted' 2500 + ) : isFollowedTags ? ( 2501 + <span> 2502 + {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => ( 2503 + <span key={tag} class="status-followed-tag-item"> 2504 + #{tag} 2505 + </span> 2506 + ))} 2507 + </span> 2446 2508 ) : ( 2447 2509 <RelativeTime datetime={createdAtDate} format="micro" /> 2448 2510 )}
+9 -1
src/components/timeline.jsx
··· 44 44 refresh, 45 45 view, 46 46 filterContext, 47 + showFollowedTags, 47 48 }) { 48 49 const snapStates = useSnapshot(states); 49 50 const [items, setItems] = useState([]); ··· 391 392 filterContext={filterContext} 392 393 key={status.id + status?._pinned + view} 393 394 view={view} 395 + showFollowedTags={showFollowedTags} 394 396 /> 395 397 ))} 396 398 {showMore && ··· 478 480 // allowFilters, 479 481 filterContext, 480 482 view, 483 + showFollowedTags, 481 484 }) { 482 485 const { id: statusID, reblog, items, type, _pinned } = status; 483 486 if (_pinned) useItemID = false; ··· 567 570 !_differentAuthor && 568 571 !items[i - 1]._differentAuthor && 569 572 !items[i + 1]._differentAuthor))); 573 + const isStart = i === 0; 570 574 const isEnd = i === items.length - 1; 571 575 return ( 572 576 <li 573 577 key={`timeline-${statusID}`} 574 578 class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${ 575 - i === 0 ? 'start' : isEnd ? 'end' : 'middle' 579 + isStart ? 'start' : isEnd ? 'end' : 'middle' 576 580 } ${_differentAuthor ? 'timeline-item-diff-author' : ''}`} 577 581 > 578 582 <Link class="status-link timeline-item" to={url}> ··· 583 587 statusID={statusID} 584 588 instance={instance} 585 589 enableCommentHint={isEnd} 590 + showFollowedTags={showFollowedTags} 586 591 // allowFilters={allowFilters} 587 592 /> 588 593 ) : ( ··· 590 595 status={item} 591 596 instance={instance} 592 597 enableCommentHint={isEnd} 598 + showFollowedTags={showFollowedTags} 593 599 // allowFilters={allowFilters} 594 600 /> 595 601 )} ··· 631 637 statusID={statusID} 632 638 instance={instance} 633 639 enableCommentHint 640 + showFollowedTags={showFollowedTags} 634 641 // allowFilters={allowFilters} 635 642 /> 636 643 ) : ( ··· 638 645 status={status} 639 646 instance={instance} 640 647 enableCommentHint 648 + showFollowedTags={showFollowedTags} 641 649 // allowFilters={allowFilters} 642 650 /> 643 651 )}
+11
src/index.css
··· 54 54 --reply-to-text-color: #b36200; 55 55 --favourite-color: var(--red-color); 56 56 --reply-to-faded-color: #ffa60020; 57 + --hashtag-color: LightSeaGreen; 58 + --hashtag-faded-color: color-mix( 59 + in srgb, 60 + var(--hashtag-color) 15%, 61 + transparent 62 + ); 63 + --hashtag-text-color: color-mix( 64 + in lch, 65 + var(--hashtag-color) 40%, 66 + var(--text-color) 60% 67 + ); 57 68 --outline-color: rgba(128, 128, 128, 0.2); 58 69 --outline-hover-color: rgba(128, 128, 128, 0.7); 59 70 --divider-color: rgba(0, 0, 0, 0.1);
+2 -13
src/pages/followed-hashtags.jsx
··· 5 5 import Loader from '../components/loader'; 6 6 import NavMenu from '../components/nav-menu'; 7 7 import { api } from '../utils/api'; 8 + import { fetchFollowedTags } from '../utils/followed-tags'; 8 9 import useTitle from '../utils/useTitle'; 9 - 10 - const LIMIT = 200; 11 10 12 11 function FollowedHashtags() { 13 12 const { masto, instance } = api(); ··· 19 18 setUIState('loading'); 20 19 (async () => { 21 20 try { 22 - const iterator = masto.v1.followedTags.list({ 23 - limit: LIMIT, 24 - }); 25 - const tags = []; 26 - do { 27 - const { value, done } = await iterator.next(); 28 - if (done || value?.length === 0) break; 29 - tags.push(...value); 30 - } while (true); 31 - tags.sort((a, b) => a.name.localeCompare(b.name)); 32 - console.log(tags); 21 + const tags = await fetchFollowedTags(); 33 22 setFollowedHashtags(tags); 34 23 setUIState('default'); 35 24 } catch (e) {
+8 -1
src/pages/following.jsx
··· 6 6 import { filteredItems } from '../utils/filters'; 7 7 import states from '../utils/states'; 8 8 import { getStatus, saveStatus } from '../utils/states'; 9 - import { dedupeBoosts } from '../utils/timeline-utils'; 9 + import { 10 + assignFollowedTags, 11 + clearFollowedTagsState, 12 + dedupeBoosts, 13 + } from '../utils/timeline-utils'; 10 14 import useTitle from '../utils/useTitle'; 11 15 12 16 const LIMIT = 20; ··· 37 41 saveStatus(item, instance); 38 42 }); 39 43 value = dedupeBoosts(value, instance); 44 + if (firstLoad) clearFollowedTagsState(); 45 + assignFollowedTags(value, instance); 40 46 41 47 // ENFORCE sort by datetime (Latest first) 42 48 value.sort((a, b) => { ··· 118 124 {...props} 119 125 // allowFilters 120 126 filterContext="home" 127 + showFollowedTags 121 128 /> 122 129 ); 123 130 }
+62
src/utils/followed-tags.js
··· 1 + import { api } from '../utils/api'; 2 + import store from '../utils/store'; 3 + 4 + const LIMIT = 200; 5 + const MAX_FETCH = 10; 6 + 7 + export async function fetchFollowedTags() { 8 + const { masto } = api(); 9 + const iterator = masto.v1.followedTags.list({ 10 + limit: LIMIT, 11 + }); 12 + const tags = []; 13 + let fetchCount = 0; 14 + do { 15 + const { value, done } = await iterator.next(); 16 + if (done || value?.length === 0) break; 17 + tags.push(...value); 18 + fetchCount++; 19 + } while (fetchCount < MAX_FETCH); 20 + tags.sort((a, b) => a.name.localeCompare(b.name)); 21 + console.log(tags); 22 + 23 + if (tags.length) { 24 + setTimeout(() => { 25 + // Save to local storage, with saved timestamp 26 + store.account.set('followedTags', { 27 + tags, 28 + updatedAt: Date.now(), 29 + }); 30 + }, 1); 31 + } 32 + 33 + return tags; 34 + } 35 + 36 + const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day 37 + export async function getFollowedTags() { 38 + try { 39 + const { tags, updatedAt } = store.account.get('followedTags') || {}; 40 + if (!tags?.length) return await fetchFollowedTags(); 41 + if (Date.now() - updatedAt > MAX_AGE) { 42 + // Stale-while-revalidate 43 + fetchFollowedTags(); 44 + return tags; 45 + } 46 + return tags; 47 + } catch (e) { 48 + return []; 49 + } 50 + } 51 + 52 + const fauxDiv = document.createElement('div'); 53 + export const extractTagsFromStatus = (content) => { 54 + if (!content) return []; 55 + if (content.indexOf('#') === -1) return []; 56 + fauxDiv.innerHTML = content; 57 + const hashtagLinks = fauxDiv.querySelectorAll('a.hashtag'); 58 + if (!hashtagLinks.length) return []; 59 + return Array.from(hashtagLinks).map((a) => 60 + a.innerText.trim().replace(/^[^#]*#+/, ''), 61 + ); 62 + };
+1
src/utils/states.js
··· 31 31 scrollPositions: {}, 32 32 unfurledLinks: {}, 33 33 statusQuotes: {}, 34 + statusFollowedTags: {}, 34 35 accounts: {}, 35 36 routeNotification: null, 36 37 // Modals
+32
src/utils/timeline-utils.jsx
··· 1 + import { extractTagsFromStatus, getFollowedTags } from './followed-tags'; 2 + import states, { statusKey } from './states'; 1 3 import store from './store'; 2 4 3 5 export function groupBoosts(values) { ··· 175 177 176 178 return newItems; 177 179 } 180 + 181 + export async function assignFollowedTags(items, instance) { 182 + const followedTags = await getFollowedTags(); // [{name: 'tag'}, {...}] 183 + if (!followedTags.length) return; 184 + const { statusFollowedTags } = states; 185 + items.forEach((item) => { 186 + if (item.reblog) return; 187 + const { id, content, tags = [] } = item; 188 + const sKey = statusKey(id, instance); 189 + if (statusFollowedTags[sKey]?.length) return; 190 + const extractedTags = extractTagsFromStatus(content); 191 + if (!extractedTags.length && !tags.length) return; 192 + const itemFollowedTags = followedTags.reduce((acc, tag) => { 193 + if ( 194 + extractedTags.some((t) => t.toLowerCase() === tag.name.toLowerCase()) || 195 + tags.some((t) => t.name.toLowerCase() === tag.name.toLowerCase()) 196 + ) { 197 + acc.push(tag.name); 198 + } 199 + return acc; 200 + }, []); 201 + if (itemFollowedTags.length) { 202 + statusFollowedTags[sKey] = itemFollowedTags; 203 + } 204 + }); 205 + } 206 + 207 + export function clearFollowedTagsState() { 208 + states.statusFollowedTags = {}; 209 + }