mirror of
https://github.com/cheeaun/phanpy.git
synced 2025-02-16 23:31:19 +03:00
Experimental multi-hashtag timeline
This commit is contained in:
parent
49ef7e9ee4
commit
1f2dbb8e06
3 changed files with 253 additions and 12 deletions
21
src/app.css
21
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);
|
border-bottom: var(--hairline-width) solid var(--divider-color);
|
||||||
min-height: 3em;
|
min-height: 3em;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr max-content 1fr;
|
grid-template-columns: 1fr minmax(0, max-content) 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.deck > header .header-grid > .header-side:last-of-type {
|
.deck > header .header-grid > .header-side:last-of-type {
|
||||||
|
@ -126,6 +125,9 @@ a[href^='http'][rel*='nofollow']:visited:not(:has(div)) {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 1.2em;
|
font-size: 1.2em;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.deck > header .header-grid.header-grid-2 {
|
.deck > header .header-grid.header-grid-2 {
|
||||||
grid-template-columns: 1fr max-content;
|
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) {
|
.szh-menu__item:not(.szh-menu__item--disabled, .szh-menu__item--hover) {
|
||||||
color: var(--text-color);
|
color: var(--text-color);
|
||||||
}
|
}
|
||||||
.szh-menu .szh-menu__item--hover {
|
.szh-menu .szh-menu__item--hover:not(.menu-field) {
|
||||||
color: var(--button-text-color);
|
color: var(--button-text-color);
|
||||||
background-color: var(--button-bg-color);
|
background-color: var(--button-bg-color);
|
||||||
}
|
}
|
||||||
|
@ -1094,6 +1096,19 @@ body:has(.status-deck) .media-post-link {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
font-weight: normal;
|
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 */
|
/* GLASS MENU */
|
||||||
|
|
||||||
|
|
|
@ -75,7 +75,8 @@ const TYPE_PARAMS = {
|
||||||
text: '#',
|
text: '#',
|
||||||
name: 'hashtag',
|
name: 'hashtag',
|
||||||
type: 'text',
|
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 data = new FormData(e.target);
|
||||||
const result = {};
|
const result = {};
|
||||||
data.forEach((value, key) => {
|
data.forEach((value, key) => {
|
||||||
result[key] = value;
|
result[key] = value?.trim();
|
||||||
});
|
});
|
||||||
if (!result.type) return;
|
if (!result.type) return;
|
||||||
onSubmit(result);
|
onSubmit(result);
|
||||||
|
@ -348,7 +349,7 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {
|
||||||
</label>
|
</label>
|
||||||
</p>
|
</p>
|
||||||
{TYPE_PARAMS[currentType]?.map?.(
|
{TYPE_PARAMS[currentType]?.map?.(
|
||||||
({ text, name, type, placeholder }) => {
|
({ text, name, type, placeholder, pattern }) => {
|
||||||
if (currentType === 'list') {
|
if (currentType === 'list') {
|
||||||
return (
|
return (
|
||||||
<p>
|
<p>
|
||||||
|
@ -382,6 +383,7 @@ function ShortcutForm({ type, lists, followedHashtags, onSubmit, disabled }) {
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"
|
autocapitalize="off"
|
||||||
spellcheck={false}
|
spellcheck={false}
|
||||||
|
pattern={pattern}
|
||||||
/>
|
/>
|
||||||
{currentType === 'hashtag' && followedHashtags.length > 0 && (
|
{currentType === 'hashtag' && followedHashtags.length > 0 && (
|
||||||
<datalist id="followed-hashtags-datalist">
|
<datalist id="followed-hashtags-datalist">
|
||||||
|
|
|
@ -1,17 +1,39 @@
|
||||||
import { useRef } from 'preact/hooks';
|
import {
|
||||||
import { useParams } from 'react-router-dom';
|
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 Timeline from '../components/timeline';
|
||||||
import { api } from '../utils/api';
|
import { api } from '../utils/api';
|
||||||
|
import states from '../utils/states';
|
||||||
import useTitle from '../utils/useTitle';
|
import useTitle from '../utils/useTitle';
|
||||||
|
|
||||||
const LIMIT = 20;
|
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) {
|
function Hashtags(props) {
|
||||||
|
const navigate = useNavigate();
|
||||||
let { hashtag, ...params } = useParams();
|
let { hashtag, ...params } = useParams();
|
||||||
if (props.hashtag) hashtag = props.hashtag;
|
if (props.hashtag) hashtag = props.hashtag;
|
||||||
const { masto, instance } = api({ instance: params.instance });
|
let hashtags = hashtag.trim().split(/[\s+]+/);
|
||||||
const title = instance ? `#${hashtag} on ${instance}` : `#${hashtag}`;
|
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`);
|
useTitle(title, `/:instance?/t/:hashtag`);
|
||||||
const latestItem = useRef();
|
const latestItem = useRef();
|
||||||
|
|
||||||
|
@ -20,6 +42,7 @@ function Hashtags(props) {
|
||||||
if (firstLoad || !hashtagsIterator.current) {
|
if (firstLoad || !hashtagsIterator.current) {
|
||||||
hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, {
|
hashtagsIterator.current = masto.v1.timelines.listHashtag(hashtag, {
|
||||||
limit: LIMIT,
|
limit: LIMIT,
|
||||||
|
any: hashtags.slice(1),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const results = await hashtagsIterator.current.next();
|
const results = await hashtagsIterator.current.next();
|
||||||
|
@ -37,6 +60,7 @@ function Hashtags(props) {
|
||||||
const results = await masto.v1.timelines
|
const results = await masto.v1.timelines
|
||||||
.listHashtag(hashtag, {
|
.listHashtag(hashtag, {
|
||||||
limit: 1,
|
limit: 1,
|
||||||
|
any: hashtags.slice(1),
|
||||||
since_id: latestItem.current,
|
since_id: latestItem.current,
|
||||||
})
|
})
|
||||||
.next();
|
.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 (
|
return (
|
||||||
<Timeline
|
<Timeline
|
||||||
key={hashtag}
|
key={hashtagTitle}
|
||||||
title={title}
|
title={title}
|
||||||
titleComponent={
|
titleComponent={
|
||||||
!!instance && (
|
!!instance && (
|
||||||
<h1 class="header-account">
|
<h1 class="header-account">
|
||||||
<b>#{hashtag}</b>
|
<b>{hashtagTitle}</b>
|
||||||
<div>{instance}</div>
|
<div>{instance}</div>
|
||||||
</h1>
|
</h1>
|
||||||
)
|
)
|
||||||
|
@ -68,6 +109,189 @@ function Hashtags(props) {
|
||||||
errorText="Unable to load posts with this tag"
|
errorText="Unable to load posts with this tag"
|
||||||
fetchItems={fetchHashtags}
|
fetchItems={fetchHashtags}
|
||||||
checkForUpdates={checkForUpdates}
|
checkForUpdates={checkForUpdates}
|
||||||
|
headerEnd={
|
||||||
|
<Menu
|
||||||
|
portal={{
|
||||||
|
target: document.body,
|
||||||
|
}}
|
||||||
|
setDownOverflow
|
||||||
|
overflow="auto"
|
||||||
|
viewScroll="close"
|
||||||
|
position="anchor"
|
||||||
|
boundingBoxPadding="8 8 8 8"
|
||||||
|
menuButton={
|
||||||
|
<button type="button" class="plain">
|
||||||
|
<Icon icon="more" size="l" />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{!!info && hashtags.length === 1 && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
disabled={followUIState === 'loading' || !authenticated}
|
||||||
|
onClick={() => {
|
||||||
|
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 ? (
|
||||||
|
<>
|
||||||
|
<Icon icon="check-circle" /> <span>Following…</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="plus" /> <span>Follow</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuDivider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<FocusableItem className="menu-field" disabled={reachLimit}>
|
||||||
|
{({ ref }) => (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
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('+')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="hashtag" />
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
type="text"
|
||||||
|
placeholder={
|
||||||
|
reachLimit ? `Max ${TOTAL_TAGS_LIMIT} tags` : 'Add hashtag'
|
||||||
|
}
|
||||||
|
required
|
||||||
|
// no spaces, no hashtags
|
||||||
|
pattern="[^\s#]+"
|
||||||
|
disabled={reachLimit}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</FocusableItem>
|
||||||
|
<MenuGroup takeOverflow>
|
||||||
|
{hashtags.map((t, i) => (
|
||||||
|
<MenuItem
|
||||||
|
key={t}
|
||||||
|
onClick={(e) => {
|
||||||
|
hashtags.splice(i, 1);
|
||||||
|
hashtags.sort();
|
||||||
|
navigate(
|
||||||
|
instance
|
||||||
|
? `/${instance}/t/${hashtags.join('+')}`
|
||||||
|
: `/t/${hashtags.join('+')}`,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />{' '}
|
||||||
|
<Icon icon="hashtag" />
|
||||||
|
<span>{t}</span>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MenuGroup>
|
||||||
|
<MenuDivider />
|
||||||
|
<MenuItem
|
||||||
|
disabled={!authenticated}
|
||||||
|
onClick={() => {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon icon="shortcut" /> <span>Add to Shorcuts</span>
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue