From aa8cbe046cb49eea6dc9e1ea60d3d4b71abdb75e Mon Sep 17 00:00:00 2001 From: Lim Chee Aun Date: Fri, 15 Dec 2023 01:58:29 +0800 Subject: [PATCH] New experiment: followed tag indicator --- src/components/shortcuts-settings.jsx | 9 +--- src/components/status.css | 52 +++++++++++++++++- src/components/status.jsx | 78 ++++++++++++++++++++++++--- src/components/timeline.jsx | 10 +++- src/index.css | 11 ++++ src/pages/followed-hashtags.jsx | 15 +----- src/pages/following.jsx | 9 +++- src/utils/followed-tags.js | 62 +++++++++++++++++++++ src/utils/states.js | 1 + src/utils/timeline-utils.jsx | 32 +++++++++++ 10 files changed, 248 insertions(+), 31 deletions(-) create mode 100644 src/utils/followed-tags.js diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx index 67570d9..ae4b0f7 100644 --- a/src/components/shortcuts-settings.jsx +++ b/src/components/shortcuts-settings.jsx @@ -13,6 +13,7 @@ import multiColumnUrl from '../assets/multi-column.svg'; import tabMenuBarUrl from '../assets/tab-menu-bar.svg'; import { api } from '../utils/api'; +import { fetchFollowedTags } from '../utils/followed-tags'; import pmem from '../utils/pmem'; import showToast from '../utils/show-toast'; import states from '../utils/states'; @@ -500,13 +501,7 @@ function ShortcutForm({ (async () => { if (currentType !== 'hashtag') return; try { - const iterator = masto.v1.followedTags.list(); - const tags = []; - do { - const { value, done } = await iterator.next(); - if (done || value?.length === 0) break; - tags.push(...value); - } while (true); + const tags = await fetchFollowedTags(); setFollowedHashtags(tags); } catch (e) { console.error(e); diff --git a/src/components/status.css b/src/components/status.css index 70355af..edee2e0 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -14,6 +14,13 @@ transparent min(160px, 50%) ); } +.status-followed-tags { + background: linear-gradient( + 160deg, + var(--hashtag-faded-color), + transparent min(160px, 50%) + ); +} .status-reply-to { background: linear-gradient( 160deg, @@ -21,7 +28,7 @@ transparent min(160px, 50%) ); } -:is(.status-reblog, .status-group) .status-reply-to { +:is(.status-reblog, .status-group, .status-followed-tags) .status-reply-to { background: linear-gradient( -20deg, var(--reply-to-faded-color), @@ -63,6 +70,49 @@ margin-right: 4px; vertical-align: text-bottom; } +.status-followed-tags { + .status-pre-meta { + position: relative; + z-index: 1; + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; + + .icon { + color: var(--hashtag-color); + margin-right: 4px; + vertical-align: text-bottom; + } + a { + color: var(--hashtag-text-color); + font-weight: bold; + font-size: 12px; + text-decoration-color: var(--hashtag-faded-color); + text-underline-offset: 2px; + text-decoration-thickness: 2px; + display: inline-block; + padding: 2px; + vertical-align: top; + text-transform: uppercase; + text-shadow: 0 1px var(--bg-color); + + &:hover { + color: var(--text-color); + text-decoration-color: var(--hashtag-color); + } + } + } + + .status-followed-tag-item { + color: var(--hashtag-text-color); + padding: 2px; + font-weight: bold; + font-size: 12px; + text-transform: uppercase; + margin-inline-end: 0.5em; + } +} /* STATUS */ diff --git a/src/components/status.jsx b/src/components/status.jsx index 0b58629..b323724 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -92,11 +92,12 @@ function Status({ statusID, status, instance: propInstance, - withinContext, size = 'm', - skeleton, - readOnly, contentTextWeight, + readOnly, + enableCommentHint, + withinContext, + skeleton, enableTranslate, forceTranslate: _forceTranslate, previewMode, @@ -104,7 +105,7 @@ function Status({ onMediaClick, quoted, onStatusLinkClick = () => {}, - enableCommentHint, + showFollowedTags, }) { if (skeleton) { return ( @@ -174,6 +175,7 @@ function Status({ uri, url, emojis, + tags, // Non-API props _deleted, _pinned, @@ -214,6 +216,7 @@ function Status({ containerProps={{ onMouseEnter: debugHover, }} + showFollowedTags /> ); } @@ -302,6 +305,39 @@ function Status({ ); } + // Check followedTags + if (showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length) { + return ( +
+
+ {' '} + {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ +
+ ); + } + const isSizeLarge = size === 'l'; const [forceTranslate, setForceTranslate] = useState(_forceTranslate); @@ -2372,7 +2408,14 @@ function nicePostURL(url) { const unfurlMastodonLink = throttle(_unfurlMastodonLink); -function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { +function FilteredStatus({ + status, + filterInfo, + instance, + containerProps = {}, + showFollowedTags, +}) { + const snapStates = useSnapshot(states); const { id: statusID, account: { avatar, avatarStatic, bot, group }, @@ -2399,7 +2442,8 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { ); const statusPeekRef = useTruncated(); - const sKey = + const sKey = statusKey(status.id, instance); + const ssKey = statusKey(status.id, instance) + ' ' + (statusKey(reblog?.id, instance) || ''); @@ -2408,10 +2452,20 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { const url = instance ? `/${instance}/s/${actualStatusID}` : `/s/${actualStatusID}`; + const isFollowedTags = + showFollowedTags && !!snapStates.statusFollowedTags[sKey]?.length; return (
{ @@ -2420,7 +2474,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) { }} {...bindLongPressPeek()} > -
+
{' '} {isReblog ? ( 'boosted' + ) : isFollowedTags ? ( + + {snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => ( + + #{tag} + + ))} + ) : ( )} diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index 942f21e..e390133 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -44,6 +44,7 @@ function Timeline({ refresh, view, filterContext, + showFollowedTags, }) { const snapStates = useSnapshot(states); const [items, setItems] = useState([]); @@ -391,6 +392,7 @@ function Timeline({ filterContext={filterContext} key={status.id + status?._pinned + view} view={view} + showFollowedTags={showFollowedTags} /> ))} {showMore && @@ -478,6 +480,7 @@ function TimelineItem({ // allowFilters, filterContext, view, + showFollowedTags, }) { const { id: statusID, reblog, items, type, _pinned } = status; if (_pinned) useItemID = false; @@ -567,12 +570,13 @@ function TimelineItem({ !_differentAuthor && !items[i - 1]._differentAuthor && !items[i + 1]._differentAuthor))); + const isStart = i === 0; const isEnd = i === items.length - 1; return (
  • @@ -583,6 +587,7 @@ function TimelineItem({ statusID={statusID} instance={instance} enableCommentHint={isEnd} + showFollowedTags={showFollowedTags} // allowFilters={allowFilters} /> ) : ( @@ -590,6 +595,7 @@ function TimelineItem({ status={item} instance={instance} enableCommentHint={isEnd} + showFollowedTags={showFollowedTags} // allowFilters={allowFilters} /> )} @@ -631,6 +637,7 @@ function TimelineItem({ statusID={statusID} instance={instance} enableCommentHint + showFollowedTags={showFollowedTags} // allowFilters={allowFilters} /> ) : ( @@ -638,6 +645,7 @@ function TimelineItem({ status={status} instance={instance} enableCommentHint + showFollowedTags={showFollowedTags} // allowFilters={allowFilters} /> )} diff --git a/src/index.css b/src/index.css index 8fcbe49..52abb55 100644 --- a/src/index.css +++ b/src/index.css @@ -54,6 +54,17 @@ --reply-to-text-color: #b36200; --favourite-color: var(--red-color); --reply-to-faded-color: #ffa60020; + --hashtag-color: LightSeaGreen; + --hashtag-faded-color: color-mix( + in srgb, + var(--hashtag-color) 15%, + transparent + ); + --hashtag-text-color: color-mix( + in lch, + var(--hashtag-color) 40%, + var(--text-color) 60% + ); --outline-color: rgba(128, 128, 128, 0.2); --outline-hover-color: rgba(128, 128, 128, 0.7); --divider-color: rgba(0, 0, 0, 0.1); diff --git a/src/pages/followed-hashtags.jsx b/src/pages/followed-hashtags.jsx index 8fd4a29..36f32e1 100644 --- a/src/pages/followed-hashtags.jsx +++ b/src/pages/followed-hashtags.jsx @@ -5,10 +5,9 @@ import Link from '../components/link'; import Loader from '../components/loader'; import NavMenu from '../components/nav-menu'; import { api } from '../utils/api'; +import { fetchFollowedTags } from '../utils/followed-tags'; import useTitle from '../utils/useTitle'; -const LIMIT = 200; - function FollowedHashtags() { const { masto, instance } = api(); useTitle(`Followed Hashtags`, `/ft`); @@ -19,17 +18,7 @@ function FollowedHashtags() { setUIState('loading'); (async () => { try { - const iterator = masto.v1.followedTags.list({ - limit: LIMIT, - }); - const tags = []; - do { - const { value, done } = await iterator.next(); - if (done || value?.length === 0) break; - tags.push(...value); - } while (true); - tags.sort((a, b) => a.name.localeCompare(b.name)); - console.log(tags); + const tags = await fetchFollowedTags(); setFollowedHashtags(tags); setUIState('default'); } catch (e) { diff --git a/src/pages/following.jsx b/src/pages/following.jsx index be40907..3fab5e2 100644 --- a/src/pages/following.jsx +++ b/src/pages/following.jsx @@ -6,7 +6,11 @@ import { api } from '../utils/api'; import { filteredItems } from '../utils/filters'; import states from '../utils/states'; import { getStatus, saveStatus } from '../utils/states'; -import { dedupeBoosts } from '../utils/timeline-utils'; +import { + assignFollowedTags, + clearFollowedTagsState, + dedupeBoosts, +} from '../utils/timeline-utils'; import useTitle from '../utils/useTitle'; const LIMIT = 20; @@ -37,6 +41,8 @@ function Following({ title, path, id, ...props }) { saveStatus(item, instance); }); value = dedupeBoosts(value, instance); + if (firstLoad) clearFollowedTagsState(); + assignFollowedTags(value, instance); // ENFORCE sort by datetime (Latest first) value.sort((a, b) => { @@ -118,6 +124,7 @@ function Following({ title, path, id, ...props }) { {...props} // allowFilters filterContext="home" + showFollowedTags /> ); } diff --git a/src/utils/followed-tags.js b/src/utils/followed-tags.js new file mode 100644 index 0000000..6a80e16 --- /dev/null +++ b/src/utils/followed-tags.js @@ -0,0 +1,62 @@ +import { api } from '../utils/api'; +import store from '../utils/store'; + +const LIMIT = 200; +const MAX_FETCH = 10; + +export async function fetchFollowedTags() { + const { masto } = api(); + const iterator = masto.v1.followedTags.list({ + limit: LIMIT, + }); + const tags = []; + let fetchCount = 0; + do { + const { value, done } = await iterator.next(); + if (done || value?.length === 0) break; + tags.push(...value); + fetchCount++; + } while (fetchCount < MAX_FETCH); + tags.sort((a, b) => a.name.localeCompare(b.name)); + console.log(tags); + + if (tags.length) { + setTimeout(() => { + // Save to local storage, with saved timestamp + store.account.set('followedTags', { + tags, + updatedAt: Date.now(), + }); + }, 1); + } + + return tags; +} + +const MAX_AGE = 24 * 60 * 60 * 1000; // 1 day +export async function getFollowedTags() { + try { + const { tags, updatedAt } = store.account.get('followedTags') || {}; + if (!tags?.length) return await fetchFollowedTags(); + if (Date.now() - updatedAt > MAX_AGE) { + // Stale-while-revalidate + fetchFollowedTags(); + return tags; + } + return tags; + } catch (e) { + return []; + } +} + +const fauxDiv = document.createElement('div'); +export const extractTagsFromStatus = (content) => { + if (!content) return []; + if (content.indexOf('#') === -1) return []; + fauxDiv.innerHTML = content; + const hashtagLinks = fauxDiv.querySelectorAll('a.hashtag'); + if (!hashtagLinks.length) return []; + return Array.from(hashtagLinks).map((a) => + a.innerText.trim().replace(/^[^#]*#+/, ''), + ); +}; diff --git a/src/utils/states.js b/src/utils/states.js index 314af7f..6ab72c9 100644 --- a/src/utils/states.js +++ b/src/utils/states.js @@ -31,6 +31,7 @@ const states = proxy({ scrollPositions: {}, unfurledLinks: {}, statusQuotes: {}, + statusFollowedTags: {}, accounts: {}, routeNotification: null, // Modals diff --git a/src/utils/timeline-utils.jsx b/src/utils/timeline-utils.jsx index 1ed2d96..7ba3953 100644 --- a/src/utils/timeline-utils.jsx +++ b/src/utils/timeline-utils.jsx @@ -1,3 +1,5 @@ +import { extractTagsFromStatus, getFollowedTags } from './followed-tags'; +import states, { statusKey } from './states'; import store from './store'; export function groupBoosts(values) { @@ -175,3 +177,33 @@ export function groupContext(items) { return newItems; } + +export async function assignFollowedTags(items, instance) { + const followedTags = await getFollowedTags(); // [{name: 'tag'}, {...}] + if (!followedTags.length) return; + const { statusFollowedTags } = states; + items.forEach((item) => { + if (item.reblog) return; + const { id, content, tags = [] } = item; + const sKey = statusKey(id, instance); + if (statusFollowedTags[sKey]?.length) return; + const extractedTags = extractTagsFromStatus(content); + if (!extractedTags.length && !tags.length) return; + const itemFollowedTags = followedTags.reduce((acc, tag) => { + if ( + extractedTags.some((t) => t.toLowerCase() === tag.name.toLowerCase()) || + tags.some((t) => t.name.toLowerCase() === tag.name.toLowerCase()) + ) { + acc.push(tag.name); + } + return acc; + }, []); + if (itemFollowedTags.length) { + statusFollowedTags[sKey] = itemFollowedTags; + } + }); +} + +export function clearFollowedTagsState() { + states.statusFollowedTags = {}; +}