Merge pull request #94 from cheeaun/main

Update from main
This commit is contained in:
Chee Aun 2023-04-07 21:57:29 +08:00 committed by GitHub
commit 982f7b3ec4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 1319 additions and 68 deletions

View file

@ -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: [

View file

@ -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) {

View file

@ -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 && (
<Route path="/notifications" element={<Notifications />} />
)}
{isLoggedIn && <Route path="/mentions" element={<Mentions />} />}
{isLoggedIn && <Route path="/following" element={<Following />} />}
{isLoggedIn && <Route path="/b" element={<Bookmarks />} />}
{isLoggedIn && <Route path="/f" element={<Favourites />} />}
@ -238,6 +241,7 @@ function App() {
<Route index element={<Public />} />
<Route path="l" element={<Public local />} />
</Route>
<Route path="/:instance?/trending" element={<Trending />} />
<Route path="/:instance?/search" element={<Search />} />
{/* <Route path="/:anything" element={<NotFound />} /> */}
</Routes>

View file

@ -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;

View file

@ -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 }) {
<Icon icon="translate" />
<span>Translate bio</span>
</MenuItem>
{/* Add/remove from lists is only possible if following the account */}
{following && (
<MenuItem
onClick={() => {
setShowAddRemoveLists(true);
}}
>
<Icon icon="list" />
<span>Add/remove from Lists</span>
</MenuItem>
)}
<MenuDivider />
</>
)}
@ -840,6 +848,18 @@ function RelatedActions({ info, instance, authenticated }) {
<TranslatedBioSheet note={note} fields={fields} />
</Modal>
)}
{!!showAddRemoveLists && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowAddRemoveLists(false);
}
}}
>
<AddRemoveListsSheet accountID={accountID.current} />
</Modal>
)}
</>
);
}
@ -900,4 +920,127 @@ function TranslatedBioSheet({ note, fields }) {
</div>
);
}
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 (
<div class="sheet" id="list-add-remove-container">
<header>
<h2>Add/Remove from Lists</h2>
</header>
<main>
{lists.length > 0 ? (
<ul class="list-add-remove">
{lists.map((list) => {
const inList = listsContainingAccount.some(
(l) => l.id === list.id,
);
return (
<li>
<button
type="button"
class={`light ${inList ? 'checked' : ''}`}
disabled={uiState === 'loading'}
onClick={() => {
setUiState('loading');
(async () => {
try {
if (inList) {
await masto.v1.lists.removeAccount(list.id, {
accountIds: [accountID],
});
} else {
await masto.v1.lists.addAccount(list.id, {
accountIds: [accountID],
});
}
// setUiState('default');
reload();
} catch (e) {
console.error(e);
setUiState('error');
alert(
inList
? 'Unable to remove from list.'
: 'Unable to add to list.',
);
}
})();
}}
>
<Icon icon="check-circle" />
<span>{list.title}</span>
</button>
</li>
);
})}
</ul>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load lists.</p>
) : (
<p class="ui-state">No lists.</p>
)}
<button
type="button"
class="plain2"
onClick={() => setShowListAddEditModal(true)}
disabled={uiState !== 'default'}
>
<Icon icon="plus" size="l" /> <span>New list</span>
</button>
</main>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
}
}}
>
<ListAddEdit
list={showListAddEditModal?.list}
onClose={(result) => {
if (result.state === 'success') {
reload();
}
setShowListAddEditModal(false);
}}
/>
</Modal>
)}
</div>
);
}
export default AccountInfo;

View file

@ -893,7 +893,7 @@ function Compose({
</option>
<option value="unlisted">Unlisted</option>
<option value="private">Followers only</option>
<option value="direct">Direct</option>
<option value="direct">Private mention</option>
</select>
</label>{' '}
</div>

View file

@ -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');

View file

@ -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 (
<div class="sheet">
<header>
<h2>{editMode ? 'Edit list' : 'New list'}</h2>
</header>
<main>
<form
class="list-form"
onSubmit={(e) => {
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.',
);
}
})();
}}
>
<div class="list-form-row">
<label for="list-title">
Name{' '}
<input
ref={nameFieldRef}
type="text"
id="list-title"
name="title"
required
disabled={uiState === 'loading'}
/>
</label>
</div>
<div class="list-form-row">
<select
ref={repliesPolicyFieldRef}
name="replies_policy"
required
disabled={uiState === 'loading'}
>
<option value="list">Show replies to list members</option>
<option value="followed">Show replies to people I follow</option>
<option value="none">Don't show replies</option>
</select>
</div>
<div class="list-form-footer">
<button type="submit" disabled={uiState === 'loading'}>
{editMode ? 'Save' : 'Create'}
</button>
{editMode && (
<button
type="button"
class="light danger"
disabled={uiState === 'loading'}
onClick={() => {
const yes = confirm('Delete this list?');
if (!yes) return;
setUiState('loading');
(async () => {
try {
await masto.v1.lists.remove(list.id);
setUiState('default');
onClose({
state: 'deleted',
});
} catch (e) {
console.error(e);
setUiState('error');
alert('Unable to delete list.');
}
})();
}}
>
Delete
</button>
)}
</div>
</form>
</main>
</div>
);
}
export default ListAddEdit;

View file

@ -179,7 +179,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
}}
>
{showOriginal || autoGIFAnimate ? (
isGIF ? (
isGIF && showOriginal ? (
<QuickPinchZoom {...quickPinchZoomProps}>
<div
ref={mediaRef}
@ -190,6 +190,7 @@ function Media({ media, showOriginal, autoAnimate, onClick = () => {} }) {
</QuickPinchZoom>
) : (
<div
class="video-container"
dangerouslySetInnerHTML={{
__html: videoHTML,
}}

View file

@ -103,6 +103,9 @@ function NavMenu(props) {
<Icon icon="following" size="l" /> <span>Following</span>
</MenuLink>
)}
<MenuLink to="/mentions">
<Icon icon="at" size="l" /> <span>Mentions</span>
</MenuLink>
<MenuLink to="/notifications">
<Icon icon="notification" size="l" /> <span>Notifications</span>
{snapStates.notificationsShowNew && (
@ -137,6 +140,9 @@ function NavMenu(props) {
<MenuLink to={`/${instance}/p`}>
<Icon icon="earth" size="l" /> <span>Federated</span>
</MenuLink>
<MenuLink to={`/${instance}/trending`}>
<Icon icon="chart" size="l" /> <span>Trending</span>
</MenuLink>
{authenticated && (
<>
<MenuDivider />

View file

@ -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;

View file

@ -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,

View file

@ -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);
}

View file

@ -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({
<div class="status-pre-meta">
<Icon icon="rocket" size="l" />{' '}
<NameText account={status.account} instance={instance} showAvatar />{' '}
boosted
<span>boosted</span>
</div>
<Status
status={statusID ? null : reblog}
@ -228,6 +230,7 @@ function Status({
if (!snapStates.settings.contentTranslation) enableTranslate = false;
const [showEdited, setShowEdited] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const spoilerContentRef = useRef(null);
useResizeObserver({
@ -300,7 +303,8 @@ function Status({
const boostStatus = async () => {
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({
</MenuItem>
)}
{(!isSizeLarge || !!editedAt) && <MenuDivider />}
{isSizeLarge && (
<MenuItem onClick={() => setShowReactions(true)}>
<Icon icon="react" />
<span>
Boosted/Favourited by<span class="more-insignificant"></span>
</span>
</MenuItem>
)}
{!isSizeLarge && sameInstance && (
<>
<MenuItem onClick={replyStatus}>
@ -450,9 +465,10 @@ function Status({
<MenuItem
onClick={async () => {
try {
await boostStatus();
if (!isSizeLarge)
const done = await boostStatus();
if (!isSizeLarge && done) {
showToast(reblogged ? 'Unboosted' : 'Boosted');
}
} catch (e) {}
}}
>
@ -774,6 +790,11 @@ function Status({
</span>
))}
</div>
{visibility === 'direct' && (
<>
<div class="status-direct-badge">Private mention</div>{' '}
</>
)}
{!withinContext && (
<>
{inReplyToAccountId === status.account?.id ||
@ -1113,6 +1134,18 @@ function Status({
/>
</Modal>
)}
{showReactions && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowReactions(false);
}
}}
>
<ReactionsModal statusID={id} instance={instance} />
</Modal>
)}
</article>
);
}
@ -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 (
<div id="reactions-container" class="sheet">
<header>
<h2>Boosted/Favourited by</h2>
</header>
<main>
{accounts.length > 0 ? (
<>
<ul class="reactions-list">
{accounts.map((account) => {
const { _types } = account;
return (
<li key={account.id + _types}>
<div class="reactions-block">
{_types.map((type) => (
<Icon
icon={
{
reblog: 'rocket',
favourite: 'heart',
}[type]
}
class={`${type}-icon`}
/>
))}
</div>
<AccountBlock account={account} instance={instance} />
</li>
);
})}
</ul>
{uiState === 'default' ? (
showMore ? (
<InView
onChange={(inView) => {
if (inView) {
fetchAccounts();
}
}}
>
<button
type="button"
class="plain block"
onClick={() => fetchAccounts()}
>
Show more&hellip;
</button>
</InView>
) : (
<p class="ui-state insignificant">The end.</p>
)
) : (
uiState === 'loading' && (
<p class="ui-state">
<Loader abrupt />
</p>
)
)}
</>
) : uiState === 'loading' ? (
<p class="ui-state">
<Loader abrupt />
</p>
) : uiState === 'error' ? (
<p class="ui-state">Unable to load accounts</p>
) : (
<p class="ui-state insignificant">No one yet.</p>
)}
</main>
</div>
);
}
function StatusButton({
checked,
count,

View file

@ -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);
}
})();

View file

@ -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);
}

View file

@ -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 (
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={() => masto.v1.accounts.fetch(id)}
authenticated={authenticated}
standalone
/>
<>
<AccountInfo
instance={instance}
account={cachedAccount || id}
fetchAccount={() => masto.v1.accounts.fetch(id)}
authenticated={authenticated}
standalone
/>
<div class="filter-bar" ref={filterBarRef}>
{filtered ? (
<Link
to={`/${instance}/a/${id}`}
class="insignificant filter-clear"
title="Clear filters"
>
<Icon icon="x" size="l" />
</Link>
) : (
<Icon icon="filter" class="insignificant" size="l" />
)}
<Link
to={`/${instance}/a/${id}${excludeReplies ? '?replies=1' : ''}`}
class={excludeReplies ? '' : 'is-active'}
>
+ Replies
</Link>
<Link
to={`/${instance}/a/${id}${excludeBoosts ? '' : '?boosts=0'}`}
class={!excludeBoosts ? '' : 'is-active'}
>
- Boosts
</Link>
<Link
to={`/${instance}/a/${id}${media ? '' : '?media=1'}`}
class={media ? 'is-active' : ''}
>
Media
</Link>
{featuredTags.map((tag) => (
<Link
to={`/${instance}/a/${id}${
tagged === tag.name
? ''
: `?tagged=${encodeURIComponent(tag.name)}`
}`}
class={tagged === tag.name ? 'is-active' : ''}
>
<span>
<span class="more-insignificant">#</span>
{tag.name}
</span>
{
// The count differs based on instance 😅
}
{/* <span class="filter-count">{tag.statusesCount}</span> */}
</Link>
))}
</div>
</>
);
}, [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 (
<Timeline
@ -127,6 +223,7 @@ function AccountStatuses() {
useItemID
boostsCarousel={snapStates.settings.boostsCarousel}
timelineStart={TimelineStart}
refresh={excludeReplies + excludeBoosts + tagged + media}
/>
);
}

View file

@ -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');

View file

@ -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 (
<Timeline
title={title}
id="list"
emptyText="Nothing yet."
errorText="Unable to load posts."
instance={instance}
fetchItems={fetchList}
checkForUpdates={checkForUpdates}
useItemID
boostsCarousel
allowFilters
headerStart={
<Link to="/l" class="button plain">
<Icon icon="list" size="l" />
</Link>
<>
<Timeline
title={list.title}
id="list"
emptyText="Nothing yet."
errorText="Unable to load posts."
instance={instance}
fetchItems={fetchList}
checkForUpdates={checkForUpdates}
useItemID
boostsCarousel
allowFilters
// refresh={reloadCount}
headerStart={
<Link to="/l" class="button plain">
<Icon icon="list" size="l" />
</Link>
}
headerEnd={
<Menu
portal={{
target: document.body,
}}
setDownOverflow
overflow="auto"
viewScroll="close"
position="anchor"
boundingBoxPadding="8 8 8 8"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
</button>
}
>
<MenuItem
onClick={() =>
setShowListAddEditModal({
list,
})
}
>
<Icon icon="pencil" size="l" />
<span>Edit</span>
</MenuItem>
<MenuItem onClick={() => setShowManageMembersModal(true)}>
<Icon icon="group" size="l" />
<span>Manage members</span>
</MenuItem>
</Menu>
}
/>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
}
}}
>
<ListAddEdit
list={showListAddEditModal?.list}
onClose={(result) => {
if (result.state === 'success' && result.list) {
setList(result.list);
// reload();
} else if (result.state === 'deleted') {
navigate('/l');
}
setShowListAddEditModal(false);
}}
/>
</Modal>
)}
{showManageMembersModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowManageMembersModal(false);
}
}}
>
<ListManageMembers listID={id} />
</Modal>
)}
</>
);
}
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 (
<div class="sheet" id="list-manage-members-container">
<header>
<h2>Manage members</h2>
</header>
<main>
<ul>
{members.map((member) => (
<li key={member.id}>
<AccountBlock account={member} instance={instance} />
<RemoveAddButton account={member} listID={listID} />
</li>
))}
{showMore && uiState === 'default' && (
<InView as="li" onChange={(inView) => inView && fetchMembers()}>
<button type="button" class="light block" onClick={fetchMembers}>
Show more&hellip;
</button>
</InView>
)}
</ul>
</main>
</div>
);
}
function RemoveAddButton({ account, listID }) {
const { masto } = api();
const [uiState, setUIState] = useState('default');
const [removed, setRemoved] = useState(false);
return (
<button
type="button"
class={`light ${removed ? '' : 'danger'}`}
disabled={uiState === 'loading'}
onClick={() => {
if (removed) {
setUIState('loading');
(async () => {
try {
await masto.v1.lists.addAccount(listID, {
accountIds: [account.id],
});
setUIState('default');
setRemoved(false);
} catch (e) {
setUIState('error');
}
})();
} else {
const yes = confirm(`Remove ${account.username} from this list?`);
if (!yes) return;
setUIState('loading');
(async () => {
try {
await masto.v1.lists.removeAccount(listID, {
accountIds: [account.id],
});
setUIState('default');
setRemoved(true);
} catch (e) {
setUIState('error');
}
})();
}
}}
>
{removed ? 'Add' : 'Remove…'}
</button>
);
}

33
src/pages/lists.css Normal file
View file

@ -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;
}

View file

@ -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 (
<div id="lists-page" class="deck-container" tabIndex="-1">
@ -40,7 +47,15 @@ function Lists() {
</Link>
</div>
<h1>Lists</h1>
<div class="header-side" />
<div class="header-side">
<button
type="button"
class="plain"
onClick={() => setShowListAddEditModal(true)}
>
<Icon icon="plus" size="l" alt="New list" />
</button>
</div>
</div>
</header>
<main>
@ -49,7 +64,22 @@ function Lists() {
{lists.map((list) => (
<li>
<Link to={`/l/${list.id}`}>
<Icon icon="list" /> <span>{list.title}</span>
<span>
<Icon icon="list" /> <span>{list.title}</span>
</span>
{/* <button
type="button"
class="plain"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowListAddEditModal({
list,
});
}}
>
<Icon icon="pencil" />
</button> */}
</Link>
</li>
))}
@ -65,6 +95,26 @@ function Lists() {
)}
</main>
</div>
{showListAddEditModal && (
<Modal
class="light"
onClick={(e) => {
if (e.target === e.currentTarget) {
setShowListAddEditModal(false);
}
}}
>
<ListAddEdit
list={showListAddEditModal?.list}
onClose={(result) => {
if (result.state === 'success') {
reload();
}
setShowListAddEditModal(false);
}}
/>
</Modal>
)}
</div>
);
}

76
src/pages/mentions.jsx Normal file
View file

@ -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 (
<Timeline
title="Mentions"
id="mentions"
emptyText="No one mentioned you :("
errorText="Unable to load mentions."
instance={instance}
fetchItems={fetchMentions}
checkForUpdates={checkForUpdates}
useItemID
/>
);
}
export default Mentions;

View file

@ -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;
}

View file

@ -167,7 +167,7 @@ function Notifications() {
<div class="header-side">
<Menu />
<Link to="/" class="button plain">
<Icon icon="home" size="l" />
<Icon icon="home" size="l" alt="Home" />
</Link>
</div>
<h1>Notifications</h1>

View file

@ -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;

130
src/pages/trending.jsx Normal file
View file

@ -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 (
<Timeline
key={instance}
title={title}
titleComponent={
<h1 class="header-account">
<b>Trending</b>
<div>{instance}</div>
</h1>
}
id="trending"
instance={instance}
emptyText="No trending posts."
errorText="Unable to load posts"
fetchItems={fetchTrend}
checkForUpdates={checkForUpdates}
checkForUpdatesInterval={5 * 60 * 1000} // 5 minutes
useItemID
headerStart={<></>}
boostsCarousel={snapStates.settings.boostsCarousel}
allowFilters
headerEnd={
<Menu
portal={{
target: document.body,
}}
// setDownOverflow
overflow="auto"
viewScroll="close"
position="anchor"
boundingBoxPadding="8 8 8 8"
menuButton={
<button type="button" class="plain">
<Icon icon="more" size="l" />
</button>
}
>
<MenuItem
onClick={() => {
let newInstance = prompt(
'Enter a new instance e.g. "mastodon.social"',
);
if (!/\./.test(newInstance)) {
if (newInstance) alert('Invalid instance');
return;
}
if (newInstance) {
newInstance = newInstance.toLowerCase().trim();
navigate(`/${newInstance}/trending`);
}
}}
>
<Icon icon="bus" /> <span>Go to another instance</span>
</MenuItem>
</Menu>
}
/>
);
}
export default Trending;

View file

@ -18,7 +18,10 @@ export function groupBoosts(values) {
}
// if boostStash is more than quarter of values
// or if there are 3 or more boosts in a row
if (boostStash.length > values.length / 4 || serialBoosts >= 3) {
if (
values.length > 10 &&
(boostStash.length > values.length / 4 || serialBoosts >= 3)
) {
// if boostStash is more than 3 quarter of values
const boostStashID = boostStash.map((status) => status.id);
if (boostStash.length > (values.length * 3) / 4) {

View file

@ -27,6 +27,9 @@ export default defineConfig({
__BUILD_TIME__: JSON.stringify(now),
__COMMIT_HASH__: JSON.stringify(commitHash),
},
server: {
host: true,
},
plugins: [
preact(),
splitVendorChunkPlugin(),