diff --git a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx index e2ff35bde9dc71fc9c85343214b2aeaa005f46d8..0d651c26ab842d1ed7efcdae2955a954eeb234a1 100644 --- a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx +++ b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx @@ -9,20 +9,17 @@ import ChangeTitleDialog from '@/blog/features/community-profile/change-title-di import DetailsCardHover from '@/blog/features/list-of-posts/details-card-hover'; import ReblogTrigger from '@/blog/features/list-of-posts/reblog-trigger'; import { useDeletePostMutation } from '@/blog/features/post-editor/hooks/use-post-mutation'; -import { postClassName } from '@/blog/features/post-editor/lib/utils'; import PostForm from '@/blog/features/post-editor/post-form'; import PostingLoader from '@/blog/features/post-editor/posting-loader'; import { ReplyTextbox } from '@/blog/features/post-editor/reply-textbox'; import { AlertDialogFlag } from '@/blog/features/post-rendering/alert-window-flag'; -import CommentList from '@/blog/features/post-rendering/comment-list'; -import CommentSelectFilter from '@/blog/features/post-rendering/comment-select-filter'; +import CommentsSection from '@/blog/features/post-rendering/comments-section'; import ContextLinks from '@/blog/features/post-rendering/context-links'; import DetailsCardVoters from '@/blog/features/post-rendering/details-card-voters'; import FlagIcon from '@/blog/features/post-rendering/flag-icon'; -import ImageGallery from '@/blog/features/post-rendering/image-gallery'; import MutePostDialog from '@/blog/features/post-rendering/mute-post-dialog'; +import PostBodySection from '@/blog/features/post-rendering/post-body-section'; import { PostDeleteDialog } from '@/blog/features/post-rendering/post-delete-dialog'; -import RendererContainer from '@/blog/features/post-rendering/rendererContainer'; import { SharePost } from '@/blog/features/post-rendering/share-post-dialog'; import FacebookShare from '@/blog/features/post-rendering/share-post-facebook'; import LinkedInShare from '@/blog/features/post-rendering/share-post-linkedin'; @@ -33,6 +30,7 @@ import { UserPopoverCard } from '@/blog/features/post-rendering/user-popover-car import AnimatedList from '@/blog/features/suggestions-posts/animated-tab'; import SuggestionsList from '@/blog/features/suggestions-posts/list'; import { useTranslation } from '@/blog/i18n/client'; +import { postContainerClasses } from '@/blog/lib/post-layout-classes'; import sorter, { SortOrder } from '@/blog/lib/sorter'; import { DEFAULT_OBSERVER } from '@/blog/lib/utils'; import { getBasePath } from '@/blog/utils/PathUtils'; @@ -45,7 +43,6 @@ import { Badge } from '@ui/components/badge'; import { Button } from '@ui/components/button'; import { Icons } from '@ui/components/icons'; import Loading from '@ui/components/loading'; -import { Separator } from '@ui/components/separator'; import TimeAgo from '@ui/components/time-ago'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@ui/components/tooltip'; import dmcaList from '@ui/config/lists/dmca-list'; @@ -58,7 +55,7 @@ import { Clock, Link2 } from 'lucide-react'; import moment from 'moment'; import { Link } from '@hive/ui'; import { useParams, usePathname, useRouter, useSearchParams } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { CircleSpinner } from 'react-spinners-kit'; import { useLocalStorage } from 'usehooks-ts'; import { useUserClient } from '@smart-signer/lib/auth/use-user-client'; @@ -353,6 +350,17 @@ const PostContent = () => { useEffect(() => { setCommentsPage(1); }, [author, permlink]); + + // Stable callback for CommentsSection + const handleSetCommentsPage = useCallback((page: number | ((prev: number) => number)) => { + setCommentsPage(page); + }, []); + + // Stable callback for PostBodySection + const handleShowMutedContent = useCallback(() => { + setMutedPost(false); + }, []); + if (userFromGDPR || (!postData && !postIsLoading)) return ; return ( @@ -361,8 +369,8 @@ const PostContent = () => {
{suggestionData ? : null}
-
-
+
+
{crossedPost ? (
@@ -464,26 +472,16 @@ const PostContent = () => {
{t('global.unavailable_for_legal_reasons')}
) : copyRightCheck || userFromDMCA ? (
{t('post_content.body.copyright')}
- ) : mutedPost ? ( - <> - -
- {t('post_content.body.content_were_hidden')} - -
- ) : ( - - - + )}
{!commentSite ? ( @@ -792,85 +790,16 @@ const PostContent = () => { ) : null}
{!!postData && paginatedDiscussionState ? ( -
-
- {t('select_sort.sort_comments.sort')} - -
- - {paginatedDiscussionState.totalPages > 1 && ( -
- - {Array.from({ length: paginatedDiscussionState.totalPages }, (_, i) => i + 1).map( - (pageNum) => { - // Show only a few pages around the current page - const showPage = - pageNum === 1 || - pageNum === paginatedDiscussionState.totalPages || - (pageNum >= paginatedDiscussionState.currentPage - 2 && - pageNum <= paginatedDiscussionState.currentPage + 2); - - if (!showPage) { - // Show ellipses - if ( - pageNum === paginatedDiscussionState.currentPage - 3 || - pageNum === paginatedDiscussionState.currentPage + 3 - ) { - return ( - - ... - - ); - } - return null; - } - - return ( - - ); - } - )} - -
- )} -
+ ) : null}
diff --git a/apps/blog/features/account-profile/posts-content.tsx b/apps/blog/features/account-profile/posts-content.tsx index cee534031bdb1db0e883364dd26b342a10ae7889..fe4776237984c7c6f6ea0adc063bcbe92ca6bd67 100644 --- a/apps/blog/features/account-profile/posts-content.tsx +++ b/apps/blog/features/account-profile/posts-content.tsx @@ -74,6 +74,7 @@ const PostsContent = ({ query }: { query: QueryTypes }) => { return t('user_profile.no_posts_yet', { username: username }); if (query === 'payout') return t('user_profile.no_pending_payouts'); if (query === 'replies') return t('user_profile.no_replies_yet', { username: username }); + if (query === 'feed') return t('user_profile.empty_feed_not_following'); return t('user_profile.no_blogging_yet', { username: username }); }; diff --git a/apps/blog/features/list-of-posts/post-img.tsx b/apps/blog/features/list-of-posts/post-img.tsx index 102b32eede7ad4636aa1fa67c105d271573e96e4..046d40baeca9a14b7a857252b5af14f1ed4a5d16 100644 --- a/apps/blog/features/list-of-posts/post-img.tsx +++ b/apps/blog/features/list-of-posts/post-img.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; import clsx from 'clsx'; import { Link } from '@hive/ui'; -import { proxifyImageUrl } from '@ui/lib/old-profixy'; +import { proxifyImageSrc } from '@ui/lib/proxify-images'; import { getDefaultImageUrl, getUserAvatarUrl } from '@ui/lib/avatar-utils'; import { customEndsWith } from '@/blog/lib/ends-with'; import { @@ -25,57 +25,55 @@ export function find_first_img(post: Entry) { 'jpg' ]) ) { - return proxifyImageUrl( - post.json_metadata.links[0].slice(0, post.json_metadata.links[0].length - 1), - true + return proxifyImageSrc( + post.json_metadata.links[0].slice(0, post.json_metadata.links[0].length - 1) ); } if (post.original_entry && post.original_entry.json_metadata.images) { - return proxifyImageUrl(post.original_entry.json_metadata.images[0], true); + return proxifyImageSrc(post.original_entry.json_metadata.images[0]); } if (post.original_entry && post.original_entry.json_metadata.image) { - return proxifyImageUrl(post.original_entry.json_metadata.image[0], true); + return proxifyImageSrc(post.original_entry.json_metadata.image[0]); } if (post.json_metadata.image && post.json_metadata.image[0]) { if (post.json_metadata.image[0].includes('youtu-')) { - return proxifyImageUrl( - `https://img.youtube.com/vi/${post.json_metadata.image[0].slice(6)}/0.jpg`, - true + return proxifyImageSrc( + `https://img.youtube.com/vi/${post.json_metadata.image[0].slice(6)}/0.jpg` ); } - return proxifyImageUrl(post.json_metadata.image[0], true); + return proxifyImageSrc(post.json_metadata.image[0]); } const regex_any_img = /!\[.*?\]\((.*?)\)/; const match = post.body.match(regex_any_img); if (match && match[1]) { - return proxifyImageUrl(match[1], true); + return proxifyImageSrc(match[1]); } if (post.json_metadata.images && post.json_metadata.images[0]) { - return proxifyImageUrl(post.json_metadata.images[0], true); + return proxifyImageSrc(post.json_metadata.images[0]); } if (post.json_metadata.flow?.pictures && post.json_metadata.flow?.pictures[0]) { - return proxifyImageUrl(post.json_metadata.flow?.pictures[0].url, true); + return proxifyImageSrc(post.json_metadata.flow?.pictures[0].url); } const youtube_id = extractYouTubeVideoIds(extractUrlsFromJsonString(post.body)); if (youtube_id[0]) { - return proxifyImageUrl(`https://img.youtube.com/vi/${youtube_id[0]}/0.jpg`, true); + return proxifyImageSrc(`https://img.youtube.com/vi/${youtube_id[0]}/0.jpg`); } if (post.json_metadata?.tags && post.json_metadata?.tags.includes('nsfw')) { - return proxifyImageUrl(getUserAvatarUrl(post.author, 'small'), true); + return proxifyImageSrc(getUserAvatarUrl(post.author, 'small')); } const pictures_extracted = extractPictureFromPostBody(extractUrlsFromJsonString(post.body)); if (pictures_extracted[0]) { - return proxifyImageUrl(pictures_extracted[0], true); + return proxifyImageSrc(pictures_extracted[0]); } const regex_for_peakd = /https:\/\/files\.peakd\.com\/[^\s]+\.jpg/; const peakd_img = post.body.match(regex_for_peakd); if (peakd_img !== null) { - return proxifyImageUrl(peakd_img[0], true); + return proxifyImageSrc(peakd_img[0]); } const regexgif = / { }; export const postClassName = - 'font-source text-[16.5px] prose-h1:text-[26.4px] prose-h2:text-[23.1px] prose-h3:text-[19.8px] prose-h4:text-[18.1px] sm:text-[17.6px] sm:prose-h1:text-[28px] sm:prose-h2:text-[24.7px] sm:prose-h3:text-[22.1px] sm:prose-h4:text-[19.4px] lg:text-[19.2px] lg:prose-h1:text-[30.7px] lg:prose-h2:text-[28.9px] lg:prose-h3:text-[23px] lg:prose-h4:text-[21.1px] prose-p:mb-6 prose-p:mt-0 prose-img:cursor-pointer'; + 'font-source text-[16.5px] prose-h1:text-[26.4px] prose-h2:text-[23.1px] prose-h3:text-[19.8px] prose-h4:text-[18.1px] sm:text-[17.6px] sm:prose-h1:text-[28px] sm:prose-h2:text-[24.7px] sm:prose-h3:text-[22.1px] sm:prose-h4:text-[19.4px] lg:text-[19.2px] lg:prose-h1:text-[30.7px] lg:prose-h2:text-[28.9px] lg:prose-h3:text-[23px] lg:prose-h4:text-[21.1px] prose-p:mb-6 prose-p:mt-0 prose-img:cursor-pointer prose-img:max-w-full prose-img:h-auto'; diff --git a/apps/blog/features/post-editor/reply-textbox.tsx b/apps/blog/features/post-editor/reply-textbox.tsx index 510aee898846d25682c3b914992a55187a47f4eb..cb0063baace14ac4495edcc5c0d4b2891d794702 100644 --- a/apps/blog/features/post-editor/reply-textbox.tsx +++ b/apps/blog/features/post-editor/reply-textbox.tsx @@ -1,6 +1,6 @@ import { Link } from '@hive/ui'; import { Button } from '@ui/components/button'; -import { useEffect, useState, useRef } from 'react'; +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from '@/blog/i18n/client'; import { useLocalStorage } from 'usehooks-ts'; import { Icons } from '@ui/components/icons'; @@ -19,6 +19,75 @@ import { useUserClient } from '@smart-signer/lib/auth/use-user-client'; const logger = getLogger('app'); +// Comment draft storage with expiration (1 month) +const COMMENT_DRAFT_PREFIX = 'replyTo-'; +const COMMENT_DRAFT_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +interface CommentDraft { + text: string; + expiresAt: number; +} + +function saveCommentDraft(key: string, text: string): void { + const draft: CommentDraft = { + text, + expiresAt: Date.now() + COMMENT_DRAFT_EXPIRY_MS + }; + localStorage.setItem(key, JSON.stringify(draft)); +} + +function loadCommentDraft(key: string): string | null { + const stored = localStorage.getItem(key); + if (!stored) return null; + + try { + const parsed = JSON.parse(stored); + // Handle legacy format (just string) + if (typeof parsed === 'string') { + return parsed; + } + // New format with expiration + const draft = parsed as CommentDraft; + if (draft.expiresAt && Date.now() > draft.expiresAt) { + localStorage.removeItem(key); + return null; + } + return draft.text; + } catch { + localStorage.removeItem(key); + return null; + } +} + +function cleanupExpiredCommentDrafts(): void { + const keysToRemove: string[] = []; + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith(COMMENT_DRAFT_PREFIX)) { + const stored = localStorage.getItem(key); + if (stored) { + try { + const parsed = JSON.parse(stored); + // Check if it's the new format with expiration + if (typeof parsed === 'object' && parsed.expiresAt) { + if (Date.now() > parsed.expiresAt) { + keysToRemove.push(key); + } + } + // Legacy format without expiration - convert or remove if very old + // We can't know the age, so we leave them for now + } catch { + // Invalid JSON, remove it + keysToRemove.push(key); + } + } + } + } + + keysToRemove.forEach((key) => localStorage.removeItem(key)); +} + export function ReplyTextbox({ onSetReply, username, @@ -39,34 +108,112 @@ export function ReplyTextbox({ discussionPermlink: string; }) { const { user } = useUserClient(); - const [storedPost, storePost, removePost] = useLocalStorage( - `replyTo-/${username}/${permlink}-${user.username}`, - '' + const storageKey = useMemo( + () => (user.username ? `replyTo-/${username}/${permlink}-${user.username}` : null), + [username, permlink, user.username] ); + + // Memoize comment body to avoid recalculation on every render + const commentBody = useMemo( + () => (typeof comment === 'string' ? comment : (comment?.body ?? '')), + [comment] + ); + const { manabarsData } = useManabars(user.username); const [preferences] = useLocalStorage( `user-preferences-${user.username}`, DEFAULT_PREFERENCES ); const { t } = useTranslation('common_blog'); - const [text, setText] = useState( - storedPost ? storedPost : typeof comment === 'string' ? comment : comment.body ? comment.body : '' - ); + + // Initialize with empty string to avoid hydration mismatch, then hydrate from localStorage + const [text, setText] = useState(''); + const [isHydrated, setIsHydrated] = useState(false); + const commentMutation = useCommentMutation(); const updateCommentMutation = useUpdateCommentMutation(); + const btnRef = useRef(null); + const debounceTimerRef = useRef(null); + // Hydrate text from localStorage or comment body after mount + // Also cleanup expired drafts on first mount useEffect(() => { - storePost(text); - }, [text]); - const btnRef = useRef(null); + if (!isHydrated && storageKey) { + // Cleanup expired drafts on component mount + cleanupExpiredCommentDrafts(); + + if (editMode) { + // In edit mode, always use current comment body + setText(commentBody); + } else { + // For new replies, check localStorage first + const storedText = loadCommentDraft(storageKey); + if (storedText) { + setText(storedText); + } else { + setText(commentBody); + } + } + setIsHydrated(true); + } + }, [isHydrated, editMode, commentBody, storageKey]); + + // In edit mode, update text when comment changes (e.g., after save and re-edit) + useEffect(() => { + if (isHydrated && editMode) { + setText(commentBody); + } + }, [isHydrated, editMode, commentBody]); + + // Debounced save to localStorage without causing re-renders + const saveToStorage = useCallback( + (value: string) => { + if (!storageKey) return; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + debounceTimerRef.current = setTimeout(() => { + if (value) { + saveCommentDraft(storageKey, value); + } else { + localStorage.removeItem(storageKey); + } + }, 500); + }, + [storageKey] + ); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); + } + }; + }, []); + + const removePost = useCallback(() => { + if (storageKey) { + localStorage.removeItem(storageKey); + } + }, [storageKey]); const handleCancel = () => { + // Always remove the reply box state localStorage.removeItem(storageId); - if (text === '') return onSetReply(false); + + if (text === '') { + // No text to save, just close and cleanup any existing draft + removePost(); + onSetReply(false); + return; + } + + // Ask user to confirm discarding their draft const confirmed = confirm(t('post_content.footer.comment.exit_editor')); if (confirmed) { - onSetReply(false); removePost(); + onSetReply(false); } }; @@ -107,8 +254,8 @@ export function ReplyTextbox({ } } setText(''); - removePost(); - localStorage.removeItem(storageId); + removePost(); // Remove stored comment text + localStorage.removeItem(storageId); // Remove reply box state onSetReply(false); if (btnRef.current) { btnRef.current.disabled = true; @@ -135,12 +282,8 @@ export function ReplyTextbox({ { - if (value === '') { - setText(value); - removePost(); - } else { - setText(value); - } + setText(value); + saveToStorage(value); }} persistedValue={text} placeholder={t('post_content.footer.comment.reply')} diff --git a/apps/blog/features/post-editor/select-image-item.tsx b/apps/blog/features/post-editor/select-image-item.tsx index 623ca1a74074173a886c983862691aef36f0c306..d52f51ef7a7897b370115c54f3927afd766b69d2 100644 --- a/apps/blog/features/post-editor/select-image-item.tsx +++ b/apps/blog/features/post-editor/select-image-item.tsx @@ -1,5 +1,5 @@ import { Button } from '@ui/components'; -import { proxifyImageUrl } from '@ui/lib/old-profixy'; +import { proxifyImageSrc } from '@ui/lib/proxify-images'; import clsx from 'clsx'; import React, { useState } from 'react'; import { imagePicker } from './lib/utils'; @@ -25,7 +25,7 @@ const SelectImageItem: React.FC = ({ data, onChange, value onClick={() => onChange(data)} > cover img setInvalidImages(true)} loading="lazy" diff --git a/apps/blog/features/post-rendering/comment-list-item.tsx b/apps/blog/features/post-rendering/comment-list-item.tsx index eab3ef85ac1f92ccff0bcd5c1ef069271b91b14e..1a34c545f6c43912b2a1bc4055a51a2d178398d9 100644 --- a/apps/blog/features/post-rendering/comment-list-item.tsx +++ b/apps/blog/features/post-rendering/comment-list-item.tsx @@ -7,14 +7,13 @@ import { cn } from '@hive/ui/lib/utils'; import { Link } from '@hive/ui'; import { Separator } from '@ui/components/separator'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@ui/components/accordion'; -import { useEffect, useRef, useState, type ReactNode } from 'react'; +import { memo, useEffect, useRef, useState, type ReactNode } from 'react'; import DetailsCardVoters from '@/blog/features/post-rendering/details-card-voters'; import { ReplyTextbox } from '../post-editor/reply-textbox'; import DetailsCardHover from '../list-of-posts/details-card-hover'; import { IFollowList, Entry } from '@transaction/lib/extended-hive.chain'; import clsx from 'clsx'; import { Badge } from '@ui/components/badge'; -import { useLocalStorage } from 'usehooks-ts'; import { useUserClient } from '@smart-signer/lib/auth/use-user-client'; import DialogLogin from '../../components/dialog-login'; @@ -50,9 +49,9 @@ interface CommentListProps { children?: ReactNode; } export const commentClassName = - 'font-sanspro text-[12.5px] prose-h1:text-[20px] prose-h2:text-[17.5px] prose-h4:text-[13.7px] sm:text-[13.4px] sm:prose-h1:text-[21.5px] sm:prose-h2:text-[18.7px] sm:prose-h3:text-[16px] sm:prose-h4:text-[14.7px] lg:text-[14.6px] lg:prose-h1:text-[23.3px] lg:prose-h2:text-[20.4px] lg:prose-h3:text-[17.5px] lg:prose-h4:text-[16px] prose-h3:text-[15px] prose-p:mb-[9.6px] prose-p:mt-[1.6px] last:prose-p:mb-[3.2px] prose-img:max-w-[400px] prose-img:max-h-[400px]'; + 'font-sanspro text-[12.5px] prose-h1:text-[20px] prose-h2:text-[17.5px] prose-h4:text-[13.7px] sm:text-[13.4px] sm:prose-h1:text-[21.5px] sm:prose-h2:text-[18.7px] sm:prose-h3:text-[16px] sm:prose-h4:text-[14.7px] lg:text-[14.6px] lg:prose-h1:text-[23.3px] lg:prose-h2:text-[20.4px] lg:prose-h3:text-[17.5px] lg:prose-h4:text-[16px] prose-h3:text-[15px] prose-p:mb-[9.6px] prose-p:mt-[1.6px] last:prose-p:mb-[3.2px] prose-img:max-w-full prose-img:h-auto prose-img:max-h-[400px]'; -const CommentListItem = ({ +const CommentListItem = memo(function CommentListItem({ permissionToMute, comment, parent_depth, @@ -63,7 +62,7 @@ const CommentListItem = ({ discussionPermlink, onCommnentLinkClick, children -}: CommentListProps) => { +}: CommentListProps) { const { t } = useTranslation('common_blog'); const { user } = useUserClient(); const ref = useRef(null); @@ -73,21 +72,56 @@ const CommentListItem = ({ const [openState, setOpenState] = useState(comment.stats?.gray && hiddenComment ? '' : 'item-1'); const [tempraryHidden, setTemporaryHidden] = useState(false); const commentId = `@${comment.author}/${comment.permlink}`; - const storageId = `replybox-/${comment.author}/${comment.permlink}-${user.username}`; const [edit, setEdit] = useState(false); - const [storedBox, storeBox, removeBox] = useLocalStorage(storageId, false); - const [reply, setReply] = useState(storedBox !== undefined ? storedBox : false); + const [reply, setReplyState] = useState(false); + + // Build storageId only when user is logged in + const storageId = user.isLoggedIn + ? `replybox-/${comment.author}/${comment.permlink}-${user.username}` + : null; + + // Load reply state from localStorage on mount (with expiration check) + useEffect(() => { + if (!storageId) return; + + const stored = localStorage.getItem(storageId); + if (stored) { + try { + const data = JSON.parse(stored); + // Check expiration (30 days) + if (data.expiresAt && Date.now() < data.expiresAt) { + setReplyState(true); + } else { + // Expired, remove it + localStorage.removeItem(storageId); + } + } catch { + // Legacy format or invalid - remove it + localStorage.removeItem(storageId); + } + } + }, [storageId]); + + const setReply = (value: boolean | ((prev: boolean) => boolean)) => { + setReplyState((prev) => { + const newValue = typeof value === 'function' ? value(prev) : value; + if (storageId) { + if (newValue) { + // Save with 30-day expiration + const data = { expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000 }; + localStorage.setItem(storageId, JSON.stringify(data)); + } else { + localStorage.removeItem(storageId); + } + } + return newValue; + }); + }; const userFromDMCA = dmcaUserList.some((e) => e === comment.author); const legalBlockedUser = userIllegalContent.some((e) => e === comment.author); const userFromGDPR = gdprUserList.some((e) => e === comment.author); const parentFromGDPR = gdprUserList.some((e) => e === comment.parent_author); - useEffect(() => { - if (reply) { - storeBox(reply); - } - }, [reply]); - useEffect(() => { setOpenState(comment.stats?.gray && hiddenComment ? '' : 'item-1'); setTemporaryHidden(comment.stats?.gray ? true : false); @@ -117,10 +151,10 @@ const CommentListItem = ({ return ( <> {currentDepth < 8 ? ( -
  • -
    +
  • +
    - - + + - + {legalBlockedUser ? (
    {t('global.unavailable_for_legal_reasons')}
    ) : userFromDMCA ? ( @@ -320,7 +354,7 @@ const CommentListItem = ({ username={comment.parent_author} permlink={comment.permlink} parentPermlink={comment.parent_permlink} - storageId={storageId} + storageId={storageId!} comment={comment} discussionPermlink={discussionPermlink} /> @@ -379,9 +413,7 @@ const CommentListItem = ({ {user.isLoggedIn ? ( + {Array.from({ length: paginatedDiscussionState.totalPages }, (_, i) => i + 1).map((pageNum) => { + const showPage = + pageNum === 1 || + pageNum === paginatedDiscussionState.totalPages || + (pageNum >= paginatedDiscussionState.currentPage - 2 && + pageNum <= paginatedDiscussionState.currentPage + 2); + + if (!showPage) { + if ( + pageNum === paginatedDiscussionState.currentPage - 3 || + pageNum === paginatedDiscussionState.currentPage + 3 + ) { + return ( + + ... + + ); + } + return null; + } + + return ( + + ); + })} + +
    + )} +
  • + ); +}); + +export default CommentsSection; diff --git a/apps/blog/features/post-rendering/lib/renderer.ts b/apps/blog/features/post-rendering/lib/renderer.ts index 9825db04e25d29f48e4f655725505cfee2c79ae0..462de6bda788ea93cec4ee252baacba3db6db619 100644 --- a/apps/blog/features/post-rendering/lib/renderer.ts +++ b/apps/blog/features/post-rendering/lib/renderer.ts @@ -1,5 +1,5 @@ import { DefaultRenderer, TablePlugin } from '@hive/renderer'; -import { getDoubleSize, proxifyImageUrl } from '@ui/lib/old-profixy'; +import { proxifyImageSrc } from '@ui/lib/proxify-images'; import imageUserBlocklist from '@hive/ui/config/lists/image-user-blocklist'; @@ -22,7 +22,7 @@ const renderDefaultOptions = { assetsHeight: 480, // Note: Instagram and Twitter use iframe-only via embedders (no external scripts needed) plugins: [new TablePlugin()], - imageProxyFn: (url: string) => getDoubleSize(proxifyImageUrl(url, true).replace(/ /g, '%20')), + imageProxyFn: (url: string) => proxifyImageSrc(url, 1536, 0), usertagUrlFn: (account: string) => (basePath ? `${basePath}/@${account}` : `/@${account}`), hashtagUrlFn: (hashtag: string) => (basePath ? `${basePath}/trending/${hashtag}` : `/trending/${hashtag}`), isLinkSafeFn: (url: string) => diff --git a/apps/blog/features/post-rendering/post-body-section.tsx b/apps/blog/features/post-rendering/post-body-section.tsx new file mode 100644 index 0000000000000000000000000000000000000000..085e81c9b92a836f2fed56c8b58d1ec7a9d33897 --- /dev/null +++ b/apps/blog/features/post-rendering/post-body-section.tsx @@ -0,0 +1,63 @@ +'use client'; + +import { memo } from 'react'; +import { Separator } from '@ui/components/separator'; +import { Button } from '@ui/components/button'; +import { useTranslation } from '@/blog/i18n/client'; +import ImageGallery from './image-gallery'; +import RendererContainer from './rendererContainer'; +import { postClassName } from '@/blog/features/post-editor/lib/utils'; + +interface PostBodySectionProps { + body: string; + author: string; + permlink: string; + mainPost: boolean; + crossPostBody?: string; + mutedPost: boolean; + onShowMutedContent: () => void; +} + +/** + * Memoized component for rendering the post body content. + * This prevents unnecessary re-renders when the parent's reply state changes. + */ +const PostBodySection = memo(function PostBodySection({ + body, + author, + permlink, + mainPost, + crossPostBody, + mutedPost, + onShowMutedContent +}: PostBodySectionProps) { + const { t } = useTranslation('common_blog'); + + if (mutedPost) { + return ( + <> + +
    + {t('post_content.body.content_were_hidden')} + +
    + + ); + } + + return ( + + + + ); +}); + +export default PostBodySection; diff --git a/apps/blog/features/suggestions-posts/card.tsx b/apps/blog/features/suggestions-posts/card.tsx index 73f22dadddefbed15d068448fea21f38e3766243..f8cc9a54d66a7983a2b1b00395addfd258c7bdbe 100644 --- a/apps/blog/features/suggestions-posts/card.tsx +++ b/apps/blog/features/suggestions-posts/card.tsx @@ -1,7 +1,7 @@ import { Entry } from '@transaction/lib/extended-hive.chain'; import { find_first_img } from '../list-of-posts/post-img'; import { Link } from '@hive/ui'; -import { proxifyImageUrl } from '@ui/lib/old-profixy'; +import { proxifyImageSrc } from '@ui/lib/proxify-images'; import { useState } from 'react'; import { getDefaultImageUrl } from '@hive/ui'; @@ -20,7 +20,7 @@ const SuggestionsCard = ({ entry }: { entry: Entry }) => {
    setImage(getDefaultImageUrl())} /> diff --git a/apps/blog/features/tags-pages/list-of-posts.tsx b/apps/blog/features/tags-pages/list-of-posts.tsx index 9676135819318e94ca5fd78249caa12152d47584..05d07e12cf272bb1a7e6ba57e139582f527e46d0 100644 --- a/apps/blog/features/tags-pages/list-of-posts.tsx +++ b/apps/blog/features/tags-pages/list-of-posts.tsx @@ -73,6 +73,16 @@ const SortedPagesPosts = ({ sort, tag = '' }: { sort: SortTypes; tag?: string }) return null; } + // Handle empty feed for "my" (friends) page + const isEmpty = !data?.pages?.[0]?.length; + if (isEmpty && tag === 'my') { + return ( +
    +

    {t('user_profile.empty_feed_not_following')}

    +
    + ); + } + return ( <> {!data diff --git a/apps/blog/lib/post-layout-classes.ts b/apps/blog/lib/post-layout-classes.ts new file mode 100644 index 0000000000000000000000000000000000000000..9845e3058aef08f60012c353e71417158a698727 --- /dev/null +++ b/apps/blog/lib/post-layout-classes.ts @@ -0,0 +1,13 @@ +/** + * Shared Tailwind CSS classes for post content layout. + * Used in content.tsx and comments-section.tsx for consistent responsive widths. + */ + +/** Base responsive width classes for post content containers */ +export const postContentWidthClasses = 'w-full max-w-4xl'; + +/** Post content container with background and padding */ +export const postContainerClasses = `relative mx-auto my-0 bg-background p-4 ${postContentWidthClasses}`; + +/** Comments section container */ +export const commentsSectionClasses = `mx-auto w-full max-w-4xl overflow-hidden`; diff --git a/apps/blog/locales/en/common_blog.json b/apps/blog/locales/en/common_blog.json index bb54884b6040c556c2d82432207cfe3455b94f93..8a71cbe215e38fe0ba1739e21e2a0dc0980e25c9 100644 --- a/apps/blog/locales/en/common_blog.json +++ b/apps/blog/locales/en/common_blog.json @@ -255,6 +255,7 @@ "no_pending_payouts": "No pending payouts.", "load_newer": "Load Newer", "nothing_more_to_load": "Nothing more to load", + "empty_feed_not_following": "You haven't followed anyone yet! Follow other users to see their posts here.", "no_replies_yet": "@{{username}} hasn't had any replies yet.", "no_notifications_yet": "@{{username}} hasn't had any notifications yet.", "lists": { diff --git a/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts b/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts index dca8e6fa20f776f3ce553513ac747902ee3f57b9..8025de609d39d757017e711350f95dd5aca007f8 100644 --- a/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts +++ b/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts @@ -40,9 +40,10 @@ export class TwitterEmbedder extends AbstractEmbedder { return undefined; } - public processEmbed(id: string, size: {width: number; height: number}): string { + public processEmbed(id: string, _size: {width: number; height: number}): string { // Use platform.twitter.com which is the stable embed domain (not affected by x.com rebrand) + // Note: width/height are handled by CSS for responsiveness const embedUrl = `https://platform.twitter.com/embed/Tweet.html?id=${id}`; - return `
    `; + return `
    `; } } diff --git a/packages/tailwindcss/globals.css b/packages/tailwindcss/globals.css index 58aad37df7dec5f4217ae1ec38205295007df25b..2569b001fa324d938e95330425a3ba05545ae7a6 100644 --- a/packages/tailwindcss/globals.css +++ b/packages/tailwindcss/globals.css @@ -150,7 +150,9 @@ .prose a.link-external, .prose a { position: relative; - display: inline-block; + display: inline; + overflow-wrap: anywhere; + word-break: break-word; } .prose a.link-external:after { content: ''; @@ -189,6 +191,23 @@ max-height: 75vw; } +#articleBody .twitterWrapper, +.prose .twitterWrapper { + display: flex; + justify-content: center; + width: 100%; + max-width: 550px; + margin: 0 auto; +} + +#articleBody .twitterWrapper iframe, +.prose .twitterWrapper iframe { + width: 100% !important; + max-width: 550px; + height: 600px; + max-height: 80vh; +} + .markdown-view { word-wrap: break-word; word-break: break-word; diff --git a/packages/ui/lib/old-profixy.ts b/packages/ui/lib/old-profixy.ts deleted file mode 100644 index ce52289dcaf0df60a2cc1629837463bfeb862a32..0000000000000000000000000000000000000000 --- a/packages/ui/lib/old-profixy.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { configuredImagesEndpoint } from '@hive/ui/config/public-vars'; - -/** - * this regular expression should capture all possible proxy domains - * Possible URL schemes are: - * / - * /{int}x{int}/ - * /{int}x{int}/[.../{int}x{int}/] - * /{int}x{int}/[/{int}x{int}/]/ - * @type {RegExp} - */ -const rProxyDomain = /^http(s)?:\/\/images.hive.blog\//g; -const rProxyDomainsDimensions = /http(s)?:\/\/images.hive.blog\/([0-9]+x[0-9]+)\//g; -const NATURAL_SIZE = '0x0/'; -const CAPPED_SIZE = '768x0/'; -const DOUBLE_CAPPED_SIZE = '1536x0/'; - -export const imageProxy = () => `${configuredImagesEndpoint}`; -export const defaultSrcSet = (url: string) => { - return `${url} 1x, ${url.replace(CAPPED_SIZE, DOUBLE_CAPPED_SIZE)} 2x`; -}; -export const getDoubleSize = (url: string) => { - return url.replace(CAPPED_SIZE, DOUBLE_CAPPED_SIZE); -}; -export const isDefaultImageSize = (url: string) => { - return url.startsWith(`${imageProxy()}${CAPPED_SIZE}`); -}; -export const defaultWidth = () => { - return Number.parseInt(CAPPED_SIZE.split('x')[0]); -}; - -/** - * Strips all proxy domains from the beginning of the url. Adds the global proxy if dimension is specified - * @param {string} url - * @param {string|boolean} dimensions - optional - if provided. url is proxied && global var img_proxy_prefix is avail. resp will be "img_proxy_prefix{dimensions}/{sanitized url}" - * if falsy, all proxies are stripped. - * if true, preserves the first {int}x{int} in a proxy url. If not found, uses 0x0 - * @param {boolean} allowNaturalSize - * @returns string - */ -export const proxifyImageUrl = (url: string, dimensions: string | boolean) => { - if (!url) return ''; - const proxyList = url.match(rProxyDomainsDimensions); - let respUrl = url; - if (proxyList) { - const lastProxy = proxyList[proxyList.length - 1]; - respUrl = url.substring(url.lastIndexOf(lastProxy) + lastProxy.length); - } - if (dimensions && `${configuredImagesEndpoint}`) { - let dims = dimensions + '/'; - if (typeof dimensions !== 'string') { - // @ts-ignore - dims = proxyList ? proxyList.shift().match(/([0-9]+x[0-9]+)\//g)[0] : NATURAL_SIZE; - } - - // NOTE: This forces the dimensions to be `CAPPED_SIZE` to save on - // bandwidth costs. Do not modify gifs. - if (!respUrl.match(/\.gif$/) && dims === NATURAL_SIZE) { - dims = CAPPED_SIZE; - } - - if ((NATURAL_SIZE !== dims && CAPPED_SIZE !== dims) || !rProxyDomain.test(respUrl)) { - return `${configuredImagesEndpoint}` + dims + respUrl; - } - } - return respUrl; -}; diff --git a/packages/ui/lib/proxify-images.ts b/packages/ui/lib/proxify-images.ts index e3069dfdec4e56c944c3c919cb7bf3b7cd459970..f69aabc608eb60ca8a168a32cae2a7165ea81f2f 100644 --- a/packages/ui/lib/proxify-images.ts +++ b/packages/ui/lib/proxify-images.ts @@ -4,6 +4,14 @@ import { configuredImagesEndpoint } from '@hive/ui/config/public-vars'; const proxyBase = configuredImagesEndpoint; +interface ProxyOptions { + [key: string]: string | number | undefined; + format: string; + mode: string; + width?: number; + height?: number; +} + export function extractPHash(url: string): string | null { if (url.startsWith(`${proxyBase}/p/`)) { const [hash] = url.split('/p/')[1].split('?'); @@ -42,9 +50,11 @@ export function proxifyImageSrc(url?: string, width = 0, height = 0, format = 'm // For other external images, use the complex /p/hash system const realUrl = getLatestUrl(url); - const pHash = extractPHash(realUrl); + // Encode spaces and special characters in URL + const encodedUrl = encodeURI(realUrl).replace(/ /g, '%20'); + const pHash = extractPHash(encodedUrl); - const options: Record = { + const options: ProxyOptions = { format, mode: 'fit' }; @@ -64,7 +74,10 @@ export function proxifyImageSrc(url?: string, width = 0, height = 0, format = 'm return `${proxyBase}/p/${pHash}?${qs}`; } - const b58url = multihash.toB58String(Buffer.from(realUrl.toString()) as unknown as Uint8Array); + // Use TextEncoder for browser compatibility instead of Buffer + const encoder = new TextEncoder(); + const bytes = encoder.encode(encodedUrl); + const b58url = multihash.toB58String(bytes); return `${proxyBase}/p/${b58url}?${qs}`; }