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( +
+
+
+
+
+
+

{header}

+
+
+ + + +
+
+
+
+
+ +
+
+ + +
+
+
+
+
+
+
, + 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 */} - {Object.values(NSFWFilter).map((item, index) => ( - - ))} + {/* source filter options */} diff --git a/src/components/Filters/ModsFilter.tsx b/src/components/Filters/ModsFilter.tsx index 3d72d0c..44f182d 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 */} - {Object.values(NSFWFilter).map((item, index) => ( - - ))} + {/* repost filter options */} diff --git a/src/components/Filters/NsfwFilterOptions.tsx b/src/components/Filters/NsfwFilterOptions.tsx new file mode 100644 index 0000000..05ab906 --- /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 } from 'hooks' +import { DEFAULT_FILTER_OPTIONS } from 'utils' + +interface NsfwFilterOptionsProps { + filterKey: string +} + +export const NsfwFilterOptions = ({ filterKey }: NsfwFilterOptionsProps) => { + const [, setFilterOptions] = useLocalStorage( + filterKey, + DEFAULT_FILTER_OPTIONS + ) + const [showNsfwPopup, setShowNsfwPopup] = useState(false) + const [selectedNsfwOption, setSelectedNsfwOption] = useState< + NSFWFilter | undefined + >() + const [confirmNsfw] = useLocalStorage('confirm-nsfw', false) + const handleConfirm = (confirm: boolean) => { + if (confirm && selectedNsfwOption) { + setFilterOptions((prev) => ({ + ...prev, + nsfw: selectedNsfwOption + })) + } + } + + return ( + <> + {Object.values(NSFWFilter).map((item, index) => ( + + ))} + {showNsfwPopup && ( + setShowNsfwPopup(false)} + /> + )} + + ) +} diff --git a/src/components/NsfwAlertPopup.tsx b/src/components/NsfwAlertPopup.tsx new file mode 100644 index 0000000..1685a97 --- /dev/null +++ b/src/components/NsfwAlertPopup.tsx @@ -0,0 +1,36 @@ +import { AlertPopupProps } from 'types' +import { AlertPopup } from './AlertPopup' +import { useLocalStorage } from 'hooks' + +type NsfwAlertPopup = Omit + +/** + * 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] = useLocalStorage( + 'confirm-nsfw', + false + ) + + return ( + !confirmNsfw && ( + { + 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(storedValue: T, initialValue: T): T { + if (typeof storedValue === 'object' && storedValue !== null) { + return { ...initialValue, ...storedValue } + } + return storedValue +} + +export function useSessionStorage( + key: string, + initialValue: T +): [T, React.Dispatch>] { + 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.useCallback( + (v: React.SetStateAction) => { + 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[] | 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(null) @@ -147,19 +146,7 @@ export const BlogsPage = () => { - {Object.values(NSFWFilter).map((item, index) => ( - - ))} + diff --git a/src/pages/settings/preference.tsx b/src/pages/settings/preference.tsx index 985a509..7f6c8e0 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, + useLocalStorage +} 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] = useLocalStorage('confirm-nsfw', false) + const [showNsfwPopup, setShowNsfwPopup] = useState(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) + } + }} /> @@ -238,6 +259,12 @@ export const PreferencesSetting = () => { Save + {showNsfwPopup && ( + setShowNsfwPopup(false)} + /> + )} diff --git a/src/types/index.ts b/src/types/index.ts index d26ffe0..7b2f35c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,3 +5,4 @@ export * from './user' export * from './zap' export * from './blog' export * from './category' +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 f6de84d..d8c7a4d 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(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 })) +}