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 }) { <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; 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 ( + <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; 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 ( - <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 = 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 ( + <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> ); } 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 ( <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> ); }