diff --git a/public/sw.js b/public/sw.js index 14514039..b44a94b0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -39,7 +39,7 @@ registerRoute(imageRoute); // - /api/v1/preferences // - /api/v1/lists/:id const apiExtendedRoute = new RegExpRoute( - /^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+)/, + /^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+)$/, new StaleWhileRevalidate({ cacheName: 'api-extended', plugins: [ diff --git a/src/app.css b/src/app.css index 1537beba..efdcae6b 100644 --- a/src/app.css +++ b/src/app.css @@ -1641,6 +1641,63 @@ ul.link-list li a .icon { } } +/* FILTER BAR */ + +.filter-bar { + padding: 8px 16px; + background-color: var(--bg-faded-color); + display: flex; + gap: 8px; + overflow-x: auto; + mask-image: linear-gradient( + to right, + transparent, + black 16px, + black calc(100% - 16px), + transparent + ); + align-items: center; +} +@media (min-width: 40em) { + .filter-bar { + background-color: transparent; + } +} +.filter-bar > a:not(.filter-clear) { + padding: 8px 16px; + border-radius: 999px; + background-color: var(--bg-color); + color: var(--link-color); + text-decoration: none; + white-space: nowrap; + border: 2px solid transparent; + transition: all 0.3s ease-out; + display: inline-flex; + align-items: center; + gap: 8px; +} +.filter-bar > a:is(:hover, :focus) { + border-color: var(--link-light-color); +} +.filter-bar > a > * { + vertical-align: middle; +} +.filter-bar > a.is-active { + border-color: var(--link-color); + box-shadow: inset 0 0 8px var(--link-faded-color); +} +.filter-bar > a > .filter-count { + font-size: 80%; + display: inline-block; + color: var(--text-insignificant-color); + min-width: 16px; + min-height: 16px; + padding: 4px; + margin: -4px -8px -4px 0; + background-color: var(--bg-faded-color); + border-radius: 999px; +} + /* OTHERS */ @media (min-width: 40em) { diff --git a/src/app.jsx b/src/app.jsx index 8e4001aa..79dd9b04 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -36,11 +36,13 @@ import Home from './pages/home'; import List from './pages/list'; import Lists from './pages/lists'; import Login from './pages/login'; +import Mentions from './pages/mentions'; import Notifications from './pages/notifications'; import Public from './pages/public'; import Search from './pages/search'; import Settings from './pages/settings'; import Status from './pages/status'; +import Trending from './pages/trending'; import Welcome from './pages/welcome'; import { api, @@ -144,7 +146,7 @@ function App() { const columns = document.getElementById('columns'); if (columns) { // Focus first column - columns.querySelector('.deck-container')?.focus?.(); + // columns.querySelector('.deck-container')?.focus?.(); } else { const backDrop = document.querySelector('.deck-backdrop'); if (backDrop) return; @@ -222,6 +224,7 @@ function App() { {isLoggedIn && ( } /> )} + {isLoggedIn && } />} {isLoggedIn && } />} {isLoggedIn && } />} {isLoggedIn && } />} @@ -238,6 +241,7 @@ function App() { } /> } /> + } /> } /> {/* } /> */} diff --git a/src/components/account-info.css b/src/components/account-info.css index f14780bb..03be99c0 100644 --- a/src/components/account-info.css +++ b/src/components/account-info.css @@ -260,6 +260,34 @@ animation: shine 1s ease-in-out 1s; } +#list-add-remove-container .list-add-remove { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 8px 0; + list-style: none; +} +#list-add-remove-container .list-add-remove button { + border-radius: 16px; + display: flex; + align-items: center; + gap: 8px; + width: 100%; + text-align: start; +} +#list-add-remove-container .list-add-remove button .icon { + opacity: 0.15; +} +#list-add-remove-container .list-add-remove button.checked { + border-color: var(--green-color); + font-weight: bold; +} +#list-add-remove-container .list-add-remove button.checked .icon { + opacity: 1; + color: var(--green-color); +} + @media (min-width: 40em) { .timeline-start .account-container { --item-radius: 16px; diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx index a7fa85c2..0acc64b4 100644 --- a/src/components/account-info.jsx +++ b/src/components/account-info.jsx @@ -1,13 +1,7 @@ import './account-info.css'; -import { - Menu, - MenuDivider, - MenuHeader, - MenuItem, - SubMenu, -} from '@szhsin/react-menu'; -import { useEffect, useRef, useState } from 'preact/hooks'; +import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu'; +import { useEffect, useReducer, useRef, useState } from 'preact/hooks'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; @@ -24,6 +18,8 @@ import AccountBlock from './account-block'; import Avatar from './avatar'; import Icon from './icon'; import Link from './link'; +import ListAddEdit from './list-add-edit'; +import Loader from './loader'; import Modal from './modal'; import TranslationBlock from './translation-block'; @@ -487,6 +483,7 @@ function RelatedActions({ info, instance, authenticated }) { const menuInstanceRef = useRef(null); const [showTranslatedBio, setShowTranslatedBio] = useState(false); + const [showAddRemoveLists, setShowAddRemoveLists] = useState(false); return ( <> @@ -583,6 +580,17 @@ function RelatedActions({ info, instance, authenticated }) { Translate bio + {/* Add/remove from lists is only possible if following the account */} + {following && ( + { + setShowAddRemoveLists(true); + }} + > + + Add/remove from Lists + + )} )} @@ -840,6 +848,18 @@ function RelatedActions({ info, instance, authenticated }) { )} + {!!showAddRemoveLists && ( + { + if (e.target === e.currentTarget) { + setShowAddRemoveLists(false); + } + }} + > + + + )} ); } @@ -900,4 +920,127 @@ function TranslatedBioSheet({ note, fields }) { ); } + +function AddRemoveListsSheet({ accountID }) { + const { masto } = api(); + const [uiState, setUiState] = useState('default'); + const [lists, setLists] = useState([]); + const [listsContainingAccount, setListsContainingAccount] = useState([]); + const [reloadCount, reload] = useReducer((c) => c + 1, 0); + + useEffect(() => { + setUiState('loading'); + (async () => { + try { + const lists = await masto.v1.lists.list(); + const listsContainingAccount = await masto.v1.accounts.listLists( + accountID, + ); + console.log({ lists, listsContainingAccount }); + setLists(lists); + setListsContainingAccount(listsContainingAccount); + setUiState('default'); + } catch (e) { + console.error(e); + setUiState('error'); + } + })(); + }, [reloadCount]); + + const [showListAddEditModal, setShowListAddEditModal] = useState(false); + + return ( +
+
+

Add/Remove from Lists

+
+
+ {lists.length > 0 ? ( +
    + {lists.map((list) => { + const inList = listsContainingAccount.some( + (l) => l.id === list.id, + ); + return ( +
  • + +
  • + ); + })} +
+ ) : uiState === 'loading' ? ( +

+ +

+ ) : uiState === 'error' ? ( +

Unable to load lists.

+ ) : ( +

No lists.

+ )} + +
+ {showListAddEditModal && ( + { + if (e.target === e.currentTarget) { + setShowListAddEditModal(false); + } + }} + > + { + if (result.state === 'success') { + reload(); + } + setShowListAddEditModal(false); + }} + /> + + )} +
+ ); +} + export default AccountInfo; diff --git a/src/components/compose.jsx b/src/components/compose.jsx index c979bf45..22176e80 100644 --- a/src/components/compose.jsx +++ b/src/components/compose.jsx @@ -893,7 +893,7 @@ function Compose({ - + {' '} diff --git a/src/components/icon.jsx b/src/components/icon.jsx index 8d6fcb10..196ca0f5 100644 --- a/src/components/icon.jsx +++ b/src/components/icon.jsx @@ -74,6 +74,9 @@ const ICONS = { time: 'mingcute:time-line', refresh: 'mingcute:refresh-2-line', emoji2: 'mingcute:emoji-2-line', + filter: 'mingcute:filter-2-line', + chart: 'mingcute:chart-line-line', + react: 'mingcute:react-line', }; const modules = import.meta.glob('/node_modules/@iconify-icons/mingcute/*.js'); diff --git a/src/components/list-add-edit.jsx b/src/components/list-add-edit.jsx new file mode 100644 index 00000000..a3fd8f3f --- /dev/null +++ b/src/components/list-add-edit.jsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; + +import { api } from '../utils/api'; + +function ListAddEdit({ list, onClose = () => {} }) { + const { masto } = api(); + const [uiState, setUiState] = useState('default'); + const editMode = !!list; + const nameFieldRef = useRef(); + const repliesPolicyFieldRef = useRef(); + useEffect(() => { + if (editMode) { + nameFieldRef.current.value = list.title; + repliesPolicyFieldRef.current.value = list.repliesPolicy; + } + }, [editMode]); + return ( +
+
+

{editMode ? 'Edit list' : 'New list'}

+
+
+
{ + e.preventDefault(); // Get form values + + const formData = new FormData(e.target); + const title = formData.get('title'); + const repliesPolicy = formData.get('replies_policy'); + console.log({ + title, + repliesPolicy, + }); + setUiState('loading'); + + (async () => { + try { + let listResult; + + if (editMode) { + listResult = await masto.v1.lists.update(list.id, { + title, + replies_policy: repliesPolicy, + }); + } else { + listResult = await masto.v1.lists.create({ + title, + replies_policy: repliesPolicy, + }); + } + + console.log(listResult); + setUiState('default'); + onClose({ + state: 'success', + list: listResult, + }); + } catch (e) { + console.error(e); + setUiState('error'); + alert( + editMode ? 'Unable to edit list.' : 'Unable to create list.', + ); + } + })(); + }} + > +
+ +
+
+ +
+ +
+
+
+ ); +} + +export default ListAddEdit; diff --git a/src/components/media.jsx b/src/components/media.jsx index fdf6b066..0e83fbdb 100644 --- a/src/components/media.jsx +++ b/src/components/media.jsx @@ -179,7 +179,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) { }} > {showOriginal || autoGIFAnimate ? ( - isGIF ? ( + isGIF && showOriginal ? (
{} }) { ) : (
Following )} + + Mentions + Notifications {snapStates.notificationsShowNew && ( @@ -137,6 +140,9 @@ function NavMenu(props) { Federated + + Trending + {authenticated && ( <> diff --git a/src/components/shortcuts-settings.css b/src/components/shortcuts-settings.css index f485bcd7..c3bf0dcd 100644 --- a/src/components/shortcuts-settings.css +++ b/src/components/shortcuts-settings.css @@ -25,6 +25,7 @@ } #shortcuts-settings-container .shortcuts-list li .shortcut-text { flex-grow: 1; + min-width: 0; } #shortcuts-settings-container .shortcuts-list li .shortcut-actions { flex-shrink: 0; diff --git a/src/components/shortcuts-settings.jsx b/src/components/shortcuts-settings.jsx index 4d3a1a1f..ed9e189a 100644 --- a/src/components/shortcuts-settings.jsx +++ b/src/components/shortcuts-settings.jsx @@ -18,26 +18,30 @@ const SHORTCUTS_LIMIT = 9; const TYPES = [ 'following', + 'mentions', 'notifications', 'list', 'public', + 'trending', // NOTE: Hide for now // 'search', // Search on Mastodon ain't great // 'account-statuses', // Need @acct search first + 'hashtag', 'bookmarks', 'favourites', - 'hashtag', ]; const TYPE_TEXT = { following: 'Home / Following', notifications: 'Notifications', list: 'List', - public: 'Public', + public: 'Public (Local / Federated)', search: 'Search', 'account-statuses': 'Account', bookmarks: 'Bookmarks', favourites: 'Favourites', hashtag: 'Hashtag', + trending: 'Trending', + mentions: 'Mentions', }; const TYPE_PARAMS = { list: [ @@ -59,6 +63,14 @@ const TYPE_PARAMS = { placeholder: 'e.g. mastodon.social', }, ], + trending: [ + { + text: 'Instance', + name: 'instance', + type: 'text', + placeholder: 'e.g. mastodon.social', + }, + ], search: [ { text: 'Search term', @@ -91,6 +103,12 @@ export const SHORTCUTS_META = { path: '/', icon: 'home', }, + mentions: { + id: 'mentions', + title: 'Mentions', + path: '/mentions', + icon: 'at', + }, notifications: { id: 'notifications', title: 'Notifications', @@ -118,6 +136,12 @@ export const SHORTCUTS_META = { path: ({ local, instance }) => `/${instance}/p${local ? '/l' : ''}`, icon: ({ local }) => (local ? 'group' : 'earth'), }, + trending: { + id: 'trending', + title: 'Trending', + path: ({ instance }) => `/${instance}/trending`, + icon: 'chart', + }, search: { id: 'search', title: ({ query }) => query, diff --git a/src/components/status.css b/src/components/status.css index b98c3cd1..780666a7 100644 --- a/src/components/status.css +++ b/src/components/status.css @@ -37,7 +37,6 @@ /* STATUS PRE META */ .status-pre-meta { - line-height: 1.4; padding: 8px 16px 0; opacity: 0.75; font-size: smaller; @@ -48,7 +47,9 @@ margin-bottom: -8px; } .status-pre-meta .name-text { - display: inline; + display: inline-flex; + gap: 4px; + align-items: center; } .status-pre-meta > * { vertical-align: middle; @@ -239,16 +240,18 @@ .status-reply-badge { display: inline-flex; - margin-left: 4px; + margin: 2px 0 2px 4px; gap: 4px; align-items: center; + vertical-align: middle; } .status-reply-badge .icon { color: var(--reply-to-color); } .status-thread-badge { + vertical-align: middle; display: inline-flex; - margin: 4px 0 0 0; + margin: 2px 0; gap: 4px; align-items: center; color: var(--reply-to-text-color); @@ -269,6 +272,24 @@ ); font-weight: bold; } +.status-direct-badge { + vertical-align: middle; + display: inline-flex; + margin: 2px 0; + gap: 4px; + align-items: center; + color: var(--reply-to-text-color); + background-color: var(--bg-color); + border: 1px solid var(--reply-to-text-color); + border-radius: 4px; + padding: 4px; + font-size: 10px; + line-height: 1; + text-transform: uppercase; + opacity: 0.75; + font-weight: bold; + box-shadow: inset 0 0 0 1px var(--reply-to-color); +} .status-filtered-badge { flex-shrink: 0; color: var(--text-insignificant-color); @@ -556,6 +577,7 @@ a:focus-visible .status .media img { body:has(#modal-container .carousel) .status .media img:hover { animation: none; } +.status .media .video-container, .status .media video { width: 100%; height: 100%; @@ -667,14 +689,16 @@ body:has(#modal-container .carousel) .status .media img:hover { border-radius: 8px; color: var(--text-color); padding: 4px 8px; - border: 1px solid var(--outline-color); - box-shadow: 0 4px 16px var(--outline-color); + background-color: var(--bg-blur-color); + border: var(--hairline-width) solid var(--bg-blur-color); + box-shadow: 0 4px 16px var(--drop-shadow-color); max-width: min(var(--main-width), calc(100% - 32px)); display: flex; align-items: center; gap: 8px; font-size: 90%; z-index: 1; + text-shadow: 0 var(--hairline-width) var(--bg-color); } .carousel-item button.media-alt .media-alt-desc { overflow: hidden; @@ -1200,3 +1224,41 @@ a.card:is(:hover, :focus) { bottom: 8px; right: 8px; } + +/* REACTIONS */ + +#reactions-container main ul { + list-style: none; + margin: 0; + padding: 8px 0; + display: flex; + flex-wrap: wrap; + flex-direction: row; + column-gap: 1.5em; + row-gap: 16px; +} +#reactions-container main ul li { + display: flex; + flex-grow: 1; + flex-basis: 16em; + align-items: center; + margin: 0; + padding: 0; + gap: 8px; +} +#reactions-container main ul li .account-block-acct { + font-size: 80%; + color: var(--text-insignificant-color); + display: block; +} +#reactions-container .reactions-block { + display: flex; + flex-direction: column; + align-self: center; +} +#reactions-container .reactions-block .favourite-icon { + color: var(--favourite-color); +} +#reactions-container .reactions-block .reblog-icon { + color: var(--reblog-color); +} diff --git a/src/components/status.jsx b/src/components/status.jsx index f10f7ae3..240a381e 100644 --- a/src/components/status.jsx +++ b/src/components/status.jsx @@ -14,11 +14,13 @@ import mem from 'mem'; import pThrottle from 'p-throttle'; import { memo } from 'preact/compat'; import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; +import { InView } from 'react-intersection-observer'; import 'swiped-events'; import { useLongPress } from 'use-long-press'; import useResizeObserver from 'use-resize-observer'; import { useSnapshot } from 'valtio'; +import AccountBlock from '../components/account-block'; import Loader from '../components/loader'; import Modal from '../components/modal'; import NameText from '../components/name-text'; @@ -62,7 +64,7 @@ const visibilityText = { public: 'Public', unlisted: 'Unlisted', private: 'Followers only', - direct: 'Direct', + direct: 'Private mention', }; function Status({ @@ -208,7 +210,7 @@ function Status({
{' '} {' '} - boosted + boosted
{ if (!sameInstance || !authenticated) { - return alert(unauthInteractionErrorMessage); + alert(unauthInteractionErrorMessage); + return false; } try { if (!reblogged) { @@ -314,7 +318,7 @@ function Status({ } const yes = confirm(confirmText); if (!yes) { - return; + return false; } } // Optimistic @@ -326,14 +330,17 @@ function Status({ if (reblogged) { const newStatus = await masto.v1.statuses.unreblog(id); saveStatus(newStatus, instance); + return true; } else { const newStatus = await masto.v1.statuses.reblog(id); saveStatus(newStatus, instance); + return true; } } catch (e) { console.error(e); // Revert optimistism states.statuses[sKey] = status; + return false; } }; @@ -440,6 +447,14 @@ function Status({ )} {(!isSizeLarge || !!editedAt) && } + {isSizeLarge && ( + setShowReactions(true)}> + + + Boosted/Favourited by + + + )} {!isSizeLarge && sameInstance && ( <> @@ -450,9 +465,10 @@ function Status({ { try { - await boostStatus(); - if (!isSizeLarge) + const done = await boostStatus(); + if (!isSizeLarge && done) { showToast(reblogged ? 'Unboosted' : 'Boosted'); + } } catch (e) {} }} > @@ -774,6 +790,11 @@ function Status({ ))}
+ {visibility === 'direct' && ( + <> +
Private mention
{' '} + + )} {!withinContext && ( <> {inReplyToAccountId === status.account?.id || @@ -1113,6 +1134,18 @@ function Status({ /> )} + {showReactions && ( + { + if (e.target === e.currentTarget) { + setShowReactions(false); + } + }} + > + + + )} ); } @@ -1531,6 +1564,154 @@ function EditedAtModal({ ); } +const REACTIONS_LIMIT = 80; +function ReactionsModal({ statusID, instance }) { + const { masto } = api({ instance }); + const [uiState, setUIState] = useState('default'); + const [accounts, setAccounts] = useState([]); + const [showMore, setShowMore] = useState(false); + + const reblogIterator = useRef(); + const favouriteIterator = useRef(); + + async function fetchAccounts(firstLoad) { + setShowMore(false); + setUIState('loading'); + (async () => { + try { + if (firstLoad) { + reblogIterator.current = masto.v1.statuses.listRebloggedBy(statusID, { + limit: REACTIONS_LIMIT, + }); + favouriteIterator.current = masto.v1.statuses.listFavouritedBy( + statusID, + { + limit: REACTIONS_LIMIT, + }, + ); + } + const [{ value: reblogResults }, { value: favouriteResults }] = + await Promise.allSettled([ + reblogIterator.current.next(), + favouriteIterator.current.next(), + ]); + if (reblogResults.value?.length || favouriteResults.value?.length) { + if (reblogResults.value?.length) { + for (const account of reblogResults.value) { + const theAccount = accounts.find((a) => a.id === account.id); + if (!theAccount) { + accounts.push({ + ...account, + _types: ['reblog'], + }); + } else { + theAccount._types.push('reblog'); + } + } + } + if (favouriteResults.value?.length) { + for (const account of favouriteResults.value) { + const theAccount = accounts.find((a) => a.id === account.id); + if (!theAccount) { + accounts.push({ + ...account, + _types: ['favourite'], + }); + } else { + theAccount._types.push('favourite'); + } + } + } + setAccounts(accounts); + setShowMore(!reblogResults.done || !favouriteResults.done); + } else { + setShowMore(false); + } + setUIState('default'); + } catch (e) { + console.error(e); + setUIState('error'); + } + })(); + } + + useEffect(() => { + fetchAccounts(true); + }, []); + + return ( +
+
+

Boosted/Favourited by…

+
+
+ {accounts.length > 0 ? ( + <> +
    + {accounts.map((account) => { + const { _types } = account; + return ( +
  • +
    + {_types.map((type) => ( + + ))} +
    + +
  • + ); + })} +
+ {uiState === 'default' ? ( + showMore ? ( + { + if (inView) { + fetchAccounts(); + } + }} + > + + + ) : ( +

The end.

+ ) + ) : ( + uiState === 'loading' && ( +

+ +

+ ) + )} + + ) : uiState === 'loading' ? ( +

+ +

+ ) : uiState === 'error' ? ( +

Unable to load accounts

+ ) : ( +

No one yet.

+ )} +
+
+ ); +} + function StatusButton({ checked, count, diff --git a/src/components/timeline.jsx b/src/components/timeline.jsx index c07a5bc8..25a76aab 100644 --- a/src/components/timeline.jsx +++ b/src/components/timeline.jsx @@ -33,6 +33,7 @@ function Timeline({ headerEnd, timelineStart, allowFilters, + refresh, }) { const [items, setItems] = useState([]); const [uiState, setUIState] = useState('default'); @@ -184,6 +185,9 @@ function Timeline({ scrollableRef.current?.scrollTo({ top: 0 }); loadItems(true); }, []); + useEffect(() => { + loadItems(true); + }, [refresh]); useEffect(() => { if (reachStart) { @@ -207,7 +211,7 @@ function Timeline({ console.log('✨ Check updates'); const hasUpdate = await checkForUpdates(); if (hasUpdate) { - console.log('✨ Has new updates'); + console.log('✨ Has new updates', id); setShowNew(true); } })(); @@ -227,7 +231,7 @@ function Timeline({ console.log('✨ Check updates'); const hasUpdate = await checkForUpdates(); if (hasUpdate) { - console.log('✨ Has new updates'); + console.log('✨ Has new updates', id); setShowNew(true); } })(); diff --git a/src/index.css b/src/index.css index 339df71f..7002c099 100644 --- a/src/index.css +++ b/src/index.css @@ -175,7 +175,7 @@ button, :is(button, .button).plain2 { background-color: transparent; color: var(--link-color); - backdrop-filter: blur(12px) invert(0.25) brightness(1.5); + backdrop-filter: blur(12px) invert(0.1); } :is(button, .button).plain3 { background-color: transparent; @@ -194,6 +194,9 @@ button, color: var(--text-color); border: 1px solid var(--outline-color); } +:is(button, .button).light:not(:disabled, .disabled):is(:hover, :focus) { + border-color: var(--outline-hover-color); +} :is(button, .button).light.danger:not(:disabled, .disabled) { color: var(--red-color); } diff --git a/src/pages/account-statuses.jsx b/src/pages/account-statuses.jsx index 19b9cacb..376fee6b 100644 --- a/src/pages/account-statuses.jsx +++ b/src/pages/account-statuses.jsx @@ -1,8 +1,10 @@ import { useEffect, useMemo, useRef, useState } from 'preact/hooks'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { useSnapshot } from 'valtio'; import AccountInfo from '../components/account-info'; +import Icon from '../components/icon'; +import Link from '../components/link'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; @@ -15,6 +17,11 @@ const LIMIT = 20; function AccountStatuses() { const snapStates = useSnapshot(states); const { id, ...params } = useParams(); + const [searchParams, setSearchParams] = useSearchParams(); + const excludeReplies = !searchParams.get('replies'); + const excludeBoosts = !!searchParams.get('boosts'); + const tagged = searchParams.get('tagged'); + const media = !!searchParams.get('media'); const { masto, instance, authenticated } = api({ instance: params.instance }); const accountStatusesIterator = useRef(); async function fetchAccountStatuses(firstLoad) { @@ -25,7 +32,7 @@ function AccountStatuses() { pinned: true, }) .next(); - if (pinnedStatuses?.length) { + if (pinnedStatuses?.length && !tagged && !media) { pinnedStatuses.forEach((status) => { status._pinned = true; saveStatus(status, instance); @@ -45,6 +52,10 @@ function AccountStatuses() { if (firstLoad || !accountStatusesIterator.current) { accountStatusesIterator.current = masto.v1.accounts.listStatuses(id, { limit: LIMIT, + exclude_replies: excludeReplies, + exclude_reblogs: excludeBoosts, + only_media: media, + tagged, }); } const { value, done } = await accountStatusesIterator.current.next(); @@ -62,6 +73,7 @@ function AccountStatuses() { } const [account, setAccount] = useState(); + const [featuredTags, setFeaturedTags] = useState([]); useTitle( `${account?.displayName ? account.displayName + ' ' : ''}@${ account?.acct ? account.acct : 'Account posts' @@ -77,23 +89,107 @@ function AccountStatuses() { } catch (e) { console.error(e); } + try { + const featuredTags = await masto.v1.accounts.listFeaturedTags(id); + console.log({ featuredTags }); + setFeaturedTags(featuredTags); + } catch (e) { + console.error(e); + } })(); }, [id]); const { displayName, acct, emojis } = account || {}; + const filterBarRef = useRef(); const TimelineStart = useMemo(() => { const cachedAccount = snapStates.accounts[`${id}@${instance}`]; + const filtered = !excludeReplies || excludeBoosts || tagged || media; return ( - masto.v1.accounts.fetch(id)} - authenticated={authenticated} - standalone - /> + <> + masto.v1.accounts.fetch(id)} + authenticated={authenticated} + standalone + /> +
+ {filtered ? ( + + + + ) : ( + + )} + + + Replies + + + - Boosts + + + Media + + {featuredTags.map((tag) => ( + + + # + {tag.name} + + { + // The count differs based on instance 😅 + } + {/* {tag.statusesCount} */} + + ))} +
+ ); - }, [id, instance, authenticated]); + }, [ + id, + instance, + authenticated, + excludeReplies, + excludeBoosts, + featuredTags, + tagged, + media, + ]); + + useEffect(() => { + // Focus on .is-active + const active = filterBarRef.current?.querySelector('.is-active'); + if (active) { + console.log('active', active, active.offsetLeft); + filterBarRef.current.scrollTo({ + behavior: 'smooth', + left: + active.offsetLeft - + (filterBarRef.current.offsetWidth - active.offsetWidth) / 2, + }); + } + }, [featuredTags, tagged, media, excludeReplies, excludeBoosts]); return ( ); } diff --git a/src/pages/followed-hashtags.jsx b/src/pages/followed-hashtags.jsx index 21e0f290..5e07a53f 100644 --- a/src/pages/followed-hashtags.jsx +++ b/src/pages/followed-hashtags.jsx @@ -28,6 +28,7 @@ function FollowedHashtags() { if (done || value?.length === 0) break; tags.push(...value); } while (true); + tags.sort((a, b) => a.name.localeCompare(b.name)); console.log(tags); setFollowedHashtags(tags); setUiState('default'); diff --git a/src/pages/list.jsx b/src/pages/list.jsx index 54c53c58..707c5be0 100644 --- a/src/pages/list.jsx +++ b/src/pages/list.jsx @@ -1,8 +1,15 @@ -import { useEffect, useRef, useState } from 'preact/hooks'; -import { useParams } from 'react-router-dom'; +import './lists.css'; +import { Menu, MenuItem } from '@szhsin/react-menu'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { InView } from 'react-intersection-observer'; +import { useNavigate, useParams } from 'react-router-dom'; + +import AccountBlock from '../components/account-block'; import Icon from '../components/icon'; import Link from '../components/link'; +import ListAddEdit from '../components/list-add-edit'; +import Modal from '../components/modal'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; import { filteredItems } from '../utils/filters'; @@ -14,7 +21,9 @@ const LIMIT = 20; function List(props) { const { masto, instance } = api(); const id = props?.id || useParams()?.id; + const navigate = useNavigate(); const latestItem = useRef(); + // const [reloadCount, reload] = useReducer((c) => c + 1, 0); const listIterator = useRef(); async function fetchList(firstLoad) { @@ -55,37 +64,231 @@ function List(props) { } } - const [title, setTitle] = useState(`List`); - useTitle(title, `/l/:id`); + const [list, setList] = useState({ title: 'List' }); + // const [title, setTitle] = useState(`List`); + useTitle(list.title, `/l/:id`); useEffect(() => { (async () => { try { const list = await masto.v1.lists.fetch(id); - setTitle(list.title); + setList(list); + // setTitle(list.title); } catch (e) { console.error(e); } })(); }, [id]); + const [showListAddEditModal, setShowListAddEditModal] = useState(false); + const [showManageMembersModal, setShowManageMembersModal] = useState(false); + return ( - - - + <> + + + + } + headerEnd={ + + + + } + > + + setShowListAddEditModal({ + list, + }) + } + > + + Edit + + setShowManageMembersModal(true)}> + + Manage members + + + } + /> + {showListAddEditModal && ( + { + if (e.target === e.currentTarget) { + setShowListAddEditModal(false); + } + }} + > + { + if (result.state === 'success' && result.list) { + setList(result.list); + // reload(); + } else if (result.state === 'deleted') { + navigate('/l'); + } + setShowListAddEditModal(false); + }} + /> + + )} + {showManageMembersModal && ( + { + if (e.target === e.currentTarget) { + setShowManageMembersModal(false); + } + }} + > + + + )} + + ); +} + +const MEMBERS_LIMIT = 40; +function ListManageMembers({ listID }) { + // Show list of members with [Remove] button + // API only returns 40 members at a time, so this need to be paginated with infinite scroll + // Show [Add] button after removing a member + const { masto, instance } = api(); + const [members, setMembers] = useState([]); + const [uiState, setUIState] = useState('default'); + const [showMore, setShowMore] = useState(false); + + const membersIterator = useRef(); + + async function fetchMembers(firstLoad) { + setShowMore(false); + setUIState('loading'); + (async () => { + try { + if (firstLoad || !membersIterator.current) { + membersIterator.current = masto.v1.lists.listAccounts(listID, { + limit: MEMBERS_LIMIT, + }); + } + const results = await membersIterator.current.next(); + let { done, value } = results; + if (value?.length) { + if (firstLoad) { + setMembers(value); + } else { + setMembers(members.concat(value)); + } + setShowMore(!done); + } else { + setShowMore(false); + } + setUIState('default'); + } catch (e) { + setUIState('error'); } - /> + })(); + } + + useEffect(() => { + fetchMembers(true); + }, []); + + return ( +
+
+

Manage members

+
+
+
    + {members.map((member) => ( +
  • + + +
  • + ))} + {showMore && uiState === 'default' && ( + inView && fetchMembers()}> + + + )} +
+
+
+ ); +} + +function RemoveAddButton({ account, listID }) { + const { masto } = api(); + const [uiState, setUIState] = useState('default'); + const [removed, setRemoved] = useState(false); + + return ( + ); } diff --git a/src/pages/lists.css b/src/pages/lists.css new file mode 100644 index 00000000..dcf5ffc6 --- /dev/null +++ b/src/pages/lists.css @@ -0,0 +1,33 @@ +.list-form { + padding: 8px 0; + display: flex; + gap: 8px; + flex-direction: column; +} + +.list-form-row :is(input, select) { + width: 100%; +} + +.list-form-footer { + display: flex; + gap: 8px; + justify-content: space-between; +} +.list-form-footer button[type='submit'] { + padding-inline: 24px; +} + +#list-manage-members-container ul { + display: block; + list-style: none; + padding: 8px 0; + margin: 0; +} +#list-manage-members-container ul li { + display: flex; + gap: 8px; + align-items: center; + justify-content: space-between; + padding: 8px 0; +} diff --git a/src/pages/lists.jsx b/src/pages/lists.jsx index 48ba6899..5aa3ceea 100644 --- a/src/pages/lists.jsx +++ b/src/pages/lists.jsx @@ -1,9 +1,13 @@ -import { useEffect, useState } from 'preact/hooks'; +import './lists.css'; + +import { useEffect, useReducer, useRef, useState } from 'preact/hooks'; import Icon from '../components/icon'; import Link from '../components/link'; +import ListAddEdit from '../components/list-add-edit'; import Loader from '../components/loader'; import Menu from '../components/menu'; +import Modal from '../components/modal'; import { api } from '../utils/api'; import useTitle from '../utils/useTitle'; @@ -12,6 +16,7 @@ function Lists() { useTitle(`Lists`, `/l`); const [uiState, setUiState] = useState('default'); + const [reloadCount, reload] = useReducer((c) => c + 1, 0); const [lists, setLists] = useState([]); useEffect(() => { setUiState('loading'); @@ -26,7 +31,9 @@ function Lists() { setUiState('error'); } })(); - }, []); + }, [reloadCount]); + + const [showListAddEditModal, setShowListAddEditModal] = useState(false); return (
@@ -40,7 +47,15 @@ function Lists() {

Lists

-
+
+ +
@@ -49,7 +64,22 @@ function Lists() { {lists.map((list) => (
  • - {list.title} + + {list.title} + + {/* */}
  • ))} @@ -65,6 +95,26 @@ function Lists() { )}
    + {showListAddEditModal && ( + { + if (e.target === e.currentTarget) { + setShowListAddEditModal(false); + } + }} + > + { + if (result.state === 'success') { + reload(); + } + setShowListAddEditModal(false); + }} + /> + + )} ); } diff --git a/src/pages/mentions.jsx b/src/pages/mentions.jsx new file mode 100644 index 00000000..7bde5099 --- /dev/null +++ b/src/pages/mentions.jsx @@ -0,0 +1,76 @@ +import { useRef } from 'preact/hooks'; + +import Timeline from '../components/timeline'; +import { api } from '../utils/api'; +import { saveStatus } from '../utils/states'; +import useTitle from '../utils/useTitle'; + +const LIMIT = 20; + +function Mentions() { + useTitle('Mentions', '/mentions'); + const { masto, instance } = api(); + const mentionsIterator = useRef(); + const latestItem = useRef(); + + async function fetchMentions(firstLoad) { + if (firstLoad || !mentionsIterator.current) { + mentionsIterator.current = masto.v1.notifications.list({ + limit: LIMIT, + types: ['mention'], + }); + } + const results = await mentionsIterator.current.next(); + let { value } = results; + if (value?.length) { + if (firstLoad) { + latestItem.current = value[0].id; + console.log('First load', latestItem.current); + } + + value.forEach(({ status: item }) => { + saveStatus(item, instance); + }); + } + return { + ...results, + value: value.map((item) => item.status), + }; + } + + async function checkForUpdates() { + try { + const results = await masto.v1.notifications + .list({ + limit: 1, + types: ['mention'], + since_id: latestItem.current, + }) + .next(); + let { value } = results; + console.log('checkForUpdates', latestItem.current, value); + if (value?.length) { + latestItem.current = value[0].id; + return true; + } + return false; + } catch (e) { + return false; + } + } + + return ( + + ); +} + +export default Mentions; diff --git a/src/pages/notifications.css b/src/pages/notifications.css index 0782e7b6..3e8f688b 100644 --- a/src/pages/notifications.css +++ b/src/pages/notifications.css @@ -7,7 +7,7 @@ .notification.notification-mention { margin-top: 16px; } -.only-mentions .notification:not(.mention), +.only-mentions .notification:not(.notification-mention), .only-mentions .timeline-empty { display: none; } diff --git a/src/pages/notifications.jsx b/src/pages/notifications.jsx index ad82177a..7b4dda1c 100644 --- a/src/pages/notifications.jsx +++ b/src/pages/notifications.jsx @@ -167,7 +167,7 @@ function Notifications() {
    - +

    Notifications

    diff --git a/src/pages/status.jsx b/src/pages/status.jsx index be6aa0b8..922b25ff 100644 --- a/src/pages/status.jsx +++ b/src/pages/status.jsx @@ -955,14 +955,19 @@ function SubComments({ ); } +const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/([^\/]+)\/?$/i; +const statusNoteRegex = /\/notes\/([^\/]+)\/?$/i; function getInstanceStatusURL(url) { // Regex /:username/:id, where username = @username or @username@domain, id = anything - const statusRegex = /\/@([^@\/]+)@?([^\/]+)?\/([^\/]+)\/?$/i; const { hostname, pathname } = new URL(url); const [, username, domain, id] = pathname.match(statusRegex) || []; if (id) { return `/${hostname}/s/${id}`; } + const [, noteId] = pathname.match(statusNoteRegex) || []; + if (noteId) { + return `/${hostname}/s/${noteId}`; + } } export default StatusPage; diff --git a/src/pages/trending.jsx b/src/pages/trending.jsx new file mode 100644 index 00000000..dfed5ea7 --- /dev/null +++ b/src/pages/trending.jsx @@ -0,0 +1,130 @@ +import { Menu, MenuItem } from '@szhsin/react-menu'; +import { useRef } from 'preact/hooks'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useSnapshot } from 'valtio'; + +import Icon from '../components/icon'; +import Timeline from '../components/timeline'; +import { api } from '../utils/api'; +import { filteredItems } from '../utils/filters'; +import states from '../utils/states'; +import { saveStatus } from '../utils/states'; +import useTitle from '../utils/useTitle'; + +const LIMIT = 20; + +function Trending(props) { + const snapStates = useSnapshot(states); + const params = useParams(); + const { masto, instance } = api({ + instance: props?.instance || params.instance, + }); + const title = `Trending (${instance})`; + useTitle(title, `/:instance?/trending`); + const navigate = useNavigate(); + const latestItem = useRef(); + + const trendIterator = useRef(); + async function fetchTrend(firstLoad) { + if (firstLoad || !trendIterator.current) { + trendIterator.current = masto.v1.trends.listStatuses({ + limit: LIMIT, + }); + } + const results = await trendIterator.current.next(); + let { value } = results; + if (value?.length) { + if (firstLoad) { + latestItem.current = value[0].id; + } + + value = filteredItems(value, 'public'); // Might not work here + value.forEach((item) => { + saveStatus(item, instance); + }); + } + return results; + } + + async function checkForUpdates() { + try { + const results = await masto.v1.trends + .listStatuses({ + limit: 1, + // NOT SUPPORTED + // since_id: latestItem.current, + }) + .next(); + let { value } = results; + value = filteredItems(value, 'public'); + if (value?.length && value[0].id !== latestItem.current) { + latestItem.current = value[0].id; + return true; + } + return false; + } catch (e) { + return false; + } + } + + return ( +