import { FocusableItem, MenuDivider, MenuGroup, MenuHeader, MenuItem, } from '@szhsin/react-menu'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import Icon from '../components/icon'; import Menu2 from '../components/menu2'; import MenuConfirm from '../components/menu-confirm'; import { SHORTCUTS_LIMIT } from '../components/shortcuts-settings'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; import { filteredItems } from '../utils/filters'; import showToast from '../utils/show-toast'; import states from '../utils/states'; import { saveStatus } from '../utils/states'; import { isMediaFirstInstance } from '../utils/store-utils'; import useTitle from '../utils/useTitle'; const LIMIT = 20; // Limit is 4 per "mode" // https://github.com/mastodon/mastodon/issues/15194 // Hard-coded https://github.com/mastodon/mastodon/blob/19614ba2477f3d12468f5ec251ce1cc5f8c6210c/app/models/tag_feed.rb#L4 const TAGS_LIMIT_PER_MODE = 4; const TOTAL_TAGS_LIMIT = TAGS_LIMIT_PER_MODE + 1; function Hashtags({ media: mediaView, columnMode, ...props }) { // const navigate = useNavigate(); let { hashtag, ...params } = columnMode ? {} : useParams(); if (props.hashtag) hashtag = props.hashtag; let hashtags = hashtag.trim().split(/[\s+]+/); hashtags.sort(); hashtag = hashtags[0]; const [searchParams, setSearchParams] = useSearchParams(); const media = mediaView || !!searchParams.get('media'); const linkParams = media ? '?media=1' : ''; const { masto, instance, authenticated } = api({ instance: props?.instance || params.instance, }); const { masto: currentMasto, instance: currentInstance, authenticated: currentAuthenticated, } = api(); const hashtagTitle = hashtags.map((t) => `#${t}`).join(' '); const hashtagPostTitle = media ? ` (Media only)` : ''; const title = instance ? `${hashtagTitle}${hashtagPostTitle} on ${instance}` : `${hashtagTitle}${hashtagPostTitle}`; useTitle(title, `/:instance?/t/:hashtag`); const latestItem = useRef(); const mediaFirst = useMemo(() => isMediaFirstInstance(), []); // const hashtagsIterator = useRef(); const maxID = useRef(undefined); async function fetchHashtags(firstLoad) { // if (firstLoad || !hashtagsIterator.current) { // hashtagsIterator.current = masto.v1.timelines.tag.$select(hashtag).list({ // limit: LIMIT, // any: hashtags.slice(1), // }); // } // const results = await hashtagsIterator.current.next(); // NOTE: Temporary fix for listHashtag not persisting `any` in subsequent calls. const results = await masto.v1.timelines.tag .$select(hashtag) .list({ limit: LIMIT, any: hashtags.slice(1), maxId: firstLoad ? undefined : maxID.current, onlyMedia: media ? true : undefined, }) .next(); let { value } = results; if (value?.length) { if (firstLoad) { latestItem.current = value[0].id; } // value = filteredItems(value, 'public'); value.forEach((item) => { saveStatus(item, instance, { skipThreading: media || mediaFirst, // If media view, no need to form threads }); }); maxID.current = value[value.length - 1].id; } return { ...results, value, }; } async function checkForUpdates() { try { const results = await masto.v1.timelines.tag .$select(hashtag) .list({ limit: 1, any: hashtags.slice(1), since_id: latestItem.current, onlyMedia: media, }) .next(); let { value } = results; const valueContainsLatestItem = value[0]?.id === latestItem.current; // since_id might not be supported if (value?.length && !valueContainsLatestItem) { value = filteredItems(value, 'public'); return true; } return false; } catch (e) { return false; } } const [followUIState, setFollowUIState] = useState('default'); const [info, setInfo] = useState(); // Get hashtag info useEffect(() => { (async () => { try { const info = await masto.v1.tags.$select(hashtag).fetch(); console.log(info); setInfo(info); } catch (e) { console.error(e); } })(); }, [hashtag]); const reachLimit = hashtags.length >= TOTAL_TAGS_LIMIT; return ( {hashtagTitle}
{instance}
) } id="hashtag" instance={instance} emptyText="No one has posted anything with this tag yet." errorText="Unable to load posts with this tag" fetchItems={fetchHashtags} checkForUpdates={checkForUpdates} useItemID view={media || mediaFirst ? 'media' : undefined} refresh={media} // allowFilters filterContext="public" headerEnd={ } > {!!info && hashtags.length === 1 && ( <> { setFollowUIState('loading'); if (info.following) { // const yes = confirm(`Unfollow #${hashtag}?`); // if (!yes) { // setFollowUIState('default'); // return; // } masto.v1.tags .$select(hashtag) .unfollow() .then(() => { setInfo({ ...info, following: false }); showToast(`Unfollowed #${hashtag}`); }) .catch((e) => { alert(e); console.error(e); }) .finally(() => { setFollowUIState('default'); }); } else { masto.v1.tags .$select(hashtag) .follow() .then(() => { setInfo({ ...info, following: true }); showToast(`Followed #${hashtag}`); }) .catch((e) => { alert(e); console.error(e); }) .finally(() => { setFollowUIState('default'); }); } }} > {info.following ? ( <> Following… ) : ( <> Follow )} )} {!mediaFirst && ( <> Filters { if (media) { searchParams.delete('media'); } else { searchParams.set('media', '1'); } setSearchParams(searchParams); }} > {' '} Media only )} {({ ref }) => (
{ e.preventDefault(); const newHashtag = e.target[0].value?.trim?.(); // Use includes but need to be case insensitive if ( newHashtag && !hashtags.some( (t) => t.toLowerCase() === newHashtag.toLowerCase(), ) ) { hashtags.push(newHashtag); hashtags.sort(); // navigate( // instance // ? `/${instance}/t/${hashtags.join('+')}` // : `/t/${hashtags.join('+')}`, // ); location.hash = instance ? `/${instance}/t/${hashtags.join('+')}` : `/t/${hashtags.join('+')}${linkParams}`; } }} > )}
{hashtags.map((t, i) => ( { hashtags.splice(i, 1); hashtags.sort(); // navigate( // instance // ? `/${instance}/t/${hashtags.join('+')}` // : `/t/${hashtags.join('+')}`, // ); location.hash = instance ? `/${instance}/t/${hashtags.join('+')}${linkParams}` : `/t/${hashtags.join('+')}${linkParams}`; }} > # {t} ))} { if (states.shortcuts.length >= SHORTCUTS_LIMIT) { alert( `Max ${SHORTCUTS_LIMIT} shortcuts reached. Unable to add shortcut.`, ); return; } const shortcut = { type: 'hashtag', hashtag: hashtags.join(' '), instance, media: media ? 'on' : undefined, }; // Check if already exists const exists = states.shortcuts.some( (s) => s.type === shortcut.type && s.hashtag .split(/[\s+]+/) .sort() .join(' ') === shortcut.hashtag .split(/[\s+]+/) .sort() .join(' ') && (s.instance ? s.instance === shortcut.instance : true) && (s.media ? !!s.media === !!shortcut.media : true), ); if (exists) { alert('This shortcut already exists'); } else { states.shortcuts.push(shortcut); showToast(`Hashtag shortcut added`); } }} > Add to Shorcuts { let newInstance = prompt( 'Enter a new instance e.g. "mastodon.social"', ); if (!/\./.test(newInstance)) { if (newInstance) alert('Invalid instance'); return; } if (newInstance) { newInstance = newInstance.toLowerCase().trim(); // navigate(`/${newInstance}/t/${hashtags.join('+')}`); location.hash = `/${newInstance}/t/${hashtags.join( '+', )}${linkParams}`; } }} > Go to another instance… {currentInstance !== instance && ( { location.hash = `/${currentInstance}/t/${hashtags.join( '+', )}${linkParams}`; }} > {' '} Go to my instance ({currentInstance}) )}
} /> ); } export default Hashtags;