From 1ee56ba91ae185993fcb60d742f19a9529e85321 Mon Sep 17 00:00:00 2001 From: enes <enes@nostrdev.com> Date: Tue, 3 Dec 2024 15:09:40 +0100 Subject: [PATCH 1/2] feat: generic alert popup and nsfw popup confirmation --- src/components/AlertPopup.tsx | 72 ++++++++++++++++++++ src/components/Filters/BlogsFilter.tsx | 23 +------ src/components/Filters/ModsFilter.tsx | 16 +---- src/components/Filters/NsfwFilterOptions.tsx | 64 +++++++++++++++++ src/components/NsfwAlertPopup.tsx | 36 ++++++++++ src/components/ReportPopup.tsx | 4 +- src/hooks/index.ts | 1 + src/hooks/useSessionStorage.tsx | 71 +++++++++++++++++++ src/pages/blogs/index.tsx | 27 ++------ src/pages/settings/preference.tsx | 29 +++++++- src/types/index.ts | 1 + src/types/popup.ts | 9 +++ src/utils/index.ts | 1 + src/utils/sessionStorage.ts | 32 +++++++++ 14 files changed, 329 insertions(+), 57 deletions(-) create mode 100644 src/components/AlertPopup.tsx create mode 100644 src/components/Filters/NsfwFilterOptions.tsx create mode 100644 src/components/NsfwAlertPopup.tsx create mode 100644 src/hooks/useSessionStorage.tsx create mode 100644 src/types/popup.ts create mode 100644 src/utils/sessionStorage.ts diff --git a/src/components/AlertPopup.tsx b/src/components/AlertPopup.tsx new file mode 100644 index 0000000..e9334ee --- /dev/null +++ b/src/components/AlertPopup.tsx @@ -0,0 +1,72 @@ +import { createPortal } from 'react-dom' +import { AlertPopupProps } from 'types' + +export const AlertPopup = ({ + header, + label, + handleConfirm, + handleClose +}: AlertPopupProps) => { + return createPortal( + <div className='popUpMain'> + <div className='ContainerMain'> + <div className='popUpMainCardWrapper'> + <div className='popUpMainCard popUpMainCardQR'> + <div className='popUpMainCardTop'> + <div className='popUpMainCardTopInfo'> + <h3>{header}</h3> + </div> + <div className='popUpMainCardTopClose' onClick={handleClose}> + <svg + xmlns='http://www.w3.org/2000/svg' + viewBox='-96 0 512 512' + width='1em' + height='1em' + fill='currentColor' + style={{ zIndex: 1 }} + > + <path d='M310.6 361.4c12.5 12.5 12.5 32.75 0 45.25C304.4 412.9 296.2 416 288 416s-16.38-3.125-22.62-9.375L160 301.3L54.63 406.6C48.38 412.9 40.19 416 32 416S15.63 412.9 9.375 406.6c-12.5-12.5-12.5-32.75 0-45.25l105.4-105.4L9.375 150.6c-12.5-12.5-12.5-32.75 0-45.25s32.75-12.5 45.25 0L160 210.8l105.4-105.4c12.5-12.5 32.75-12.5 45.25 0s12.5 32.75 0 45.25l-105.4 105.4L310.6 361.4z'></path> + </svg> + </div> + </div> + <div className='pUMCB_Zaps'> + <div className='pUMCB_ZapsInside'> + <div className='inputLabelWrapperMain'> + <label + className='form-label labelMain' + style={{ fontWeight: 'bold' }} + > + {label} + </label> + </div> + <div + style={{ + display: 'flex', + width: '100%', + gap: '10px' + }} + > + <button + className='btn btnMain btnMainPopup' + type='button' + onPointerDown={() => handleConfirm(true)} + > + Yes + </button> + <button + className='btn btnMain btnMainPopup' + type='button' + onPointerDown={() => handleConfirm(false)} + > + No + </button> + </div> + </div> + </div> + </div> + </div> + </div> + </div>, + document.body + ) +} diff --git a/src/components/Filters/BlogsFilter.tsx b/src/components/Filters/BlogsFilter.tsx index 71b4098..5efc7d2 100644 --- a/src/components/Filters/BlogsFilter.tsx +++ b/src/components/Filters/BlogsFilter.tsx @@ -1,16 +1,11 @@ import { useAppSelector, useLocalStorage } from 'hooks' import React from 'react' -import { - FilterOptions, - ModeratedFilter, - NSFWFilter, - SortBy, - WOTFilterOptions -} from 'types' +import { FilterOptions, ModeratedFilter, SortBy, WOTFilterOptions } from 'types' import { DEFAULT_FILTER_OPTIONS } from 'utils' import { Dropdown } from './Dropdown' import { Option } from './Option' import { Filter } from '.' +import { NsfwFilterOptions } from './NsfwFilterOptions' type Props = { author?: string | undefined @@ -115,19 +110,7 @@ export const BlogsFilter = React.memo( {/* nsfw filter options */} <Dropdown label={filterOptions.nsfw}> - {Object.values(NSFWFilter).map((item, index) => ( - <Option - key={`nsfwFilterItem-${index}`} - onClick={() => - setFilterOptions((prev) => ({ - ...prev, - nsfw: item - })) - } - > - {item} - </Option> - ))} + <NsfwFilterOptions filterKey={filterKey} /> </Dropdown> {/* source filter options */} diff --git a/src/components/Filters/ModsFilter.tsx b/src/components/Filters/ModsFilter.tsx index afa43c4..8604db5 100644 --- a/src/components/Filters/ModsFilter.tsx +++ b/src/components/Filters/ModsFilter.tsx @@ -5,13 +5,13 @@ import { SortBy, ModeratedFilter, WOTFilterOptions, - NSFWFilter, RepostFilter } from 'types' import { DEFAULT_FILTER_OPTIONS } from 'utils' import { Filter } from '.' import { Dropdown } from './Dropdown' import { Option } from './Option' +import { NsfwFilterOptions } from './NsfwFilterOptions' type Props = { author?: string | undefined @@ -115,19 +115,7 @@ export const ModFilter = React.memo( {/* nsfw filter options */} <Dropdown label={filterOptions.nsfw}> - {Object.values(NSFWFilter).map((item, index) => ( - <Option - key={`nsfwFilterItem-${index}`} - onClick={() => - setFilterOptions((prev) => ({ - ...prev, - nsfw: item - })) - } - > - {item} - </Option> - ))} + <NsfwFilterOptions filterKey={filterKey} /> </Dropdown> {/* repost filter options */} diff --git a/src/components/Filters/NsfwFilterOptions.tsx b/src/components/Filters/NsfwFilterOptions.tsx new file mode 100644 index 0000000..71392f8 --- /dev/null +++ b/src/components/Filters/NsfwFilterOptions.tsx @@ -0,0 +1,64 @@ +import { FilterOptions, NSFWFilter } from 'types' +import { Option } from './Option' +import { NsfwAlertPopup } from 'components/NsfwAlertPopup' +import { useState } from 'react' +import { useLocalStorage, useSessionStorage } from 'hooks' +import { DEFAULT_FILTER_OPTIONS } from 'utils' + +interface NsfwFilterOptionsProps { + filterKey: string +} + +export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => { + const [, setFilterOptions] = useLocalStorage<FilterOptions>( + filterKey, + DEFAULT_FILTER_OPTIONS + ) + const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false) + const [selectedNsfwOption, setSelectedNsfwOption] = useState< + NSFWFilter | undefined + >() + const [confirmNsfw] = useSessionStorage<boolean>('confirm-nsfw', false) + const handleConfirm = (confirm: boolean) => { + if (confirm && selectedNsfwOption) { + setFilterOptions((prev) => ({ + ...prev, + nsfw: selectedNsfwOption + })) + } + } + + return ( + <> + {Object.values(NSFWFilter).map((item, index) => ( + <Option + key={`nsfwFilterItem-${index}`} + onClick={() => { + // Trigger NSFW popup + if ( + (item === NSFWFilter.Only_NSFW || + item === NSFWFilter.Show_NSFW) && + !confirmNsfw + ) { + setSelectedNsfwOption(item) + setShowNsfwPopup(true) + } else { + setFilterOptions((prev) => ({ + ...prev, + nsfw: item + })) + } + }} + > + {item} + </Option> + ))} + {showNsfwPopup && ( + <NsfwAlertPopup + handleConfirm={handleConfirm} + handleClose={() => setShowNsfwPopup(false)} + /> + )} + </> + ) +} diff --git a/src/components/NsfwAlertPopup.tsx b/src/components/NsfwAlertPopup.tsx new file mode 100644 index 0000000..dbb9c60 --- /dev/null +++ b/src/components/NsfwAlertPopup.tsx @@ -0,0 +1,36 @@ +import { AlertPopupProps } from 'types' +import { AlertPopup } from './AlertPopup' +import { useSessionStorage } from 'hooks' + +type NsfwAlertPopup = Omit<AlertPopupProps, 'header' | 'label'> + +/** + * Triggers when the user wants to switch the filter to see any of the NSFW options + * (including preferences) + * + * Option will be remembered for the session only and will not show the popup again + */ +export const NsfwAlertPopup = ({ + handleConfirm, + handleClose +}: NsfwAlertPopup) => { + const [confirmNsfw, setConfirmNsfw] = useSessionStorage<boolean>( + 'confirm-nsfw', + false + ) + + return ( + !confirmNsfw && ( + <AlertPopup + header='Confirm' + label='Are you above 18 years of age?' + handleClose={handleClose} + handleConfirm={(confirm: boolean) => { + setConfirmNsfw(confirm) + handleConfirm(confirm) + handleClose() + }} + /> + ) + ) +} diff --git a/src/components/ReportPopup.tsx b/src/components/ReportPopup.tsx index d31e4b3..ace813d 100644 --- a/src/components/ReportPopup.tsx +++ b/src/components/ReportPopup.tsx @@ -3,12 +3,12 @@ import { CheckboxFieldUncontrolled } from 'components/Inputs' import { useEffect } from 'react' import { ReportReason } from 'types/report' import { LoadingSpinner } from './LoadingSpinner' +import { PopupProps } from 'types' type ReportPopupProps = { openedAt: number reasons: ReportReason[] - handleClose: () => void -} +} & PopupProps export const ReportPopup = ({ openedAt, diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 3daf9f4..c01237e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -8,3 +8,4 @@ export * from './useReactions' export * from './useNDKContext' export * from './useScrollDisable' export * from './useLocalStorage' +export * from './useSessionStorage' diff --git a/src/hooks/useSessionStorage.tsx b/src/hooks/useSessionStorage.tsx new file mode 100644 index 0000000..8f50422 --- /dev/null +++ b/src/hooks/useSessionStorage.tsx @@ -0,0 +1,71 @@ +import React from 'react' +import { + getSessionStorageItem, + removeSessionStorageItem, + setSessionStorageItem +} from 'utils' + +const useSessionStorageSubscribe = (callback: () => void) => { + window.addEventListener('sessionStorage', callback) + return () => window.removeEventListener('sessionStorage', callback) +} + +function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T { + if (typeof storedValue === 'object' && storedValue !== null) { + return { ...initialValue, ...storedValue } + } + return storedValue +} + +export function useSessionStorage<T>( + key: string, + initialValue: T +): [T, React.Dispatch<React.SetStateAction<T>>] { + const getSnapshot = () => { + // Get the stored value + const storedValue = getSessionStorageItem(key, initialValue) + + // Parse the value + const parsedStoredValue = JSON.parse(storedValue) + + // Merge the default and the stored in case some of the required fields are missing + return JSON.stringify( + mergeWithInitialValue(parsedStoredValue, initialValue) + ) + } + + const data = React.useSyncExternalStore( + useSessionStorageSubscribe, + getSnapshot + ) + + const setState: React.Dispatch<React.SetStateAction<T>> = React.useCallback( + (v: React.SetStateAction<T>) => { + try { + const nextState = + typeof v === 'function' + ? (v as (prevState: T) => T)(JSON.parse(data)) + : v + + if (nextState === undefined || nextState === null) { + removeSessionStorageItem(key) + } else { + setSessionStorageItem(key, JSON.stringify(nextState)) + } + } catch (e) { + console.warn(e) + } + }, + [data, key] + ) + + React.useEffect(() => { + // Set session storage only when it's empty + const data = window.sessionStorage.getItem(key) + if (data === null) { + setSessionStorageItem(key, JSON.stringify(initialValue)) + } + }, [key, initialValue]) + + return [JSON.parse(data) as T, setState] +} diff --git a/src/pages/blogs/index.tsx b/src/pages/blogs/index.tsx index 2155b39..d4b4fab 100644 --- a/src/pages/blogs/index.tsx +++ b/src/pages/blogs/index.tsx @@ -14,17 +14,16 @@ import { LoadingSpinner } from 'components/LoadingSpinner' import { Filter } from 'components/Filters' import { Dropdown } from 'components/Filters/Dropdown' import { Option } from 'components/Filters/Option' +import { NsfwFilterOptions } from 'components/Filters/NsfwFilterOptions' export const BlogsPage = () => { const navigation = useNavigation() const blogs = useLoaderData() as Partial<BlogCardDetails>[] | undefined - const [filterOptions, setFilterOptions] = useLocalStorage( - 'filter-blog-curated', - { - sort: SortBy.Latest, - nsfw: NSFWFilter.Hide_NSFW - } - ) + const filterKey = 'filter-blog-curated' + const [filterOptions, setFilterOptions] = useLocalStorage(filterKey, { + sort: SortBy.Latest, + nsfw: NSFWFilter.Hide_NSFW + }) // Search const searchTermRef = useRef<HTMLInputElement>(null) @@ -147,19 +146,7 @@ export const BlogsPage = () => { </Dropdown> <Dropdown label={filterOptions.nsfw}> - {Object.values(NSFWFilter).map((item, index) => ( - <Option - key={`nsfwFilterItem-${index}`} - onClick={() => - setFilterOptions((prev) => ({ - ...prev, - nsfw: item - })) - } - > - {item} - </Option> - ))} + <NsfwFilterOptions filterKey={filterKey} /> </Dropdown> </Filter> diff --git a/src/pages/settings/preference.tsx b/src/pages/settings/preference.tsx index 985a509..c2b0d42 100644 --- a/src/pages/settings/preference.tsx +++ b/src/pages/settings/preference.tsx @@ -1,6 +1,12 @@ import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk' import { LoadingSpinner } from 'components/LoadingSpinner' -import { useAppDispatch, useAppSelector, useNDKContext } from 'hooks' +import { NsfwAlertPopup } from 'components/NsfwAlertPopup' +import { + useAppDispatch, + useAppSelector, + useNDKContext, + useSessionStorage +} from 'hooks' import { kinds, UnsignedEvent, Event } from 'nostr-tools' import { useEffect, useState } from 'react' import { toast } from 'react-toastify' @@ -19,6 +25,13 @@ export const PreferencesSetting = () => { const [wotLevel, setWotLevel] = useState(userWotLevel) const [isSaving, setIsSaving] = useState(false) + const [nsfw, setNsfw] = useState(false) + const [confirmNsfw] = useSessionStorage<boolean>('confirm-nsfw', false) + const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false) + const handleNsfwConfirm = (confirm: boolean) => { + setNsfw(confirm) + } + useEffect(() => { if (user?.pubkey) { const hexPubkey = user.pubkey as string @@ -191,6 +204,14 @@ export const PreferencesSetting = () => { type='checkbox' className='CheckboxMain' name='NSFWPreference' + checked={nsfw} + onChange={(e) => { + if (e.currentTarget.checked && !confirmNsfw) { + setShowNsfwPopup(true) + } else { + setNsfw(e.currentTarget.checked) + } + }} /> </div> </div> @@ -238,6 +259,12 @@ export const PreferencesSetting = () => { Save </button> </div> + {showNsfwPopup && ( + <NsfwAlertPopup + handleConfirm={handleNsfwConfirm} + handleClose={() => setShowNsfwPopup(false)} + /> + )} </div> </div> </div> diff --git a/src/types/index.ts b/src/types/index.ts index 8fe37df..64a1407 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,3 +4,4 @@ export * from './nostr' export * from './user' export * from './zap' export * from './blog' +export * from './popup' diff --git a/src/types/popup.ts b/src/types/popup.ts new file mode 100644 index 0000000..b1551cf --- /dev/null +++ b/src/types/popup.ts @@ -0,0 +1,9 @@ +export interface PopupProps { + handleClose: () => void +} + +export interface AlertPopupProps extends PopupProps { + header: string + label: string + handleConfirm: (confirm: boolean) => void +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 91fe37b..4e3c1d1 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,6 +4,7 @@ export * from './url' export * from './utils' export * from './zap' export * from './localStorage' +export * from './sessionStorage' export * from './consts' export * from './blog' export * from './curationSets' diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts new file mode 100644 index 0000000..e40b0e9 --- /dev/null +++ b/src/utils/sessionStorage.ts @@ -0,0 +1,32 @@ +export function getSessionStorageItem<T>(key: string, defaultValue: T): string { + try { + const data = window.sessionStorage.getItem(key) + if (data === null) return JSON.stringify(defaultValue) + return data + } catch (err) { + console.error(`Error while fetching session storage value: `, err) + return JSON.stringify(defaultValue) + } +} + +export function setSessionStorageItem(key: string, value: string) { + try { + window.sessionStorage.setItem(key, value) + dispatchSessionStorageEvent(key, value) + } catch (err) { + console.error(`Error while saving session storage value: `, err) + } +} + +export function removeSessionStorageItem(key: string) { + try { + window.sessionStorage.removeItem(key) + dispatchSessionStorageEvent(key, null) + } catch (err) { + console.error(`Error while deleting session storage value: `, err) + } +} + +function dispatchSessionStorageEvent(key: string, newValue: string | null) { + window.dispatchEvent(new StorageEvent('sessionStorage', { key, newValue })) +} From 71f934129c2543cc71811854fd4498a7cb0e82d3 Mon Sep 17 00:00:00 2001 From: enes <enes@nostrdev.com> Date: Tue, 3 Dec 2024 17:32:55 +0100 Subject: [PATCH 2/2] refactor: use local storage instead of session for nsfw preference --- src/components/Filters/NsfwFilterOptions.tsx | 4 ++-- src/components/NsfwAlertPopup.tsx | 4 ++-- src/pages/settings/preference.tsx | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Filters/NsfwFilterOptions.tsx b/src/components/Filters/NsfwFilterOptions.tsx index 71392f8..05ab906 100644 --- a/src/components/Filters/NsfwFilterOptions.tsx +++ b/src/components/Filters/NsfwFilterOptions.tsx @@ -2,7 +2,7 @@ import { FilterOptions, NSFWFilter } from 'types' import { Option } from './Option' import { NsfwAlertPopup } from 'components/NsfwAlertPopup' import { useState } from 'react' -import { useLocalStorage, useSessionStorage } from 'hooks' +import { useLocalStorage } from 'hooks' import { DEFAULT_FILTER_OPTIONS } from 'utils' interface NsfwFilterOptionsProps { @@ -18,7 +18,7 @@ export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => { const [selectedNsfwOption, setSelectedNsfwOption] = useState< NSFWFilter | undefined >() - const [confirmNsfw] = useSessionStorage<boolean>('confirm-nsfw', false) + const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false) const handleConfirm = (confirm: boolean) => { if (confirm && selectedNsfwOption) { setFilterOptions((prev) => ({ diff --git a/src/components/NsfwAlertPopup.tsx b/src/components/NsfwAlertPopup.tsx index dbb9c60..1685a97 100644 --- a/src/components/NsfwAlertPopup.tsx +++ b/src/components/NsfwAlertPopup.tsx @@ -1,6 +1,6 @@ import { AlertPopupProps } from 'types' import { AlertPopup } from './AlertPopup' -import { useSessionStorage } from 'hooks' +import { useLocalStorage } from 'hooks' type NsfwAlertPopup = Omit<AlertPopupProps, 'header' | 'label'> @@ -14,7 +14,7 @@ export const NsfwAlertPopup = ({ handleConfirm, handleClose }: NsfwAlertPopup) => { - const [confirmNsfw, setConfirmNsfw] = useSessionStorage<boolean>( + const [confirmNsfw, setConfirmNsfw] = useLocalStorage<boolean>( 'confirm-nsfw', false ) diff --git a/src/pages/settings/preference.tsx b/src/pages/settings/preference.tsx index c2b0d42..7f6c8e0 100644 --- a/src/pages/settings/preference.tsx +++ b/src/pages/settings/preference.tsx @@ -5,7 +5,7 @@ import { useAppDispatch, useAppSelector, useNDKContext, - useSessionStorage + useLocalStorage } from 'hooks' import { kinds, UnsignedEvent, Event } from 'nostr-tools' import { useEffect, useState } from 'react' @@ -26,7 +26,7 @@ export const PreferencesSetting = () => { const [isSaving, setIsSaving] = useState(false) const [nsfw, setNsfw] = useState(false) - const [confirmNsfw] = useSessionStorage<boolean>('confirm-nsfw', false) + const [confirmNsfw] = useLocalStorage<boolean>('confirm-nsfw', false) const [showNsfwPopup, setShowNsfwPopup] = useState<boolean>(false) const handleNsfwConfirm = (confirm: boolean) => { setNsfw(confirm)