Conversation
📝 WalkthroughWalkthroughAdds an internal publishing flow at /publish: editor UI, toolbars, tag input, state/hooks, HTML↔Markdown converters, permlink generator, publish mutation, route integration, auth/store adjustments, synchronized localStorage, and dependency + config updates (createPostUrl → "/publish"). Changes
Sequence DiagramsequenceDiagram
participant User
participant UI as Publish UI
participant State as usePublishState
participant Editor as TipTap (usePublishEditor)
participant Mut as usePublishPost
participant Hive as Hive Blockchain
participant Router as App Router
User->>UI: enter title, tags, content
UI->>State: setTitleState / setTagsState / setContentState
Editor->>State: onUpdate -> htmlToMarkdown -> persist content
User->>UI: click Publish
UI->>Mut: call publish(title, markdown, tags)
Mut->>Mut: validate, createPermlink
Mut->>Hive: broadcast comment operation
Hive-->>Mut: success (permlink)
Mut->>State: clearAll()
Mut->>Router: navigate to /blog?filter=posts
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/self-hosted/src/features/auth/components/create-post-button.tsx (1)
12-29:⚠️ Potential issue | 🟠 MajorUse
hrefprop for external URLs instead ofto; whencreatePostUrlis external,towon't work correctly.TanStack Link supports external URLs, but requires the
hrefprop (notto). SincecreatePostUrldefaults to"https://ecency.com/submit"and is configurable, it may be external. The code should detect external URLs and usehrefinstead ofto:const createPostUrl = InstanceConfigManager.getConfigValue( ({ configuration }) => configuration.general.createPostUrl || "https://ecency.com/submit", ); const isExternal = /^https?:\/\//i.test(createPostUrl); // Only show for blog owner when auth is enabled if (!isAuthEnabled || !isBlogOwner) { return null; } return ( <Link {...(isExternal ? { href: createPostUrl } : { to: createPostUrl })} {...(isExternal && { target: "_blank", rel: "noopener noreferrer" })} className="fixed bottom-6 right-30 z-50 px-4 py-2 flex items-center text-sm !no-underline rounded-full border border-gray-400 dark:border-gray-600 !font-serif" > <UilPen className="w-4 h-4" /> <span className="hidden sm:block">{t("create_post")}</span> </Link> );
🤖 Fix all issues with AI agents
In `@apps/self-hosted/package.json`:
- Around line 39-54: Remove the unnecessary devDependency "@types/marked"
(marked ships its own types) by deleting it from devDependencies, and resolve
the speakingurl/type mismatch by either downgrading the "speakingurl" dependency
to a version that matches "@types/speakingurl@13.0.6" (e.g., speakingurl@13.x)
or updating/removing "@types/speakingurl" so types align with
"speakingurl@14.0.1"; update package.json accordingly and run a TypeScript build
to verify the change (look for entries "@types/marked" and "@types/speakingurl"
in devDependencies and the "speakingurl" entry in dependencies to make the
change).
In `@apps/self-hosted/src/features/publish/components/publish-tags-selector.tsx`:
- Around line 46-131: validateTag currently rejects tags with two hyphens
because tag.split("-").length > 2 is wrong, and handlePaste bypasses validateTag
allowing invalid tags; fix by changing the hyphen check in validateTag to count
hyphens (e.g. (tag.match(/-/g) || []).length > 2) so up to two hyphens are
allowed, and update handlePaste to validate each candidate before adding (call
validateTag for each sanitized part or reuse addTag) so pasted tags go through
the same validation and warning logic as manual input (references: validateTag,
handlePaste, addTag, sanitizeTagInput).
In `@apps/self-hosted/src/features/publish/hooks/use-publish-post.ts`:
- Around line 36-42: The first call to createPermlink(title) is dead code
because it's immediately overwritten by createPermlink(title, true); remove the
redundant call and initialize permlink only once (keep the existing
createPermlink(title, true) if you always want a random suffix), or if you need
conditional suffixing, replace with a single conditional call that uses
createPermlink(title) when no suffix is needed and createPermlink(title, true)
when a suffix is required; update the variable declaration around permlink
accordingly.
- Around line 48-49: The payload currently sets parent_author and
parent_permlink to empty strings; for top-level Hive posts parent_permlink must
be the first tag (community/category). In the hook (use-publish-post.ts) where
the post object is built (referencing parent_author and parent_permlink), set
parent_permlink to tags[0] (or tags?.[0]) when parent_author is empty/undefined
and tags exist; ensure you handle the case of no tags by falling back to an
empty string and normalize the tag (e.g., lowercase/trim) before assigning.
In `@apps/self-hosted/src/features/publish/utils/markdown.ts`:
- Around line 81-89: The image rule's replacement inserts alt and src directly
into markdown (in the .addRule("image" replacement function using variables alt
and src), which allows markdown-breaking chars; escape special characters in alt
(at least ']' and backslashes) by backslash-escaping them and ensure src is
safely wrapped or escaped (e.g., wrap the URL in angle brackets <...> or
percent-encode problematic chars like ')' and spaces) before returning
`` so markdown structure cannot be broken or injected into.
In `@apps/self-hosted/src/features/publish/utils/permlink.ts`:
- Around line 24-34: Truncate the base derived from slug before appending the
random suffix so the suffix from permlinkRnd() is preserved: compute
parts/permBase from slug (using parts, perm), if random generate rnd =
permlinkRnd().toLowerCase(), compute allowedBaseLen = 255 - (1 + rnd.length),
truncate permBase to allowedBaseLen (or keep full base if shorter), then set
perm = `${permBase}-${rnd}`; if not random simply ensure perm is no longer than
255 by truncating permBase to 255. Use the variables slug, perm, permBase,
random and function permlinkRnd() to locate and modify the logic.
🧹 Nitpick comments (8)
apps/self-hosted/package.json (1)
21-30: Consider standardizing TipTap package versions for consistency (optional improvement).While
@tiptappackages in the 2.9.x / 2.11.x / 2.12.x range are peer-compatible and won't cause conflicts, pinning all@tiptap/* packages to the same version is a best practice to prevent potential real-world mismatches during updates and to improve maintainability.apps/self-hosted/src/features/publish/hooks/use-publish-state.ts (1)
12-13: Consider exportingMAX_TITLE_LENGTHto avoid duplication.This constant is also defined in
publish-editor.tsx(line 11). Exporting it from this file would ensure consistency and avoid drift if the value changes.♻️ Proposed refactor to export constants
-const MAX_TITLE_LENGTH = 255; -const MAX_TAG_LENGTH = 24; +export const MAX_TITLE_LENGTH = 255; +export const MAX_TAG_LENGTH = 24;Then in
publish-editor.tsx:import { usePublishState, MAX_TITLE_LENGTH } from "../hooks/use-publish-state";apps/self-hosted/src/features/publish/hooks/use-publish-post.ts (1)
54-55: Redundant check:tags.length > 0already validated.Lines 32-34 throw an error if
tags.lengthis 0, so this ternary is always true.♻️ Simplify
json_metadata: JSON.stringify({ - tags: tags.length > 0 ? tags : [], + tags, app: "ecency-selfhost/1.0",apps/self-hosted/src/features/publish/hooks/use-publish-editor.ts (2)
67-72: Missing ESLint exhaustive-deps warning:setEditorContentandpublishState.contentnot in dependency array.While the comment indicates "Only on mount" intent, this pattern can cause subtle bugs if
editoris recreated. Consider using a ref to track if initial load has occurred, or add a disable comment with justification.♻️ Alternative using ref to track initialization
+import { useCallback, useEffect, useRef } from "react"; export function usePublishEditor() { const publishState = usePublishState(); + const initializedRef = useRef(false); // ... editor setup ... // Load content from state when editor is ready useEffect(() => { - if (editor && publishState.content) { + if (editor && publishState.content && !initializedRef.current) { + initializedRef.current = true; setEditorContent(publishState.content); } - }, [editor]); // Only on mount + }, [editor, publishState.content, setEditorContent]);
38-43: Consider debouncing content state updates.
onUpdatefires on every keystroke, triggering HTML-to-Markdown conversion and localStorage writes. For large documents, this could impact performance. Consider debouncing the state update.apps/self-hosted/src/features/publish/components/publish-action-bar.tsx (1)
44-51: Missingtoprop onLinkcomponent.The
Linkcomponent hassearchbuttoprop comes after. While this works, havingtobeforesearchimproves readability and follows typical usage patterns.♻️ Reorder props for clarity
<Link + to="/blog" search={{ filter: "posts" }} className="text-sm flex items-center gap-2 whitespace-nowrap" - to="/blog" >apps/self-hosted/src/routes/publish.tsx (2)
15-30: MoveusePublishEditor()below the guard check to avoid initializing the editor for unauthorized users.The
usePublishEditor()hook (line 18) initializes a TipTap editor with multiple extensions before the authorization guard (lines 28-30). For unauthorized users, this allocates resources that are immediately discarded upon redirect.♻️ Proposed fix
function RouteComponent() { const isBlogOwner = useIsBlogOwner(); const isAuthEnabled = useIsAuthEnabled(); - const { editor } = usePublishEditor(); const navigate = useNavigate(); // Redirect if auth is disabled or user is not blog owner useEffect(() => { if (!isAuthEnabled || !isBlogOwner) { navigate({ to: "/blog", search: { filter: "posts" } }); } }, [isAuthEnabled, isBlogOwner, navigate]); if (!isAuthEnabled || !isBlogOwner) { return null; } + return <AuthorizedPublishContent />; +} + +function AuthorizedPublishContent() { + const { editor } = usePublishEditor(); + return ( <div className="min-h-screen bg-theme-primary">
11-13: Consider using TanStack Router'sbeforeLoadfor route-level authorization.The current useEffect-based redirect works but the component still mounts and hooks execute before the redirect. TanStack Router's
beforeLoadcan prevent unauthorized access at the route level, before the component renders.♻️ Example using beforeLoad
export const Route = createFileRoute("/publish")({ beforeLoad: ({ context }) => { // Access auth state from router context const { isAuthEnabled, isBlogOwner } = context; if (!isAuthEnabled || !isBlogOwner) { throw redirect({ to: "/blog", search: { filter: "posts" } }); } }, component: RouteComponent });This requires setting up the auth state in your router context, but provides cleaner separation of authorization from rendering logic.
Also applies to: 21-26
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@apps/self-hosted/src/features/publish/hooks/use-publish-post.ts`:
- Line 49: The current assignment uses tags[0] directly for parent_permlink
which can be mixed-case; in the usePublishPost hook update the payload to set
parent_permlink to a lowercased value (e.g., use tags?.[0]?.toLowerCase() or
(tags && tags.length ? tags[0].toLowerCase() : '') ) and guard for empty/missing
tags to avoid undefined; modify the code around the parent_permlink assignment
so it always sends a lowercase string (reference: parent_permlink and tags[0] in
use-publish-post.ts / usePublishPost).
🧹 Nitpick comments (4)
apps/self-hosted/src/utils/use-synchronized-local-storage.ts (2)
22-34: Same instance handles its own dispatched event, causing a redundantsetValuecall.When
setValueSynccallssetValue(v)on Line 38 and then dispatches the CustomEvent on Line 39-44, the same hook instance's event listener (Line 23-30) picks up that event and callssetValue(v)again. Same applies toclearValueSync. React will likely bail out of re-rendering since the value is identical, but it's still unnecessary work.A simple guard — e.g., comparing the incoming value to the current one, or using a ref to skip self-dispatched events — would avoid the redundant update.
Example fix using a self-dispatch guard
+import { useCallback, useEffect, useRef } from "react"; -import { useCallback, useEffect } from "react"; import { useLocalStorage } from "react-use"; ... export function useSynchronizedLocalStorage<T>( key: string, initialValue: T, ): readonly [T, (value: T) => void, () => void] { const [value, setValue, removeValue] = useLocalStorage<T>(key, initialValue); + const skipNextEvent = useRef(false); useEffect(() => { const handler = (e: Event) => { + if (skipNextEvent.current) { + skipNextEvent.current = false; + return; + } const typedEvent = e as CustomEvent<SynchronizedLocalStorageEvent<T>>; if (typedEvent.detail.key !== key) return; ... }; ... }, [key, setValue, removeValue]); const setValueSync = useCallback( (v: T) => { setValue(v); + skipNextEvent.current = true; window.dispatchEvent( ... ); }, [key, setValue], );Also applies to: 36-47
11-15:CustomEventonly synchronizes within the same tab — not across browser tabs.The doc comment says "live sync" across "all hook instances," which is accurate for multiple components within the same page. However,
window.dispatchEvent(new CustomEvent(...))does not propagate to other tabs/windows. If cross-tab draft synchronization is ever intended, you'd need to additionally listen for the nativestorageevent, which fires in other tabs whenlocalStorageis mutated.Since the underlying
useLocalStoragefromreact-usealready writes tolocalStorage, the nativestorageevent would fire automatically in other tabs — you'd just need to subscribe to it. If cross-tab sync is not a goal, consider clarifying the doc comment to say "within the same browsing context."Also applies to: 32-33
apps/self-hosted/src/features/publish/hooks/use-publish-post.ts (1)
54-55: Redundant ternary —tagsis guaranteed non-empty here.The validation on line 32 already throws if
tags.length === 0, sotags.length > 0 ? tags : []always takes the truthy branch.🧹 Proposed fix
- tags: tags.length > 0 ? tags : [], + tags,apps/self-hosted/src/features/publish/components/publish-action-bar.tsx (1)
50-94: Redundant nested container with duplicatemax-w-[1024px] mx-auto.The outer
div(line 51) already appliesmax-w-[1024px] mx-auto, and the innermotion.div(line 65) repeatscontainer max-w-[1024px] mx-autoplus extra padding. This double-constrains the width and adds unnecessary nesting. Consider removing the duplicate constraint from the inner element.♻️ Proposed simplification
<motion.div initial={{ opacity: 0, y: -32 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -32 }} transition={{ delay: 0.4 }} - className="container max-w-[1024px] mx-auto px-2 md:px-4 py-4 flex justify-end" + className="px-2 md:px-4 py-4 flex justify-end" >
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/self-hosted/src/features/auth/components/hiveauth-login.tsx (1)
48-69:⚠️ Potential issue | 🟡 MinorStale closure:
statuscheck on line 63 won't reflect event-listener updates during the same call.When
handleLoginexecutes,statusin the closure is the value from the last render. ThesetStatus('idle')call on line 55 doesn't update the localstatusvariable, and if thehiveauth:errorevent fires and callssetStatus('error')during theawait login(...), the closure'sstatusstill holds the render-time value. Sostatus !== 'error'on line 63 is effectively alwaystrueduring a single invocation, making the deduplication logic unreliable.If you want to avoid showing duplicate errors (one from the event listener, one from the catch), consider using a ref to track the error status:
♻️ Sketch using a ref
+ const errorHandledRef = useRef(false); + const handleLogin = useCallback(async () => { // ... setLoading(true); setStatus('idle'); setQrData(null); + errorHandledRef.current = false; try { await login('hiveauth', username.trim().toLowerCase()); onSuccess?.(); } catch (error) { - if (status !== 'error') { + if (!errorHandledRef.current) { onError?.(error instanceof Error ? error.message : 'Login failed'); } } finally { setLoading(false); } - }, [username, onSuccess, onError, status]); + }, [username, onSuccess, onError]);And in the error event listener, set
errorHandledRef.current = truealongsidesetStatus('error').apps/self-hosted/src/features/auth/auth-provider.tsx (1)
96-100:⚠️ Potential issue | 🟡 Minor
isSessionExpiringSoonis stale — it never re-evaluates as time passes.
useMemocapturesDate.now()once and only recomputes whenuser?.expiresAtchanges. If a user's session has 10 minutes left at login, this staysfalseand never flips totrueat the 5-minute mark. Consider driving this from the same interval that checks full expiry, usinguseStateupdated by the timer.🐛 Proposed fix — derive from the expiry-check interval
+ const [isSessionExpiringSoon, setIsSessionExpiringSoon] = useState(false); + // Periodically check token expiry and auto-logout if expired useEffect(() => { if (!user?.expiresAt) return; const checkExpiry = () => { if (user.expiresAt && Date.now() > user.expiresAt) { // Token expired, logout setUser(undefined); setSession(undefined); clearUser(); clearHiveAuthSession(); + } else { + const fiveMinutes = 5 * 60 * 1000; + setIsSessionExpiringSoon( + !!user.expiresAt && user.expiresAt < Date.now() + fiveMinutes + ); } }; const interval = setInterval(checkExpiry, 60 * 1000); checkExpiry(); return () => clearInterval(interval); }, [user?.expiresAt]); - // Check if session is expiring within 5 minutes - const isSessionExpiringSoon = useMemo(() => { - if (!user?.expiresAt) return false; - const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000; - return user.expiresAt < fiveMinutesFromNow; - }, [user?.expiresAt]);
🧹 Nitpick comments (11)
apps/self-hosted/src/store/authentication.ts (1)
11-19:useStoreis redundant when usingcreate.In Zustand v4,
create()returns a bound React hook directly. You can simplifyuseAuthStoreby calling the store as a hook, reservinguseStorefor vanilla stores created withcreateStore.♻️ Suggested simplification
-import { create, useStore } from "zustand"; +import { create } from "zustand"; // ... store definition ... -export function useAuthStore() { - return useStore(authenticationStore); -} +export const useAuthStore = authenticationStore;apps/self-hosted/src/routes/__root.tsx (2)
30-52: Expiry checks are duplicated —getUser()andgetHiveAuthSession()already handle expiration.Looking at
apps/self-hosted/src/features/auth/storage.ts, bothgetUser()(line 29) andgetHiveAuthSession()(line 85) already check expiry and returnnull(after clearing storage) when expired. The expiry checks on lines 36–39 and 46–47 here are dead code —storedUserandstoredHiveAuthwill never be non-null and expired at this point.♻️ Simplified beforeLoad
beforeLoad: () => { const storedUser = getUser(); const storedHiveAuth = getHiveAuthSession(); - if (storedUser) { - // Check if token has expired - if (storedUser.expiresAt && Date.now() > storedUser.expiresAt) { - // Token expired, clear session - clearUser(); - clearHiveAuthSession(); - } else { - authenticationStore.getState().setUser(storedUser); - } + if (storedUser) { + authenticationStore.getState().setUser(storedUser); } - if (storedHiveAuth) { - // Check if HiveAuth session has expired - if (Date.now() > storedHiveAuth.expire * 1000) { - clearHiveAuthSession(); - } else { - authenticationStore.getState().setSession(storedHiveAuth); - } + if (storedHiveAuth) { + authenticationStore.getState().setSession(storedHiveAuth); } },
74-84: Minor:isDevis already defined at module scope (line 17) — reuse it instead of re-checkingprocess.env.NODE_ENV.♻️ Suggested fix
function AuthorizedFloatingMenu() { const isBlogOwner = useIsBlogOwner(); - const isDevelopment = process.env.NODE_ENV === "development"; - if (!isDevelopment && !isBlogOwner) { + if (!isDev && !isBlogOwner) { return null; }apps/self-hosted/src/features/auth/hooks.ts (1)
52-54:useBroadcastno longer uses any hooks — consider whether the hook wrapper is still needed.Since
useBroadcastnow just returns a module-level import with no React hook calls, consumers could importbroadcastdirectly fromauth-actions(or the barrel). The hook wrapper is harmless but adds indirection. Fine to keep for backward compatibility if existing call sites depend on it.apps/self-hosted/src/features/publish/hooks/use-publish-post.ts (1)
57-58: Redundant ternary —tagsis guaranteed non-empty here.Line 35 already validates
tags.length > 0, sotags.length > 0 ? tags : []is alwaystags.Suggested fix
- tags: tags.length > 0 ? tags : [], + tags,apps/self-hosted/src/features/auth/components/vote-button.tsx (1)
44-71:broadcastin the dependency array is a no-op — it's a module-level import.Module-scoped functions have a stable identity and never trigger re-creation. Including
broadcastin the deps array is harmless but misleading. Same applies inreblog-button.tsx(Line 61) andcomment-form.tsx(Line 90).Suggested fix
- [user, author, permlink, broadcast, queryClient] + [user, author, permlink, queryClient]apps/self-hosted/src/features/auth/auth-actions.ts (2)
73-76:login("hivesigner", …)is a silent no-op — consider throwing or redirecting.If a caller invokes
login("hivesigner", username), nothing happens. The actual redirect lives inloginWithHivesigner(). This split is easy to misuse. Consider either performing the redirect inside the"hivesigner"case or throwing an error directing callers to useloginWithHivesigner()instead.
26-77: Missingdefaultcase inlogin()switch — unlikebroadcast()which has one.
broadcast()correctly throws on an unknownloginType.login()silently falls through for any unrecognized method. Adding adefault: throw new Error(…)would provide consistency and a safety net for runtime values.Suggested fix
case "hivesigner": // Redirect handles the rest break; + + default: + throw new Error(`Unknown login method: ${method}`); }apps/self-hosted/src/features/auth/auth-provider.tsx (3)
33-34:availableMethodscreates a new array reference every render, defeating theuseMemoonvalue.
(authConfig?.methods ?? []) as AuthMethod[]produces a fresh array on each render. Since it's listed as a dependency of thevaluememo (Line 111), the context value object is recreated every render, causing all consumers to re-render unnecessarily.♻️ Memoize availableMethods
- const availableMethods = (authConfig?.methods ?? []) as AuthMethod[]; + const availableMethods = useMemo( + () => (authConfig?.methods ?? []) as AuthMethod[], + [authConfig?.methods] + );
44-46: Redundant null guard onblogOwner.Line 43 already returns
falsewhen!blogOwner, so(blogOwner ?? "")on Line 45 is unnecessary.✏️ Simplify
- return ( - (user.username ?? "").toLowerCase() === (blogOwner ?? "").toLowerCase() - ); + return user.username?.toLowerCase() === blogOwner.toLowerCase();
50-70:setUserandsetSessionare missing from the dependency array.If these store setters are stable references (common with Zustand/similar), this is safe in practice, but the
react-hooks/exhaustive-depsESLint rule will flag it. Consider adding them to the array, or suppressing the lint with a comment if they're guaranteed stable.
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (7)
apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx (1)
91-121: Extract website rendering into a helper oruseMemorather than an inline IIFE.Using an IIFE inside JSX to perform URL normalization, validation, and conditional rendering is non-idiomatic React. It mixes side-effect-free logic with render output in a hard-to-test, hard-to-read way. Extract it as either:
- a
useMemo(or plain derived variable) before thereturn, or- a small helper function/component.
The inner
websiteUrl &&guard on line 96 is also redundant — the outerdata?.profile?.website &&condition already guarantees truthiness.♻️ Proposed refactor – derive in the render body, remove redundant guard
function BlogSidebarContent({ username }: { username: string }) { const { data } = useQuery({ ... }); const joinDate = useMemo(() => { if (!data?.created) return null; return formatMonthYear(data.created); }, [data?.created]); + const websiteHref = useMemo(() => { + const raw = data?.profile?.website; + if (!raw) return null; + const url = raw.startsWith("http://") || raw.startsWith("https://") + ? raw + : `https://${raw}`; + try { new URL(url); } catch { return null; } + return url; + }, [data?.profile?.website]); return ( <div ...> ... - {data?.profile?.website && - (() => { - let websiteUrl = data.profile.website; - if ( - websiteUrl && - !websiteUrl.startsWith("http://") && - !websiteUrl.startsWith("https://") - ) { - websiteUrl = `https://${websiteUrl}`; - } - try { - new URL(websiteUrl); - } catch { - return null; - } - return ( - <div className="text-xs text-theme-muted"> - <span className="font-medium">{t("website")}:</span>{" "} - <a - href={websiteUrl} - target="_blank" - rel="noopener noreferrer" - className="underline text-theme-accent" - > - {data.profile.website} - </a> - </div> - ); - })()} + {websiteHref && ( + <div className="text-xs text-theme-muted"> + <span className="font-medium">{t("website")}:</span>{" "} + <a + href={websiteHref} + target="_blank" + rel="noopener noreferrer" + className="underline text-theme-accent" + > + {data!.profile!.website} + </a> + </div> + )} </div> ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx` around lines 91 - 121, Extract the inline IIFE that normalizes/validates and renders the website into a small helper or a precomputed value (e.g., useMemo) before JSX return: read data?.profile?.website, normalize it to include https:// when missing (use the same logic currently in the IIFE), validate with new URL(...) and if valid produce the normalized websiteUrl (or null if invalid). Replace the IIFE with rendering that conditionally shows the link only when the precomputed websiteUrl is non-null, and remove the redundant inner websiteUrl && guard; reference the current identifiers data.profile.website and websiteUrl (or the chosen helper name) when implementing.apps/self-hosted/src/features/auth/auth-actions.ts (1)
27-27: DeadsetSessionat outer scope; redundantgetState()re-call insideonSuccess.
setSessiondestructured on line 27 is never used outside theonSuccesscallback. Inside the callback,authenticationStore.getState()is called a second time (lines 52–53) to obtain the same setter. Because Zustand setters are stable references,setSessionfrom line 27 andsetSessionStatefrom line 53 are the same function — the re-fetch is redundant.♻️ Proposed cleanup
- const { setUser, setSession } = authenticationStore.getState(); + const { setUser, setSession } = authenticationStore.getState();…then use the outer references directly inside
onSuccess:- onSuccess: (session) => { - const { setUser: setUserState, setSession: setSessionState } = - authenticationStore.getState(); - const newUser: AuthUser = { - username, - loginType: "hiveauth", - expiresAt: session.expire * 1000, - }; - setUserState(newUser); - saveUser(newUser); - setSessionState(session); - saveHiveAuthSession(session); - }, + onSuccess: (session) => { + const newUser: AuthUser = { + username, + loginType: "hiveauth", + expiresAt: session.expire * 1000, + }; + setUser(newUser); + saveUser(newUser); + setSession(session); + saveHiveAuthSession(session); + },Also applies to: 51-63
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/self-hosted/src/features/auth/auth-actions.ts` at line 27, The outer destructure const { setUser, setSession } = authenticationStore.getState() is the correct stable reference; remove the redundant authenticationStore.getState() call inside the onSuccess callback and replace uses of setSessionState (and any inner setUser retrieval) with the outer setSession (and setUser) variables so the callback reuses the stable setter references instead of re-fetching state.apps/self-hosted/src/features/publish/components/publish-action-bar.tsx (2)
28-42:onSuccess?.()is invoked after navigation has already been triggered.
mutateAsyncresolves only after the mutation's ownonSuccesshandler fires — which callsbeforeNavigate()(clearAll) and thennavigate({ to: "/blog", ... }). By the time line 37 is reached, the router has already been directed to/blogand the component may be in the process of unmounting. AnyonSuccesscallback that performs state updates or DOM interactions could behave unexpectedly.If
onSuccessmust run before navigation, consider moving the callback intousePublishPost'sonSuccessbefore thenavigatecall, or accept it as a parameter passed intousePublishPost.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/self-hosted/src/features/publish/components/publish-action-bar.tsx` around lines 28 - 42, The onSuccess callback is being invoked in handlePublish after publishPost resolves but publishPost's internal onSuccess already triggers navigation (via beforeNavigate/clearAll then navigate), so by the time line 37 runs the component may be unmounting; move the onSuccess invocation into the mutation's onSuccess inside usePublishPost (or change usePublishPost to accept an onSuccess parameter) and call that passed-in onSuccess before calling beforeNavigate() / navigate() so user callbacks run prior to navigation; locate handlePublish, publishPost, and the usePublishPost mutation's onSuccess handler to implement this change.
59-59: Redundant width constraint on the innermotion.div.The outer
div(line 45) already appliesmax-w-[1024px] mx-auto. Themotion.div'scontainer max-w-[1024px] mx-autois redundant and thecontainerTailwind class applies its own responsivemax-widthbreakpoints, potentially conflicting with the explicitmax-w-[1024px]override.♻️ Proposed simplification
- className="container max-w-[1024px] mx-auto px-2 md:px-4 py-4 flex justify-end" + className="px-2 md:px-4 py-4 flex justify-end"🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/self-hosted/src/features/publish/components/publish-action-bar.tsx` at line 59, The inner motion.div's className includes "container max-w-[1024px] mx-auto", which is redundant and may conflict with the outer div's "max-w-[1024px] mx-auto"; remove the "container max-w-[1024px] mx-auto" portion from the motion.div in the PublishActionBar component and leave only the necessary layout/padding/flex utilities (e.g., px, py, flex, justify-end) so the outer wrapper controls width while the motion.div keeps its alignment and spacing.apps/self-hosted/src/features/publish/components/publish-tags-selector.tsx (2)
46-69:#in character-class patterns is unreachable dead code
sanitizeTagInput(line 31) replaces#with a space, so by the timevalidateTagis called — whether fromaddTagorhandlePaste— the tag string can never contain#. The#entries in^[a-z0-9-#]+$(line 55) and^[a-z-#](line 59) are therefore dead code. A tag that somehow passed these checks with a leading#would render as##tagin the UI.🔧 Proposed cleanup
- if (!/^[a-z0-9-#]+$/.test(tag)) { + if (!/^[a-z0-9-]+$/.test(tag)) { setWarning("Use only lowercase letters, numbers, and hyphens"); return false; } - if (!/^[a-z-#]/.test(tag)) { + if (!/^[a-z-]/.test(tag)) { setWarning("Tag must start with a letter or hyphen"); return false; }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/self-hosted/src/features/publish/components/publish-tags-selector.tsx` around lines 46 - 69, The regexes in validateTag (function validateTag) include '#' characters that are unreachable because sanitizeTagInput removes '#' before validateTag is invoked (from addTag and handlePaste); remove '#' from the character classes and start-anchor class (change /^[a-z0-9-#]+$/ and /^[a-z-#]/ to exclude '#') so validation reflects the actual input, and run tests or try addTag/handlePaste flows to confirm no leading/trailing or hyphen-count checks are affected.
23-35: ExtractsanitizeTagInputand related constants to a shared packageThis function and its associated
specialCharMapandspecialCharRegexare duplicated fromapps/web/src/app/submit/_components/tag-selector/index.tsx. Keeping multiple copies in sync across the monorepo is error-prone and will diverge over time. Extract these utilities into a shared package (e.g.,@ecency/uior a new@ecency/utils) and import from both apps to maintain architectural parity.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/self-hosted/src/features/publish/components/publish-tags-selector.tsx` around lines 23 - 35, The sanitizeTagInput function and its dependencies (specialCharMap and specialCharRegex) are duplicated; extract these three symbols into a shared utilities package (e.g., create/export from `@ecency/utils` or `@ecency/ui`) and replace the local copies by importing sanitizeTagInput, specialCharMap, and specialCharRegex from that package in both locations (the tag selector implementations). Ensure the shared module exports typed implementations and update both components to use the imported sanitizeTagInput so all logic is maintained in one place and the local definitions are removed.apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx (1)
125-134: Missingaria-expandedon the heading dropdown trigger buttonScreen readers have no signal that this button controls a popup menu. Add
aria-expandedand, optionally,aria-haspopup="menu":♿ Proposed fix
<button type="button" onClick={() => setShowHeadingMenu(!showHeadingMenu)} + aria-expanded={showHeadingMenu} + aria-haspopup="menu" className={`p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 ${ editor.isActive("heading") ? "bg-gray-200 dark:bg-gray-600" : "" }`} title="Headings" >🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx` around lines 125 - 134, The heading dropdown trigger button (the element using setShowHeadingMenu, showHeadingMenu and editor.isActive with the UilTextSize icon) lacks accessibility attributes; add aria-expanded={showHeadingMenu} and aria-haspopup="menu" to that button so assistive technologies know it controls a popup and its current state, ensuring the attributes update when setShowHeadingMenu toggles the menu.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/self-hosted/src/features/auth/auth-actions.ts`:
- Around line 73-76: The "hivesigner" branch in the login() switch currently
no-ops; update the case in auth-actions.ts so it either throws a clear error
(e.g., throw new Error("Use loginWithHivesigner(username) for hivesigner flow"))
to signal misuse, or explicitly delegate to the correct flow by calling
loginWithHivesigner(username) and returning its Promise; change the case
"hivesigner": block to perform one of these two actions and ensure the function
signature and returned Promise behavior remain consistent with other branches.
In `@apps/self-hosted/src/features/publish/components/publish-action-bar.tsx`:
- Around line 71-75: The button's className in PublishActionBar currently always
includes "cursor-pointer" while the disabled branch adds "cursor-not-allowed",
causing conflicting cursor utilities; update the className in the
PublishActionBar component (the template string that references canPublish and
isPublishing) so that "cursor-pointer" is moved into the enabled branch (the ?
branch where you add "bg-black hover:bg-black/80 text-white") and ensure the
disabled branch only includes "cursor-not-allowed" (with "bg-gray-300..."
classes) so the cursor utilities are not both applied.
- Around line 54-58: The motion.div inside PublishActionBar currently uses an
exit prop that will be ignored unless wrapped by an AnimatePresence; either
remove the exit={{ opacity: 0, y: -32 }} prop from the motion.div in
PublishActionBar if you don't need exit animations, or wrap the PublishActionBar
(or its parent) with Framer Motion's <AnimatePresence> and ensure the motion.div
has a unique key so the exit animation runs; update the component that renders
PublishActionBar (or the PublishActionBar component itself) to import and
include AnimatePresence and verify the exit prop is preserved when
AnimatePresence is used.
In `@apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx`:
- Around line 51-65: The isFocusingTable state (setIsFocusingTable) is only
updated on selectionUpdate so it never resets when the user clicks outside the
editor; enhance the effect that registers handleSelectionUpdate to also register
a global mousedown/focus handler that clears isFocusingTable when the
click/relatedTarget is outside both the editor DOM and the
PublishEditorTableToolbar DOM: obtain the editor DOM node (via editor.view?.dom
or editorRef) and the toolbar container (via a ref passed to
PublishEditorTableToolbar), add a document mousedown (or focusout with
relatedTarget check) listener that checks contains(event.target) for both nodes
and calls setIsFocusingTable(false) when outside, ensure
PublishEditorTableToolbar prevents the blur issue by handling onMouseDown with
event.preventDefault(), and clean up the added listeners in the effect return.
- Line 23: The headings array is currently inferred as number[] causing
TypeScript errors with TipTap's toggleHeading which expects Level = 1|2|3|4|5|6;
update the headings declaration in publish-editor-toolbar.tsx (symbol: headings)
to use a readonly literal tuple (add "as const") so its items are literal types,
and ensure any places calling editor.chain().focus().toggleHeading({ level })
use those typed values; this will satisfy the Level type and eliminate the
strict-mode type error.
In `@apps/self-hosted/src/features/publish/components/publish-tags-selector.tsx`:
- Around line 120-136: The paste handler clears validation feedback
unconditionally via setWarning(""), which masks warnings set by validateTag;
update handlePaste to stop unconditionally clearing the warning and instead
preserve or set warnings based on validation results: remove the unconditional
setWarning("") call, track whether any validation errors occurred while
iterating parts (or collect the warning message(s) from validateTag), call
setWarning(...) with the appropriate message when a part is invalid, and only
clear the warning (setWarning("")) if new tags were successfully added (e.g.,
next.length > tags.length) or if you explicitly determine there are no
validation errors; keep references to handlePaste, validateTag, setWarning,
setValue, onChange, tags, MAX_TAGS, and MAX_TAG_LENGTH when making the change.
---
Duplicate comments:
In `@apps/self-hosted/src/features/publish/components/publish-tags-selector.tsx`:
- Around line 46-69: No changes required: the validateTag function correctly
enforces tag rules (max length via MAX_TAG_LENGTH, hyphen limit via
tag.split("-").length > 3, allowed characters via /^[a-z0-9-#]+$/, start char
via /^[a-z-#]/, and end char via /[a-z0-9]$/) and the paste handling now calls
validateTag per part before adding; leave validateTag and the paste logic as-is.
---
Nitpick comments:
In `@apps/self-hosted/src/features/auth/auth-actions.ts`:
- Line 27: The outer destructure const { setUser, setSession } =
authenticationStore.getState() is the correct stable reference; remove the
redundant authenticationStore.getState() call inside the onSuccess callback and
replace uses of setSessionState (and any inner setUser retrieval) with the outer
setSession (and setUser) variables so the callback reuses the stable setter
references instead of re-fetching state.
In `@apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx`:
- Around line 91-121: Extract the inline IIFE that normalizes/validates and
renders the website into a small helper or a precomputed value (e.g., useMemo)
before JSX return: read data?.profile?.website, normalize it to include https://
when missing (use the same logic currently in the IIFE), validate with new
URL(...) and if valid produce the normalized websiteUrl (or null if invalid).
Replace the IIFE with rendering that conditionally shows the link only when the
precomputed websiteUrl is non-null, and remove the redundant inner websiteUrl &&
guard; reference the current identifiers data.profile.website and websiteUrl (or
the chosen helper name) when implementing.
In `@apps/self-hosted/src/features/publish/components/publish-action-bar.tsx`:
- Around line 28-42: The onSuccess callback is being invoked in handlePublish
after publishPost resolves but publishPost's internal onSuccess already triggers
navigation (via beforeNavigate/clearAll then navigate), so by the time line 37
runs the component may be unmounting; move the onSuccess invocation into the
mutation's onSuccess inside usePublishPost (or change usePublishPost to accept
an onSuccess parameter) and call that passed-in onSuccess before calling
beforeNavigate() / navigate() so user callbacks run prior to navigation; locate
handlePublish, publishPost, and the usePublishPost mutation's onSuccess handler
to implement this change.
- Line 59: The inner motion.div's className includes "container max-w-[1024px]
mx-auto", which is redundant and may conflict with the outer div's
"max-w-[1024px] mx-auto"; remove the "container max-w-[1024px] mx-auto" portion
from the motion.div in the PublishActionBar component and leave only the
necessary layout/padding/flex utilities (e.g., px, py, flex, justify-end) so the
outer wrapper controls width while the motion.div keeps its alignment and
spacing.
In `@apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx`:
- Around line 125-134: The heading dropdown trigger button (the element using
setShowHeadingMenu, showHeadingMenu and editor.isActive with the UilTextSize
icon) lacks accessibility attributes; add aria-expanded={showHeadingMenu} and
aria-haspopup="menu" to that button so assistive technologies know it controls a
popup and its current state, ensuring the attributes update when
setShowHeadingMenu toggles the menu.
In `@apps/self-hosted/src/features/publish/components/publish-tags-selector.tsx`:
- Around line 46-69: The regexes in validateTag (function validateTag) include
'#' characters that are unreachable because sanitizeTagInput removes '#' before
validateTag is invoked (from addTag and handlePaste); remove '#' from the
character classes and start-anchor class (change /^[a-z0-9-#]+$/ and /^[a-z-#]/
to exclude '#') so validation reflects the actual input, and run tests or try
addTag/handlePaste flows to confirm no leading/trailing or hyphen-count checks
are affected.
- Around line 23-35: The sanitizeTagInput function and its dependencies
(specialCharMap and specialCharRegex) are duplicated; extract these three
symbols into a shared utilities package (e.g., create/export from `@ecency/utils`
or `@ecency/ui`) and replace the local copies by importing sanitizeTagInput,
specialCharMap, and specialCharRegex from that package in both locations (the
tag selector implementations). Ensure the shared module exports typed
implementations and update both components to use the imported sanitizeTagInput
so all logic is maintained in one place and the local definitions are removed.
| case "hivesigner": | ||
| // Redirect handles the rest | ||
| break; | ||
| } |
There was a problem hiding this comment.
login('hivesigner', …) silently no-ops — consider throwing to signal misuse.
A caller invoking login('hivesigner', username) receives a resolved Promise<void> with no action taken and no redirect performed. Since loginWithHivesigner() is the correct entry-point for that flow, the hivesigner branch in login() should either throw to prevent misuse or explicitly delegate:
🛡️ Proposed fix
case "hivesigner":
- // Redirect handles the rest
- break;
+ // Hivesigner uses a redirect-based flow; call loginWithHivesigner() directly.
+ throw new Error(
+ "Use loginWithHivesigner() for the Hivesigner auth flow"
+ );📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| case "hivesigner": | |
| // Redirect handles the rest | |
| break; | |
| } | |
| case "hivesigner": | |
| // Hivesigner uses a redirect-based flow; call loginWithHivesigner() directly. | |
| throw new Error( | |
| "Use loginWithHivesigner() for the Hivesigner auth flow" | |
| ); | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/self-hosted/src/features/auth/auth-actions.ts` around lines 73 - 76, The
"hivesigner" branch in the login() switch currently no-ops; update the case in
auth-actions.ts so it either throws a clear error (e.g., throw new Error("Use
loginWithHivesigner(username) for hivesigner flow")) to signal misuse, or
explicitly delegate to the correct flow by calling loginWithHivesigner(username)
and returning its Promise; change the case "hivesigner": block to perform one of
these two actions and ensure the function signature and returned Promise
behavior remain consistent with other branches.
| <motion.div | ||
| initial={{ opacity: 0, y: -32 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| exit={{ opacity: 0, y: -32 }} | ||
| transition={{ delay: 0.4 }} |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# Find all files that reference PublishActionBar
rg -l "PublishActionBar" --type ts --type tsxRepository: ecency/vision-next
Length of output: 89
🏁 Script executed:
# Get a summary of where PublishActionBar is imported/used
rg "PublishActionBar|import.*PublishActionBar" --type ts --type tsx -nRepository: ecency/vision-next
Length of output: 89
🏁 Script executed:
# Find files containing PublishActionBar
rg "PublishActionBar" -nRepository: ecency/vision-next
Length of output: 1423
🏁 Script executed:
# List TypeScript/TSX files in the publish component area to understand the structure
fd -e tsx -e ts apps/self-hosted/src/features/publish/Repository: ecency/vision-next
Length of output: 519
🏁 Script executed:
# Check the publish.tsx routes file where PublishActionBar is used
cat -n apps/self-hosted/src/routes/publish.tsxRepository: ecency/vision-next
Length of output: 2265
🏁 Script executed:
# Check the actual publish-action-bar.tsx file to see the motion.div in question
cat -n apps/self-hosted/src/features/publish/components/publish-action-bar.tsx | head -70Repository: ecency/vision-next
Length of output: 2654
🏁 Script executed:
# Search for AnimatePresence usage throughout the codebase
rg "AnimatePresence" -nRepository: ecency/vision-next
Length of output: 14730
🏁 Script executed:
# Check if framer-motion is imported in publish.tsx
grep -n "framer-motion\|AnimatePresence" apps/self-hosted/src/routes/publish.tsxRepository: ecency/vision-next
Length of output: 44
🏁 Script executed:
# Check if AnimatePresence is used anywhere in apps/self-hosted
rg "AnimatePresence" apps/self-hosted/Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
# Check the full PublishActionBar component to see the complete structure
cat -n apps/self-hosted/src/features/publish/components/publish-action-bar.tsxRepository: ecency/vision-next
Length of output: 3507
Remove the exit animation prop or wrap with AnimatePresence.
The exit animation on this motion.div will be silently ignored. Framer Motion only triggers exit animations when the animating element is a descendant of an <AnimatePresence> component. Currently, PublishActionBar is rendered in apps/self-hosted/src/routes/publish.tsx without any AnimatePresence wrapper, and the component itself does not provide one either.
Either remove the exit={{ opacity: 0, y: -32 }} prop if the exit animation is not needed, or wrap the component with AnimatePresence to enable the animation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/self-hosted/src/features/publish/components/publish-action-bar.tsx`
around lines 54 - 58, The motion.div inside PublishActionBar currently uses an
exit prop that will be ignored unless wrapped by an AnimatePresence; either
remove the exit={{ opacity: 0, y: -32 }} prop from the motion.div in
PublishActionBar if you don't need exit animations, or wrap the PublishActionBar
(or its parent) with Framer Motion's <AnimatePresence> and ensure the motion.div
has a unique key so the exit animation runs; update the component that renders
PublishActionBar (or the PublishActionBar component itself) to import and
include AnimatePresence and verify the exit prop is preserved when
AnimatePresence is used.
| className={`px-6 py-2 rounded-lg font-medium text-sm transition-colors cursor-pointer ${ | ||
| canPublish && !isPublishing | ||
| ? "bg-black hover:bg-black/80 text-white" | ||
| : "bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed" | ||
| }`} |
There was a problem hiding this comment.
cursor-pointer and cursor-not-allowed conflict when button is disabled.
cursor-pointer is unconditionally present in the base class string, while cursor-not-allowed is only added in the disabled branch. When the button is disabled, both cursor classes are applied to the same element simultaneously. Tailwind resolves conflicts based on the order utilities appear in the generated CSS file, not the HTML class attribute, so the effective cursor is indeterminate — not-allowed may not reliably show.
Move cursor-pointer into the enabled branch:
🐛 Proposed fix
-className={`px-6 py-2 rounded-lg font-medium text-sm transition-colors cursor-pointer ${
+className={`px-6 py-2 rounded-lg font-medium text-sm transition-colors ${
canPublish && !isPublishing
- ? "bg-black hover:bg-black/80 text-white"
+ ? "bg-black hover:bg-black/80 text-white cursor-pointer"
: "bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed"
}`}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| className={`px-6 py-2 rounded-lg font-medium text-sm transition-colors cursor-pointer ${ | |
| canPublish && !isPublishing | |
| ? "bg-black hover:bg-black/80 text-white" | |
| : "bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed" | |
| }`} | |
| className={`px-6 py-2 rounded-lg font-medium text-sm transition-colors ${ | |
| canPublish && !isPublishing | |
| ? "bg-black hover:bg-black/80 text-white cursor-pointer" | |
| : "bg-gray-300 dark:bg-gray-600 text-gray-500 dark:text-gray-400 cursor-not-allowed" | |
| }`} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/self-hosted/src/features/publish/components/publish-action-bar.tsx`
around lines 71 - 75, The button's className in PublishActionBar currently
always includes "cursor-pointer" while the disabled branch adds
"cursor-not-allowed", causing conflicting cursor utilities; update the className
in the PublishActionBar component (the template string that references
canPublish and isPublishing) so that "cursor-pointer" is moved into the enabled
branch (the ? branch where you add "bg-black hover:bg-black/80 text-white") and
ensure the disabled branch only includes "cursor-not-allowed" (with
"bg-gray-300..." classes) so the cursor utilities are not both applied.
| editor: Editor | null; | ||
| } | ||
|
|
||
| const headings = [1, 2, 3, 4, 5, 6]; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Check if the file exists and get its size
if [ -f "apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx" ]; then
echo "=== File found ==="
wc -l "apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx"
else
echo "File not found at expected location"
git ls-files | grep -i "publish-editor-toolbar"
fiRepository: ecency/vision-next
Length of output: 160
🏁 Script executed:
#!/bin/bash
# Read the file content focusing on lines around 23 and 157
cat -n "apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx" | sed -n '15,35p'Repository: ecency/vision-next
Length of output: 950
🏁 Script executed:
#!/bin/bash
# Check around line 157 for the toggleHeading call
cat -n "apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx" | sed -n '150,165p'Repository: ecency/vision-next
Length of output: 779
🏁 Script executed:
#!/bin/bash
# Find TipTap extension-heading to check the type definitions
find . -type f -name "*.d.ts" -o -name "*.ts" | xargs grep -l "toggleHeading\|type Level" | head -20Repository: ecency/vision-next
Length of output: 124
🏁 Script executed:
#!/bin/bash
# Check package.json for TipTap version
cat package.json | grep -A 2 "@tiptap"Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Read package.json to find TipTap version
cat package.json | grep -A 20 '"dependencies"' | grep tiptapRepository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Search for the extension-heading package type definitions
find node_modules -path "*@tiptap/extension-heading*" -name "*.d.ts" | head -5Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Check tsconfig.json for strict mode
cat tsconfig.json | grep -A 10 '"compilerOptions"' | grep -i "strict"Repository: ecency/vision-next
Length of output: 107
🏁 Script executed:
#!/bin/bash
# List root directory files
ls -la | head -20Repository: ecency/vision-next
Length of output: 1054
🏁 Script executed:
#!/bin/bash
# Try to find package.json
find . -maxdepth 2 -name "package.json" -type fRepository: ecency/vision-next
Length of output: 76
🏁 Script executed:
#!/bin/bash
# Look for tiptap packages in node_modules
ls -d node_modules/@tiptap/extension-heading 2>/dev/null || echo "Directory not found"Repository: ecency/vision-next
Length of output: 81
🏁 Script executed:
#!/bin/bash
# Try to find any tsconfig file
find . -maxdepth 3 -name "tsconfig*.json" -type fRepository: ecency/vision-next
Length of output: 299
🏁 Script executed:
#!/bin/bash
# Read package.json
cat package.jsonRepository: ecency/vision-next
Length of output: 2454
🏁 Script executed:
#!/bin/bash
# Check the self-hosted tsconfig for strict mode
cat apps/self-hosted/tsconfig.jsonRepository: ecency/vision-next
Length of output: 458
🏁 Script executed:
#!/bin/bash
# Look for tiptap packages in pnpm store
find node_modules -type d -name "*tiptap*" | head -10Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Search for toggleHeading in node_modules
rg "toggleHeading" node_modules --type ts --type json -i 2>/dev/null | head -20Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Check the self-hosted app package.json for dependencies
cat apps/self-hosted/package.jsonRepository: ecency/vision-next
Length of output: 2138
🏁 Script executed:
#!/bin/bash
# Look for tiptap in the self-hosted directory
find apps/self-hosted/node_modules -type d -name "*tiptap*" 2>/dev/null | head -10Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Search in the entire node_modules for type definitions for extension-heading
find . -path "*/extension-heading*" -name "*.d.ts" 2>/dev/null | head -5Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Check if pnpm-lock.yaml has tiptap references
grep -A 5 "@tiptap/extension-heading" pnpm-lock.yaml | head -20Repository: ecency/vision-next
Length of output: 1109
🏁 Script executed:
#!/bin/bash
# Find the actual extension-heading types in node_modules
find node_modules -path "*@tiptap*" -path "*extension-heading*" -type f | grep -E "\.(d\.ts|ts)$" | head -10Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Search for the Heading extension class/type definition
rg "class Heading\|interface.*Heading\|export.*Heading" node_modules/@tiptap --type ts -A 5 2>/dev/null | head -50Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Look for the toggleHeading command definition
rg "toggleHeading|Level.*=|type Level" node_modules/@tiptap --type ts --type json -B 2 -A 2 2>/dev/null | head -100Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Check if pnpm structure is used (packages in .pnpm)
ls -la node_modules/.pnpm | grep tiptap | head -10Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Try searching in .pnpm directory structure
find node_modules/.pnpm -type d -name "*tiptap*extension-heading*" 2>/dev/nullRepository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Look for index.d.ts files in .pnpm
find node_modules/.pnpm -path "*extension-heading*" -name "*.d.ts" 2>/dev/nullRepository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Check what's actually in the `@tiptap` directory under pnpm
ls -la node_modules/.pnpm | grep "@tiptap" | head -5Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Check the actual structure of node_modules
ls -la node_modules | head -20Repository: ecency/vision-next
Length of output: 858
🏁 Script executed:
#!/bin/bash
# Look for any tiptap-related directories
find node_modules -maxdepth 2 -type d -name "*tiptap*" 2>/dev/nullRepository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Check if the entire file uses strict typing
cat -n apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx | head -50Repository: ecency/vision-next
Length of output: 1695
🏁 Script executed:
#!/bin/bash
# Search for Editor type import to understand the context
rg "import.*Editor|from.*@tiptap" apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsxRepository: ecency/vision-next
Length of output: 177
🏁 Script executed:
#!/bin/bash
# List what's in .pnpm for tiptap
ls node_modules/.pnpm | grep tiptap | head -20Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Try a different approach - search for dist/index files in pnpm
find node_modules/.pnpm -path "*extension-heading*" -name "index.d.ts" 2>/dev/nullRepository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# List the full path for extension-heading in pnpm
find node_modules/.pnpm -type d -name "*extension-heading*" 2>/dev/nullRepository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Check what Node types are exported
rg "export\s+(type|interface|const)\s+Level" node_modules --type ts 2>/dev/null | head -20Repository: ecency/vision-next
Length of output: 44
🏁 Script executed:
#!/bin/bash
# Search the codebase for other uses of toggleHeading
rg "toggleHeading" apps/self-hosted --type tsRepository: ecency/vision-next
Length of output: 210
🏁 Script executed:
#!/bin/bash
# Check the entire publish-editor-toolbar file for type annotations
cat apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx | grep -n "level\|Level\|headings"Repository: ecency/vision-next
Length of output: 349
🏁 Script executed:
#!/bin/bash
# Look at the complete file to understand the pattern
wc -l apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx
cat apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx | tail -100Repository: ecency/vision-next
Length of output: 3443
🌐 Web query:
TipTap toggleHeading type signature Level 1 2 3 4 5 6
💡 Result:
In @tiptap/extension-heading, the types are:
export type Level = 1 | 2 | 3 | 4 | 5 | 6
toggleHeading: (attributes: { level: Level }) => ReturnTypeSo usage is:
editor.commands.toggleHeading({ level: 1 }) // ... up to 6Sources: Tiptap Heading docs (command usage) [1], extension TypeScript declarations (Level + toggleHeading signature) [2].
headings array should use as const to match TipTap's Level type
const headings = [1, 2, 3, 4, 5, 6] is inferred as number[] by TypeScript, but TipTap's toggleHeading command expects { level: Level } where Level = 1 | 2 | 3 | 4 | 5 | 6. With strict mode enabled, passing a number type will cause a type error. Add as const to constrain the array to literal values.
Proposed fix
-const headings = [1, 2, 3, 4, 5, 6];
+const headings = [1, 2, 3, 4, 5, 6] as const;Also applies to: 157-157
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx`
at line 23, The headings array is currently inferred as number[] causing
TypeScript errors with TipTap's toggleHeading which expects Level = 1|2|3|4|5|6;
update the headings declaration in publish-editor-toolbar.tsx (symbol: headings)
to use a readonly literal tuple (add "as const") so its items are literal types,
and ensure any places calling editor.chain().focus().toggleHeading({ level })
use those typed values; this will satisfy the Level type and eliminate the
strict-mode type error.
| useEffect(() => { | ||
| if (!editor) { | ||
| return; | ||
| } | ||
|
|
||
| const handleSelectionUpdate = () => { | ||
| setIsFocusingTable(editor.isActive("table")); | ||
| }; | ||
|
|
||
| editor.on("selectionUpdate", handleSelectionUpdate); | ||
|
|
||
| return () => { | ||
| editor.off("selectionUpdate", handleSelectionUpdate); | ||
| }; | ||
| }, [editor]); |
There was a problem hiding this comment.
isFocusingTable is not reset when the editor loses focus entirely
isFocusingTable is driven solely by selectionUpdate. If the user positions the cursor inside a table and then clicks outside the editor (not on the table toolbar), no selectionUpdate fires and PublishEditorTableToolbar stays visible indefinitely.
Note: naively adding a blur listener to reset the flag would break the table-toolbar buttons — mousedown causes the editor to blur before the click reaches the button, causing the toolbar to unmount and swallow the click. A safe resolution is to use mousedown-based detection (e.g., onMouseDown with event.preventDefault() on the table toolbar, or listening to blur only when the relatedTarget is outside both the editor and the table toolbar container).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/self-hosted/src/features/publish/components/publish-editor-toolbar.tsx`
around lines 51 - 65, The isFocusingTable state (setIsFocusingTable) is only
updated on selectionUpdate so it never resets when the user clicks outside the
editor; enhance the effect that registers handleSelectionUpdate to also register
a global mousedown/focus handler that clears isFocusingTable when the
click/relatedTarget is outside both the editor DOM and the
PublishEditorTableToolbar DOM: obtain the editor DOM node (via editor.view?.dom
or editorRef) and the toolbar container (via a ref passed to
PublishEditorTableToolbar), add a document mousedown (or focusout with
relatedTarget check) listener that checks contains(event.target) for both nodes
and calls setIsFocusingTable(false) when outside, ensure
PublishEditorTableToolbar prevents the blur issue by handling onMouseDown with
event.preventDefault(), and clean up the added listeners in the effect return.
| const handlePaste = useCallback( | ||
| (e: React.ClipboardEvent<HTMLInputElement>) => { | ||
| e.preventDefault(); | ||
| const pasted = sanitizeTagInput(e.clipboardData.getData("text")); | ||
| const parts = pasted.trim().split(/\s+/); | ||
| let next = [...tags]; | ||
| for (const part of parts) { | ||
| if (next.length >= MAX_TAGS) break; | ||
| const tag = part.slice(0, MAX_TAG_LENGTH).trim(); | ||
| if (tag && !next.includes(tag) && validateTag(tag)) next.push(tag); | ||
| } | ||
| onChange(next.slice(0, MAX_TAGS)); | ||
| setValue(""); | ||
| setWarning(""); | ||
| }, | ||
| [tags, onChange, validateTag] | ||
| ); |
There was a problem hiding this comment.
Paste validation warnings are always suppressed by the unconditional setWarning("") on line 133
validateTag may call setWarning(...) for invalid parts inside the loop, but line 133 unconditionally clears the warning immediately after. Because React 18 batches all setState calls within an event handler, the setWarning("") call always wins and users receive no feedback explaining why certain pasted tags were silently rejected.
🛠 Proposed fix
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pasted = sanitizeTagInput(e.clipboardData.getData("text"));
const parts = pasted.trim().split(/\s+/);
let next = [...tags];
+ let rejectedCount = 0;
for (const part of parts) {
if (next.length >= MAX_TAGS) break;
const tag = part.slice(0, MAX_TAG_LENGTH).trim();
- if (tag && !next.includes(tag) && validateTag(tag)) next.push(tag);
+ if (!tag || next.includes(tag)) continue;
+ if (validateTag(tag)) {
+ next.push(tag);
+ } else {
+ rejectedCount++;
+ }
}
onChange(next.slice(0, MAX_TAGS));
setValue("");
- setWarning("");
+ if (rejectedCount > 0) {
+ setWarning(`${rejectedCount} tag(s) were invalid and skipped`);
+ } else {
+ setWarning("");
+ }
},
[tags, onChange, validateTag]
);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const handlePaste = useCallback( | |
| (e: React.ClipboardEvent<HTMLInputElement>) => { | |
| e.preventDefault(); | |
| const pasted = sanitizeTagInput(e.clipboardData.getData("text")); | |
| const parts = pasted.trim().split(/\s+/); | |
| let next = [...tags]; | |
| for (const part of parts) { | |
| if (next.length >= MAX_TAGS) break; | |
| const tag = part.slice(0, MAX_TAG_LENGTH).trim(); | |
| if (tag && !next.includes(tag) && validateTag(tag)) next.push(tag); | |
| } | |
| onChange(next.slice(0, MAX_TAGS)); | |
| setValue(""); | |
| setWarning(""); | |
| }, | |
| [tags, onChange, validateTag] | |
| ); | |
| const handlePaste = useCallback( | |
| (e: React.ClipboardEvent<HTMLInputElement>) => { | |
| e.preventDefault(); | |
| const pasted = sanitizeTagInput(e.clipboardData.getData("text")); | |
| const parts = pasted.trim().split(/\s+/); | |
| let next = [...tags]; | |
| let rejectedCount = 0; | |
| for (const part of parts) { | |
| if (next.length >= MAX_TAGS) break; | |
| const tag = part.slice(0, MAX_TAG_LENGTH).trim(); | |
| if (!tag || next.includes(tag)) continue; | |
| if (validateTag(tag)) { | |
| next.push(tag); | |
| } else { | |
| rejectedCount++; | |
| } | |
| } | |
| onChange(next.slice(0, MAX_TAGS)); | |
| setValue(""); | |
| if (rejectedCount > 0) { | |
| setWarning(`${rejectedCount} tag(s) were invalid and skipped`); | |
| } else { | |
| setWarning(""); | |
| } | |
| }, | |
| [tags, onChange, validateTag] | |
| ); |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/self-hosted/src/features/publish/components/publish-tags-selector.tsx`
around lines 120 - 136, The paste handler clears validation feedback
unconditionally via setWarning(""), which masks warnings set by validateTag;
update handlePaste to stop unconditionally clearing the warning and instead
preserve or set warnings based on validation results: remove the unconditional
setWarning("") call, track whether any validation errors occurred while
iterating parts (or collect the warning message(s) from validateTag), call
setWarning(...) with the appropriate message when a part is invalid, and only
clear the warning (setWarning("")) if new tags were successfully added (e.g.,
next.length > tags.length) or if you explicitly determine there are no
validation errors; keep references to handlePaste, validateTag, setWarning,
setValue, onChange, tags, MAX_TAGS, and MAX_TAG_LENGTH when making the change.
Summary by CodeRabbit
New Features
Bug Fixes