From b81c311acac67505206138ed05df50454ca99593 Mon Sep 17 00:00:00 2001 From: Pedro Ribeiro <47680931+tubarao312@users.noreply.github.com> Date: Thu, 8 Feb 2024 04:37:04 +0000 Subject: [PATCH] Address Tags Rework (#61) * Completely rework entire tag input holy shit * Remove unused dependencies, add tags to nodes * remove unused libraries --- package.json | 3 - src/App.tsx | 5 +- src/components/common/Badge.tsx | 14 +- src/components/graph/Graph.tsx | 46 ++-- .../graph/analysis_window/AnalysisWindow.tsx | 2 +- .../analysis_window/{ => header}/Header.tsx | 109 +++++++-- .../graph/analysis_window/header/TagInput.tsx | 222 ++++++++++++++++++ .../analysis_window/header/TagsPopover.tsx | 104 ++++++++ .../AddressNode/AddressNode/AddressNode.tsx | 40 +++- .../hotbar/components/NewAddressModal.tsx | 7 +- src/components/graph/search_bar/SearchBar.tsx | 14 +- src/hooks/useAuthState.tsx | 28 +++ src/services/auth/auth.services.ts | 46 +--- src/services/{firebase => }/firebase.ts | 0 src/services/firebase/analytics/analytics.ts | 16 -- src/services/firebase/firestore.ts | 50 ---- .../analytics}/analytics.ts | 2 +- .../firestore/graph/addresses/custom-tags.ts | 140 +++++++++++ .../graph}/short-urls.ts | 2 +- src/services/firestore/user/custom-tags.ts | 123 ++++++++++ .../user}/search-history.ts | 2 +- src/templates/GraphTemplate.tsx | 56 ----- src/types/hotKeys.tsx | 3 +- yarn.lock | 35 +-- 24 files changed, 812 insertions(+), 257 deletions(-) rename src/components/graph/analysis_window/{ => header}/Header.tsx (75%) create mode 100644 src/components/graph/analysis_window/header/TagInput.tsx create mode 100644 src/components/graph/analysis_window/header/TagsPopover.tsx create mode 100644 src/hooks/useAuthState.tsx rename src/services/{firebase => }/firebase.ts (100%) delete mode 100644 src/services/firebase/analytics/analytics.ts delete mode 100644 src/services/firebase/firestore.ts rename src/services/{firebase => firestore/analytics}/analytics.ts (87%) create mode 100644 src/services/firestore/graph/addresses/custom-tags.ts rename src/services/{firebase/short-urls => firestore/graph}/short-urls.ts (95%) create mode 100644 src/services/firestore/user/custom-tags.ts rename src/services/{firebase/search-history => firestore/user}/search-history.ts (98%) delete mode 100644 src/templates/GraphTemplate.tsx diff --git a/package.json b/package.json index 6e0af87f..237eff8f 100644 --- a/package.json +++ b/package.json @@ -19,9 +19,7 @@ "@heroicons/react": "^2.1.1", "axios": "^1.6.2", "clsx": "^2.0.0", - "dotenv": "^16.3.1", "firebase": "^10.7.1", - "framer-motion": "^10.17.9", "orval": "^6.23.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -36,7 +34,6 @@ }, "devDependencies": { "@tailwindcss/forms": "^0.5.7", - "@types/dotenv": "^8.2.0", "@types/react": "^18.2.45", "@types/react-dom": "^18.2.15", "@types/uuid": "^9.0.7", diff --git a/src/App.tsx b/src/App.tsx index f2422923..317177b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,8 +1,7 @@ import { QueryClientProvider } from "react-query"; import useCustomQueryClient from "./hooks/useCustomQueryClient"; -import authService from "./services/auth/auth.services"; -import "./services/firebase/firebase"; +import useAuthState from "./hooks/useAuthState"; import PrivateApp from "./PrivateApp"; import PublicApp from "./PublicApp"; @@ -10,7 +9,7 @@ import { MobileWarningTemplate } from "./templates"; function App() { const queryClient = useCustomQueryClient(); - const user = authService.useAuthState().user; + const { user } = useAuthState(); return ( <> diff --git a/src/components/common/Badge.tsx b/src/components/common/Badge.tsx index 82ef8e5e..2ead81aa 100644 --- a/src/components/common/Badge.tsx +++ b/src/components/common/Badge.tsx @@ -6,6 +6,7 @@ interface BadgeProps { text: string; Icon?: any; className?: string; + onClick?: () => void; } /** This component is a badge that displays a text and a color. @@ -13,10 +14,17 @@ interface BadgeProps { * @param text: The text of the badge * @param color: The color of the badge * @param Icon: The icon of the badge - * @param className: The class name of the + * @param className: The class name of the badge + * @param onClick: The function to call when the badge is clicked */ -export default function Badge({ color, text, Icon, className }: BadgeProps) { +export default function Badge({ + color, + text, + Icon, + className, + onClick, +}: BadgeProps) { const { text: textColor, background, ring } = ColorMap[color]; return ( @@ -27,7 +35,9 @@ export default function Badge({ color, text, Icon, className }: BadgeProps) { textColor, background, ring, + onClick && "cursor-pointer", )} + onClick={onClick} > {Icon && } {text} diff --git a/src/components/graph/Graph.tsx b/src/components/graph/Graph.tsx index 9438f042..8a576556 100644 --- a/src/components/graph/Graph.tsx +++ b/src/components/graph/Graph.tsx @@ -25,7 +25,7 @@ import ReactFlow, { } from "reactflow"; import "reactflow/dist/style.css"; -import authService from "../../services/auth/auth.services"; +import useAuthState from "../../hooks/useAuthState"; import { AddressAnalysis } from "../../api/model"; @@ -47,11 +47,9 @@ import { convertNodeListToRecord, } from "./graph_calculations"; -import analytics from "../../services/firebase/analytics/analytics"; -import { storeAddress } from "../../services/firebase/search-history/search-history"; -import firestore, { - StoreUrlObject, -} from "../../services/firebase/short-urls/short-urls"; +import analytics from "../../services/firestore/analytics/analytics"; +import { storeAddress } from "../../services/firestore/user/search-history"; + import generateShortUrl from "../../utils/generateShortUrl"; import TutorialPopup from "./tutorial/TutorialPopup"; import DraggableWindow from "./analysis_window/AnalysisWindow"; @@ -642,22 +640,24 @@ const GraphProvided: FC = ({ } async function copyLink(shortenedUrl: string): Promise { - const link = getLink(); - const key = shortenedUrl.split("/").pop()!; - - const storeUrlObj: StoreUrlObject = { - originalUrl: link, - key: key, - }; - - await firestore.storeUrl(storeUrlObj).then(async (id) => { - if (id) { - await navigator.clipboard.writeText(shortenedUrl); - analytics.logAnalyticsEvent("copy_link", { - link: shortenedUrl, - }); - } - }); + console.log(getLink()); + console.log(shortenedUrl); + // const link = getLink(); + // const key = shortenedUrl.split("/").pop()!; + + // const storeUrlObj: StoreUrlObject = { + // originalUrl: link, + // key: key, + // }; + + // await firestore.storeUrl(storeUrlObj).then(async (id) => { + // if (id) { + // await navigator.clipboard.writeText(shortenedUrl); + // analytics.logAnalyticsEvent("copy_link", { + // link: shortenedUrl, + // }); + // } + // }); } // Getting the node count so that we can show the legend dynamically --------- @@ -763,7 +763,7 @@ const PublicGraph: FC = ({ const [searchedAddresses, setSearchedAddresses] = useState(initialAddresses); - const { user } = authService.useAuthState(); + const { user } = useAuthState(); const onSetSearchedAddress = (newAddress: string) => { setSearchedAddresses([newAddress]); diff --git a/src/components/graph/analysis_window/AnalysisWindow.tsx b/src/components/graph/analysis_window/AnalysisWindow.tsx index 71fe4e81..4668d030 100644 --- a/src/components/graph/analysis_window/AnalysisWindow.tsx +++ b/src/components/graph/analysis_window/AnalysisWindow.tsx @@ -10,7 +10,7 @@ import { Transition } from "@headlessui/react"; import Draggable from "react-draggable"; import { Advanced, Transactions, Overview } from "./content"; -import Header from "./Header"; +import Header from "./header/Header"; import { AddressAnalysis } from "../../../api/model"; diff --git a/src/components/graph/analysis_window/Header.tsx b/src/components/graph/analysis_window/header/Header.tsx similarity index 75% rename from src/components/graph/analysis_window/Header.tsx rename to src/components/graph/analysis_window/header/Header.tsx index 628ca315..a57deb7e 100644 --- a/src/components/graph/analysis_window/Header.tsx +++ b/src/components/graph/analysis_window/header/Header.tsx @@ -5,23 +5,36 @@ import { ArrowsPointingInIcon, IdentificationIcon, } from "@heroicons/react/20/solid"; + +import { XMarkIcon as XMarkSmallIcon } from "@heroicons/react/16/solid"; + import clsx from "clsx"; -import { FC, useContext, useCallback } from "react"; +import { FC, useContext, useCallback, useState, useEffect } from "react"; -import EntityLogo from "../../common/EntityLogo"; -import BlockExplorerAddressIcon from "../../common/utility_icons/BlockExplorerAddressIcon"; -import CopyToClipboardIcon from "../../common/utility_icons/CopyToClipboardIcon"; -import LabelList from "../../common/LabelList"; -import RiskIndicator from "../../common/RiskIndicator"; +import { AddressAnalysis, Category } from "../../../../api/model"; +import { CategoryClasses } from "../../../../utils/categories"; + +import { + deleteCustomAddressTag, + getCustomAddressesTags, + storeCustomAddressesTags, +} from "../../../../services/firestore/graph/addresses/custom-tags"; -import { AnalysisContext } from "./AnalysisWindow"; -import { GraphContext } from "../Graph"; -import { AnalysisMode, AnalysisModes } from "./AnalysisWindow"; -import { AddressAnalysis, Category } from "../../../api/model"; +import { Colors } from "../../../../utils/colors"; -import { PathExpansionArgs } from "../Graph"; +import EntityLogo from "../../../common/EntityLogo"; +import BlockExplorerAddressIcon from "../../../common/utility_icons/BlockExplorerAddressIcon"; +import CopyToClipboardIcon from "../../../common/utility_icons/CopyToClipboardIcon"; +import Badge from "../../../common/Badge"; +import RiskIndicator from "../../../common/RiskIndicator"; -import { CategoryClasses } from "../../../utils/categories"; +import { AnalysisContext } from "../AnalysisWindow"; +import { GraphContext } from "../../Graph"; +import { AnalysisMode, AnalysisModes } from "../AnalysisWindow"; + +import { PathExpansionArgs } from "../../Graph"; + +import TagInput from "./TagInput"; interface ModeButtonProps { isActive: boolean; @@ -53,7 +66,16 @@ const ModeButton: FC = ({ })} aria-hidden="true" /> - {analysisMode.name} + + {analysisMode.name} + ); }; @@ -110,6 +132,63 @@ function getCategoryRisk(category: string): number { return categoryClass.risk; } +// Label + Tag + Tag Input Wrapped Component + +const LabelsAndTags: FC = () => { + const { analysisData, address } = useContext(AnalysisContext); + + // Labels get displayed first in the flex-wrap + const labels = analysisData!.labels; + + // Tags need to be fetched from firestore async + const [tags, setTags] = useState([]); + + async function fetchAddressTags(address: string) { + getCustomAddressesTags(address).then((tags) => { + setTags(tags); + }); + } + + useEffect(() => { + fetchAddressTags(address); + }, [address]); + + const onCreateCustomAddressTag = useCallback( + (tag: string) => { + storeCustomAddressesTags(address, [...tags, tag]); + setTags([...tags, tag]); + }, + [tags], + ); + + // Display everything and user input at the end in a flex-wrap + return ( + + {labels.map((label) => ( + + ))} + {tags.map((tag) => ( + { + deleteCustomAddressTag(address, tag); + const newTags = tags.filter((t) => t !== tag); + setTags(newTags); + }} + /> + ))} + + + ); +}; + interface HeaderProps { onExit: () => void; setAnalysisMode: (mode: AnalysisMode) => void; @@ -227,9 +306,9 @@ const Header: FC = ({ /> )} - + {/* List of labels using Badges shown underneath the address. */} - + diff --git a/src/components/graph/analysis_window/header/TagInput.tsx b/src/components/graph/analysis_window/header/TagInput.tsx new file mode 100644 index 00000000..fe4c4e7a --- /dev/null +++ b/src/components/graph/analysis_window/header/TagInput.tsx @@ -0,0 +1,222 @@ +import { FC, useRef, useState, KeyboardEvent, useMemo, useEffect } from "react"; + +import TagsPopover from "./TagsPopover"; +import { HotKeysType } from "../../../../types/hotKeys"; + +import { + getCustomAddressesTags, + storeCustomAddressesTags, +} from "../../../../services/firestore/graph/addresses/custom-tags"; +import { + getCustomUserTags, + storeCustomUserTags, +} from "../../../../services/firestore/user/custom-tags"; +import { PencilIcon } from "@heroicons/react/16/solid"; + +interface TagInputProps { + address: string; + initialTags: string[]; + onCreateCustomAddressTag?: (tag: string) => void; +} + +const TagInput: FC = ({ + address, + onCreateCustomAddressTag = () => {}, + initialTags, +}) => { + // Ref for the popover hotkeys + const popoverRef = useRef(null); + + // Current user input + const [userInput, setUserInput] = useState(""); + + // Whether or not to open the popover + const [isTagsPopoverOpen, setIsTagsPopoverOpen] = useState(false); + + // Get existing address tags and user tags + const [addressCustomTags, setAddressCustomTags] = + useState(initialTags); + const [userCustomTags, setUserCustomTags] = useState([]); + + // Fetch the address and user tags from the database + async function fetchAddressTags() { + getCustomAddressesTags(address).then((tags) => { + setAddressCustomTags(tags); + console.log("Address tags are ", tags); + }); + } + + async function fetchUserTags() { + getCustomUserTags().then((tags) => { + setUserCustomTags(tags); + console.log("User tags are ", tags); + }); + } + + useEffect(() => { + fetchAddressTags(); + fetchUserTags(); + }, []); + + // Options = user custom tags that include the user input - address custom tags (which already include the user input) + const { options } = useMemo(() => { + const options = userCustomTags.filter((tag) => + tag.toLowerCase().includes(userInput.toLowerCase()), + ); + + // Must all be unique + const uniqueOptions = Array.from(new Set(options)); + + // Subtract already existing tags from the options + return { + options: uniqueOptions.filter((tag) => !addressCustomTags.includes(tag)), + }; + }, [userInput, addressCustomTags, userCustomTags]); + + // When a tag is created, the user input is cleared and the component above is updated + const onAddTagHandler = async (input: string) => { + // If the tag is already in the address tags, return early + if (addressCustomTags.includes(input)) return; + + onCreateCustomAddressTag(input); + + // Store custom tags in the database + await storeCustomAddressesTags(address, [...addressCustomTags, input]); + await storeCustomUserTags([...userCustomTags, input]); + + // Re-fetch the tags + await fetchAddressTags(); + await fetchUserTags(); + + setUserInput(""); + }; + + // Hotkeys for moving up and down + const [selectedOptionIndex, setSelectedOptionIndex] = useState( + null, + ); + const moveSelectedIndexUp = () => { + // If null and there are options, select the last option + if (selectedOptionIndex === null && options.length > 0) { + setSelectedOptionIndex(options.length - 1); + return; + } + + // If not null, move the selected index up. If it's gonna be negative, set to null + if (selectedOptionIndex !== null && selectedOptionIndex === 0) { + setSelectedOptionIndex(null); + return; + } + + // If not null and not 0, move the selected index up + if (selectedOptionIndex !== null) { + setSelectedOptionIndex(selectedOptionIndex - 1); + } + }; + const moveSelectedIndexDown = () => { + // If null and there are options, select the first option + if (selectedOptionIndex === null && options.length > 0) { + setSelectedOptionIndex(0); + return; + } + + // If not null, move the selected index down. If it's gonna be greater than the length, set to null + if ( + selectedOptionIndex !== null && + selectedOptionIndex === options.length - 1 + ) { + setSelectedOptionIndex(null); + return; + } + + // If not null and not the last index, move the selected index down + if (selectedOptionIndex !== null) { + setSelectedOptionIndex(selectedOptionIndex + 1); + } + }; + + const hotKeysMap: HotKeysType = { + SEARCH: { + key: "enter", + asyncHandler: async (event: KeyboardEvent) => { + event.preventDefault(); + + // If an option is selected, use that. Else, use the written user input + const input: string = + selectedOptionIndex === null + ? userInput + : options[selectedOptionIndex]; + + await onAddTagHandler(input); + }, + }, + ARROWUP: { + key: "arrowup", + handler: (event: KeyboardEvent) => { + event.preventDefault(); + moveSelectedIndexUp(); + }, + }, + ARROWDOWN: { + key: "arrowdown", + handler: (event: KeyboardEvent) => { + event.preventDefault(); + moveSelectedIndexDown(); + }, + }, + }; + + return ( +
+ { + setUserInput(event.target.value); + setSelectedOptionIndex(null); + }} + onKeyDown={async (event) => { + const hotKey = event.key.toLocaleLowerCase(); + switch (hotKey) { + case hotKeysMap.SEARCH.key: + await hotKeysMap.SEARCH.asyncHandler!(event); + break; + case hotKeysMap.ARROWUP.key: + hotKeysMap.ARROWUP.handler!(event); + break; + case hotKeysMap.ARROWDOWN.key: + hotKeysMap.ARROWDOWN.handler!(event); + break; + } + }} + onFocus={() => { + setIsTagsPopoverOpen(true); + }} + onBlur={(event) => { + // If the popover is open and the user clicks outside of the popover, close the popover + if ( + popoverRef.current && + !popoverRef.current.contains(event.relatedTarget as Node) + ) { + setIsTagsPopoverOpen(false); + } + }} + className="w-32 rounded-md border-0 py-[0.04rem] pl-5 text-xs font-semibold leading-6 text-gray-900 ring-1 ring-inset ring-gray-300 transition-all placeholder:text-gray-400 focus:outline focus:outline-[3px] focus:outline-blue-200 focus:ring-2 focus:ring-blue-400" + /> +
+ ); +}; + +export default TagInput; diff --git a/src/components/graph/analysis_window/header/TagsPopover.tsx b/src/components/graph/analysis_window/header/TagsPopover.tsx new file mode 100644 index 00000000..6362c931 --- /dev/null +++ b/src/components/graph/analysis_window/header/TagsPopover.tsx @@ -0,0 +1,104 @@ +import { Popover, Transition } from "@headlessui/react"; +import { PlusIcon, TagIcon } from "@heroicons/react/24/solid"; +import clsx from "clsx"; +import { FC, Fragment } from "react"; + +interface CreateNewTagRowProps { + tag: string; + onClick: () => Promise; + selected: boolean; +} + +const CreateNewTagRow: FC = ({ + onClick, + selected, + tag, +}) => { + return ( +
+
+ ); +}; + +interface TagsRowProps { + onClick: () => Promise; + selected: boolean; + tag: string; +} + +const TagsRow: FC = ({ onClick, selected, tag }) => { + return ( +
+
+ ); +}; + +interface TagsPopoverProps { + input: string; + options: string[]; + selectedOptionIndex: number | null; + addTag: (input: string) => Promise; +} + +const TagsPopover: FC = ({ + input, + options, + selectedOptionIndex, + addTag, +}) => { + return ( + + + +
+
+ <> + await addTag(input)} + selected={selectedOptionIndex === null} + /> + {options.map((tag) => ( + await addTag(tag)} + selected={selectedOptionIndex === options.indexOf(tag)} + /> + ))} + +
+
+
+
+
+ ); +}; + +export default TagsPopover; diff --git a/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx b/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx index 8407380a..47a0ebf6 100644 --- a/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx +++ b/src/components/graph/custom_elements/nodes/AddressNode/AddressNode/AddressNode.tsx @@ -5,12 +5,16 @@ import { Position, Handle, Edge } from "reactflow"; import { AddressAnalysis } from "../../../../../../api/model"; import { useAnalysisAddressData } from "../../../../../../api/compliance/compliance"; +import { getCustomAddressesTags } from "../../../../../../services/firestore/graph/addresses/custom-tags"; + import EntityLogo from "../../../../../common/EntityLogo"; import RiskIndicator from "../../../../../common/RiskIndicator"; -import LabelList from "../../../../../common/LabelList"; import { GraphContext } from "../../../../Graph"; +import Badge from "../../../../../common/Badge"; +import { Colors } from "../../../../../../utils/colors"; + import { createTransfershipEdge, TransfershipEdgeStates, @@ -38,6 +42,25 @@ interface AddressNodeProps { }; } +interface LabelsAndTagsProps { + labels: string[]; + tags: string[]; +} + +const LabelsAndTags: FC = ({ labels, tags }) => { + // Display everything and user input at the end in a flex-wrap + return ( + + {labels.map((label) => ( + + ))} + {tags.map((tag) => ( + + ))} + + ); +}; + const AddressNode: FC = ({ data: { address, highlight }, }) => { @@ -115,6 +138,17 @@ const AddressNode: FC = ({ getAddressData(); }, []); + // Get the tags of the address + const [tags, setTags] = useState([]); + async function fetchAddressTags(address: string) { + getCustomAddressesTags(address).then((tags) => { + setTags(tags); + }); + } + useEffect(() => { + fetchAddressTags(address); + }, [address]); + // Context data is set to the analysis data const contextData = { analysisData, @@ -198,7 +232,9 @@ const AddressNode: FC = ({ /> )} - {analysisData && } + {analysisData && ( + + )} diff --git a/src/components/graph/hotbar/components/NewAddressModal.tsx b/src/components/graph/hotbar/components/NewAddressModal.tsx index 47d82e04..9888544b 100644 --- a/src/components/graph/hotbar/components/NewAddressModal.tsx +++ b/src/components/graph/hotbar/components/NewAddressModal.tsx @@ -4,8 +4,9 @@ import { GraphContext } from "../../Graph"; import Modal from "../../../common/Modal"; import SearchBar from "../../search_bar"; -import authService from "../../../../services/auth/auth.services"; -import { storeAddress } from "../../../../services/firebase/search-history/search-history"; +import useAuthState from "../../../../hooks/useAuthState"; + +import { storeAddress } from "../../../../services/firestore/user/search-history"; interface NewAddressModalProps { isOpen: boolean; @@ -15,7 +16,7 @@ interface NewAddressModalProps { const NewAddressModal: FC = ({ isOpen, setOpen }) => { const { addNewAddressToCenter } = useContext(GraphContext); - const { user } = authService.useAuthState(); + const { user } = useAuthState(); const handleSearchAddress = async (address: string) => { await storeAddress(address, user?.uid); diff --git a/src/components/graph/search_bar/SearchBar.tsx b/src/components/graph/search_bar/SearchBar.tsx index f80f7ab5..86e3267e 100644 --- a/src/components/graph/search_bar/SearchBar.tsx +++ b/src/components/graph/search_bar/SearchBar.tsx @@ -7,11 +7,12 @@ import { import clsx from "clsx"; import { FC, KeyboardEvent, useMemo, useRef, useState, useEffect } from "react"; +import useAuthState from "../../../hooks/useAuthState"; + import { Label, SearchLabelsBody } from "../../../api/model"; import { searchLabels } from "../../../api/labels/labels"; -import authService from "../../../services/auth/auth.services"; -import { getUserHistory } from "../../../services/firebase/search-history/search-history"; +import { getUserHistory } from "../../../services/firestore/user/search-history"; import { HotKeysType } from "../../../types/hotKeys"; @@ -27,7 +28,6 @@ const InvalidAddressPopover: FC = () => {

- {" "} Your address is invalid. Please check if it is valid for the compatible blockchains:

@@ -52,7 +52,7 @@ interface SearchBarProps { const SearchBar: FC = ({ className, onSearchAddress }) => { const popoverRef = useRef(null); // Popever ref for hotkeys - const { user } = authService.useAuthState(); // Current user + const { user } = useAuthState(); // Current user // The current query the user is typing const [query, setQuery] = useState(""); @@ -239,13 +239,13 @@ const SearchBar: FC = ({ className, onSearchAddress }) => { const hotKey = event.key.toLocaleLowerCase(); switch (hotKey) { case hotKeysMap.SEARCH.key: - hotKeysMap.SEARCH.handler(event); + hotKeysMap.SEARCH.handler!(event); break; case hotKeysMap.ARROWUP.key: - hotKeysMap.ARROWUP.handler(event); + hotKeysMap.ARROWUP.handler!(event); break; case hotKeysMap.ARROWDOWN.key: - hotKeysMap.ARROWDOWN.handler(event); + hotKeysMap.ARROWDOWN.handler!(event); break; } }} diff --git a/src/hooks/useAuthState.tsx b/src/hooks/useAuthState.tsx new file mode 100644 index 00000000..55f2a296 --- /dev/null +++ b/src/hooks/useAuthState.tsx @@ -0,0 +1,28 @@ +import { User } from "firebase/auth"; +import { useEffect, useState } from "react"; +import { auth } from "../services/firebase"; + +const useAuthState = () => { + const [user, setUser] = useState(null); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // This listener will be called whenever the user's sign-in state changes + const unsubscribe = auth.onAuthStateChanged((currentUser) => { + setUser(currentUser); + setIsLoading(false); + }); + + // Cleanup subscription on unmount + return () => unsubscribe(); + }, []); // Empty array ensures this effect runs only once on mount + + useEffect(() => { + setIsAuthenticated(user?.emailVerified || false); + }, [user]); + + return { user, isAuthenticated, isLoading }; +}; + +export default useAuthState; diff --git a/src/services/auth/auth.services.ts b/src/services/auth/auth.services.ts index 93253c62..7663aecb 100644 --- a/src/services/auth/auth.services.ts +++ b/src/services/auth/auth.services.ts @@ -1,16 +1,12 @@ -import { useState, useEffect } from "react"; - import { - User, createUserWithEmailAndPassword, - onAuthStateChanged, signInWithEmailAndPassword, signInWithPopup, signOut, } from "@firebase/auth"; import { sendEmailVerification, sendPasswordResetEmail } from "firebase/auth"; -import { auth, googleProvider } from "../firebase/firebase"; +import { auth, googleProvider } from "../firebase"; import AuthApiErrorCodes from "./auth.errors"; /** @@ -119,23 +115,11 @@ const signUpWithGoogle = async ( * * @returns current user */ -const getCurrentUser = (callback: (user: User | null) => void) => { - return onAuthStateChanged(auth, (user) => { - callback(user); - }); +const getCurrentUser = () => { + const user = auth.currentUser; + return user; }; -/** - * Check if the user is logged in - * - * @param setUser - callback function to be executed on successful login - */ -const isLoggedIn = (setUser: (user: User | null) => void) => - onAuthStateChanged(auth, async (user) => { - await user?.reload(); - setUser(user); - }); - /** * Send a password reset email * @@ -158,22 +142,9 @@ const resetUserPassword = async ( }); }; -const useAuthState = () => { - const [user, setUser] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - // This listener will be called whenever the user's sign-in state changes - const unsubscribe = auth.onAuthStateChanged((currentUser) => { - setUser(currentUser); - setIsLoading(false); - }); - - // Cleanup subscription on unmount - return () => unsubscribe(); - }, []); // Empty array ensures this effect runs only once on mount - - return { user, isLoading }; +const isAuthenticated = async () => { + const user = await auth.currentUser; + return user?.emailVerified; }; const authService = { @@ -182,9 +153,8 @@ const authService = { logout, signUpWithGoogle, getCurrentUser, - isLoggedIn, resetUserPassword, - useAuthState, + isAuthenticated, }; export default authService; diff --git a/src/services/firebase/firebase.ts b/src/services/firebase.ts similarity index 100% rename from src/services/firebase/firebase.ts rename to src/services/firebase.ts diff --git a/src/services/firebase/analytics/analytics.ts b/src/services/firebase/analytics/analytics.ts deleted file mode 100644 index 701e3ccc..00000000 --- a/src/services/firebase/analytics/analytics.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { analytics } from "../firebase"; -import { logEvent } from "firebase/analytics"; - -/** - * Logs an event to Google Analytics - * - * @param eventName - * @param eventParams - */ -const logAnalyticsEvent = (eventName: string, eventParams: any) => { - logEvent(analytics, eventName, eventParams); -}; - -export default { - logAnalyticsEvent, -}; diff --git a/src/services/firebase/firestore.ts b/src/services/firebase/firestore.ts deleted file mode 100644 index a7388ab7..00000000 --- a/src/services/firebase/firestore.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { collection, doc, getDoc, setDoc } from "firebase/firestore"; -import { db } from "./firebase"; - -/** - * Get the original url from the database - * - * @param key - * @returns - */ -const getOriginalUrl = async (key: string) => { - const docRef = doc(db, "shortenedUrls", key); - const docSnap = await getDoc(docRef); - - if (docSnap.exists()) { - return docSnap.data().originalUrl; - } else { - return null; - } -}; - -export interface StoreUrlObject { - originalUrl: string; - key: string; -} - -/** - * Store the url in the database and return the key - * - * @param url - * @returns - */ -const storeUrl = async (obj: StoreUrlObject) => { - const key = obj.key; - - try { - await setDoc(doc(collection(db, "shortenedUrls"), key), { - originalUrl: obj.originalUrl, - created_at: new Date(), - }); - - return key; - } catch (e) { - console.error(e); - } -}; - -export default { - getOriginalUrl, - storeUrl, -}; diff --git a/src/services/firebase/analytics.ts b/src/services/firestore/analytics/analytics.ts similarity index 87% rename from src/services/firebase/analytics.ts rename to src/services/firestore/analytics/analytics.ts index 363e74fa..e75143f9 100644 --- a/src/services/firebase/analytics.ts +++ b/src/services/firestore/analytics/analytics.ts @@ -1,4 +1,4 @@ -import { analytics } from "./firebase"; +import { analytics } from "../../firebase"; import { logEvent } from "firebase/analytics"; /** diff --git a/src/services/firestore/graph/addresses/custom-tags.ts b/src/services/firestore/graph/addresses/custom-tags.ts new file mode 100644 index 00000000..bb215b1a --- /dev/null +++ b/src/services/firestore/graph/addresses/custom-tags.ts @@ -0,0 +1,140 @@ +import { + addDoc, + collection, + doc, + getDocs, + limit, + query, + updateDoc, + where, +} from "firebase/firestore"; +import authService from "../../../auth/auth.services"; +import { db } from "../../../firebase"; + +export interface CustomAddressTag { + address: string; + user?: string; + tags: string[]; + created_at: Date; +} + +/** + * Get user custom addresses tags + * + * @param address - address to get tags + * @returns - tags of address + */ +const getCustomAddressesTags = async (address: string): Promise => { + const isAuthenticated = authService.isAuthenticated(); + + if (!isAuthenticated) { + return []; + } + + const user = authService.getCurrentUser(); + + try { + const q = query( + collection(db, "customAddressesTags"), + where("address", "==", address), + where("user", "==", user?.uid), + limit(1), + ); + const querySnapshot = await getDocs(q); + + if (!querySnapshot.empty) { + const data = querySnapshot.docs[0].data(); + return data.tags; + } + return []; + } catch (error) { + console.log(error); + return []; + } +}; + +/** + * Store user custom addresses tags if not exists + * If exists, update tags + * + * @param address - address to store tags + * @param tags - tags to store + */ +const storeCustomAddressesTags = async (address: string, tags: string[]) => { + const isAuthenticated = authService.isAuthenticated(); + + if (!isAuthenticated) { + console.error("User tried to store tags but was not logged in"); + return; + } + + const user = authService.getCurrentUser(); + + let q; + try { + q = query( + collection(db, "customAddressesTags"), + where("address", "==", address), + where("user", "==", user?.uid), + ); + } catch (error) { + console.log(error); + } + const querySnapshot = q ? await getDocs(q) : undefined; + + if (querySnapshot && !querySnapshot.empty) { + const docRef = doc(db, "customAddressesTags", querySnapshot.docs[0].id); + await updateDoc(docRef, { + tags, + }); + } else { + await addDoc(collection(db, "customAddressesTags"), { + address, + user: user?.uid, + tags, + created_at: new Date(), + }); + } +}; + +/** + * Delete user custom addresses tag + * + * @param address - address to delete tag + * @param tag - tag to delete + */ +const deleteCustomAddressTag = async (address: string, tag: string) => { + const isAuthenticated = authService.isAuthenticated(); + + if (!isAuthenticated) { + console.error("User tried to delete tag but was not logged in"); + return; + } + + const user = authService.getCurrentUser(); + + const q = query( + collection(db, "customAddressesTags"), + where("address", "==", address), + where("user", "==", user?.uid), + ); + const querySnapshot = await getDocs(q); + + console.log(querySnapshot.empty); + + if (!querySnapshot.empty) { + const docRef = doc(db, "customAddressesTags", querySnapshot.docs[0].id); + const data = querySnapshot.docs[0].data(); + const tags = data.tags.filter((t: string) => t !== tag); + console.log(tags); + await updateDoc(docRef, { + tags, + }); + } +}; + +export { + deleteCustomAddressTag, + getCustomAddressesTags, + storeCustomAddressesTags, +}; diff --git a/src/services/firebase/short-urls/short-urls.ts b/src/services/firestore/graph/short-urls.ts similarity index 95% rename from src/services/firebase/short-urls/short-urls.ts rename to src/services/firestore/graph/short-urls.ts index 34b75cb7..6e109055 100644 --- a/src/services/firebase/short-urls/short-urls.ts +++ b/src/services/firestore/graph/short-urls.ts @@ -1,5 +1,5 @@ import { collection, doc, getDoc, setDoc } from "firebase/firestore"; -import { db } from "../firebase"; +import { db } from "../../firebase"; /** * Get the original url from the database diff --git a/src/services/firestore/user/custom-tags.ts b/src/services/firestore/user/custom-tags.ts new file mode 100644 index 00000000..354b3465 --- /dev/null +++ b/src/services/firestore/user/custom-tags.ts @@ -0,0 +1,123 @@ +import { + addDoc, + collection, + getDocs, + limit, + query, + updateDoc, + where, +} from "firebase/firestore"; +import authService from "../../auth/auth.services"; +import { db } from "../../firebase"; + +export interface CustomUserTag { + user: string; + tags: string[]; + created_at: Date; +} + +/** + * Get user custom tags + * + * @returns - tags of user + * + */ +const getCustomUserTags = async (): Promise => { + const isAuthenticated = authService.isAuthenticated(); + + if (!isAuthenticated) { + return []; + } + + const user = authService.getCurrentUser(); + + try { + const q = query( + collection(db, "customUserTags"), + where("user", "==", user?.uid), + limit(1), + ); + const querySnapshot = await getDocs(q); + let data: string[] = []; + if (!querySnapshot.empty) { + querySnapshot.forEach((doc) => { + data = doc.data().tags; + }); + } + return data; + } catch (error) { + console.log(error); + return []; + } +}; + +/** + * Store user custom tags if not exists + * If exists, update tags + * + * @param tags - tags to store + * + */ +const storeCustomUserTags = async (tags: string[]) => { + const isAuthenticated = authService.isAuthenticated(); + + if (!isAuthenticated) { + console.error("User tried to store tags but was not logged in"); + return; + } + + const user = authService.getCurrentUser(); + + let q: any; + try { + q = query(collection(db, "customUserTags"), where("user", "==", user?.uid)); + } catch (error) { + console.log(error); + } + const querySnapshot = await getDocs(q); + if (querySnapshot.empty) { + await addDoc(collection(db, "customUserTags"), { + user: user?.uid, + tags: tags, + created_at: new Date(), + }); + } else { + querySnapshot.forEach(async (doc) => { + await updateDoc(doc.ref, { + tags: tags, + }); + }); + } +}; + +/** + * Remove user custom tag + * + * @param tag - tag to remove + */ +const removeCustomUserTag = async (tag: string) => { + const isAuthenticated = authService.isAuthenticated(); + + if (!isAuthenticated) { + console.error("User tried to remove tag but was not logged in"); + return; + } + + const user = authService.getCurrentUser(); + + const q = query( + collection(db, "customUserTags"), + where("user", "==", user?.uid), + ); + const querySnapshot = await getDocs(q); + if (!querySnapshot.empty) { + querySnapshot.forEach(async (doc) => { + const tags = doc.data().tags.filter((t: string) => t !== tag); + await updateDoc(doc.ref, { + tags: tags, + }); + }); + } +}; + +export { getCustomUserTags, removeCustomUserTag, storeCustomUserTags }; diff --git a/src/services/firebase/search-history/search-history.ts b/src/services/firestore/user/search-history.ts similarity index 98% rename from src/services/firebase/search-history/search-history.ts rename to src/services/firestore/user/search-history.ts index fea3a1cf..b223e00a 100644 --- a/src/services/firebase/search-history/search-history.ts +++ b/src/services/firestore/user/search-history.ts @@ -8,7 +8,7 @@ import { setDoc, where, } from "firebase/firestore"; -import { db } from "../firebase"; +import { db } from "../../firebase"; export interface AddressHistoryAPIObject { address: string; diff --git a/src/templates/GraphTemplate.tsx b/src/templates/GraphTemplate.tsx deleted file mode 100644 index 707cd848..00000000 --- a/src/templates/GraphTemplate.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { FC, useMemo } from "react"; -import authService from "../services/auth/auth.services"; -import { PublicGraph, PrivateGraph } from "../components/graph/Graph"; - -import Socials from "../components/socials"; -import Banner from "../components/banner"; -import Navbar from "../components/navbar"; -import RedirectTemplate from "./RedirectTemplate"; - -const getURLSearchParams = () => { - const urlParams = new URLSearchParams(window.location.search); - const addresses = urlParams.get("addresses")?.split(",") || []; - const paths = urlParams.get("paths")?.split(",") || []; - return { addresses, paths }; -}; - -const GraphTemplate: FC = () => { - // Get the current user - const { user, isLoading } = authService.useAuthState(); - const isAutenticated = useMemo(() => { - return user !== null; - }, [user]); - - // On initial load, get the addresses and paths from the URL - const { initialAddresses, initialPaths } = useMemo(() => { - const { addresses, paths } = getURLSearchParams(); - return { initialAddresses: addresses, initialPaths: paths }; - }, []); - - return ( -
- {isLoading ? ( - - ) : isAutenticated ? ( -
- - -
- ) : ( - <> - - - - )} - -
- ); -}; - -export default GraphTemplate; diff --git a/src/types/hotKeys.tsx b/src/types/hotKeys.tsx index 01d59b16..160ec2c2 100644 --- a/src/types/hotKeys.tsx +++ b/src/types/hotKeys.tsx @@ -3,6 +3,7 @@ import { KeyboardEvent } from "react"; export interface HotKeysType { [key: string]: { key: string; - handler: (event: KeyboardEvent) => void; + handler?: (event: KeyboardEvent) => void; + asyncHandler?: (event: KeyboardEvent) => Promise; }; } diff --git a/yarn.lock b/yarn.lock index b14b83d4..698f3dbe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -276,18 +276,6 @@ resolved "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.1.13.tgz" integrity sha512-calbMa7Gcyo+/t23XBaqQqon8LlgE9regey4UVoikoenKBXvUnCUL3s9RP6USCxttfr0XWVICtYUuKMdehKqMw== -"@emotion/is-prop-valid@^0.8.2": - version "0.8.8" - resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz" - integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== - dependencies: - "@emotion/memoize" "0.7.4" - -"@emotion/memoize@0.7.4": - version "0.7.4" - resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz" - integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== - "@esbuild/android-arm64@0.19.9": version "0.19.9" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.9.tgz#683794bdc3d27222d3eced7b74cad15979548031" @@ -1702,13 +1690,6 @@ "@types/d3-transition" "*" "@types/d3-zoom" "*" -"@types/dotenv@^8.2.0": - version "8.2.0" - resolved "https://registry.npmjs.org/@types/dotenv/-/dotenv-8.2.0.tgz" - integrity sha512-ylSC9GhfRH7m1EUXBXofhgx4lUWmFeQDINW5oLuS+gxWdfUeW4zJdeVTYVkexEW+e2VUvlZR2kGnGGipAWR7kw== - dependencies: - dotenv "*" - "@types/es-aggregate-error@^1.0.2": version "1.0.6" resolved "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz" @@ -2597,11 +2578,6 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dotenv@*, dotenv@^16.3.1: - version "16.3.1" - resolved "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz" - integrity sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ== - electron-to-chromium@^1.4.601: version "1.4.614" resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.614.tgz" @@ -3094,15 +3070,6 @@ fraction.js@^4.3.6: resolved "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz" integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== -framer-motion@^10.17.9: - version "10.17.9" - resolved "https://registry.npmjs.org/framer-motion/-/framer-motion-10.17.9.tgz" - integrity sha512-z2NpP8r+XuALoPA7ZVZHm/OoTnwkQNJFBu91sC86o/FYvJ4x7ar3eQnixgwYWFK7kEqOtQ6whtNM37tn1KrOOA== - dependencies: - tslib "^2.4.0" - optionalDependencies: - "@emotion/is-prop-valid" "^0.8.2" - fs-extra@^11.2.0: version "11.2.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz" @@ -5129,7 +5096,7 @@ tslib@^1.14.1: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.6.0, tslib@^2.6.2: +tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.6.0, tslib@^2.6.2: version "2.6.2" resolved "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==