diff --git a/src/components/markdown/markdown.tsx b/src/components/markdown/markdown.tsx
index 9a418df2..98aa086b 100644
--- a/src/components/markdown/markdown.tsx
+++ b/src/components/markdown/markdown.tsx
@@ -15,7 +15,7 @@ import { is5chanLink, transform5chanLinkToInternal, isValidCrossboardPattern } f
import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils';
import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils';
import usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store';
-import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
+import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import { useComment } from '@bitsocial/bitsocial-react-hooks';
import ReplyQuotePreview from '../reply-quote-preview/reply-quote-preview';
import ExternalNumberQuoteLink from './external-number-quote-link';
diff --git a/src/components/mod-queue-button/index.ts b/src/components/mod-queue-button/index.ts
new file mode 100644
index 00000000..371e6002
--- /dev/null
+++ b/src/components/mod-queue-button/index.ts
@@ -0,0 +1 @@
+export { ModQueueButton } from './mod-queue-button';
diff --git a/src/components/mod-queue-button/mod-queue-button.module.css b/src/components/mod-queue-button/mod-queue-button.module.css
new file mode 100644
index 00000000..9ffa5068
--- /dev/null
+++ b/src/components/mod-queue-button/mod-queue-button.module.css
@@ -0,0 +1,23 @@
+/* Count badge styling for the board mod-queue button. Mirrors the route's
+ ModQueueBoardCount styling, which shares the same visual; kept here so the
+ button stays self-contained and does not import the route's stylesheet. */
+.modQueueButtonCount {
+ font-weight: bold;
+}
+
+/* Number blinking, changing color to red */
+.modQueueButtonCountAlert {
+ animation: blink-color 2s infinite;
+}
+
+@keyframes blink-color {
+ 0% {
+ color: inherit;
+ }
+ 50% {
+ color: var(--mod-queue-alert-color);
+ }
+ 100% {
+ color: inherit;
+ }
+}
diff --git a/src/components/mod-queue-button/mod-queue-button.tsx b/src/components/mod-queue-button/mod-queue-button.tsx
new file mode 100644
index 00000000..1d8e906a
--- /dev/null
+++ b/src/components/mod-queue-button/mod-queue-button.tsx
@@ -0,0 +1,152 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Link } from 'react-router-dom';
+import { Comment, useCommunity, useFeed } from '@bitsocial/bitsocial-react-hooks';
+import useModQueueStore from '../../stores/use-mod-queue-store';
+import { areSameBoardAddress, getCommunityAddress } from '../../lib/utils/route-utils';
+import { useDirectories } from '../../hooks/use-directories';
+import { isPendingApprovalAwaiting } from '../../lib/utils/pending-approval-moderation';
+import { useCommunityIdentifier, useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
+import { useCurrentTime } from '../../hooks/use-current-time';
+import { canAccessBoardModQueue, hasModQueueAccessRole } from '../../lib/utils/mod-access';
+import { useModeratedCommunityAddressInputs, useModeratedCommunityAddressesForInputs } from '../../hooks/use-moderated-community-addresses';
+import { getAddressListFromKey, getAddressListKey } from '../../lib/utils/mod-queue-utils';
+import { useLocallyModeratedModQueueFeed } from '../../hooks/use-locally-moderated-mod-queue-feed';
+import ModQueueCommunityMetadataLoader from '../mod-queue-community-metadata-loader/mod-queue-community-metadata-loader';
+import styles from './mod-queue-button.module.css';
+
+interface ModQueueButtonProps {
+ boardIdentifier?: string;
+ isMobile?: boolean;
+}
+
+interface ModQueueButtonContentProps {
+ feed: Comment[];
+ alertThresholdSeconds: number;
+ boardIdentifier?: string;
+ isMobile?: boolean;
+}
+
+const ModQueueButtonContent = ({ feed, alertThresholdSeconds, boardIdentifier, isMobile }: ModQueueButtonContentProps) => {
+ const { t } = useTranslation();
+ const currentTime = useCurrentTime();
+ const locallyModeratedFeed = useLocallyModeratedModQueueFeed(feed, currentTime);
+
+ const { normalCount, urgentCount } = useMemo(() => {
+ let normal = 0;
+ let urgent = 0;
+ for (const comment of locallyModeratedFeed) {
+ if (!isPendingApprovalAwaiting(comment)) continue;
+ const timeWaiting = currentTime - (comment.timestamp ?? 0);
+ if (timeWaiting > alertThresholdSeconds) urgent++;
+ else normal++;
+ }
+ return { normalCount: normal, urgentCount: urgent };
+ }, [alertThresholdSeconds, currentTime, locallyModeratedFeed]);
+
+ const totalCount = normalCount + urgentCount;
+ const to = boardIdentifier ? `/${boardIdentifier}/mod/queue` : '/mod/queue';
+
+ const buttonContent = (
+
+ {t('mod_queue')}
+ {totalCount > 0 && (
+
+ (
+ {urgentCount > 0 && normalCount > 0 ? (
+ <>
+ {normalCount}
+
+ {'+'}
+ {urgentCount}
+
+ >
+ ) : urgentCount > 0 ? (
+ {urgentCount}
+ ) : (
+ {totalCount}
+ )}
+ )
+
+ )}
+
+ );
+
+ return isMobile ? buttonContent : <>[{buttonContent}]>;
+};
+
+export const ModQueueButton = ({ boardIdentifier, isMobile }: ModQueueButtonProps) => {
+ const getAlertThresholdSeconds = useModQueueStore((state) => state.getAlertThresholdSeconds);
+
+ const moderatedCommunityAddressInputs = useModeratedCommunityAddressInputs();
+ const accountAddress = moderatedCommunityAddressInputs.accountAddress;
+ const rawAccountCommunityAddresses = useModeratedCommunityAddressesForInputs(moderatedCommunityAddressInputs);
+ const accountCommunityAddressesKey = getAddressListKey(rawAccountCommunityAddresses);
+ const accountCommunityAddresses = useMemo(() => getAddressListFromKey(accountCommunityAddressesKey), [accountCommunityAddressesKey]);
+
+ const directories = useDirectories();
+
+ const resolvedAddress = useMemo(() => {
+ if (boardIdentifier) {
+ return getCommunityAddress(boardIdentifier, directories);
+ }
+ return undefined;
+ }, [boardIdentifier, directories]);
+ const resolvedCommunity = useCommunityIdentifier(resolvedAddress);
+ const community = useCommunity(resolvedCommunity ? { community: resolvedCommunity } : undefined);
+
+ const communityAddresses = useMemo(() => {
+ if (resolvedAddress) {
+ return [resolvedAddress];
+ }
+ return accountCommunityAddresses;
+ }, [resolvedAddress, accountCommunityAddresses]);
+
+ const accountRole = accountAddress ? community?.roles?.[accountAddress]?.role : undefined;
+ const hasBoardAccessFromAccountCommunities = resolvedAddress
+ ? accountCommunityAddresses.some((address) => areSameBoardAddress(address, resolvedAddress))
+ : accountCommunityAddresses.length > 0;
+ const hasBoardAccess = canAccessBoardModQueue({
+ boardAddress: resolvedAddress,
+ accountCommunityAddresses,
+ accountRole,
+ });
+ const isBoardAccessLoading =
+ Boolean(resolvedAddress) &&
+ Boolean(accountAddress) &&
+ !hasModQueueAccessRole(accountRole) &&
+ !hasBoardAccessFromAccountCommunities &&
+ community?.state !== 'succeeded' &&
+ community?.state !== 'failed';
+
+ // Only fetch if we have addresses to check and permissions
+ const shouldFetch = !isBoardAccessLoading && communityAddresses.length > 0 && hasBoardAccess;
+
+ const feedAddresses = shouldFetch ? communityAddresses : [];
+ const feedCommunities = useCommunityIdentifiers(feedAddresses);
+ const feedOptions = useMemo(
+ () => ({
+ communities: feedCommunities,
+ modQueue: ['pendingApproval'],
+ sortType: 'new' as const,
+ postsPerPage: 200,
+ }),
+ [feedCommunities],
+ );
+ const { feed } = useFeed(feedOptions);
+ const metadataLoader =
;
+
+ if (!shouldFetch || communityAddresses.length === 0) {
+ return metadataLoader;
+ }
+
+ const alertThresholdSeconds = getAlertThresholdSeconds();
+ // Remount when switching boards so memoized counts reset cleanly.
+ const contentKey = communityAddresses.join(',');
+ return (
+ <>
+ {metadataLoader}
+
+ >
+ );
+};
diff --git a/src/components/mod-queue-community-metadata-loader/index.ts b/src/components/mod-queue-community-metadata-loader/index.ts
new file mode 100644
index 00000000..13f44848
--- /dev/null
+++ b/src/components/mod-queue-community-metadata-loader/index.ts
@@ -0,0 +1 @@
+export { default } from './mod-queue-community-metadata-loader';
diff --git a/src/components/mod-queue-community-metadata-loader/mod-queue-community-metadata-loader.tsx b/src/components/mod-queue-community-metadata-loader/mod-queue-community-metadata-loader.tsx
new file mode 100644
index 00000000..1493d0b8
--- /dev/null
+++ b/src/components/mod-queue-community-metadata-loader/mod-queue-community-metadata-loader.tsx
@@ -0,0 +1,14 @@
+import { memo } from 'react';
+import { useCommunities } from '@bitsocial/bitsocial-react-hooks';
+import { useCommunityIdentifiers } from '../../hooks/use-community-identifiers';
+
+// Warms community metadata for the candidate mod queue boards without rendering
+// anything. Shared by the mod queue route and the board mod-queue button so
+// neither has to reach into the other for it.
+const ModQueueCommunityMetadataLoader = memo(({ candidateCommunityAddresses }: { candidateCommunityAddresses: string[] }) => {
+ const candidateCommunities = useCommunityIdentifiers(candidateCommunityAddresses);
+ useCommunities(candidateCommunities.length > 0 ? { communities: candidateCommunities } : undefined);
+ return null;
+});
+
+export default ModQueueCommunityMetadataLoader;
diff --git a/src/components/post-desktop/post-desktop.tsx b/src/components/post-desktop/post-desktop.tsx
index 8a681139..e3552d7f 100644
--- a/src/components/post-desktop/post-desktop.tsx
+++ b/src/components/post-desktop/post-desktop.tsx
@@ -8,7 +8,7 @@ import styles from '../../views/post/post.module.css';
import { CommentMediaInfo, getHasThumbnail, getMediaDimensions, getPostMediaTypeLabel, getYouTubeEmbedPostMediaFileLink } from '../../lib/utils/media-utils';
import { hashStringToColor, getTextColorForBackground } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
-import { approvePendingCommentModeration, isPendingApprovalAwaiting, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
+import { isPendingApprovalAwaiting } from '../../lib/utils/pending-approval-moderation';
import { isValidURL, parseHttpUrl } from '../../lib/utils/url-utils';
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay, truncateWithEllipsisInMiddle } from '../../lib/utils/string-utils';
@@ -44,13 +44,11 @@ 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';
-import useChallengesStore from '../../stores/use-challenges-store';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
-import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
-import { usePublishCommentModeration } from '@bitsocial/bitsocial-react-hooks';
+import usePendingCommentModerationActions from '../../hooks/use-pending-comment-moderation-actions';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
import useProgressiveRender from '../../hooks/use-progressive-render';
import useFreshReplies from '../../hooks/use-fresh-replies';
@@ -105,78 +103,12 @@ const PendingModerationActions = ({ cid, communityAddress, post }: { cid: string
const { t } = useTranslation();
const {
- publishCommentModeration: approvePending,
- state: approvePendingState,
- error: approvePendingError,
- } = usePublishCommentModeration({
- commentCid: cid,
- communityAddress,
- commentModeration: approvePendingCommentModeration,
- onChallenge: async (...args: any) => {
- useChallengesStore.getState().addChallenge([...args, post]);
- },
- onChallengeVerification: async (challengeVerification, comment) => {
- alertChallengeVerificationFailed(challengeVerification, comment);
- },
- onError: (error: Error & { details?: unknown }) => {
- console.error('Approve failed:', error, error.details);
- },
- });
-
- const {
- publishCommentModeration: rejectPending,
- state: rejectPendingState,
- error: rejectPendingError,
- } = usePublishCommentModeration({
- commentCid: cid,
- communityAddress,
- commentModeration: rejectPendingCommentModeration,
- onChallenge: async (...args: any) => {
- useChallengesStore.getState().addChallenge([...args, post]);
- },
- onChallengeVerification: async (challengeVerification, comment) => {
- alertChallengeVerificationFailed(challengeVerification, comment);
- },
- onError: (error: Error & { details?: unknown }) => {
- console.error('Reject failed:', error, error.details);
- },
- });
-
- const [initiatedPendingAction, setInitiatedPendingAction] = useState<'approve' | 'reject' | null>(null);
- const handlePendingApprove = useCallback(async () => {
- if (!window.confirm(t('double_confirm'))) return;
- setInitiatedPendingAction('approve');
- try {
- await approvePending();
- } catch (e) {
- console.error(e);
- }
- }, [approvePending, t]);
-
- const handlePendingReject = useCallback(async () => {
- if (!window.confirm(t('double_confirm'))) return;
- setInitiatedPendingAction('reject');
- try {
- await rejectPending();
- } catch (e) {
- console.error(e);
- }
- }, [rejectPending, t]);
-
- const isApprovingPending =
- initiatedPendingAction === 'approve' && approvePendingState !== 'initializing' && approvePendingState !== 'succeeded' && approvePendingState !== 'failed';
- const isRejectingPending =
- initiatedPendingAction === 'reject' && rejectPendingState !== 'initializing' && rejectPendingState !== 'succeeded' && rejectPendingState !== 'failed';
- const isPublishingPending = isApprovingPending || isRejectingPending;
-
- const approvePendingSucceeded = initiatedPendingAction === 'approve' && approvePendingState === 'succeeded';
- const rejectPendingSucceeded = initiatedPendingAction === 'reject' && rejectPendingState === 'succeeded';
- const approvePendingFailed = initiatedPendingAction === 'approve' && approvePendingState === 'failed';
- const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
-
- const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
- const pendingError = approvePendingFailed ? approvePendingError : rejectPendingFailed ? rejectPendingError : undefined;
- const pendingErrorMessage = formatErrorForDisplay(pendingError);
+ handleApprove: handlePendingApprove,
+ handleReject: handlePendingReject,
+ isPublishing: isPublishingPending,
+ status: pendingStatus,
+ errorMessage: pendingErrorMessage,
+ } = usePendingCommentModerationActions({ comment: post, commentCid: cid, communityAddress });
return (
diff --git a/src/components/post-form/post-form.tsx b/src/components/post-form/post-form.tsx
index 5650f535..073823e9 100644
--- a/src/components/post-form/post-form.tsx
+++ b/src/components/post-form/post-form.tsx
@@ -4,7 +4,7 @@ import type { TFunction } from 'i18next';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Comment, setAccount, useAccount, useEditedComment } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
-import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages';
+import { communitiesPagesStore as useCommunitiesPagesStore } from '../../lib/bitsocial-internals/stores';
import { getDisplayMediaInfoType, getLinkMediaInfo, getTwimgMediaFilePublishUrl } from '../../lib/utils/media-utils';
import {
getExpiringMediaLinkAlert,
diff --git a/src/components/post-mobile/post-mobile.tsx b/src/components/post-mobile/post-mobile.tsx
index 58e13988..5b921813 100644
--- a/src/components/post-mobile/post-mobile.tsx
+++ b/src/components/post-mobile/post-mobile.tsx
@@ -2,14 +2,14 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useLocation, useNavigationType, useParams } from 'react-router-dom';
import { Virtuoso, VirtuosoHandle, StateSnapshot } from 'react-virtuoso';
-import { Comment, useEditedComment, useReplies, useAccount, usePublishCommentModeration } from '@bitsocial/bitsocial-react-hooks';
+import { Comment, useEditedComment, useReplies, useAccount } from '@bitsocial/bitsocial-react-hooks';
import getShortAddress from '../../lib/get-short-address';
import styles from '../../views/post/post.module.css';
import { shouldShowSnow } from '../../lib/snow';
import { getHasThumbnail } from '../../lib/utils/media-utils';
import { getTextColorForBackground, hashStringToColor } from '../../lib/utils/post-utils';
import { getFormattedDate, getFormattedTimeAgo } from '../../lib/utils/time-utils';
-import { approvePendingCommentModeration, isPendingApprovalAwaiting, rejectPendingCommentModeration } from '../../lib/utils/pending-approval-moderation';
+import { isPendingApprovalAwaiting } from '../../lib/utils/pending-approval-moderation';
import { isAllView, isModQueueView, isModView, isPendingPostView, isPostPageView, isSubscriptionsView } from '../../lib/utils/view-utils';
import { formatUserIDForDisplay } from '../../lib/utils/string-utils';
import useModQueueStore from '../../stores/use-mod-queue-store';
@@ -39,12 +39,11 @@ 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';
import useFeedResetStore from '../../stores/use-feed-reset-store';
import useThreadLiveUpdatesStore from '../../stores/use-thread-live-updates-store';
import useRegisterFreshReplies from '../../hooks/use-register-fresh-replies';
-import { alertChallengeVerificationFailed } from '../../lib/utils/challenge-utils';
import useQuotedByMap from '../../hooks/use-quoted-by-map';
+import usePendingCommentModerationActions from '../../hooks/use-pending-comment-moderation-actions';
import useProgressiveRender from '../../hooks/use-progressive-render';
import useReplyHeightEstimates from '../../hooks/use-reply-height-estimates';
import useFreshReplies from '../../hooks/use-fresh-replies';
@@ -128,86 +127,18 @@ const PostInfoAndMedia = ({
// Moderation actions for pending approval posts
const {
- publishCommentModeration: approvePending,
- state: approvePendingState,
- error: approvePendingError,
- } = usePublishCommentModeration({
+ handleApprove: handlePendingApprove,
+ handleReject: handlePendingReject,
+ isPublishing: isPublishingPending,
+ status: pendingStatus,
+ errorMessage: pendingErrorMessage,
+ } = usePendingCommentModerationActions({
+ comment: resolvedPost,
commentCid: cid,
- communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
- commentModeration: approvePendingCommentModeration,
- onChallenge: async (...args: any) => {
- useChallengesStore.getState().addChallenge([...args, resolvedPost]);
- },
- onChallengeVerification: async (challengeVerification, comment) => {
- alertChallengeVerificationFailed(challengeVerification, comment);
- },
- onError: (error: Error & { details?: unknown }) => {
- console.error('Approve failed:', error, error.details);
- },
+ communityAddress,
+ enabled: !!shouldShowPendingApprovalButtons,
});
- const {
- publishCommentModeration: rejectPending,
- state: rejectPendingState,
- error: rejectPendingError,
- } = usePublishCommentModeration({
- commentCid: cid,
- communityAddress: shouldShowPendingApprovalButtons ? communityAddress : undefined,
- commentModeration: rejectPendingCommentModeration,
- onChallenge: async (...args: any) => {
- useChallengesStore.getState().addChallenge([...args, resolvedPost]);
- },
- onChallengeVerification: async (challengeVerification, comment) => {
- alertChallengeVerificationFailed(challengeVerification, comment);
- },
- onError: (error: Error & { details?: unknown }) => {
- console.error('Reject failed:', error, error.details);
- },
- });
-
- const [initiatedPendingAction, setInitiatedPendingAction] = useState<'approve' | 'reject' | null>(null);
-
- const handlePendingApprove = useCallback(async () => {
- const confirm = window.confirm(t('double_confirm'));
- if (!confirm) {
- return;
- }
- setInitiatedPendingAction('approve');
- try {
- await approvePending();
- } catch (e) {
- console.error(e);
- }
- }, [approvePending, t]);
-
- const handlePendingReject = useCallback(async () => {
- const confirm = window.confirm(t('double_confirm'));
- if (!confirm) {
- return;
- }
- setInitiatedPendingAction('reject');
- try {
- await rejectPending();
- } catch (e) {
- console.error(e);
- }
- }, [rejectPending, t]);
-
- const isApprovingPending =
- initiatedPendingAction === 'approve' && approvePendingState !== 'initializing' && approvePendingState !== 'succeeded' && approvePendingState !== 'failed';
- const isRejectingPending =
- initiatedPendingAction === 'reject' && rejectPendingState !== 'initializing' && rejectPendingState !== 'succeeded' && rejectPendingState !== 'failed';
- const isPublishingPending = isApprovingPending || isRejectingPending;
-
- const approvePendingSucceeded = initiatedPendingAction === 'approve' && approvePendingState === 'succeeded';
- const rejectPendingSucceeded = initiatedPendingAction === 'reject' && rejectPendingState === 'succeeded';
- const approvePendingFailed = initiatedPendingAction === 'approve' && approvePendingState === 'failed';
- const rejectPendingFailed = initiatedPendingAction === 'reject' && rejectPendingState === 'failed';
-
- const pendingStatus = approvePendingSucceeded ? 'approved' : rejectPendingSucceeded ? 'rejected' : approvePendingFailed || rejectPendingFailed ? 'failed' : null;
- const pendingError = approvePendingFailed ? approvePendingError : rejectPendingFailed ? rejectPendingError : undefined;
- const pendingErrorMessage = formatErrorForDisplay(pendingError);
-
const commentMediaInfo = useCommentMediaInfo(link, thumbnailUrl, linkWidth, linkHeight);
const hasThumbnail = getHasThumbnail(commentMediaInfo, link);
diff --git a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx
index d2d4f234..af2c7741 100644
--- a/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx
+++ b/src/components/settings-modal/p2p-stats-settings/p2p-stats-settings.tsx
@@ -1,1026 +1,24 @@
-import { Fragment, memo, useEffect, useReducer } from 'react';
+import { Fragment, memo } from 'react';
import { useAccount, usePkcRpcSettings } from '@bitsocial/bitsocial-react-hooks';
import { useTranslation } from 'react-i18next';
import { getCountryFlagPosition, getCountryLabel, normalizeCountryCode } from '../../../lib/country-flags';
+import { getApproximateCountryCode } from '../../../lib/peer-geo';
+import { getP2PRuntimeMode } from '../../../lib/p2p-runtime';
import {
- fetchIpMapLocation,
- fetchOwnIpCountryCode,
- fetchOwnPublicEndpoint,
- fetchPeerMapLocation,
- getApproximateCountryCode,
- getCountryConsistentLocation,
- getFirstPublicIpFromAddresses,
- isPrivateOrReservedIpv4,
- type PeerMapLocation,
- type PublicEndpoint,
-} from '../../../lib/peer-geo';
-import { getP2PRuntimeMode, type P2PRuntimeMode } from '../../../lib/p2p-runtime';
-import LoadingEllipsis from '../../loading-ellipsis';
+ SEEDER_REPO_URL,
+ formatCount,
+ formatPeerTransferBytes,
+ transparentPixelSrc,
+ type AccountShape,
+ type ConnectedPeersStatRow,
+ type NodeEndpointStatRow,
+ type TextStatRow,
+} from '../../../lib/p2p-stats';
+import useP2PStats from '../../../hooks/use-p2p-stats';
+import LoadingEllipsis from '../../loading-ellipsis/loading-ellipsis';
import PeerWorldMap from './peer-world-map';
import styles from './p2p-stats-settings.module.css';
-type AccountShape = Record;
-
-type TextStatRow = {
- name: string;
- type?: 'text';
- value: string;
-};
-
-type ConnectedPeerEntry = {
- address: string;
- countryCode?: string;
- direction?: string;
- id: string;
- location?: PeerMapLocation;
- peerId: string;
- role?: PeerConnectionRole;
- status?: string;
- transferStats?: TransferStats;
- transport: string;
-};
-
-type PeerConnectionRole = 'leecher' | 'seeder';
-
-type PeerMapEntry = {
- address: string;
- id: string;
- location?: PeerMapLocation;
- peerId: string;
- role?: PeerConnectionRole;
-};
-
-type ConnectedPeersStatRow = {
- connectionCount: number;
- entries: ConnectedPeerEntry[];
- mapEntries?: PeerMapEntry[];
- name: string;
- peerCount: number;
- type: 'connectedPeers';
-};
-
-type NodeEndpointStatRow = {
- countryCode?: string;
- ip: string;
- name: string;
- type: 'nodeEndpoint';
-};
-
-type StatRow = ConnectedPeersStatRow | NodeEndpointStatRow | TextStatRow;
-
-type StatsState = {
- error?: string;
- loading: boolean;
- rows: StatRow[];
- updatedAt?: number;
-};
-
-type StatsAction =
- | {
- type: 'loading';
- }
- | {
- rows: StatRow[];
- timestamp: number;
- type: 'loaded';
- }
- | {
- error: string;
- timestamp: number;
- type: 'failed';
- };
-
-type Libp2pClientShape = {
- _helia?: {
- libp2p?: {
- getConnections?: () => unknown[] | Promise;
- getPeers?: () => unknown[] | Promise;
- peerId?: { toString: () => string };
- services?: {
- pubsub?: {
- getPeers?: () => unknown[] | Promise;
- };
- };
- metrics?: unknown;
- };
- metrics?: unknown;
- routing?: {
- routers?: unknown[];
- };
- };
- heliaWithKuboRpcClientFunctions?: {
- add?: unknown;
- };
- key?: string;
-};
-
-type PkcRpcClientShape = {
- getPeers?: () => unknown | Promise;
- getStats?: () => unknown | Promise;
- state?: string;
-};
-
-type TransferStats = {
- downloadedBytes?: number;
- uploadedBytes?: number;
-};
-
-type TransferStatsSnapshot = {
- peers: Map;
- totals: TransferStats;
-};
-
-type ObservedTransferStats = {
- connections: WeakSet