diff --git a/public/sw.js b/public/sw.js index 14514039..b44a94b0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -39,7 +39,7 @@ registerRoute(imageRoute); // - /api/v1/preferences // - /api/v1/lists/:id const apiExtendedRoute = new RegExpRoute( - /^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+)/, + /^https?:\/\/[^\/]+\/api\/v\d+\/(instance|custom_emojis|preferences|lists\/\d+)$/, new StaleWhileRevalidate({ cacheName: 'api-extended', plugins: [ diff --git a/src/components/account-info.css b/src/components/account-info.css index f14780bb..06bf133b 100644 --- a/src/components/account-info.css +++ b/src/components/account-info.css @@ -260,6 +260,28 @@ 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 { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} +#list-add-remove-container .list-add-remove button .icon { + opacity: 0.15; +} +#list-add-remove-container .list-add-remove button.checked .icon { + opacity: 1; + color: var(--green-color); +} + @media (min-width: 40em) { .timeline-start .account-container { --item-radius: 16px; diff --git a/src/components/account-info.jsx b/src/components/account-info.jsx index a7fa85c2..0acc64b4 100644 --- a/src/components/account-info.jsx +++ b/src/components/account-info.jsx @@ -1,13 +1,7 @@ import './account-info.css'; -import { - Menu, - MenuDivider, - MenuHeader, - MenuItem, - SubMenu, -} from '@szhsin/react-menu'; -import { useEffect, useRef, useState } from 'preact/hooks'; +import { Menu, MenuDivider, MenuItem, SubMenu } from '@szhsin/react-menu'; +import { useEffect, useReducer, useRef, useState } from 'preact/hooks'; import { api } from '../utils/api'; import emojifyText from '../utils/emojify-text'; @@ -24,6 +18,8 @@ import AccountBlock from './account-block'; import Avatar from './avatar'; import Icon from './icon'; import Link from './link'; +import ListAddEdit from './list-add-edit'; +import Loader from './loader'; import Modal from './modal'; import TranslationBlock from './translation-block'; @@ -487,6 +483,7 @@ function RelatedActions({ info, instance, authenticated }) { const menuInstanceRef = useRef(null); const [showTranslatedBio, setShowTranslatedBio] = useState(false); + const [showAddRemoveLists, setShowAddRemoveLists] = useState(false); return ( <> @@ -583,6 +580,17 @@ function RelatedActions({ info, instance, authenticated }) { Translate bio + {/* Add/remove from lists is only possible if following the account */} + {following && ( + { + setShowAddRemoveLists(true); + }} + > + + Add/remove from Lists + + )} )} @@ -840,6 +848,18 @@ function RelatedActions({ info, instance, authenticated }) { )} + {!!showAddRemoveLists && ( + { + if (e.target === e.currentTarget) { + setShowAddRemoveLists(false); + } + }} + > + + + )} ); } @@ -900,4 +920,127 @@ function TranslatedBioSheet({ note, fields }) { ); } + +function AddRemoveListsSheet({ accountID }) { + const { masto } = api(); + const [uiState, setUiState] = useState('default'); + const [lists, setLists] = useState([]); + const [listsContainingAccount, setListsContainingAccount] = useState([]); + const [reloadCount, reload] = useReducer((c) => c + 1, 0); + + useEffect(() => { + setUiState('loading'); + (async () => { + try { + const lists = await masto.v1.lists.list(); + const listsContainingAccount = await masto.v1.accounts.listLists( + accountID, + ); + console.log({ lists, listsContainingAccount }); + setLists(lists); + setListsContainingAccount(listsContainingAccount); + setUiState('default'); + } catch (e) { + console.error(e); + setUiState('error'); + } + })(); + }, [reloadCount]); + + const [showListAddEditModal, setShowListAddEditModal] = useState(false); + + return ( +
+
+

Add/Remove from Lists

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

+ +

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

Unable to load lists.

+ ) : ( +

No lists.

+ )} + +
+ {showListAddEditModal && ( + { + if (e.target === e.currentTarget) { + setShowListAddEditModal(false); + } + }} + > + { + if (result.state === 'success') { + reload(); + } + setShowListAddEditModal(false); + }} + /> + + )} +
+ ); +} + export default AccountInfo; diff --git a/src/components/list-add-edit.jsx b/src/components/list-add-edit.jsx new file mode 100644 index 00000000..a3fd8f3f --- /dev/null +++ b/src/components/list-add-edit.jsx @@ -0,0 +1,133 @@ +import { useEffect, useRef, useState } from 'preact/hooks'; + +import { api } from '../utils/api'; + +function ListAddEdit({ list, onClose = () => {} }) { + const { masto } = api(); + const [uiState, setUiState] = useState('default'); + const editMode = !!list; + const nameFieldRef = useRef(); + const repliesPolicyFieldRef = useRef(); + useEffect(() => { + if (editMode) { + nameFieldRef.current.value = list.title; + repliesPolicyFieldRef.current.value = list.repliesPolicy; + } + }, [editMode]); + return ( +
+
+

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

+
+
+
{ + e.preventDefault(); // Get form values + + const formData = new FormData(e.target); + const title = formData.get('title'); + const repliesPolicy = formData.get('replies_policy'); + console.log({ + title, + repliesPolicy, + }); + setUiState('loading'); + + (async () => { + try { + let listResult; + + if (editMode) { + listResult = await masto.v1.lists.update(list.id, { + title, + replies_policy: repliesPolicy, + }); + } else { + listResult = await masto.v1.lists.create({ + title, + replies_policy: repliesPolicy, + }); + } + + console.log(listResult); + setUiState('default'); + onClose({ + state: 'success', + list: listResult, + }); + } catch (e) { + console.error(e); + setUiState('error'); + alert( + editMode ? 'Unable to edit list.' : 'Unable to create list.', + ); + } + })(); + }} + > +
+ +
+
+ +
+ +
+
+
+ ); +} + +export default ListAddEdit; diff --git a/src/index.css b/src/index.css index 339df71f..1a124fe5 100644 --- a/src/index.css +++ b/src/index.css @@ -194,6 +194,9 @@ button, color: var(--text-color); border: 1px solid var(--outline-color); } +:is(button, .button).light:not(:disabled, .disabled):is(:hover, :focus) { + border-color: var(--outline-hover-color); +} :is(button, .button).light.danger:not(:disabled, .disabled) { color: var(--red-color); } diff --git a/src/pages/list.jsx b/src/pages/list.jsx index 54c53c58..3b9155dc 100644 --- a/src/pages/list.jsx +++ b/src/pages/list.jsx @@ -1,8 +1,15 @@ -import { useEffect, useRef, useState } from 'preact/hooks'; -import { useParams } from 'react-router-dom'; +import './lists.css'; +import { Menu, MenuItem } from '@szhsin/react-menu'; +import { useEffect, useRef, useState } from 'preact/hooks'; +import { InView } from 'react-intersection-observer'; +import { useNavigate, useParams } from 'react-router-dom'; + +import AccountBlock from '../components/account-block'; import Icon from '../components/icon'; import Link from '../components/link'; +import ListAddEdit from '../components/list-add-edit'; +import Modal from '../components/modal'; import Timeline from '../components/timeline'; import { api } from '../utils/api'; import { filteredItems } from '../utils/filters'; @@ -14,7 +21,9 @@ const LIMIT = 20; function List(props) { const { masto, instance } = api(); const id = props?.id || useParams()?.id; + const navigate = useNavigate(); const latestItem = useRef(); + // const [reloadCount, reload] = useReducer((c) => c + 1, 0); const listIterator = useRef(); async function fetchList(firstLoad) { @@ -55,37 +64,231 @@ function List(props) { } } - const [title, setTitle] = useState(`List`); - useTitle(title, `/l/:id`); + const [list, setList] = useState({ title: 'List' }); + // const [title, setTitle] = useState(`List`); + useTitle(list.title, `/l/:id`); useEffect(() => { (async () => { try { const list = await masto.v1.lists.fetch(id); - setTitle(list.title); + setList(list); + // setTitle(list.title); } catch (e) { console.error(e); } })(); }, [id]); + const [showListAddEditModal, setShowListAddEditModal] = useState(false); + const [showManageMembersModal, setShowManageMembersModal] = useState(false); + return ( - - - + <> + + + + } + headerEnd={ + + + + } + > + + setShowListAddEditModal({ + list, + }) + } + > + + Edit + + setShowManageMembersModal(true)}> + + Manage members + + + } + /> + {showListAddEditModal && ( + { + if (e.target === e.currentTarget) { + setShowListAddEditModal(false); + } + }} + > + { + if (result.state === 'success' && result.list) { + setList(result.list); + // reload(); + } else if (result.state === 'deleted') { + navigate('/l'); + } + setShowListAddEditModal(false); + }} + /> + + )} + {showManageMembersModal && ( + { + if (e.target === e.currentTarget) { + setShowManageMembersModal(false); + } + }} + > + + + )} + + ); +} + +const MEMBERS_LIMIT = 10; +function ListManageMembers({ listID }) { + // Show list of members with [Remove] button + // API only returns 40 members at a time, so this need to be paginated with infinite scroll + // Show [Add] button after removing a member + const { masto, instance } = api(); + const [members, setMembers] = useState([]); + const [uiState, setUIState] = useState('default'); + const [showMore, setShowMore] = useState(false); + + const membersIterator = useRef(); + + async function fetchMembers(firstLoad) { + setShowMore(false); + setUIState('loading'); + (async () => { + try { + if (firstLoad || !membersIterator.current) { + membersIterator.current = masto.v1.lists.listAccounts(listID, { + limit: MEMBERS_LIMIT, + }); + } + const results = await membersIterator.current.next(); + let { done, value } = results; + if (value?.length) { + if (firstLoad) { + setMembers(value); + } else { + setMembers(members.concat(value)); + } + setShowMore(!done); + } else { + setShowMore(false); + } + setUIState('default'); + } catch (e) { + setUIState('error'); } - /> + })(); + } + + useEffect(() => { + fetchMembers(true); + }, []); + + return ( +
+
+

Manage members

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

Lists

-
+
+ +
@@ -49,7 +64,22 @@ function Lists() { {lists.map((list) => (
  • - {list.title} + + {list.title} + + {/* */}
  • ))} @@ -65,6 +95,26 @@ function Lists() { )}
    + {showListAddEditModal && ( + { + if (e.target === e.currentTarget) { + setShowListAddEditModal(false); + } + }} + > + { + if (result.state === 'success') { + reload(); + } + setShowListAddEditModal(false); + }} + /> + + )} ); }