Add 'more' menu

- Refactor Toast
- Fix locale for datetime strings in status
- Nicer shadow for menus
This commit is contained in:
Lim Chee Aun 2023-02-27 00:55:04 +08:00
parent f7b398e078
commit 8aaba24d1f
7 changed files with 423 additions and 212 deletions

View file

@ -1042,7 +1042,7 @@ body:has(.status-deck) .media-post-link {
background-color: var(--bg-color);
border: 1px solid var(--outline-color);
border-radius: 8px;
box-shadow: 0 3px 6px var(--drop-shadow-color);
box-shadow: 0 3px 16px -3px var(--drop-shadow-color);
text-align: left;
animation: appear-smooth 0.15s ease-in-out;
width: 16em;
@ -1052,8 +1052,25 @@ body:has(.status-deck) .media-post-link {
.szh-menu__item--focusable {
background-color: transparent;
}
.szh-menu__header {
margin: -8px 0 8px;
padding: 8px 16px;
color: var(--text-insignificant-color);
font-size: 90%;
background-color: var(--bg-faded-color);
/* background-image: linear-gradient(to top, var(--bg-faded-color), transparent); */
text-shadow: 0 1px 0 var(--bg-color);
line-height: 1.2;
/* border-bottom: 1px solid var(--outline-color); */
}
.szh-menu__header * {
vertical-align: middle;
}
.szh-menu .szh-menu__item {
display: block;
display: flex;
gap: 8px;
align-items: center;
line-height: 1;
padding: 8px 16px !important;
transition: all 0.1s ease-in-out;
white-space: nowrap;
@ -1065,14 +1082,15 @@ body:has(.status-deck) .media-post-link {
vertical-align: middle;
}
.szh-menu .szh-menu__item a {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
gap: 8px;
color: inherit;
text-decoration: none;
padding: 8px 16px !important;
margin: -8px -16px !important;
gap: 8px;
}
.szh-menu .szh-menu__item a.is-active {
font-weight: bold;

View file

@ -14,7 +14,6 @@ import {
useLocation,
useNavigate,
} from 'react-router-dom';
import Toastify from 'toastify-js';
import { useSnapshot } from 'valtio';
import Account from './components/account';
@ -53,6 +52,7 @@ import {
initPreferences,
} from './utils/api';
import { getAccessToken } from './utils/auth';
import showToast from './utils/show-toast';
import states, { getStatus, saveStatus } from './utils/states';
import store from './utils/store';
import { getCurrentAccount } from './utils/store-utils';
@ -328,26 +328,20 @@ function App() {
window.__COMPOSE__ = null;
if (newStatus) {
states.reloadStatusPage++;
setTimeout(() => {
const toast = Toastify({
className: 'shiny-pill',
text: 'Status posted. Check it out.',
duration: 10_000, // 10 seconds
gravity: 'bottom',
position: 'center',
// destination: `/#/s/${newStatus.id}`,
onClick: () => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
toast.showToast();
}, 1000);
showToast({
text: 'Status posted. Check it out.',
delay: 1000,
duration: 10_000, // 10 seconds
onClick: (toast) => {
toast.hideToast();
states.prevLocation = location;
navigate(
instance
? `/${instance}/s/${newStatus.id}`
: `/s/${newStatus.id}`,
);
},
});
}
}}
/>

View file

@ -58,6 +58,9 @@ const ICONS = {
following: 'mingcute:walk-line',
pin: 'mingcute:pin-line',
bus: 'mingcute:bus-2-line',
link: 'mingcute:link-2-line',
history: 'mingcute:history-line',
share: 'mingcute:share-2-line',
};
const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js');

View file

@ -1,6 +1,6 @@
import './status.css';
import { Menu, MenuItem } from '@szhsin/react-menu';
import { Menu, MenuDivider, MenuHeader, MenuItem } from '@szhsin/react-menu';
import mem from 'mem';
import pThrottle from 'p-throttle';
import { memo } from 'preact/compat';
@ -17,6 +17,7 @@ import enhanceContent from '../utils/enhance-content';
import handleContentLinks from '../utils/handle-content-links';
import htmlContentLength from '../utils/html-content-length';
import shortenNumber from '../utils/shorten-number';
import showToast from '../utils/show-toast';
import states, { saveStatus, statusKey } from '../utils/states';
import store from '../utils/store';
import visibilityIconsMap from '../utils/visibility-icons-map';
@ -25,6 +26,7 @@ import Avatar from './avatar';
import Icon from './icon';
import Link from './link';
import Media from './media';
import MenuLink from './MenuLink';
import RelativeTime from './relative-time';
const throttle = pThrottle({
@ -41,6 +43,13 @@ function fetchAccount(id, masto) {
}
const memFetchAccount = mem(fetchAccount);
const visibilityText = {
public: 'Public',
unlisted: 'Unlisted',
private: 'Followers only',
direct: 'Mentioned people only',
};
function Status({
statusID,
status,
@ -217,6 +226,276 @@ function Status({
const textWeight = () =>
Math.round((spoilerText.length + htmlContentLength(content)) / 140) || 1;
const locale = new Intl.DateTimeFormat().resolvedOptions().locale;
const createdDateText = Intl.DateTimeFormat(locale, {
// Show year if not current year
year: createdAtDate.getFullYear() === currentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(createdAtDate);
const editedDateText =
editedAt &&
Intl.DateTimeFormat(locale, {
// Show year if not this year
year: editedAtDate.getFullYear() === currentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(editedAtDate);
const isSizeLarge = size === 'l';
// TODO: if visibility = private, only can boost own statuses
const canBoost = authenticated && visibility !== 'direct';
const replyStatus = () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
states.showCompose = {
replyToStatus: status,
};
};
const boostStatus = async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
if (!reblogged) {
const yes = confirm('Boost this post?');
if (!yes) {
return;
}
}
// Optimistic
states.statuses[sKey] = {
...status,
reblogged: !reblogged,
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
if (reblogged) {
const newStatus = await masto.v1.statuses.unreblog(id);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.reblog(id);
saveStatus(newStatus, instance);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
};
const favouriteStatus = async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
// Optimistic
states.statuses[sKey] = {
...status,
favourited: !favourited,
favouritesCount: favouritesCount + (favourited ? -1 : 1),
};
if (favourited) {
const newStatus = await masto.v1.statuses.unfavourite(id);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.favourite(id);
saveStatus(newStatus, instance);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
};
const bookmarkStatus = async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
// Optimistic
states.statuses[sKey] = {
...status,
bookmarked: !bookmarked,
};
if (bookmarked) {
const newStatus = await masto.v1.statuses.unbookmark(id);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.bookmark(id);
saveStatus(newStatus, instance);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
};
const StatusMenuItems = (
<>
{!isSizeLarge && (
<>
<MenuHeader>
<span class="ib">
<Icon icon={visibilityIconsMap[visibility]} size="s" />{' '}
<span>{visibilityText[visibility]}</span>
</span>{' '}
<span class="ib">
{repliesCount > 0 && (
<span>
<Icon icon="reply" alt="Replies" size="s" />{' '}
<span>{shortenNumber(repliesCount)}</span>
</span>
)}{' '}
{reblogsCount > 0 && (
<span>
<Icon icon="rocket" alt="Boosts" size="s" />{' '}
<span>{shortenNumber(reblogsCount)}</span>
</span>
)}{' '}
{favouritesCount > 0 && (
<span>
<Icon icon="heart" alt="Favourites" size="s" />{' '}
<span>{shortenNumber(favouritesCount)}</span>
</span>
)}
</span>
<br />
{createdDateText}
</MenuHeader>
<MenuLink to={instance ? `/${instance}/s/${id}` : `/s/${id}`}>
<Icon icon="arrow-right" />
View post and replies
</MenuLink>
</>
)}
{!!editedAt && (
<MenuItem
onClick={() => {
setShowEdited(id);
}}
>
<Icon icon="history" />
<span>
Show Edit History
<br />
<small class="more-insignificant">Edited: {editedDateText}</small>
</span>
</MenuItem>
)}
{(!isSizeLarge || !!editedAt) && <MenuDivider />}
{!isSizeLarge && (
<>
<MenuItem onClick={replyStatus}>
<Icon icon="reply" />
<span>Reply</span>
</MenuItem>
{canBoost && (
<MenuItem
onClick={async () => {
try {
await boostStatus();
if (!isSizeLarge)
showToast(reblogged ? 'Unboosted' : 'Boosted');
} catch (e) {}
}}
>
<Icon icon="rocket" />
<span>{reblogged ? 'Unboost' : 'Boost…'}</span>
</MenuItem>
)}
<MenuItem
onClick={() => {
try {
favouriteStatus();
if (!isSizeLarge)
showToast(favourited ? 'Unfavourited' : 'Favourited');
} catch (e) {}
}}
>
<Icon icon="heart" />
<span>{favourited ? 'Unfavourite' : 'Favourite'}</span>
</MenuItem>
<MenuItem
onClick={() => {
try {
bookmarkStatus();
if (!isSizeLarge)
showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked');
} catch (e) {}
}}
>
<Icon icon="bookmark" />
<span>{bookmarked ? 'Unbookmark' : 'Bookmark'}</span>
</MenuItem>
<MenuDivider />
</>
)}
<MenuItem href={url} target="_blank">
<Icon icon="external" />
<span>Open link to post</span>
</MenuItem>
<MenuItem
onClick={() => {
// Copy url to clipboard
try {
navigator.clipboard.writeText(url);
showToast('Link copied');
} catch (e) {
console.error(e);
showToast('Unable to copy link');
}
}}
>
<Icon icon="link" />
<span>Copy link to post</span>
</MenuItem>
{navigator?.share &&
navigator?.canShare?.({
url,
}) && (
<MenuItem
onClick={() => {
try {
navigator.share({
url,
});
} catch (e) {
console.error(e);
alert("Sharing doesn't seem to work.");
}
}}
>
<Icon icon="share" />
<span>Share</span>
</MenuItem>
)}
{isSelf && (
<>
<MenuDivider />
<MenuItem
onClick={() => {
states.showCompose = {
editStatus: status,
};
}}
>
<Icon icon="pencil" />
<span>Edit</span>
</MenuItem>
</>
)}
</>
);
return (
<article
ref={statusRef}
@ -265,7 +544,7 @@ function Status({
account={status.account}
instance={instance}
showAvatar={size === 's'}
showAcct={size === 'l'}
showAcct={isSizeLarge}
/>
{/* {inReplyToAccount && !withinContext && size !== 's' && (
<>
@ -279,22 +558,42 @@ function Status({
{/* </span> */}{' '}
{size !== 'l' &&
(url ? (
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
class="time"
<Menu
portal={{
target:
document.querySelector('.status-deck') || document.body,
}}
align="end"
offsetY={4}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuButton={
<Link
to={instance ? `/${instance}/s/${id}` : `/s/${id}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
console.log('click', e);
}}
class="time"
>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibilityText[visibility]}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</Link>
}
>
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
</Link>
{StatusMenuItems}
</Menu>
) : (
<span class="time">
<Icon
icon={visibilityIconsMap[visibility]}
alt={visibility}
alt={visibilityText[visibility]}
size="s"
/>{' '}
<RelativeTime datetime={createdAtDate} format="micro" />
@ -337,7 +636,7 @@ function Status({
} ${showSpoiler ? 'show-spoiler' : ''}`}
data-content-text-weight={contentTextWeight ? textWeight() : null}
style={
(size === 'l' || contentTextWeight) && {
(isSizeLarge || contentTextWeight) && {
'--content-text-weight': textWeight(),
}
}
@ -457,12 +756,12 @@ function Status({
} ${mediaAttachments.length > 4 ? 'media-gt4' : ''}`}
>
{mediaAttachments
.slice(0, size === 'l' ? undefined : 4)
.slice(0, isSizeLarge ? undefined : 4)
.map((media, i) => (
<Media
key={media.id}
media={media}
autoAnimate={size === 'l'}
autoAnimate={isSizeLarge}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
@ -485,23 +784,13 @@ function Status({
<Card card={card} instance={currentInstance} />
)}
</div>
{size === 'l' && (
{isSizeLarge && (
<>
<div class="extra-meta">
<Icon icon={visibilityIconsMap[visibility]} alt={visibility} />{' '}
<a href={url} target="_blank">
<time class="created" datetime={createdAtDate.toISOString()}>
{Intl.DateTimeFormat('en', {
// Show year if not current year
year:
createdAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(createdAtDate)}
{createdDateText}
</time>
</a>
{editedAt && (
@ -515,17 +804,7 @@ function Status({
setShowEdited(id);
}}
>
{Intl.DateTimeFormat('en', {
// Show year if not this year
year:
editedAtDate.getFullYear() === currentYear
? undefined
: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
}).format(editedAtDate)}
{editedDateText}
</time>
</>
)}
@ -538,18 +817,10 @@ function Status({
class="reply-button"
icon="comment"
count={repliesCount}
onClick={() => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
states.showCompose = {
replyToStatus: status,
};
}}
onClick={replyStatus}
/>
</div>
{/* TODO: if visibility = private, only can reblog own statuses */}
{visibility !== 'direct' && (
{canBoost && (
<div class="action has-count">
<StatusButton
checked={reblogged}
@ -558,38 +829,7 @@ function Status({
class="reblog-button"
icon="rocket"
count={reblogsCount}
onClick={async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
if (!reblogged) {
const yes = confirm('Boost this post?');
if (!yes) {
return;
}
}
// Optimistic
states.statuses[sKey] = {
...status,
reblogged: !reblogged,
reblogsCount: reblogsCount + (reblogged ? -1 : 1),
};
if (reblogged) {
const newStatus = await masto.v1.statuses.unreblog(
id,
);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.reblog(id);
saveStatus(newStatus, instance);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
}}
onClick={boostStatus}
/>
</div>
)}
@ -601,33 +841,7 @@ function Status({
class="favourite-button"
icon="heart"
count={favouritesCount}
onClick={async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
// Optimistic
states.statuses[sKey] = {
...status,
favourited: !favourited,
favouritesCount:
favouritesCount + (favourited ? -1 : 1),
};
if (favourited) {
const newStatus = await masto.v1.statuses.unfavourite(
id,
);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.favourite(id);
saveStatus(newStatus, instance);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
}}
onClick={favouriteStatus}
/>
</div>
<div class="action">
@ -637,61 +851,33 @@ function Status({
alt={['Bookmark', 'Bookmarked']}
class="bookmark-button"
icon="bookmark"
onClick={async () => {
if (!sameInstance || !authenticated) {
return alert(unauthInteractionErrorMessage);
}
try {
// Optimistic
states.statuses[sKey] = {
...status,
bookmarked: !bookmarked,
};
if (bookmarked) {
const newStatus = await masto.v1.statuses.unbookmark(
id,
);
saveStatus(newStatus, instance);
} else {
const newStatus = await masto.v1.statuses.bookmark(id);
saveStatus(newStatus, instance);
}
} catch (e) {
console.error(e);
// Revert optimistism
states.statuses[sKey] = status;
}
}}
onClick={bookmarkStatus}
/>
</div>
{isSelf && (
<Menu
align="end"
menuButton={
<div class="action">
<button
type="button"
title="More"
class="plain more-button"
>
<Icon icon="more" size="l" alt="More" />
</button>
</div>
}
>
{isSelf && (
<MenuItem
onClick={() => {
states.showCompose = {
editStatus: status,
};
}}
<Menu
portal={{
target:
document.querySelector('.status-deck') || document.body,
}}
align="end"
offsetY={4}
overflow="auto"
viewScroll="close"
boundingBoxPadding="8 8 8 8"
menuButton={
<div class="action">
<button
type="button"
title="More"
class="plain more-button"
>
<Icon icon="pencil" /> <span>Edit&hellip;</span>
</MenuItem>
)}
</Menu>
)}
<Icon icon="more" size="l" alt="More" />
</button>
</div>
}
>
{StatusMenuItems}
</Menu>
</div>
</>
)}

View file

@ -297,6 +297,9 @@ code {
.insignificant {
color: var(--text-insignificant-color);
}
.more-insignificant {
opacity: 0.5;
}
.hide-until-focus-visible {
display: none;

View file

@ -7,11 +7,11 @@ import {
} 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 showToast from '../utils/show-toast';
import states from '../utils/states';
import useTitle from '../utils/useTitle';
@ -142,14 +142,7 @@ function Hashtags(props) {
.unfollow(hashtag)
.then(() => {
setInfo({ ...info, following: false });
const toast = Toastify({
className: 'shiny-pill',
text: `Unfollowed #${hashtag}`,
duration: 3000,
gravity: 'bottom',
position: 'center',
});
toast.showToast();
showToast(`Unfollowed #${hashtag}`);
})
.catch((e) => {
alert(e);
@ -163,14 +156,7 @@ function Hashtags(props) {
.follow(hashtag)
.then(() => {
setInfo({ ...info, following: true });
const toast = Toastify({
className: 'shiny-pill',
text: `Followed #${hashtag}`,
duration: 3000,
gravity: 'bottom',
position: 'center',
});
toast.showToast();
showToast(`Followed #${hashtag}`);
})
.catch((e) => {
alert(e);
@ -247,9 +233,11 @@ function Hashtags(props) {
);
}}
>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />{' '}
<Icon icon="hashtag" />
<span>{t}</span>
<Icon icon="x" alt="Remove hashtag" class="danger-icon" />
<span>
<span class="more-insignificant">#</span>
{t}
</span>
</MenuItem>
))}
</MenuGroup>
@ -278,14 +266,7 @@ function Hashtags(props) {
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();
showToast(`Hashtag shortcut added`);
}
}}
>

26
src/utils/show-toast.js Normal file
View file

@ -0,0 +1,26 @@
import Toastify from 'toastify-js';
function showToast(props) {
if (typeof props === 'string') {
props = { text: props };
}
const { onClick = () => {}, delay, ...rest } = props;
const toast = Toastify({
className: 'shiny-pill',
gravity: 'bottom',
position: 'center',
...rest,
onClick: () => {
onClick(toast); // Pass in the object itself!
},
});
if (delay) {
setTimeout(() => {
toast.showToast();
}, delay);
} else {
toast.showToast();
}
}
export default showToast;