diff --git a/src/app.css b/src/app.css index e36174c7..d6664e0b 100644 --- a/src/app.css +++ b/src/app.css @@ -109,9 +109,8 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { border-bottom: var(--hairline-width) solid var(--divider-color); min-height: 3em; display: grid; - grid-template-columns: 1fr max-content 1fr; + grid-template-columns: 1fr minmax(0, max-content) 1fr; align-items: center; - text-overflow: ellipsis; white-space: nowrap; } .deck > header .header-grid > .header-side:last-of-type { @@ -126,6 +125,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) { padding: 0; font-size: 1.2em; text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .deck > header .header-grid.header-grid-2 { grid-template-columns: 1fr max-content; @@ -1080,7 +1082,7 @@ body:has(.status-deck) .media-post-link { .szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) { color: var(--text-color); } -.szh-menu .szh-menu__item--hover { +.szh-menu .szh-menu__item--hover:not(.menu-field) { color: var(--button-text-color); background-color: var(--button-bg-color); } @@ -1094,6 +1096,19 @@ body:has(.status-deck) .media-post-link { opacity: 0.5; font-weight: normal; } +.szh-menu .szh-menu__item form { + display: flex; + flex: 1; + gap: 8px; + align-items: center; +} +.szh-menu .szh-menu__item form > input[type='text'] { + flex-grow: 1; +} +.szh-menu .szh-menu__item--hover .danger-icon { + color: var(--red-color); + opacity: 1; +} /* GLASS MENU */ diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx index d933ce27..d54e0a4c 100644 --- a/src/components/shortcuts-settings.jsx +++ b/src/components/shortcuts-settings.jsx @@ -75,7 +75,8 @@ const TYPE_PARAMS = { text: '#', name: 'hashtag', type: 'text', - placeholder: 'e.g PixelArt', + placeholder: 'e.g. PixelArt (Max 5, space-separated)', + pattern: '[^#]+', }, ], }; @@ -314,7 +315,7 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) { const data = new FormData(e.target); const result = {}; data.forEach((value, key) => { - result[key] = value; + result[key] = value?.trim(); }); if (!result.type) return; onSubmit(result); @@ -348,7 +349,7 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {

{TYPE_PARAMS[currentType]?.map?.( - ({ text, name, type, placeholder }) => { + ({ text, name, type, placeholder, pattern }) => { if (currentType === 'list') { return (

@@ -382,6 +383,7 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) { autocorrect="off" autocapitalize="off" spellcheck={false} + pattern={pattern} /> {currentType === 'hashtag' && followedHashtags.length > 0 && ( diff --git a/src/pages/hashtag.jsx b/src/pages/hashtag.jsx index c51ca326..5b58eca6 100644 --- a/src/pages/hashtag.jsx +++ b/src/pages/hashtag.jsx @@ -1,17 +1,39 @@ -import { useRef } from 'preact/hooks'; -import { useParams } from 'react-router-dom'; +import { + FocusableItem, + Menu, + MenuDivider, + MenuGroup, + MenuItem, +} from '@szhsin/react-menu'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { useNavigate, useParams } from 'react-router-dom'; +import Toastify from 'toastify-js'; +import Icon from '../components/icon'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; +import states from '../utils/states'; 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(props) { + const navigate = useNavigate(); let { hashtag, ...params } = useParams(); if (props.hashtag) hashtag = props.hashtag; - const { masto, instance } = api({ instance: params.instance }); - const title = instance ? `#${hashtag} on ${instance}` : `#${hashtag}`; + let hashtags = hashtag.trim().split(/[\s+]+/); + hashtags.sort(); + hashtag = hashtags[0]; + + const { masto, instance, authenticated } = api({ instance: params.instance }); + const hashtagTitle = hashtags.map((t) => `#${t}`).join(' '); + const title = instance ? `${hashtagTitle} on ${instance}` : hashtagTitle; useTitle(title, `/:instance?/t/:hashtag`); const latestItem = useRef(); @@ -20,6 +42,7 @@ function Hashtags(props) { if (firstLoad || !hashtagsIterator.current) { hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, { limit: LIMIT, + any: hashtags.slice(1), }); } const results = await hashtagsIterator.current.next(); @@ -37,6 +60,7 @@ function Hashtags(props) { const results = await masto.v1.timelines .listHashtag(hashtag, { limit: 1, + any: hashtags.slice(1), since_id: latestItem.current, }) .next(); @@ -50,14 +74,31 @@ function Hashtags(props) { } } + const [followUIState, setFollowUIState] = useState('default'); + const [info, setInfo] = useState(); + // Get hashtag info + useEffect(() => { + (async () => { + try { + const info = await masto.v1.tags.fetch(hashtag); + console.log(info); + setInfo(info); + } catch (e) { + console.error(e); + } + })(); + }, [hashtag]); + + const reachLimit = hashtags.length >= TOTAL_TAGS_LIMIT; + return (

{instance}
) @@ -68,6 +109,189 @@ function Hashtags(props) { errorText="Unable to load posts with this tag" fetchItems={fetchHashtags} checkForUpdates={checkForUpdates} + headerEnd={ + + + + } + > + {!!info && hashtags.length === 1 && ( + <> + { + setFollowUIState('loading'); + if (info.following) { + const yes = confirm(`Unfollow #${hashtag}?`); + if (!yes) { + setFollowUIState('default'); + return; + } + masto.v1.tags + .unfollow(hashtag) + .then(() => { + setInfo({ ...info, following: false }); + const toast = Toastify({ + className: 'shiny-pill', + text: `Unfollowed #${hashtag}`, + duration: 3000, + gravity: 'bottom', + position: 'center', + }); + toast.showToast(); + }) + .catch((e) => { + alert(e); + console.error(e); + }) + .finally(() => { + setFollowUIState('default'); + }); + } else { + masto.v1.tags + .follow(hashtag) + .then(() => { + setInfo({ ...info, following: true }); + const toast = Toastify({ + className: 'shiny-pill', + text: `Followed #${hashtag}`, + duration: 3000, + gravity: 'bottom', + position: 'center', + }); + toast.showToast(); + }) + .catch((e) => { + alert(e); + console.error(e); + }) + .finally(() => { + setFollowUIState('default'); + }); + } + }} + > + {info.following ? ( + <> + Following… + + ) : ( + <> + Follow + + )} + + + + )} + + {({ ref }) => ( +
{ + e.preventDefault(); + const newHashtag = e.target[0].value; + // 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('+')}`, + ); + } + }} + > + + + + )} +
+ + {hashtags.map((t, i) => ( + { + hashtags.splice(i, 1); + hashtags.sort(); + navigate( + instance + ? `/${instance}/t/${hashtags.join('+')}` + : `/t/${hashtags.join('+')}`, + ); + }} + > + {' '} + + {t} + + ))} + + + { + const shortcut = { + type: 'hashtag', + hashtag: hashtags.join(' '), + }; + // 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(' '), + ); + if (exists) { + alert('This shortcut already exists'); + } else { + states.shortcuts.push(shortcut); + const toast = Toastify({ + className: 'shiny-pill', + text: `Hashtag shortcut added`, + duration: 3000, + gravity: 'bottom', + position: 'center', + }); + toast.showToast(); + } + }} + > + Add to Shorcuts + +
+ } /> ); }