diff --git a/src/app.css b/src/app.css index ca7f4644..8110b11d 100644 --- a/src/app.css +++ b/src/app.css @@ -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; diff --git a/src/app.jsx b/src/app.jsx index 95dc9eef..3f19ae51 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -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}`, + ); + }, + }); } }} /> diff --git a/src/components/icon.jsx b/src/components/icon.jsx index 19a1f794..3e84b2f4 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -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'); diff --git a/src/components/status.jsx b/src/components/status.jsx index 9976af4c..d784ecbb 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -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 && ( + <> + + + {' '} + {visibilityText[visibility]} + {' '} + + {repliesCount > 0 && ( + + {' '} + {shortenNumber(repliesCount)} + + )}{' '} + {reblogsCount > 0 && ( + + {' '} + {shortenNumber(reblogsCount)} + + )}{' '} + {favouritesCount > 0 && ( + + {' '} + {shortenNumber(favouritesCount)} + + )} + +
+ {createdDateText} +
+ + + View post and replies + + + )} + {!!editedAt && ( + { + setShowEdited(id); + }} + > + + + Show Edit History +
+ Edited: {editedDateText} +
+
+ )} + {(!isSizeLarge || !!editedAt) && } + {!isSizeLarge && ( + <> + + + Reply + + {canBoost && ( + { + try { + await boostStatus(); + if (!isSizeLarge) + showToast(reblogged ? 'Unboosted' : 'Boosted'); + } catch (e) {} + }} + > + + {reblogged ? 'Unboost' : 'Boost…'} + + )} + { + try { + favouriteStatus(); + if (!isSizeLarge) + showToast(favourited ? 'Unfavourited' : 'Favourited'); + } catch (e) {} + }} + > + + {favourited ? 'Unfavourite' : 'Favourite'} + + { + try { + bookmarkStatus(); + if (!isSizeLarge) + showToast(bookmarked ? 'Unbookmarked' : 'Bookmarked'); + } catch (e) {} + }} + > + + {bookmarked ? 'Unbookmark' : 'Bookmark'} + + + + )} + + + Open link to post + + { + // Copy url to clipboard + try { + navigator.clipboard.writeText(url); + showToast('Link copied'); + } catch (e) { + console.error(e); + showToast('Unable to copy link'); + } + }} + > + + Copy link to post + + {navigator?.share && + navigator?.canShare?.({ + url, + }) && ( + { + try { + navigator.share({ + url, + }); + } catch (e) { + console.error(e); + alert("Sharing doesn't seem to work."); + } + }} + > + + Share… + + )} + {isSelf && ( + <> + + { + states.showCompose = { + editStatus: status, + }; + }} + > + + Edit + + + )} + + ); + return (
{/* {inReplyToAccount && !withinContext && size !== 's' && ( <> @@ -279,22 +558,42 @@ function Status({ {/* */}{' '} {size !== 'l' && (url ? ( - { + e.preventDefault(); + e.stopPropagation(); + console.log('click', e); + }} + class="time" + > + {' '} + + + } > - {' '} - - + {StatusMenuItems} + ) : ( {' '} @@ -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) => ( { e.preventDefault(); e.stopPropagation(); @@ -485,23 +784,13 @@ function Status({ )} - {size === 'l' && ( + {isSizeLarge && ( <>
{' '} {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} )} @@ -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} />
- {/* TODO: if visibility = private, only can reblog own statuses */} - {visibility !== 'direct' && ( + {canBoost && (
{ - 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} />
)} @@ -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} />
@@ -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} />
- {isSelf && ( - - - - } - > - {isSelf && ( - { - states.showCompose = { - editStatus: status, - }; - }} + + - )} + + + + } + > + {StatusMenuItems} + )} diff --git a/src/index.css b/src/index.css index 479d565e..4910509a 100644 --- a/src/index.css +++ b/src/index.css @@ -297,6 +297,9 @@ code { .insignificant { color: var(--text-insignificant-color); } +.more-insignificant { + opacity: 0.5; +} .hide-until-focus-visible { display: none; diff --git a/src/pages/hashtag.jsx b/src/pages/hashtag.jsx index 67de91aa..6219c0c3 100644 --- a/src/pages/hashtag.jsx +++ b/src/pages/hashtag.jsx @@ -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) { ); }} > - {' '} - - {t} + + + # + {t} + ))} @@ -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`); } }} > diff --git a/src/utils/show-toast.js b/src/utils/show-toast.js new file mode 100644 index 00000000..8cb02521 --- /dev/null +++ b/src/utils/show-toast.js @@ -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;