mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-03-14 12:18:30 +03:00
New experiment: followed tag indicator
This commit is contained in:
parent
b34ef09411
commit
aa8cbe046c
10 changed files with 248 additions and 31 deletions
|
@ -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);
|
||||
|
|
|
@ -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 */
|
||||
|
||||
|
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
62
src/utils/followed-tags.js
Normal file
62
src/utils/followed-tags.js
Normal file
|
@ -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(/^[^#]*#+/, ''),
|
||||
);
|
||||
};
|
|
@ -31,6 +31,7 @@ const states = proxy({
|
|||
scrollPositions: {},
|
||||
unfurledLinks: {},
|
||||
statusQuotes: {},
|
||||
statusFollowedTags: {},
|
||||
accounts: {},
|
||||
routeNotification: null,
|
||||
// Modals
|
||||
|
|
|
@ -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 = {};
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue