diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx
index 67570d98..ae4b0f74 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 70355af4..edee2e0b 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 0b58629e..b3237241 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 (
+
+ );
+ }
+
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 942f21ea..e390133e 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 8fcbe497..52abb558 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 8fd4a29b..36f32e16 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 be409077..3fab5e21 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 00000000..6a80e163
--- /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 314af7fb..6ab72c9a 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 1ed2d96a..7ba39535 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 = {};
+}