Skip to content

Refactor: Extract useComposerState hook for UI state management #669

@hellno

Description

@hellno

Summary

Extract composer UI state from useNavigationStore and useDraftStore into a dedicated useComposerState hook. This separates concerns: data (drafts) vs UI (modal open/close, active draft selection).

Current Problem

Composer state is scattered across two stores:

// useNavigationStore.ts
isNewCastModalOpen: boolean;
castModalDraftId?: UUID;
castModalView: CastModalView;  // 'new' | 'reply' | 'quote'

// useDraftStore.ts  
isDraftsModalOpen: boolean;  // Different modal\!

This creates:

  • Confusion about which store to use
  • Tight coupling between draft creation and modal opening
  • Duplicate modal state for related features

Proposed Solution

Create useComposerState hook that owns all composer UI state:

// src/stores/useComposerState.ts

interface ComposerState {
  // Modal state
  isOpen: boolean;
  
  // Active draft
  activeDraftId: UUID | null;
  
  // Compose mode
  mode: 'new' | 'reply' | 'quote';
  
  // Context for reply/quote (the cast being replied to or quoted)
  targetCast: CastType | null;
  
  // For thread mode
  isThreadMode: boolean;
  activeThreadId: UUID | null;
}

interface ComposerActions {
  // Open composer
  openNewComposer: (options?: { parentUrl?: string }) => void;
  openReplyComposer: (cast: CastType) => void;
  openQuoteComposer: (cast: CastType) => void;
  openThreadComposer: () => void;
  
  // Close composer
  closeComposer: (options?: { keepDraft?: boolean }) => void;
  
  // Draft management within composer
  switchToDraft: (draftId: UUID) => void;
  
  // Thread mode
  toggleThreadMode: () => void;
}

Key Behaviors

Smart Close Logic

closeComposer: ({ keepDraft = false } = {}) => {
  const { activeDraftId } = get();
  const draft = useDraftStore.getState().getDraft(activeDraftId);
  
  if (\!keepDraft && draft) {
    const isEmpty = \!draft.text?.trim() && \!draft.embeds?.length;
    const wasPublished = draft.status === 'published';
    
    if (isEmpty || wasPublished) {
      useDraftStore.getState().deleteDraft(activeDraftId);
    }
    // Content exists → keep draft, user can find it in /post
  }
  
  set({
    isOpen: false,
    activeDraftId: null,
    targetCast: null,
  });
}

Open Reply Composer

openReplyComposer: (cast: CastType) => {
  const { createDraft, findDraftByParent } = useDraftStore.getState();
  
  // Check for existing reply draft
  const existingDraft = findDraftByParent(undefined, {
    fid: parseInt(cast.author.fid),
    hash: cast.hash,
  });
  
  const draftId = existingDraft?.id ?? createDraft({
    parentCastId: { fid: parseInt(cast.author.fid), hash: cast.hash },
  });
  
  set({
    isOpen: true,
    activeDraftId: draftId,
    mode: 'reply',
    targetCast: cast,
  });
}

Migration Path

Phase 1: Create Hook

  1. Create src/stores/useComposerState.ts
  2. Implement all state and actions
  3. Add to app initialization

Phase 2: Update Components

Replace scattered state usage:

// Before (in CastRow.tsx)
const { setCastModalDraftId, setCastModalView, openNewCastModal } = useNavigationStore();
addNewPostDraft({
  parentCastId: {...},
  onSuccess(draftId) {
    setCastModalDraftId(draftId);
    setCastModalView(CastModalView.Reply);
    openNewCastModal();
  },
});

// After
const { openReplyComposer } = useComposerState();
openReplyComposer(cast);  // One call does everything

Phase 3: Cleanup

  • Remove isNewCastModalOpen, castModalDraftId, castModalView from useNavigationStore
  • Remove isDraftsModalOpen from useDraftStore

Acceptance Criteria

  • Create useComposerState store with all state fields
  • Implement openNewComposer, openReplyComposer, openQuoteComposer
  • Implement closeComposer with smart draft cleanup
  • Implement switchToDraft for switching between drafts
  • Update CastRow.tsx to use new hook for reply/quote
  • Update feeds/page.tsx to use new hook
  • Update inbox/page.tsx to use new hook
  • Update home/index.tsx to use new hook
  • Remove old state from useNavigationStore
  • Remove isDraftsModalOpen from useDraftStore

Files to Create

  • src/stores/useComposerState.ts - New hook

Files to Modify

  • src/stores/useNavigationStore.ts - Remove composer state
  • src/stores/useDraftStore.ts - Remove isDraftsModalOpen
  • src/common/components/CastRow.tsx - Use new hook
  • src/common/components/NewCastModal.tsx - Use new hook
  • app/(app)/feeds/page.tsx - Use new hook
  • app/(app)/inbox/page.tsx - Use new hook
  • src/home/index.tsx - Use new hook

Dependencies

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions