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 && (
+
+ )}
>
)}
@@ -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'}
+
+
+
+
+
+ );
+}
+
+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 && (
+
+ )}
{!isSizeLarge && sameInstance && (
<>
+ {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 ? (
+ <>
+
+ {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={
+
+ }
+ />
+ {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 (
+
+
+
+
+ {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 (
+