From 005a1b24c7b5bead9d37001b17484097c1c8294d Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 09:14:12 +0100 Subject: [PATCH 01/13] Add responsive width classes for post layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract shared Tailwind CSS classes for post content layout into a dedicated module. Provides consistent responsive widths across post container and comments section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/blog/lib/post-layout-classes.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 apps/blog/lib/post-layout-classes.ts diff --git a/apps/blog/lib/post-layout-classes.ts b/apps/blog/lib/post-layout-classes.ts new file mode 100644 index 000000000..289c30154 --- /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 sm:w-[600px] md:w-[700px] lg:w-[800px] xl:w-[896px]'; + +/** 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 = `pr-2 ${postContentWidthClasses}`; -- GitLab From 421c1adfbf11b0917e1690d4493d30e5441bb220 Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 09:14:43 +0100 Subject: [PATCH 02/13] Fix re-rendering of comments when typing in reply box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract CommentsSection into a memoized component to prevent unnecessary re-renders when typing in the reply textbox. Changes: - Create new CommentsSection component with memo and useCallback - Fix useMemo dependencies in comment-list.tsx (remove JSON.stringify) - Use stable callback for setCommentsPage - Apply responsive width classes to post container 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../app/[param]/[p2]/[permlink]/content.tsx | 103 +++----------- .../features/post-rendering/comment-list.tsx | 2 +- .../post-rendering/comments-section.tsx | 127 ++++++++++++++++++ 3 files changed, 148 insertions(+), 84 deletions(-) create mode 100644 apps/blog/features/post-rendering/comments-section.tsx diff --git a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx index e2ff35bde..511e44430 100644 --- a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx +++ b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx @@ -14,8 +14,7 @@ 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'; @@ -33,6 +32,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'; @@ -58,7 +58,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 +353,12 @@ const PostContent = () => { useEffect(() => { setCommentsPage(1); }, [author, permlink]); + + // Stable callback for CommentsSection + const handleSetCommentsPage = useCallback((page: number | ((prev: number) => number)) => { + setCommentsPage(page); + }, []); + if (userFromGDPR || (!postData && !postIsLoading)) return ; return ( @@ -362,7 +368,7 @@ const PostContent = () => { {suggestionData ? : null}
-
+
{crossedPost ? (
@@ -792,85 +798,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/post-rendering/comment-list.tsx b/apps/blog/features/post-rendering/comment-list.tsx index 9cda3202f..db2723fd4 100644 --- a/apps/blog/features/post-rendering/comment-list.tsx +++ b/apps/blog/features/post-rendering/comment-list.tsx @@ -46,7 +46,7 @@ const CommentList = ({ ); const unmutedContent = filtered.filter((md) => mutedContent.every((fd) => fd.post_id !== md.post_id)); return [...mutedContent, ...unmutedContent]; - }, [JSON.stringify(data), JSON.stringify(parent)]); + }, [data, parent?.author, parent?.permlink]); return (
    <> diff --git a/apps/blog/features/post-rendering/comments-section.tsx b/apps/blog/features/post-rendering/comments-section.tsx new file mode 100644 index 000000000..70909b330 --- /dev/null +++ b/apps/blog/features/post-rendering/comments-section.tsx @@ -0,0 +1,127 @@ +'use client'; + +import { memo, useCallback } from 'react'; +import { useTranslation } from '@/blog/i18n/client'; +import { commentsSectionClasses } from '@/blog/lib/post-layout-classes'; +import CommentList from './comment-list'; +import CommentSelectFilter from './comment-select-filter'; +import { Button } from '@ui/components/button'; +import { Entry, IFollowList } from '@transaction/lib/extended-hive.chain'; + +interface CommentsSectionProps { + postData: Entry; + paginatedDiscussionState: { + comments: Entry[]; + totalPages: number; + currentPage: number; + totalMainComments: number; + }; + userCanModerate: boolean; + mutedList: IFollowList[]; + flagText: string | undefined; + discussionPermlink: string; + commentsPage: number; + setCommentsPage: (page: number | ((prev: number) => number)) => void; +} + +const CommentsSection = memo(function CommentsSection({ + postData, + paginatedDiscussionState, + userCanModerate, + mutedList, + flagText, + discussionPermlink, + commentsPage, + setCommentsPage +}: CommentsSectionProps) { + const { t } = useTranslation('common_blog'); + + const handlePrevPage = useCallback(() => { + setCommentsPage((prev: number) => Math.max(1, prev - 1)); + }, [setCommentsPage]); + + const handleNextPage = useCallback(() => { + setCommentsPage((prev: number) => Math.min(paginatedDiscussionState.totalPages, prev + 1)); + }, [setCommentsPage, paginatedDiscussionState.totalPages]); + + const handlePageClick = useCallback( + (pageNum: number) => { + setCommentsPage(pageNum); + }, + [setCommentsPage] + ); + + return ( +
    +
    + {t('select_sort.sort_comments.sort')} + +
    + + {paginatedDiscussionState.totalPages > 1 && ( +
    + + {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; -- GitLab From 92fa54f7039e313b3b495b51ce9a7240c6ea70ca Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 09:15:04 +0100 Subject: [PATCH 03/13] Migrate from proxifyImageUrl to proxifyImageSrc #777 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace old proxifyImageUrl function with newer proxifyImageSrc that uses Base58 encoding for image URLs. Changes: - Update card.tsx, select-image-item.tsx, post-img.tsx, renderer.ts - Delete old-profixy.ts (no longer needed) - Fix URL encoding for special characters - Use TextEncoder for browser compatibility instead of Buffer - Add index signature to ProxyOptions for querystring.stringify Closes #777 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/blog/features/list-of-posts/post-img.tsx | 34 +++++----- .../post-editor/select-image-item.tsx | 4 +- .../features/post-rendering/lib/renderer.ts | 4 +- apps/blog/features/suggestions-posts/card.tsx | 4 +- packages/ui/lib/old-profixy.ts | 67 ------------------- packages/ui/lib/proxify-images.ts | 19 +++++- 6 files changed, 38 insertions(+), 94 deletions(-) delete mode 100644 packages/ui/lib/old-profixy.ts diff --git a/apps/blog/features/list-of-posts/post-img.tsx b/apps/blog/features/list-of-posts/post-img.tsx index 102b32eed..046d40bae 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 = / = ({ data, onChange, value onClick={() => onChange(data)} > cover img setInvalidImages(true)} loading="lazy" diff --git a/apps/blog/features/post-rendering/lib/renderer.ts b/apps/blog/features/post-rendering/lib/renderer.ts index 9825db04e..462de6bda 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/suggestions-posts/card.tsx b/apps/blog/features/suggestions-posts/card.tsx index 73f22dadd..f8cc9a54d 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/packages/ui/lib/old-profixy.ts b/packages/ui/lib/old-profixy.ts deleted file mode 100644 index ce52289dc..000000000 --- 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 e3069dfde..f69aabc60 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}`; } -- GitLab From ffc5c192c7894d01752d92c6136d9532375e2168 Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 09:15:17 +0100 Subject: [PATCH 04/13] Fix long links overflowing containers #775 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change link display from inline-block to inline and add overflow-wrap: anywhere with word-break: break-word to prevent long URLs from breaking layout. Closes #775 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- packages/tailwindcss/globals.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/tailwindcss/globals.css b/packages/tailwindcss/globals.css index 58aad37df..0e8987513 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: ''; -- GitLab From eb6c7e832da51a208ff1626ccc1cd023e2c35576 Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 09:15:33 +0100 Subject: [PATCH 05/13] Show message when My Friends feed is empty #773 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Display helpful message when user hasn't followed anyone yet on the My Friends page instead of showing empty content. Changes: - Add isEmpty check for 'my' tag in list-of-posts.tsx - Add translation key empty_feed_not_following Closes #773 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/blog/features/tags-pages/list-of-posts.tsx | 10 ++++++++++ apps/blog/locales/en/common_blog.json | 1 + 2 files changed, 11 insertions(+) diff --git a/apps/blog/features/tags-pages/list-of-posts.tsx b/apps/blog/features/tags-pages/list-of-posts.tsx index 967613581..05d07e12c 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/locales/en/common_blog.json b/apps/blog/locales/en/common_blog.json index bb54884b6..8a71cbe21 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": { -- GitLab From 2a91b5976c9da8d4a49f6a8be9a623da2ae8c1ad Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 09:15:47 +0100 Subject: [PATCH 06/13] Fix images in comments overflowing container #771 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change image max-width from fixed 400px to max-w-full with auto height. Add overflow-hidden to CardContent and wrap component with memo for performance. Closes #771 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../features/post-rendering/comment-list-item.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/blog/features/post-rendering/comment-list-item.tsx b/apps/blog/features/post-rendering/comment-list-item.tsx index eab3ef85a..c7493b244 100644 --- a/apps/blog/features/post-rendering/comment-list-item.tsx +++ b/apps/blog/features/post-rendering/comment-list-item.tsx @@ -7,7 +7,7 @@ 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'; @@ -50,9 +50,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 +63,7 @@ const CommentListItem = ({ discussionPermlink, onCommnentLinkClick, children -}: CommentListProps) => { +}: CommentListProps) { const { t } = useTranslation('common_blog'); const { user } = useUserClient(); const ref = useRef(null); @@ -308,7 +308,7 @@ const CommentListItem = ({ - + {legalBlockedUser ? (
    {t('global.unavailable_for_legal_reasons')}
    ) : userFromDMCA ? ( @@ -486,6 +486,6 @@ const CommentListItem = ({ ) : null} ); -}; +}); export default CommentListItem; -- GitLab From a87e7cde1efbb25b5ee105e98a1f5616e99c3ee0 Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 09:16:19 +0100 Subject: [PATCH 07/13] Fix edit comment showing old content and add draft expiration #767 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix issue where editing a comment second time showed original content instead of current version. Also improve localStorage handling: Changes: - Fix hydration mismatch with lazy hydration pattern - Use useMemo for commentBody calculation - Add useEffect to update text when comment changes in edit mode - Replace useLocalStorage hook with native localStorage to prevent re-renders - Add 30-day expiration for comment drafts - Cleanup expired drafts on component mount - Ensure Cancel button always removes localStorage data Closes #767 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../features/post-editor/reply-textbox.tsx | 174 ++++++++++++++++-- 1 file changed, 154 insertions(+), 20 deletions(-) diff --git a/apps/blog/features/post-editor/reply-textbox.tsx b/apps/blog/features/post-editor/reply-textbox.tsx index 510aee898..8b8fbb210 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,103 @@ export function ReplyTextbox({ discussionPermlink: string; }) { const { user } = useUserClient(); - const [storedPost, storePost, removePost] = useLocalStorage( - `replyTo-/${username}/${permlink}-${user.username}`, - '' + const storageKey = `replyTo-/${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) { + // 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 (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(() => { + 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 +245,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 +273,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')} -- GitLab From 12816fc41ab5f4f53b8f265731e31bcef92f1b88 Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 11:48:28 +0100 Subject: [PATCH 08/13] Fix Reply Button causing post content re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract memoized PostBodySection component to prevent unnecessary re-renders when reply state changes. Also fix responsive layout by changing sm: breakpoints to md: for proper grid behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../app/[param]/[p2]/[permlink]/content.tsx | 40 +++++------- .../post-rendering/post-body-section.tsx | 63 +++++++++++++++++++ 2 files changed, 79 insertions(+), 24 deletions(-) create mode 100644 apps/blog/features/post-rendering/post-body-section.tsx diff --git a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx index 511e44430..0d651c26a 100644 --- a/apps/blog/app/[param]/[p2]/[permlink]/content.tsx +++ b/apps/blog/app/[param]/[p2]/[permlink]/content.tsx @@ -9,7 +9,6 @@ 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'; @@ -18,10 +17,9 @@ 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'; @@ -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'; @@ -359,6 +356,11 @@ const PostContent = () => { setCommentsPage(page); }, []); + // Stable callback for PostBodySection + const handleShowMutedContent = useCallback(() => { + setMutedPost(false); + }, []); + if (userFromGDPR || (!postData && !postIsLoading)) return ; return ( @@ -367,7 +369,7 @@ const PostContent = () => {
    {suggestionData ? : null}
    -
    +
    {crossedPost ? (
    @@ -470,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 ? ( 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 000000000..085e81c9b --- /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; -- GitLab From 4b40d8e1e0e1a1232d1f616c73ee2f060c537dcb Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 11:48:39 +0100 Subject: [PATCH 09/13] Make Twitter/X embeds responsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CSS styles for twitterWrapper class with responsive width and fixed height. Embeds now scale properly on smaller screens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../embedder/embedders/TwitterEmbedder.ts | 5 +++-- packages/tailwindcss/globals.css | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts b/packages/renderer/src/renderers/default/embedder/embedders/TwitterEmbedder.ts index dca8e6fa2..8025de609 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 0e8987513..2569b001f 100644 --- a/packages/tailwindcss/globals.css +++ b/packages/tailwindcss/globals.css @@ -191,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; -- GitLab From 7184647b2c232a20fc1676c0a30266683b3fc326 Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 11:48:50 +0100 Subject: [PATCH 10/13] Fix empty feed message on user profile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show correct message "You haven't followed anyone yet" on /@username/feed page instead of generic "hasn't started blogging". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/blog/features/account-profile/posts-content.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/blog/features/account-profile/posts-content.tsx b/apps/blog/features/account-profile/posts-content.tsx index cee534031..fe4776237 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 }); }; -- GitLab From bd312ed311ede01f33c247f7a5bd80ae0aa2e6c9 Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 11:49:00 +0100 Subject: [PATCH 11/13] Make post images responsive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add prose-img:max-w-full and prose-img:h-auto to postClassName so images scale properly on smaller screens. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- apps/blog/features/post-editor/lib/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/blog/features/post-editor/lib/utils.ts b/apps/blog/features/post-editor/lib/utils.ts index e4d266ee2..932a074b1 100644 --- a/apps/blog/features/post-editor/lib/utils.ts +++ b/apps/blog/features/post-editor/lib/utils.ts @@ -221,4 +221,4 @@ export const insertToTextArea = (insertString: string) => { }; 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'; -- GitLab From fe269834af592d963ba4317af6ca5fbd5f7dd07f Mon Sep 17 00:00:00 2001 From: Krzysztof Kocot Date: Thu, 8 Jan 2026 11:49:11 +0100 Subject: [PATCH 12/13] Fix responsive layout for comments section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove fixed width breakpoints that caused horizontal overflow between 620-780px. Use w-full max-w-4xl instead for proper fluid responsive behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../features/post-rendering/comment-list.tsx | 24 ++++++++----------- apps/blog/lib/post-layout-classes.ts | 4 ++-- 2 files changed, 12 insertions(+), 16 deletions(-) diff --git a/apps/blog/features/post-rendering/comment-list.tsx b/apps/blog/features/post-rendering/comment-list.tsx index db2723fd4..972c92321 100644 --- a/apps/blog/features/post-rendering/comment-list.tsx +++ b/apps/blog/features/post-rendering/comment-list.tsx @@ -27,13 +27,13 @@ const CommentList = ({ flagText: string | undefined; discussionPermlink: string; }) => { - const [markedHash, setMarkedHash] = useState(""); + const [markedHash, setMarkedHash] = useState(''); useEffect(() => { - if (typeof window !== "undefined") { - setMarkedHash(window.location.hash); - } -}, []); + if (typeof window !== 'undefined') { + setMarkedHash(window.location.hash); + } + }, []); const arr = useMemo(() => { if (!data || !parent) return undefined; @@ -48,20 +48,16 @@ const CommentList = ({ return [...mutedContent, ...unmutedContent]; }, [data, parent?.author, parent?.permlink]); return ( -
      +
        <> {!!arr ? arr.map((comment: Entry, index: number) => (
        1 } - )} + className={clsx('min-w-0 pl-3', { + 'my-2 rounded border-2 border-red-600 bg-green-50 p-2 dark:bg-slate-950': + markedHash?.includes(`@${comment.author}/${comment.permlink}`) && comment.depth < 8 + })} > Date: Thu, 8 Jan 2026 11:49:22 +0100 Subject: [PATCH 13/13] Fix reply textbox localStorage handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 30-day expiration for reply box open state - Only store when textbox is open, remove key when closed - Fix storageKey to properly update when user logs in - Memoize storageKey to prevent stale references - Load draft content correctly after hydration 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../features/post-editor/reply-textbox.tsx | 43 ++++++----- .../post-rendering/comment-list-item.tsx | 76 +++++++++++++------ 2 files changed, 80 insertions(+), 39 deletions(-) diff --git a/apps/blog/features/post-editor/reply-textbox.tsx b/apps/blog/features/post-editor/reply-textbox.tsx index 8b8fbb210..cb0063baa 100644 --- a/apps/blog/features/post-editor/reply-textbox.tsx +++ b/apps/blog/features/post-editor/reply-textbox.tsx @@ -108,11 +108,14 @@ export function ReplyTextbox({ discussionPermlink: string; }) { const { user } = useUserClient(); - const storageKey = `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 ?? ''), + () => (typeof comment === 'string' ? comment : (comment?.body ?? '')), [comment] ); @@ -135,7 +138,7 @@ export function ReplyTextbox({ // Hydrate text from localStorage or comment body after mount // Also cleanup expired drafts on first mount useEffect(() => { - if (!isHydrated) { + if (!isHydrated && storageKey) { // Cleanup expired drafts on component mount cleanupExpiredCommentDrafts(); @@ -163,18 +166,22 @@ export function ReplyTextbox({ }, [isHydrated, editMode, commentBody]); // Debounced save to localStorage without causing re-renders - const saveToStorage = useCallback((value: string) => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current); - } - debounceTimerRef.current = setTimeout(() => { - if (value) { - saveCommentDraft(storageKey, value); - } else { - localStorage.removeItem(storageKey); + const saveToStorage = useCallback( + (value: string) => { + if (!storageKey) return; + if (debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current); } - }, 500); - }, [storageKey]); + debounceTimerRef.current = setTimeout(() => { + if (value) { + saveCommentDraft(storageKey, value); + } else { + localStorage.removeItem(storageKey); + } + }, 500); + }, + [storageKey] + ); // Cleanup timer on unmount useEffect(() => { @@ -186,7 +193,9 @@ export function ReplyTextbox({ }, []); const removePost = useCallback(() => { - localStorage.removeItem(storageKey); + if (storageKey) { + localStorage.removeItem(storageKey); + } }, [storageKey]); const handleCancel = () => { @@ -245,8 +254,8 @@ export function ReplyTextbox({ } } setText(''); - removePost(); // Remove stored comment text - localStorage.removeItem(storageId); // Remove reply box state + removePost(); // Remove stored comment text + localStorage.removeItem(storageId); // Remove reply box state onSetReply(false); if (btnRef.current) { btnRef.current.disabled = true; diff --git a/apps/blog/features/post-rendering/comment-list-item.tsx b/apps/blog/features/post-rendering/comment-list-item.tsx index c7493b244..1a34c545f 100644 --- a/apps/blog/features/post-rendering/comment-list-item.tsx +++ b/apps/blog/features/post-rendering/comment-list-item.tsx @@ -14,7 +14,6 @@ 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'; @@ -73,21 +72,56 @@ const CommentListItem = memo(function 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 = memo(function CommentListItem({ return ( <> {currentDepth < 8 ? ( -
      • -
        +
      • +
        - - + + - + {legalBlockedUser ? (
        {t('global.unavailable_for_legal_reasons')}
        ) : userFromDMCA ? ( @@ -320,7 +354,7 @@ const CommentListItem = memo(function 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 = memo(function CommentListItem({ {user.isLoggedIn ? (