Skip to content

Self-hosted editor#636

Merged
feruzm merged 17 commits intodevelopfrom
feature/self-hosted-editor
Feb 19, 2026
Merged

Self-hosted editor#636
feruzm merged 17 commits intodevelopfrom
feature/self-hosted-editor

Conversation

@dkildar
Copy link
Copy Markdown
Collaborator

@dkildar dkildar commented Feb 1, 2026

Summary by CodeRabbit

  • New Features

    • Full blog publishing UI at /publish: rich editor with formatting and table toolbars, tag manager, publish action bar, permlink generation, Markdown↔HTML converters, and draft save/load.
    • Cross-tab synchronized draft storage and access control that redirects non-authorized users away from /publish.
  • Bug Fixes

    • Post creation URL updated to /publish (was external).
    • Improved blog sidebar positioning and layout.

@dkildar dkildar requested a review from feruzm February 1, 2026 13:41
@dkildar dkildar self-assigned this Feb 1, 2026
@dkildar dkildar added the major Breaking changes (1.0.0 → 2.0.0), add this only if any packages/ have major changes in current PR label Feb 1, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 1, 2026

📝 Walkthrough

Walkthrough

Adds 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

Cohort / File(s) Summary
Configuration & Dependencies
apps/self-hosted/config.template.json, apps/self-hosted/package.json
Changed configuration.general.createPostUrl to "/publish". Added runtime deps for TipTap, dhive, DOMPurify, marked, turndown, speakingurl, hivesigner, react-textarea-autosize, Zustand and related types.
Publish feature (components)
apps/self-hosted/src/features/publish/components/*
New components: publish-editor.tsx, publish-editor-toolbar.tsx, publish-editor-table-toolbar.tsx, publish-action-bar.tsx, publish-tags-selector.tsx — editor UI, toolbars, table controls, publish actions, and tag input/validation.
Publish feature (hooks & utils)
apps/self-hosted/src/features/publish/hooks/*, apps/self-hosted/src/features/publish/utils/*, apps/self-hosted/src/features/publish/index.ts
New hooks: use-publish-state, use-publish-editor, use-publish-post. New utils: markdown.ts (htmlToMarkdown, markdownToHtml), permlink.ts (createPermlink). Barrel export added.
Routing & route tree
apps/self-hosted/src/routes/publish.tsx, apps/self-hosted/src/routeTree.gen.ts
Added /publish route guarded by auth/blog-owner checks and integrated PublishRoute into generated route tree/types.
Auth actions & store
apps/self-hosted/src/features/auth/auth-actions.ts, apps/self-hosted/src/store/authentication.ts, apps/self-hosted/src/store/index.ts
New standalone auth actions (login, loginWithHivesigner, logout, broadcast) and a Zustand authenticationStore with useAuthStore, re-exported from store index.
Auth surface & UI updates
apps/self-hosted/src/features/auth/*, apps/self-hosted/src/features/auth/index.ts, apps/self-hosted/src/features/auth/types.ts
Moved many auth action usages to direct imports from auth-actions; reduced AuthContext surface; re-exported actions from feature index; some public component API changes (e.g., ReblogButton requires permlink, CommentForm adds parentPermlink).
Root route init
apps/self-hosted/src/routes/__root.tsx
Added beforeLoad to hydrate/validate persisted auth and HiveAuth session into the new store at app startup.
Local storage helper
apps/self-hosted/src/utils/use-synchronized-local-storage.ts
Added useSynchronizedLocalStorage to synchronize a localStorage key across instances via CustomEvent.
Blog sidebar & minor UI
apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx
Small layout/class adjustments and translation key/style standardization; no data-flow changes.
Other publish plumbing
apps/self-hosted/src/features/publish/*
New publish feature wiring: editor hook integration, state persistence, publish mutation, markdown converters, permlink generator, and barrel export.

Sequence Diagram

sequenceDiagram
    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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 Quick hops of code, a draft takes flight,
TipTap whiskers polish words just right,
Tags trimmed neat, permalinks take wing,
Local drafts saved in a cozy ring,
To Hive they go — a rabbit’s publishing delight!

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.95% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Self-hosted editor' directly relates to the main change: adding a comprehensive publishing editor feature (PublishEditor, PublishEditorToolbar, PublishActionBar, and supporting hooks/utilities) for the self-hosted application, along with authentication refactoring and routing integration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/self-hosted-editor

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟠 Major

Use href prop for external URLs instead of to; when createPostUrl is external, to won't work correctly.

TanStack Link supports external URLs, but requires the href prop (not to). Since createPostUrl defaults to "https://ecency.com/submit" and is configurable, it may be external. The code should detect external URLs and use href instead of to:

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
`![alt](src)` 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 @tiptap packages 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 exporting MAX_TITLE_LENGTH to 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 > 0 already validated.

Lines 32-34 throw an error if tags.length is 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: setEditorContent and publishState.content not in dependency array.

While the comment indicates "Only on mount" intent, this pattern can cause subtle bugs if editor is 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.

onUpdate fires 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: Missing to prop on Link component.

The Link component has search but to prop comes after. While this works, having to before search improves 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: Move usePublishEditor() 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's beforeLoad for route-level authorization.

The current useEffect-based redirect works but the component still mounts and hooks execute before the redirect. TanStack Router's beforeLoad can 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

Comment thread apps/self-hosted/package.json Outdated
Comment thread apps/self-hosted/src/features/publish/hooks/use-publish-post.ts Outdated
Comment thread apps/self-hosted/src/features/publish/hooks/use-publish-post.ts Outdated
Comment thread apps/self-hosted/src/features/publish/utils/markdown.ts
Comment thread apps/self-hosted/src/features/publish/utils/permlink.ts
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 redundant setValue call.

When setValueSync calls setValue(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 calls setValue(v) again. Same applies to clearValueSync. 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: CustomEvent only 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 native storage event, which fires in other tabs when localStorage is mutated.

Since the underlying useLocalStorage from react-use already writes to localStorage, the native storage event 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 — tags is guaranteed non-empty here.

The validation on line 32 already throws if tags.length === 0, so tags.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 duplicate max-w-[1024px] mx-auto.

The outer div (line 51) already applies max-w-[1024px] mx-auto, and the inner motion.div (line 65) repeats container max-w-[1024px] mx-auto plus 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"
       >

Comment thread apps/self-hosted/src/features/publish/hooks/use-publish-post.ts Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 | 🟡 Minor

Stale closure: status check on line 63 won't reflect event-listener updates during the same call.

When handleLogin executes, status in the closure is the value from the last render. The setStatus('idle') call on line 55 doesn't update the local status variable, and if the hiveauth:error event fires and calls setStatus('error') during the await login(...), the closure's status still holds the render-time value. So status !== 'error' on line 63 is effectively always true during 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 = true alongside setStatus('error').

apps/self-hosted/src/features/auth/auth-provider.tsx (1)

96-100: ⚠️ Potential issue | 🟡 Minor

isSessionExpiringSoon is stale — it never re-evaluates as time passes.

useMemo captures Date.now() once and only recomputes when user?.expiresAt changes. If a user's session has 10 minutes left at login, this stays false and never flips to true at the 5-minute mark. Consider driving this from the same interval that checks full expiry, using useState updated 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: useStore is redundant when using create.

In Zustand v4, create() returns a bound React hook directly. You can simplify useAuthStore by calling the store as a hook, reserving useStore for vanilla stores created with createStore.

♻️ 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() and getHiveAuthSession() already handle expiration.

Looking at apps/self-hosted/src/features/auth/storage.ts, both getUser() (line 29) and getHiveAuthSession() (line 85) already check expiry and return null (after clearing storage) when expired. The expiry checks on lines 36–39 and 46–47 here are dead code — storedUser and storedHiveAuth will 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: isDev is already defined at module scope (line 17) — reuse it instead of re-checking process.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: useBroadcast no longer uses any hooks — consider whether the hook wrapper is still needed.

Since useBroadcast now just returns a module-level import with no React hook calls, consumers could import broadcast directly from auth-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 — tags is guaranteed non-empty here.

Line 35 already validates tags.length > 0, so tags.length > 0 ? tags : [] is always tags.

Suggested fix
-            tags: tags.length > 0 ? tags : [],
+            tags,
apps/self-hosted/src/features/auth/components/vote-button.tsx (1)

44-71: broadcast in 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 broadcast in the deps array is harmless but misleading. Same applies in reblog-button.tsx (Line 61) and comment-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 in loginWithHivesigner(). This split is easy to misuse. Consider either performing the redirect inside the "hivesigner" case or throwing an error directing callers to use loginWithHivesigner() instead.


26-77: Missing default case in login() switch — unlike broadcast() which has one.

broadcast() correctly throws on an unknown loginType. login() silently falls through for any unrecognized method. Adding a default: 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: availableMethods creates a new array reference every render, defeating the useMemo on value.

(authConfig?.methods ?? []) as AuthMethod[] produces a fresh array on each render. Since it's listed as a dependency of the value memo (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 on blogOwner.

Line 43 already returns false when !blogOwner, so (blogOwner ?? "") on Line 45 is unnecessary.

✏️ Simplify
-    return (
-      (user.username ?? "").toLowerCase() === (blogOwner ?? "").toLowerCase()
-    );
+    return user.username?.toLowerCase() === blogOwner.toLowerCase();

50-70: setUser and setSession are 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-deps ESLint rule will flag it. Consider adding them to the array, or suppressing the lint with a comment if they're guaranteed stable.

@feruzm feruzm removed the major Breaking changes (1.0.0 → 2.0.0), add this only if any packages/ have major changes in current PR label Feb 10, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 or useMemo rather 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 the return, or
  • a small helper function/component.

The inner websiteUrl && guard on line 96 is also redundant — the outer data?.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: Dead setSession at outer scope; redundant getState() re-call inside onSuccess.

setSession destructured on line 27 is never used outside the onSuccess callback. Inside the callback, authenticationStore.getState() is called a second time (lines 52–53) to obtain the same setter. Because Zustand setters are stable references, setSession from line 27 and setSessionState from 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.

mutateAsync resolves only after the mutation's own onSuccess handler fires — which calls beforeNavigate() (clearAll) and then navigate({ to: "/blog", ... }). By the time line 37 is reached, the router has already been directed to /blog and the component may be in the process of unmounting. Any onSuccess callback that performs state updates or DOM interactions could behave unexpectedly.

If onSuccess must run before navigation, consider moving the callback into usePublishPost's onSuccess before the navigate call, or accept it as a parameter passed into usePublishPost.

🤖 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 inner motion.div.

The outer div (line 45) already applies max-w-[1024px] mx-auto. The motion.div's container max-w-[1024px] mx-auto is redundant and the container Tailwind class applies its own responsive max-width breakpoints, potentially conflicting with the explicit max-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 time validateTag is called — whether from addTag or handlePaste — 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 ##tag in 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: Extract sanitizeTagInput and related constants to a shared package

This function and its associated specialCharMap and specialCharRegex are duplicated from apps/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/ui or 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: Missing aria-expanded on the heading dropdown trigger button

Screen readers have no signal that this button controls a popup menu. Add aria-expanded and, 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.

Comment on lines +73 to +76
case "hivesigner":
// Redirect handles the rest
break;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

Comment on lines +54 to +58
<motion.div
initial={{ opacity: 0, y: -32 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -32 }}
transition={{ delay: 0.4 }}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find all files that reference PublishActionBar
rg -l "PublishActionBar" --type ts --type tsx

Repository: 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 -n

Repository: ecency/vision-next

Length of output: 89


🏁 Script executed:

# Find files containing PublishActionBar
rg "PublishActionBar" -n

Repository: 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.tsx

Repository: 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 -70

Repository: ecency/vision-next

Length of output: 2654


🏁 Script executed:

# Search for AnimatePresence usage throughout the codebase
rg "AnimatePresence" -n

Repository: 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.tsx

Repository: 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.tsx

Repository: 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.

Comment on lines +71 to +75
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"
}`}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 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"
fi

Repository: 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 -20

Repository: 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 tiptap

Repository: 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 -5

Repository: 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 -20

Repository: ecency/vision-next

Length of output: 1054


🏁 Script executed:

#!/bin/bash
# Try to find package.json
find . -maxdepth 2 -name "package.json" -type f

Repository: 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 f

Repository: ecency/vision-next

Length of output: 299


🏁 Script executed:

#!/bin/bash
# Read package.json
cat package.json

Repository: ecency/vision-next

Length of output: 2454


🏁 Script executed:

#!/bin/bash
# Check the self-hosted tsconfig for strict mode
cat apps/self-hosted/tsconfig.json

Repository: 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 -10

Repository: 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 -20

Repository: 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.json

Repository: 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 -10

Repository: 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 -5

Repository: 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 -20

Repository: 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 -10

Repository: 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 -50

Repository: 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 -100

Repository: 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 -10

Repository: 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/null

Repository: 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/null

Repository: 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 -5

Repository: ecency/vision-next

Length of output: 44


🏁 Script executed:

#!/bin/bash
# Check the actual structure of node_modules
ls -la node_modules | head -20

Repository: 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/null

Repository: 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 -50

Repository: 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.tsx

Repository: 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 -20

Repository: 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/null

Repository: 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/null

Repository: 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 -20

Repository: 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 ts

Repository: 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 -100

Repository: 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 }) => ReturnType

So usage is:

editor.commands.toggleHeading({ level: 1 }) // ... up to 6

Sources: 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.

Comment on lines +51 to +65
useEffect(() => {
if (!editor) {
return;
}

const handleSelectionUpdate = () => {
setIsFocusingTable(editor.isActive("table"));
};

editor.on("selectionUpdate", handleSelectionUpdate);

return () => {
editor.off("selectionUpdate", handleSelectionUpdate);
};
}, [editor]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Comment on lines +120 to +136
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]
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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.

Suggested change
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.

@feruzm feruzm merged commit ec51710 into develop Feb 19, 2026
1 check passed
@feruzm feruzm deleted the feature/self-hosted-editor branch February 19, 2026 14:38
@coderabbitai coderabbitai Bot mentioned this pull request Mar 15, 2026
This was referenced Apr 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants