commit
982f7b3ec4
|
@ -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: [
|
||||
|
|
57
src/app.css
57
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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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');
|
||||
|
|
133
src/components/list-add-edit.jsx
Normal file
133
src/components/list-add-edit.jsx
Normal 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;
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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…
|
||||
</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,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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…
|
||||
</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
33
src/pages/lists.css
Normal 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;
|
||||
}
|
|
@ -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
76
src/pages/mentions.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
130
src/pages/trending.jsx
Normal 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;
|
|
@ -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) {
|
||||
|
|
|
@ -27,6 +27,9 @@ export default defineConfig({
|
|||
__BUILD_TIME__: JSON.stringify(now),
|
||||
__COMMIT_HASH__: JSON.stringify(commitHash),
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
},
|
||||
plugins: [
|
||||
preact(),
|
||||
splitVendorChunkPlugin(),
|
||||
|
|
Loading…
Reference in a new issue