New experiment: followed tag indicator

pull/368/head
Lim Chee Aun 2023-12-15 01:58:29 +08:00
rodzic b34ef09411
commit aa8cbe046c
10 zmienionych plików z 248 dodań i 31 usunięć

Wyświetl plik

@ -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);

Wyświetl plik

@ -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 */

Wyświetl plik

@ -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 (
<div
data-state-post-id={sKey}
class="status-followed-tags"
onMouseEnter={debugHover}
>
<div class="status-pre-meta">
<Icon icon="hashtag" size="l" />{' '}
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
<Link
key={tag}
to={instance ? `/${instance}/t/${tag}` : `/t/${tag}`}
class="status-followed-tag-item"
>
{tag}
</Link>
))}
</div>
<Status
status={statusID ? null : status}
statusID={statusID ? status.id : null}
instance={instance}
size={size}
contentTextWeight={contentTextWeight}
readOnly={readOnly}
enableCommentHint
/>
</div>
);
}
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 (
<div
class={isReblog ? (group ? 'status-group' : 'status-reblog') : ''}
class={
isReblog
? group
? 'status-group'
: 'status-reblog'
: isFollowedTags
? 'status-followed-tags'
: ''
}
{...containerProps}
title={statusPeekText}
onContextMenu={(e) => {
@ -2420,7 +2474,7 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
}}
{...bindLongPressPeek()}
>
<article data-state-post-id={sKey} class="status filtered" tabindex="-1">
<article data-state-post-id={ssKey} class="status filtered" tabindex="-1">
<b
class="status-filtered-badge clickable badge-meta"
title={filterTitleStr}
@ -2443,6 +2497,14 @@ function FilteredStatus({ status, filterInfo, instance, containerProps = {} }) {
/>{' '}
{isReblog ? (
'boosted'
) : isFollowedTags ? (
<span>
{snapStates.statusFollowedTags[sKey].slice(0, 3).map((tag) => (
<span key={tag} class="status-followed-tag-item">
#{tag}
</span>
))}
</span>
) : (
<RelativeTime datetime={createdAtDate} format="micro" />
)}

Wyświetl plik

@ -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 (
<li
key={`timeline-${statusID}`}
class={`timeline-item-container timeline-item-container-type-${type} timeline-item-container-${
i === 0 ? 'start' : isEnd ? 'end' : 'middle'
isStart ? 'start' : isEnd ? 'end' : 'middle'
} ${_differentAuthor ? 'timeline-item-diff-author' : ''}`}
>
<Link class="status-link timeline-item" to={url}>
@ -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}
/>
)}

Wyświetl plik

@ -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);

Wyświetl plik

@ -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) {

Wyświetl plik

@ -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
/>
);
}

Wyświetl plik

@ -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(/^[^#]*#+/, ''),
);
};

Wyświetl plik

@ -31,6 +31,7 @@ const states = proxy({
scrollPositions: {},
unfurledLinks: {},
statusQuotes: {},
statusFollowedTags: {},
accounts: {},
routeNotification: null,
// Modals

Wyświetl plik

@ -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 = {};
}