import React, { memo, useCallback, useMemo, useRef, useState, useEffect, } from "react"; import { Settings, CheckCircle } from "react-feather"; import { usePopper } from "react-popper"; import { useDispatch, useSelector } from "react-redux"; import styled from "styled-components/macro"; import { useFetchListCallback } from "../../hooks/useFetchListCallback"; import { useOnClickOutside } from "../../hooks/useOnClickOutside"; import { TokenList } from "@uniswap/token-lists"; import useToggle from "../../hooks/useToggle"; import { AppDispatch, AppState } from "../../state"; import { acceptListUpdate, removeList, disableList, enableList, } from "../../state/glists/actions"; import { useIsListActive, useAllLists, useActiveListUrls, } from "../../state/glists/hooks"; import { ExternalLink, LinkStyledButton, TYPE, IconWrapper } from "../../theme"; import listVersionLabel from "../../utils/listVersionLabel"; import { parseENSAddress } from "../../utils/parseENSAddress"; import uriToHttp from "../../utils/uriToHttp"; import { ButtonEmpty, ButtonPrimary } from "../Button"; import Column, { AutoColumn } from "../Column"; import ListLogo from "../ListLogo"; import Row, { RowFixed, RowBetween } from "../Row"; import { PaddedColumn, SearchInput, Separator, SeparatorDark } from "./styleds"; import { useListColor } from "../../hooks/useColor"; import useTheme from "../../hooks/useTheme"; import ListToggle from "../Toggle/ListToggle"; import Card from "../Card"; import { CurrencyModalView } from "./CurrencySearchModal"; import { UNSUPPORTED_LIST_URLS } from "../../constants/lists"; import { useWeb3 } from "../../web3"; const Wrapper = styled(Column)` width: 100%; height: 100%; `; const UnpaddedLinkStyledButton = styled(LinkStyledButton)` padding: 0; font-size: 1rem; opacity: ${({ disabled }) => (disabled ? "0.4" : "1")}; `; const PopoverContainer = styled.div<{ show: boolean }>` z-index: 100; visibility: ${(props) => (props.show ? "visible" : "hidden")}; opacity: ${(props) => (props.show ? 1 : 0)}; transition: visibility 150ms linear, opacity 150ms linear; background: ${({ theme }) => theme.bg2}; border: 1px solid ${({ theme }) => theme.bg3}; box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04), 0px 24px 32px rgba(0, 0, 0, 0.01); color: ${({ theme }) => theme.text2}; border-radius: 0.5rem; padding: 1rem; display: grid; grid-template-rows: 1fr; grid-gap: 8px; font-size: 1rem; text-align: left; `; const StyledMenu = styled.div` display: flex; justify-content: center; align-items: center; position: relative; border: none; `; const StyledTitleText = styled.div<{ active: boolean }>` font-size: 16px; overflow: hidden; text-overflow: ellipsis; font-weight: 600; color: ${({ theme, active }) => (active ? theme.white : theme.text2)}; `; const StyledListUrlText = styled(TYPE.main)<{ active: boolean }>` font-size: 12px; color: ${({ theme, active }) => (active ? theme.white : theme.text2)}; `; const RowWrapper = styled(Row)<{ bgColor: string; active: boolean }>` background-color: ${({ bgColor, active, theme }) => active ? bgColor ?? "transparent" : theme.bg2}; transition: 200ms; align-items: center; padding: 1rem; border-radius: 20px; `; function listUrlRowHTMLId(listUrl: string) { return `list-row-${listUrl.replace(/\./g, "-")}`; } const ListRow = memo(function ListRow({ listUrl }: { listUrl: string }) { const listsByUrl = useSelector<AppState, AppState["glists"]["byUrl"]>( (state) => state.glists.byUrl ); const dispatch = useDispatch<AppDispatch>(); const { current: list, pendingUpdate: pending } = listsByUrl[listUrl]; const theme = useTheme(); const listColor = useListColor(list?.logoURI); const isActive = useIsListActive(listUrl); const [open, toggle] = useToggle(false); const node = useRef<HTMLDivElement>(); const [referenceElement, setReferenceElement] = useState<HTMLDivElement>(); const [popperElement, setPopperElement] = useState<HTMLDivElement>(); const { styles, attributes } = usePopper(referenceElement, popperElement, { placement: "auto", strategy: "fixed", modifiers: [{ name: "offset", options: { offset: [8, 8] } }], }); useOnClickOutside(node, open ? toggle : undefined); const handleAcceptListUpdate = useCallback(() => { if (!pending) return; dispatch(acceptListUpdate(listUrl)); }, [dispatch, listUrl, pending]); const handleRemoveList = useCallback(() => { if ( window.prompt( `Please confirm you would like to remove this list by typing REMOVE` ) === `REMOVE` ) { dispatch(removeList(listUrl)); } }, [dispatch, listUrl]); const handleEnableList = useCallback(() => { dispatch(enableList(listUrl)); }, [dispatch, listUrl]); const handleDisableList = useCallback(() => { dispatch(disableList(listUrl)); }, [dispatch, listUrl]); if (!list) return null; return ( <RowWrapper active={isActive} bgColor={listColor} key={listUrl} id={listUrlRowHTMLId(listUrl)} > {list.logoURI ? ( <ListLogo size="40px" style={{ marginRight: "1rem" }} logoURI={list.logoURI} alt={`${list.name} list logo`} /> ) : ( <div style={{ width: "24px", height: "24px", marginRight: "1rem" }} /> )} <Column style={{ flex: "1" }}> <Row> <StyledTitleText active={isActive}>{list.name}</StyledTitleText> </Row> <RowFixed mt="4px"> <StyledListUrlText active={isActive} mr="6px"> {list.tokens.length} tokens </StyledListUrlText> <StyledMenu ref={node as any}> <ButtonEmpty onClick={toggle} ref={setReferenceElement} padding="0"> <Settings stroke={isActive ? theme.bg1 : theme.text1} size={12} /> </ButtonEmpty> {open && ( <PopoverContainer show={true} ref={setPopperElement as any} style={styles.popper} {...attributes.popper} > <div>{list && listVersionLabel(list.version)}</div> <SeparatorDark /> <ExternalLink href={`https://tokenlists.org/token-list?url=${listUrl}`} > View list </ExternalLink> <UnpaddedLinkStyledButton onClick={handleRemoveList} disabled={Object.keys(listsByUrl).length === 1} > Remove list </UnpaddedLinkStyledButton> {pending && ( <UnpaddedLinkStyledButton onClick={handleAcceptListUpdate}> Update list </UnpaddedLinkStyledButton> )} </PopoverContainer> )} </StyledMenu> </RowFixed> </Column> <ListToggle isActive={isActive} bgColor={listColor} toggle={() => { isActive ? handleDisableList() : handleEnableList(); }} /> </RowWrapper> ); }); const ListContainer = styled.div` padding: 1rem; height: 100%; overflow: auto; padding-bottom: 80px; `; export function ManageLists({ setModalView, setImportList, setListUrl, }: { setModalView: (view: CurrencyModalView) => void; setImportList: (list: TokenList) => void; setListUrl: (url: string) => void; }) { const theme = useTheme(); const [listUrlInput, setListUrlInput] = useState<string>(""); const lists = useAllLists(); // sort by active but only if not visible const activeListUrls = useActiveListUrls(); const [activeCopy, setActiveCopy] = useState<string[] | undefined>(); useEffect(() => { if (!activeCopy && activeListUrls) { setActiveCopy(activeListUrls); } }, [activeCopy, activeListUrls]); const handleInput = useCallback((e) => { setListUrlInput(e.target.value); }, []); const fetchList = useFetchListCallback(); const validUrl: boolean = useMemo(() => { return ( uriToHttp(listUrlInput).length > 0 || Boolean(parseENSAddress(listUrlInput)) ); }, [listUrlInput]); const sortedLists = useMemo(() => { const listUrls = Object.keys(lists); return listUrls .filter((listUrl) => { // only show loaded lists, hide unsupported lists return ( Boolean(lists[listUrl].current) && !Boolean(UNSUPPORTED_LIST_URLS.includes(listUrl)) ); }) .sort((u1, u2) => { const { current: l1 } = lists[u1]; const { current: l2 } = lists[u2]; // first filter on active lists if (activeCopy?.includes(u1) && !activeCopy?.includes(u2)) { return -1; } if (!activeCopy?.includes(u1) && activeCopy?.includes(u2)) { return 1; } if (l1 && l2) { return l1.name.toLowerCase() < l2.name.toLowerCase() ? -1 : l1.name.toLowerCase() === l2.name.toLowerCase() ? 0 : 1; } if (l1) return -1; if (l2) return 1; return 0; }); }, [lists, activeCopy]); // temporary fetched list for import flow const [tempList, setTempList] = useState<TokenList>(); const [addError, setAddError] = useState<string | undefined>(); const { library } = useWeb3(); useEffect(() => { async function fetchTempList() { if (!library) return; fetchList(library, listUrlInput, false) .then((list) => setTempList(list)) .catch(() => setAddError("Error importing list")); } // if valid url, fetch details for card if (validUrl) { fetchTempList(); } else { setTempList(undefined); listUrlInput !== "" && setAddError("Enter valid list location"); } // reset error if (listUrlInput === "") { setAddError(undefined); } }, [fetchList, listUrlInput, validUrl, library]); // check if list is already imported const isImported = Object.keys(lists).includes(listUrlInput); // set list values and have parent modal switch to import list view const handleImport = useCallback(() => { if (!tempList) return; setImportList(tempList); setModalView(CurrencyModalView.importList); setListUrl(listUrlInput); }, [listUrlInput, setImportList, setListUrl, setModalView, tempList]); return ( <Wrapper> <PaddedColumn gap="14px"> <Row> <SearchInput type="text" id="list-add-input" placeholder="https:// or ipfs:// or ENS name" value={listUrlInput} onChange={handleInput} /> </Row> {addError ? ( <TYPE.error title={addError} style={{ textOverflow: "ellipsis", overflow: "hidden" }} error > {addError} </TYPE.error> ) : null} </PaddedColumn> {tempList && ( <PaddedColumn style={{ paddingTop: 0 }}> <Card backgroundColor={theme.bg2} padding="12px 20px"> <RowBetween> <RowFixed> {tempList.logoURI && ( <ListLogo logoURI={tempList.logoURI} size="40px" /> )} <AutoColumn gap="4px" style={{ marginLeft: "20px" }}> <TYPE.body fontWeight={600}>{tempList.name}</TYPE.body> <TYPE.main fontSize={"12px"}> {tempList.tokens.length} tokens </TYPE.main> </AutoColumn> </RowFixed> {isImported ? ( <RowFixed> <IconWrapper stroke={theme.text2} size="16px" marginRight={"10px"} > <CheckCircle /> </IconWrapper> <TYPE.body color={theme.text2}>Loaded</TYPE.body> </RowFixed> ) : ( <ButtonPrimary style={{ fontSize: "14px" }} padding="6px 8px" width="fit-content" onClick={handleImport} > Import </ButtonPrimary> )} </RowBetween> </Card> </PaddedColumn> )} <Separator /> <ListContainer> <AutoColumn gap="md"> {sortedLists.map((listUrl) => ( <ListRow key={listUrl} listUrl={listUrl} /> ))} </AutoColumn> </ListContainer> </Wrapper> ); }