diff --git a/package.json b/package.json index d5a0de2b..5012cc47 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "i18next-browser-languagedetector": "7.1.0", "i18next-http-backend": "2.2.2", "json-stringify-pretty-compact": "4.0.0", + "kubo": "0.39.0", "lodash": "4.17.23", "memoizee": "0.4.15", "react": "19.1.2", @@ -43,8 +44,7 @@ "remark-supersub": "1.0.0", "tcp-port-used": "1.0.2", "typescript": "5.1.6", - "zustand": "4.4.3", - "kubo": "0.39.0" + "zustand": "4.4.3" }, "scripts": { "generate:assets": "node scripts/generate-asset-manifest.js", @@ -103,7 +103,14 @@ "@capacitor/android": "7.4.5", "@capacitor/cli": "7.4.5", "@capacitor/core": "7.4.5", + "@electron-forge/cli": "7.8.0", + "@electron-forge/maker-dmg": "7.8.0", + "@electron-forge/maker-squirrel": "7.8.0", + "@electron-forge/maker-zip": "7.8.0", + "@electron-forge/plugin-auto-unpack-natives": "7.8.0", "@react-scan/vite-plugin-react-scan": "0.1.8", + "@reforged/maker-appimage": "5.1.1", + "@types/lodash": "4.17.23", "@types/memoizee": "0.4.9", "@typescript/native-preview": "^7.0.0-dev.20251226.1", "@vitejs/plugin-react": "4.3.4", @@ -118,12 +125,6 @@ "cz-conventional-changelog": "3.3.0", "decompress": "4.2.1", "electron": "36.9.5", - "@electron-forge/cli": "7.8.0", - "@electron-forge/maker-dmg": "7.8.0", - "@electron-forge/maker-squirrel": "7.8.0", - "@electron-forge/maker-zip": "7.8.0", - "@electron-forge/plugin-auto-unpack-natives": "7.8.0", - "@reforged/maker-appimage": "5.1.1", "husky": "4.3.8", "isomorphic-fetch": "3.0.0", "jsdom": "27.3.0", diff --git a/src/app.tsx b/src/app.tsx index a8bdb425..07fd033a 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; import { Navigate, Outlet, Route, Routes, useLocation, useParams } from 'react-router-dom'; import { useAccount, useAccountComment, useSubplebbit } from '@plebbit/plebbit-react-hooks'; import useAccountsStore from '@plebbit/plebbit-react-hooks/dist/stores/accounts'; @@ -108,23 +108,16 @@ const BoardLayout = () => { }; const GlobalLayout = () => { - const [theme, setTheme] = useState(''); const [currentTheme] = useTheme(); useEffect(() => { - if (currentTheme !== theme) { - setTheme(currentTheme); - } - }, [currentTheme, theme]); - - useEffect(() => { - if (theme) { - document.body.classList.add(theme); + if (currentTheme) { + document.body.classList.add(currentTheme); return () => { - document.body.classList.remove(theme); + document.body.classList.remove(currentTheme); }; } - }, [theme]); + }, [currentTheme]); const { activeCid, parentNumber, threadNumber, threadCid, subplebbitAddress, closeModal, showReplyModal, scrollY } = useReplyModalStore(); diff --git a/src/components/board-buttons/board-buttons.tsx b/src/components/board-buttons/board-buttons.tsx index 62661814..8b448620 100644 --- a/src/components/board-buttons/board-buttons.tsx +++ b/src/components/board-buttons/board-buttons.tsx @@ -20,7 +20,7 @@ import CatalogSearch from '../catalog-search'; import Tooltip from '../tooltip'; import { ModQueueButton } from '../../views/mod-queue/mod-queue'; import styles from './board-buttons.module.css'; -import { capitalize } from 'lodash'; +import capitalize from 'lodash/capitalize'; interface BoardButtonsProps { address?: string | undefined; diff --git a/src/components/board-header/board-header.tsx b/src/components/board-header/board-header.tsx index dda56b87..a4ccccee 100644 --- a/src/components/board-header/board-header.tsx +++ b/src/components/board-header/board-header.tsx @@ -14,7 +14,7 @@ import useIsMobile from '../../hooks/use-is-mobile'; import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline'; import { shouldShowSnow } from '../../lib/snow'; import Tooltip from '../tooltip'; -import { startCase } from 'lodash'; +import startCase from 'lodash/startCase'; import { BANNERS } from '../../generated/asset-manifest'; const ImageBanner = () => { diff --git a/src/components/catalog-filters/catalog-filters.tsx b/src/components/catalog-filters/catalog-filters.tsx index c2eed963..e723c3ca 100644 --- a/src/components/catalog-filters/catalog-filters.tsx +++ b/src/components/catalog-filters/catalog-filters.tsx @@ -11,7 +11,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { const { filterItems, saveAndApplyFilters, currentSubplebbitAddress } = useCatalogFiltersStore(); const resetFeed = useFeedResetStore((state) => state.reset); - const [localFilterItems, setLocalFilterItems] = useState( + const [localFilterItems, setLocalFilterItems] = useState(() => filterItems.map((item) => ({ ...item, hide: item.hide ?? true, @@ -22,18 +22,6 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { const inputRefs = useRef<(HTMLInputElement | null)[]>([]); - // Update local items when store changes - useEffect(() => { - setLocalFilterItems( - filterItems.map((item) => ({ - ...item, - hide: item.hide ?? true, - top: item.top ?? false, - color: item.color ?? '', - })), - ); - }, [filterItems]); - const handleAddFilter = useCallback(() => { setLocalFilterItems((prev) => { const newIndex = prev.length; @@ -181,6 +169,7 @@ const FiltersTable = ({ onSave }: { onSave: () => void }) => { const FiltersModal = ({ closeModal }: { closeModal: () => void }) => { const { t } = useTranslation(); const [showHelp, setShowHelp] = useState(false); + const currentSubplebbitAddress = useCatalogFiltersStore((state) => state.currentSubplebbitAddress); const openHelp = () => setShowHelp(true); const closeHelp = () => setShowHelp(false); @@ -207,7 +196,7 @@ const FiltersModal = ({ closeModal }: { closeModal: () => void }) => { {!showHelp && } - {showHelp ? : } + {showHelp ? : } ); diff --git a/src/components/catalog-row/catalog-row.tsx b/src/components/catalog-row/catalog-row.tsx index 2b1f386a..c5724b03 100644 --- a/src/components/catalog-row/catalog-row.tsx +++ b/src/components/catalog-row/catalog-row.tsx @@ -22,7 +22,7 @@ import useWindowWidth from '../../hooks/use-window-width'; import { ContentPreview } from '../../views/home/popular-threads-box'; import PostMenuDesktop from '../post-desktop/post-menu-desktop'; import styles from './catalog-row.module.css'; -import { capitalize } from 'lodash'; +import capitalize from 'lodash/capitalize'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; interface CatalogPostMediaProps { diff --git a/src/components/catalog-search/catalog-search.tsx b/src/components/catalog-search/catalog-search.tsx index 86685b30..b1d89a20 100644 --- a/src/components/catalog-search/catalog-search.tsx +++ b/src/components/catalog-search/catalog-search.tsx @@ -4,7 +4,7 @@ import { useLocation, useNavigate } from 'react-router-dom'; import styles from './catalog-search.module.css'; import useIsMobile from '../../hooks/use-is-mobile'; import useCatalogFiltersStore from '../../stores/use-catalog-filters-store'; -import { debounce } from 'lodash'; +import debounce from 'lodash/debounce'; const CatalogSearch = () => { const { t } = useTranslation(); diff --git a/src/components/challenge-modal/challenge-modal.tsx b/src/components/challenge-modal/challenge-modal.tsx index eb4120cf..95420a16 100644 --- a/src/components/challenge-modal/challenge-modal.tsx +++ b/src/components/challenge-modal/challenge-modal.tsx @@ -6,7 +6,7 @@ import useIsMobile from '../../hooks/use-is-mobile'; import useChallengesStore from '../../stores/use-challenges-store'; import useTheme from '../../hooks/use-theme'; import styles from './challenge-modal.module.css'; -import { capitalize } from 'lodash'; +import capitalize from 'lodash/capitalize'; import { useSpring, animated } from '@react-spring/web'; import { useDrag } from '@use-gesture/react'; diff --git a/src/components/comment-content/comment-content.tsx b/src/components/comment-content/comment-content.tsx index 8cde1a47..88dcbae6 100644 --- a/src/components/comment-content/comment-content.tsx +++ b/src/components/comment-content/comment-content.tsx @@ -14,7 +14,7 @@ import ReplyQuotePreview from '../../components/reply-quote-preview'; import Markdown from '../../components/markdown'; import Tooltip from '../../components/tooltip'; import styles from '../../views/post/post.module.css'; -import { capitalize } from 'lodash'; +import capitalize from 'lodash/capitalize'; const QuotedCidLink = ({ cid, postCid }: { cid: string; postCid: string }) => { const commentFromStore = useSubplebbitsPagesStore((state) => state.comments[cid]); diff --git a/src/components/edit-menu/edit-menu.tsx b/src/components/edit-menu/edit-menu.tsx index f43acd84..ed054992 100644 --- a/src/components/edit-menu/edit-menu.tsx +++ b/src/components/edit-menu/edit-menu.tsx @@ -12,7 +12,7 @@ import { import styles from './edit-menu.module.css'; import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils'; import useChallengesStore from '../../stores/use-challenges-store'; -import { capitalize } from 'lodash'; +import capitalize from 'lodash/capitalize'; import useIsMobile from '../../hooks/use-is-mobile'; import useAuthorPrivileges from '../../hooks/use-author-privileges'; diff --git a/src/components/error-display/error-display.tsx b/src/components/error-display/error-display.tsx index 4eec153b..73f0c6b5 100644 --- a/src/components/error-display/error-display.tsx +++ b/src/components/error-display/error-display.tsx @@ -6,23 +6,17 @@ import styles from './error-display.module.css'; const ErrorDisplay = ({ error }: { error: any }) => { const { t } = useTranslation(); const [feedbackMessageKey, setFeedbackMessageKey] = useState(null); - const [shouldShow, setShouldShow] = useState(false); + const [showAfterDelay, setShowAfterDelay] = useState(false); + + const hasError = !!(error?.message || error?.stack || error?.details || error); useEffect(() => { - const hasError = !!(error?.message || error?.stack || error?.details || error); - if (!hasError) { - setShouldShow(false); - return; - } - - const timer = setTimeout(() => { - setShouldShow(true); - }, 1000); // delay to avoid false positives, for example when accessing cached feeds that may appear offline for a second or so - + if (!hasError) return; + const timer = setTimeout(() => setShowAfterDelay(true), 1000); return () => clearTimeout(timer); - }, [error]); + }, [hasError]); - if (!shouldShow) { + if (!hasError || !showAfterDelay) { return null; } diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx index 6e678d32..657e4570 100644 --- a/src/components/post-desktop/post-desktop.tsx +++ b/src/components/post-desktop/post-desktop.tsx @@ -22,6 +22,7 @@ import useFetchGifFirstFrame from '../../hooks/use-fetch-gif-first-frame'; import useHide from '../../hooks/use-hide'; import useStateString from '../../hooks/use-state-string'; import useScrollToReply from '../../hooks/use-scroll-to-reply'; +import { useCurrentTime } from '../../hooks/use-current-time'; import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; import CommentContent from '../comment-content'; import CommentMedia from '../comment-media'; @@ -33,7 +34,8 @@ import ReplyQuotePreview from '../reply-quote-preview'; import Tooltip from '../tooltip'; import { PostProps } from '../../views/post/post'; import { create } from 'zustand'; -import { capitalize, lowerCase } from 'lodash'; +import capitalize from 'lodash/capitalize'; +import lowerCase from 'lodash/lowerCase'; import { shouldShowSnow } from '../../lib/snow'; import useReplyModalStore from '../../stores/use-reply-modal-store'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; @@ -48,6 +50,13 @@ import { REPLIES_PER_PAGE } from '../../lib/constants'; const { addChallenge } = useChallengesStore.getState(); +const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) => + hasMore ? ( +
+ +
+ ) : null; + // Store scroll position for replies virtuoso across navigations const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; @@ -102,6 +111,7 @@ const PostInfo = ({ const isInPostPageView = isPostPageView(location.pathname, params); const isInModQueueView = isModQueueView(location.pathname); const { getAlertThresholdSeconds } = useModQueueStore(); + const currentTime = useCurrentTime(); const account = useAccount(); const accountAddress = account?.author?.address; @@ -199,7 +209,7 @@ const PostInfo = ({ const alreadyApproved = approved === true; const alreadyRejected = removed === true; const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected; - const timeWaiting = timestamp ? Date.now() / 1000 - timestamp : 0; + const timeWaiting = timestamp ? currentTime - timestamp : 0; const alertThresholdSeconds = getAlertThresholdSeconds(); const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds; @@ -762,7 +772,7 @@ const PostDesktop = ({ } }); }; - window.addEventListener('scroll', setLastVirtuosoState); + window.addEventListener('scroll', setLastVirtuosoState, { passive: true }); return () => window.removeEventListener('scroll', setLastVirtuosoState); }, [virtuosoStateKey, showAllReplies, isInPostPageView]); @@ -778,13 +788,7 @@ const PostDesktop = ({ enabled: shouldScrollToReply, }); - // Footer component for Virtuoso showing loading state - const RepliesFooter = () => - hasMore ? ( -
- -
- ) : null; + const virtuosoFooter = useCallback(() => , [hasMore, t]); return (
@@ -885,7 +889,7 @@ const PostDesktop = ({
)} useWindowScroll={true} - components={{ Footer: RepliesFooter }} + components={{ Footer: virtuosoFooter }} endReached={loadMore} ref={virtuosoRef} restoreStateFrom={lastVirtuosoState} diff --git a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx index e61d0ef7..3466c3b5 100644 --- a/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx +++ b/src/components/post-desktop/post-menu-desktop/post-menu-desktop.tsx @@ -12,7 +12,7 @@ import { getBoardPath } from '../../../lib/utils/route-utils'; import { useDirectories } from '../../../hooks/use-directories'; import { isAllView, isCatalogView, isPostPageView, isSubscriptionsView } from '../../../lib/utils/view-utils'; import useHide from '../../../hooks/use-hide'; -import { capitalize } from 'lodash'; +import capitalize from 'lodash/capitalize'; import { PostMenuProps } from '../../../lib/utils/post-menu-props'; type CopyLinkButtonProps = diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx index a05c5895..66c64155 100644 --- a/src/components/post-form/post-form.tsx +++ b/src/components/post-form/post-form.tsx @@ -18,7 +18,8 @@ import usePublishReply from '../../hooks/use-publish-reply'; import { useFileUpload } from '../../hooks/use-file-upload'; import useMediaHostingStore from '../../stores/use-media-hosting-store'; import styles from './post-form.module.css'; -import { capitalize, debounce } from 'lodash'; +import capitalize from 'lodash/capitalize'; +import debounce from 'lodash/debounce'; // Separate component for offline alert to isolate rerenders from updatingState // Only this component will rerender when updatingState changes, not the whole PostForm diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx index 6dc2daed..a9f6d389 100644 --- a/src/components/post-mobile/post-mobile.tsx +++ b/src/components/post-mobile/post-mobile.tsx @@ -21,6 +21,7 @@ import useCountLinksInReplies from '../../hooks/use-count-links-in-replies'; import useHide from '../../hooks/use-hide'; import useStateString from '../../hooks/use-state-string'; import useScrollToReply from '../../hooks/use-scroll-to-reply'; +import { useCurrentTime } from '../../hooks/use-current-time'; import { useSubplebbitField } from '../../hooks/use-stable-subplebbit'; import CommentContent from '../comment-content'; import CommentMedia from '../comment-media'; @@ -29,7 +30,8 @@ import PostMenuMobile from './post-menu-mobile'; import ReplyQuotePreview from '../reply-quote-preview'; import Tooltip from '../tooltip'; import { PostProps } from '../../views/post/post'; -import { capitalize, lowerCase } from 'lodash'; +import capitalize from 'lodash/capitalize'; +import lowerCase from 'lodash/lowerCase'; import useReplyModalStore from '../../stores/use-reply-modal-store'; import { selectPostMenuProps } from '../../lib/utils/post-menu-props'; import useChallengesStore from '../../stores/use-challenges-store'; @@ -42,6 +44,13 @@ import { REPLIES_PER_PAGE } from '../../lib/constants'; const { addChallenge } = useChallengesStore.getState(); +const RepliesFooter = ({ hasMore, loadingString }: { hasMore: boolean; loadingString: string }) => + hasMore ? ( +
+ +
+ ) : null; + // Store scroll position for replies virtuoso across navigations const lastVirtuosoStates: { [key: string]: StateSnapshot } = {}; @@ -66,6 +75,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos const isInSubscriptionsView = isSubscriptionsView(location.pathname, params); const isInModQueueView = isModQueueView(location.pathname); const { getAlertThresholdSeconds } = useModQueueStore(); + const currentTime = useCurrentTime(); const account = useAccount(); const accountAddress = account?.author?.address; @@ -166,7 +176,7 @@ const PostInfoAndMedia = ({ post, postReplyCount = 0, roles, threadNumber }: Pos const alreadyApproved = approved === true; const alreadyRejected = removed === true; const isAwaitingApproval = isInModQueueView && !alreadyApproved && !alreadyRejected; - const timeWaiting = timestamp ? Date.now() / 1000 - timestamp : 0; + const timeWaiting = timestamp ? currentTime - timestamp : 0; const alertThresholdSeconds = getAlertThresholdSeconds(); const isOverThreshold = isAwaitingApproval && timeWaiting > alertThresholdSeconds; @@ -562,7 +572,7 @@ const PostMobile = ({ } }); }; - window.addEventListener('scroll', setLastVirtuosoState); + window.addEventListener('scroll', setLastVirtuosoState, { passive: true }); return () => window.removeEventListener('scroll', setLastVirtuosoState); }, [virtuosoStateKey, showAllReplies, isInPostPageView]); @@ -578,13 +588,7 @@ const PostMobile = ({ enabled: shouldScrollToReply, }); - // Footer component for Virtuoso showing loading state - const RepliesFooter = () => - hasMore ? ( -
- -
- ) : null; + const virtuosoFooter = useCallback(() => , [hasMore, t]); return ( <> @@ -667,7 +671,7 @@ const PostMobile = ({ )} useWindowScroll={true} - components={{ Footer: RepliesFooter }} + components={{ Footer: virtuosoFooter }} endReached={loadMore} ref={virtuosoRef} restoreStateFrom={lastVirtuosoState} diff --git a/src/components/reply-modal/reply-modal.tsx b/src/components/reply-modal/reply-modal.tsx index addce17e..0653c7e9 100644 --- a/src/components/reply-modal/reply-modal.tsx +++ b/src/components/reply-modal/reply-modal.tsx @@ -13,9 +13,11 @@ import useMediaHostingStore from '../../stores/use-media-hosting-store'; import { useDirectoryByAddress } from '../../hooks/use-directories'; import usePublishReply from '../../hooks/use-publish-reply'; import useIsMobile from '../../hooks/use-is-mobile'; +import { useCurrentTime } from '../../hooks/use-current-time'; import { useFileUpload } from '../../hooks/use-file-upload'; import styles from './reply-modal.module.css'; -import { capitalize, debounce } from 'lodash'; +import capitalize from 'lodash/capitalize'; +import debounce from 'lodash/debounce'; import { useSpring, animated } from '@react-spring/web'; import { useDrag } from '@use-gesture/react'; @@ -146,9 +148,10 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa const location = useLocation(); const isInAllView = isAllView(location.pathname); const isInSubscriptionsView = isSubscriptionsView(location.pathname, useParams()); + const currentTime = useCurrentTime(); // Only subscribe to updatedAt to avoid rerenders from updatingState changes const updatedAt = useSubplebbitField(subplebbitAddress, (subplebbit) => subplebbit?.updatedAt); - const isBoardOffline = updatedAt && updatedAt < Date.now() / 1000 - 60 * 60; + const isBoardOffline = updatedAt && updatedAt < currentTime - 60 * 60; const offlineAlert = updatedAt ? isBoardOffline && (
diff --git a/src/components/settings-modal/account-settings/account-settings.tsx b/src/components/settings-modal/account-settings/account-settings.tsx index 8ab2b4f2..e519a01a 100644 --- a/src/components/settings-modal/account-settings/account-settings.tsx +++ b/src/components/settings-modal/account-settings/account-settings.tsx @@ -8,20 +8,21 @@ import { useLocation, useNavigate } from 'react-router-dom'; const isAndroid = Capacitor.getPlatform() === 'android'; -const AccountSettings = () => { +// Inner component keyed by account id so state resets when user switches account +const AccountSettingsEditor = ({ + account, +}: { + account?: { id?: string; name?: string; author?: { address?: string; shortAddress?: string }; [key: string]: unknown }; +}) => { const { t } = useTranslation(); const location = useLocation(); - const account = useAccount(); - const [text, setText] = useState(''); const accountJson = useMemo( () => stringify({ account: { ...account, plebbit: undefined, karma: undefined, plebbitReactOptions: undefined, unreadNotificationCount: undefined } }), [account], ); - useEffect(() => { - setText(accountJson); - }, [accountJson]); + const [text, setText] = useState(() => accountJson); const { accounts } = useAccounts(); const switchToNewAccountRef = useRef(false); @@ -92,7 +93,7 @@ const AccountSettings = () => { // Create a temporary download link const link = document.createElement('a'); link.href = fileUrl; - link.download = `${account.name}.json`; + link.download = `${account?.name ?? 'account'}.json`; // Append the link, trigger the download, then remove the link document.body.appendChild(link); @@ -221,7 +222,7 @@ const AccountSettings = () => {