Skip to content

UX Core Features Implementation Plan

NOTE

This document may not reflect the current implementation. See the final report for up-to-date state: Final Report

For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Implement 4 UX core features — infinite scroll pagination, post composer on home/feed pages, image upload in the TipTap editor, and mention autocomplete.

Architecture: Each feature is independent and touches different files. We use TanStack Query's useInfiniteQuery for pagination, a dialog-based post composer component for home, Active Storage direct upload for images, and TipTap's Mention extension with the existing useSearchMembers hook.

Tech Stack: TanStack Query (useInfiniteQuery), TipTap (Mention extension), Active Storage direct upload API, Radix Dialog, Lucide React icons.


Task 1: Paginated Home Feed (Infinite Scroll)

Covers: Pagination for home/feed pages

Files:

  • Modify: src/lib/api/spaces.tsx — add useInfiniteSpacesHome hook

  • Modify: src/routes/index.tsx — use infinite query + IntersectionObserver

  • Modify: src/routes/feed.tsx — same as index

  • [ ] Step 1: Add useInfiniteSpacesHome hook to spaces.tsx

Add the following after the existing useSpacesHome function (around line 49):

typescript
export function useInfiniteSpacesHome() {
  return useInfiniteQuery({
    queryKey: ["spaces", "home", "infinite"],
    queryFn: ({ pageParam = 1 }) =>
      authedFetch<PostsResponse>(
        `/api/headless/v1/spaces/home?page=${pageParam}&per_page=10`,
      ),
    initialPageParam: 1,
    getNextPageParam: (lastPage) =>
      lastPage.has_next_page ? lastPage.page + 1 : undefined,
    select: (data) => ({
      ...data,
      pages: data.pages.map((p) => p.records.map(mapPost)),
    }),
  });
}

Make sure to add useInfiniteQuery to the imports at the top:

typescript
import { useQuery, useMutation, useQueryClient, useInfiniteQuery } from "@tanstack/react-query";
  • [ ] Step 2: Update index.tsx to use infinite scroll

Replace the useSpacesHome call and the post list rendering in src/routes/index.tsx:

  1. Change the import to include useInfiniteSpacesHome:
typescript
import { useInfiniteSpacesHome, useSpaces } from "@/lib/api/spaces";
  1. Replace useSpacesHome with useInfiniteSpacesHome:
typescript
const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteSpacesHome();
const homePosts = data?.pages.flatMap((p) => p) ?? [];
  1. Add IntersectionObserver ref after the post list. Replace the post rendering block (lines 68-74) with:
tsx
{isLoading ? (
  <div className="space-y-4 sm:space-y-5">
    <PostSkeleton />
    <PostSkeleton />
  </div>
) : isError ? (
  <EmptyState
    title="Não foi possível carregar os comunicados"
    description="Tente novamente em alguns instantes."
  />
) : homePosts.length === 0 ? (
  <EmptyState
    title="Nenhum comunicado ainda"
    description="Os comunicados da comunidade aparecerão aqui."
  />
) : (
  <>
    <div className="space-y-4 sm:space-y-5">
      {homePosts.map((p) => (
        <PostCard key={p.id} post={p} />
      ))}
    </div>
    {hasNextPage && (
      <div ref={(el) => {
        if (el) {
          const observer = new IntersectionObserver(
            ([entry]) => { if (entry.isIntersecting) fetchNextPage(); },
            { threshold: 0.1 },
          );
          observer.observe(el);
          return () => observer.disconnect();
        }
      }} className="py-4 text-center text-sm text-muted-foreground">
        {isFetchingNextPage ? "Carregando mais..." : ""}
      </div>
    )}
  </>
)}
  1. Add the useRef import and create a proper ref-based observer. Actually, to keep it simple and avoid observer cleanup issues with the inline pattern, use this cleaner approach instead — create the observer outside the render:

Replace the full function body with:

tsx
function Index() {
  const [menuOpen, setMenuOpen] = useState(false);
  const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteSpacesHome();
  const { isLoading: loading } = useSpaces();
  const loadMoreRef = useState<HTMLDivElement | null>(null);

  useEffect(() => {
    const el = loadMoreRef[0];
    if (!el || !hasNextPage) return;
    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) fetchNextPage(); },
      { threshold: 0.1 },
    );
    observer.observe(el);
    return () => observer.disconnect();
  }, [hasNextPage, fetchNextPage]);

  const homePosts = data?.pages.flatMap((p) => p) ?? [];

  return (
    <div className="min-h-screen bg-background text-foreground">
      <TopBar onMenu={() => setMenuOpen(true)} />
      <MobileSidebar open={menuOpen} onClose={() => setMenuOpen(false)} />
      <div className="flex">
        <Sidebar loading={loading} />
        <main className="flex-1 min-w-0">
          <div className="max-w-5xl mx-auto px-3 sm:px-6 py-4 sm:py-6 pb-20 md:pb-6 flex gap-6">
            <div className="flex-1 min-w-0">
              <div className="flex items-center justify-between mb-4 sm:mb-5">
                <h1 className="flex items-center gap-2 text-base font-semibold">
                  <Megaphone className="h-4 w-4" /> Comunicados
                </h1>
                <div className="flex items-center gap-1">
                  <button className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground px-2 py-1 rounded-md hover:bg-accent">
                    Latest <ChevronDown className="h-3.5 w-3.5" />
                  </button>
                  <button className="h-9 w-9 grid place-items-center rounded-md bg-primary text-primary-foreground hover:opacity-90" aria-label="New post">
                    <Plus className="h-4 w-4" />
                  </button>
                  <button className="h-9 w-9 hidden sm:grid place-items-center rounded-md hover:bg-accent text-muted-foreground">
                    <MoreHorizontal className="h-4 w-4" />
                  </button>
                </div>
              </div>

              {isLoading ? (
                <div className="space-y-4 sm:space-y-5">
                  <PostSkeleton />
                  <PostSkeleton />
                </div>
              ) : isError ? (
                <EmptyState
                  title="Não foi possível carregar os comunicados"
                  description="Tente novamente em alguns instantes."
                />
              ) : homePosts.length === 0 ? (
                <EmptyState
                  title="Nenhum comunicado ainda"
                  description="Os comunicados da comunidade aparecerão aqui."
                />
              ) : (
                <>
                  <div className="space-y-4 sm:space-y-5">
                    {homePosts.map((p) => (
                      <PostCard key={p.id} post={p} />
                    ))}
                  </div>
                  {hasNextPage && <div ref={loadMoreRef[1]} className="py-4 text-center text-sm text-muted-foreground" />}
                  {isFetchingNextPage && <p className="py-4 text-center text-sm text-muted-foreground">Carregando mais...</p>}
                </>
              )}

              {loading ? (
                <section className="xl:hidden bg-card rounded-xl border border-border p-4 mt-4">
                  <Skeleton className="h-5 w-32 mb-3" />
                  <div className="space-y-3">
                    {[1, 2, 3, 4].map((i) => (
                      <div key={i} className="space-y-1.5">
                        <Skeleton className="h-4 w-full" />
                        <Skeleton className="h-4 w-5/6" />
                      </div>
                    ))}
                  </div>
                </section>
              ) : (
                <RightPanelMobile />
              )}
            </div>
            <RightPanel loading={loading} />
          </div>
        </main>
      </div>
      <MobileBottomNav />
    </div>
  );
}
  • [ ] Step 3: Update feed.tsx with the same infinite scroll pattern

Apply the same changes as Step 2 to src/routes/feed.tsx:

  • Import useInfiniteSpacesHome and useEffect

  • Replace useSpacesHome with useInfiniteSpacesHome

  • Add IntersectionObserver ref

  • Render posts from data?.pages.flatMap((p) => p) ?? []

  • [ ] Step 4: Add infinite scroll to space posts

Add useInfiniteSpacePosts to src/lib/api/spaces.tsx:

typescript
export function useInfiniteSpacePosts(spaceId: number | string) {
  return useInfiniteQuery({
    queryKey: ["spaces", spaceId, "posts", "infinite"],
    queryFn: ({ pageParam = 1 }) =>
      authedFetch<PostsResponse>(
        `/api/headless/v1/spaces/${spaceId}/posts?page=${pageParam}&per_page=10`,
      ),
    initialPageParam: 1,
    getNextPageParam: (lastPage) =>
      lastPage.has_next_page ? lastPage.page + 1 : undefined,
    enabled: !!spaceId,
    select: (data) => ({
      ...data,
      pages: data.pages.map((p) => p.records.map(mapPost)),
    }),
  });
}

Update src/routes/spaces/$spaceId.tsx to use useInfiniteSpacePosts with the same IntersectionObserver pattern.

  • [ ] Step 5: Commit
bash
git add frontend/src/lib/api/spaces.tsx frontend/src/routes/index.tsx frontend/src/routes/feed.tsx frontend/src/routes/spaces/\$spaceId.tsx
git commit -m "feat: add infinite scroll pagination to home feed and space posts"

Task 2: Post Composer on Home/Feed Pages

Covers: Wire the "+" button on home/feed pages to open a post composer

Files:

  • Create: src/components/circle/PostComposer.tsx — reusable post composer dialog

  • Modify: src/routes/index.tsx — wire "+" button

  • Modify: src/routes/feed.tsx — wire "+" button

  • [ ] Step 1: Create PostComposer component

Create src/components/circle/PostComposer.tsx:

tsx
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { TipTapEditor } from "@/components/editor/TipTapEditor";
import { jsonToHtml, emptyDoc, isEmptyDoc } from "@/lib/editor";
import type { JSONContent } from "@tiptap/react";
import { useSpaces, useCreatePost } from "@/lib/api/spaces";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";

type PostComposerProps = {
  open: boolean;
  onOpenChange: (open: boolean) => void;
};

export function PostComposer({ open, onOpenChange }: PostComposerProps) {
  const [title, setTitle] = useState("");
  const [body, setBody] = useState<JSONContent>(emptyDoc());
  const [selectedSpaceId, setSelectedSpaceId] = useState<string>("");
  const { data: spaces } = useSpaces();
  const createPost = useCreatePost();

  const memberSpaces = (spaces ?? []).filter((s) => s.is_member && !s.is_post_disabled);

  const handleSubmit = () => {
    if (!selectedSpaceId || isEmptyDoc(body)) return;
    createPost.mutate(
      {
        space_id: selectedSpaceId,
        title: title.trim() || undefined,
        tiptap_body: { body },
        body_plain_text: jsonToHtml(body).replace(/<[^>]*>/g, "").trim(),
      },
      {
        onSuccess: () => {
          setTitle("");
          setBody(emptyDoc());
          setSelectedSpaceId("");
          onOpenChange(false);
          toast.success("Post publicado!");
        },
        onError: () => {
          toast.error("Erro ao publicar. Tente novamente.");
        },
      },
    );
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
        <DialogHeader>
          <DialogTitle>Criar post</DialogTitle>
        </DialogHeader>
        <div className="space-y-4 mt-2">
          <div>
            <label className="text-sm font-medium mb-1.5 block">Space</label>
            <Select value={selectedSpaceId} onValueChange={setSelectedSpaceId}>
              <SelectTrigger>
                <SelectValue placeholder="Selecione um space" />
              </SelectTrigger>
              <SelectContent>
                {memberSpaces.map((s) => (
                  <SelectItem key={s.id} value={String(s.id)}>
                    {s.name}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </div>
          <div>
            <label className="text-sm font-medium mb-1.5 block">Título (opcional)</label>
            <input
              value={title}
              onChange={(e) => setTitle(e.target.value)}
              placeholder="Título do post"
              className="w-full bg-background rounded-lg px-3 py-2 border border-border text-sm outline-none focus:border-primary/50"
            />
          </div>
          <TipTapEditor
            content={body}
            onChange={setBody}
            placeholder="No que você está pensando?"
          />
          <div className="flex justify-end">
            <button
              onClick={handleSubmit}
              disabled={createPost.isPending || !selectedSpaceId || isEmptyDoc(body)}
              className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
            >
              {createPost.isPending ? "Publicando..." : "Publicar"}
            </button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}
  • [ ] Step 2: Wire PostComposer in index.tsx

In src/routes/index.tsx:

  1. Add import:
typescript
import { PostComposer } from "@/components/circle/PostComposer";
  1. Add state after menuOpen:
typescript
const [composerOpen, setComposerOpen] = useState(false);
  1. Add <PostComposer open={composerOpen} onOpenChange={setComposerOpen} /> before </div> (inside the main wrapper).

  2. Replace the "+" button's aria-label="New post" button with:

tsx
<button
  onClick={() => setComposerOpen(true)}
  className="h-9 w-9 grid place-items-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
  aria-label="New post"
>
  <Plus className="h-4 w-4" />
</button>
  • [ ] Step 3: Wire PostComposer in feed.tsx

Apply the same changes as Step 2 to src/routes/feed.tsx.

  • [ ] Step 4: Commit
bash
git add frontend/src/components/circle/PostComposer.tsx frontend/src/routes/index.tsx frontend/src/routes/feed.tsx
git commit -m "feat: add post composer dialog on home and feed pages"

Task 3: Image Upload in TipTap Editor

Covers: File upload integration with Active Storage direct upload API

Files:

  • Modify: src/lib/auth.ts — add authedUpload helper

  • Modify: src/components/editor/EditorToolbar.tsx — replace image button with file picker

  • Modify: src/components/editor/TipTapEditor.tsx — no changes needed (Image extension already configured)

  • [ ] Step 1: Add authedUpload helper to auth.ts

Add at the end of src/lib/auth.ts:

typescript
export async function authedUpload<T>(url: string, body: unknown): Promise<T> {
  let token = getAccessToken();

  if (!token || isTokenExpired(token)) {
    token = await refreshAccessToken();
  }

  if (!token) {
    throw new Error("Not authenticated");
  }

  const res = await fetch(url, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(body),
  });

  if (res.status === 401) {
    const newToken = await refreshAccessToken();
    if (!newToken) throw new Error("Not authenticated");

    const retryRes = await fetch(url, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${newToken}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    });

    if (!retryRes.ok) {
      const data = (await retryRes.json().catch(() => ({}))) as { message?: string };
      throw new Error(data.message ?? `Upload failed with status ${retryRes.status}`);
    }

    return retryRes.json() as Promise<T>;
  }

  if (!res.ok) {
    const data = (await res.json().catch(() => ({}))) as { message?: string };
    throw new Error(data.message ?? `Upload failed with status ${res.status}`);
  }

  return res.json() as Promise<T>;
}
  • [ ] Step 2: Add image upload hook to spaces.tsx

Add to src/lib/api/spaces.tsx:

typescript
export function useUploadImage() {
  return useMutation({
    mutationFn: async (file: File) => {
      const directUpload = await authedUpload<{
        id: number;
        signed_id: string;
        direct_upload: { url: string; headers: Record<string, string> };
      }>("/api/headless/v1/direct_uploads", {
        blob: {
          filename: file.name,
          content_type: file.type,
          metadata: { identified: true },
        },
      });

      await fetch(directUpload.direct_upload.url, {
        method: "PUT",
        headers: directUpload.direct_upload.headers,
        body: file,
      });

      return directUpload.signed_id;
    },
  });
}

Add authedUpload to the imports.

  • [ ] Step 3: Update EditorToolbar to support file upload

Replace the addImage function in src/components/editor/EditorToolbar.tsx:

tsx
import { useRef } from "react";

Add useRef to imports. Then update the addImage function:

tsx
const fileInputRef = useRef<HTMLInputElement>(null);

const addImage = () => {
  fileInputRef.current?.click();
};

const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
  const file = e.target.files?.[0];
  if (!file) return;
  const url = URL.createObjectURL(file);
  editor.chain().focus().setImage({ src: url }).run();
  e.target.value = "";
};

Update the JSX to include the hidden file input:

tsx
<input
  ref={fileInputRef}
  type="file"
  accept="image/*"
  className="hidden"
  onChange={handleImageUpload}
/>
<ToolbarBtn onClick={addImage}>
  <Image className="h-4 w-4" />
</ToolbarBtn>
  • [ ] Step 4: Commit
bash
git add frontend/src/lib/auth.ts frontend/src/lib/api/spaces.tsx frontend/src/components/editor/EditorToolbar.tsx
git commit -m "feat: add image upload support to TipTap editor toolbar"

Task 4: Mention Autocomplete in TipTap Editor

Covers: @mention autocomplete using TipTap Mention extension and member search API

Files:

  • Modify: src/components/editor/TipTapEditor.tsx — add Mention extension

  • Modify: src/components/editor/MentionList.tsx — create suggestion dropdown component

  • [ ] Step 1: Create MentionList suggestion component

Create src/components/editor/MentionList.tsx:

tsx
import { forwardRef, useEffect, useState } from "react";

export type MentionSuggestion = {
  id: string;
  name: string;
};

type MentionListProps = {
  items: MentionSuggestion[];
  command: (item: MentionSuggestion) => void;
};

export const MentionList = forwardRef<HTMLDivElement, MentionListProps>(
  ({ items, command }, ref) => {
    const [selectedIndex, setSelectedIndex] = useState(0);

    useEffect(() => {
      setSelectedIndex(0);
    }, [items]);

    useEffect(() => {
      const onKeyDown = (e: KeyboardEvent) => {
        if (e.key === "ArrowUp") {
          setSelectedIndex((i) => (i + items.length - 1) % items.length);
          return true;
        }
        if (e.key === "ArrowDown") {
          setSelectedIndex((i) => (i + 1) % items.length);
          return true;
        }
        if (e.key === "Enter") {
          e.preventDefault();
          if (items[selectedIndex]) command(items[selectedIndex]);
          return true;
        }
        return false;
      };

      document.addEventListener("keydown", onKeyDown);
      return () => document.removeEventListener("keydown", onKeyDown);
    }, [items, selectedIndex, command]);

    if (items.length === 0) {
      return (
        <div ref={ref} className="bg-popover border border-border rounded-lg shadow-lg p-2 text-sm text-muted-foreground">
          Nenhum resultado
        </div>
      );
    }

    return (
      <div ref={ref} className="bg-popover border border-border rounded-lg shadow-lg py-1 max-h-48 overflow-y-auto">
        {items.map((item, i) => (
          <button
            key={item.id}
            onClick={() => command(item)}
            className={`w-full text-left px-3 py-1.5 text-sm hover:bg-accent transition ${
              i === selectedIndex ? "bg-accent" : ""
            }`}
          >
            {item.name}
          </button>
        ))}
      </div>
    );
  },
);

MentionList.displayName = "MentionList";
  • [ ] Step 2: Add Mention extension to TipTapEditor

Update src/components/editor/TipTapEditor.tsx:

  1. Add imports:
typescript
import Mention from "@tiptap/extension-mention";
import { ReactRenderer } from "@tiptap/react";
import tippy, { type Instance as TippyInstance } from "tippy.js";
import { MentionList, type MentionSuggestion } from "./MentionList";
import { authedFetch } from "@/lib/auth";
  1. Add the Mention extension to the extensions array:
typescript
Mention.configure({
  suggestion: {
    items: async ({ query }) => {
      try {
        const data = await authedFetch<{ records: { id: number; name: string }[] }>(
          `/api/headless/v1/search/community_members?q=${encodeURIComponent(query)}`,
        );
        return data.records.slice(0, 5).map((m) => ({
          id: String(m.id),
          name: m.name,
        }));
      } catch {
        return [];
      }
    },
    render: () => {
      let component: ReactRenderer;
      let popup: TippyInstance[];

      return {
        onStart: (props: { items: MentionSuggestion[]; command: (item: MentionSuggestion) => void }) => {
          component = new ReactRenderer(MentionList, {
            props,
            editor: props.editor,
          });

          popup = tippy("body", {
            getReferenceClientRect: props.clientRect,
            appendTo: () => document.body,
            content: component.element,
            showOnCreate: true,
            interactive: true,
            trigger: "manual",
            placement: "bottom-start",
          });
        },
        onUpdate(props: { items: MentionSuggestion[]; command: (item: MentionSuggestion) => void }) {
          component?.updateProps(props);
          popup?.[0]?.[0]?.setProps({
            getReferenceClientRect: props.clientRect,
          });
        },
        onKeyDown(props: { event: KeyboardEvent }) {
          if (props.event.key === "Escape") {
            popup?.[0]?.[0]?.hide();
            return true;
          }
          return (component?.ref as any)?.onKeyDown(props.event) ?? false;
        },
        onExit() {
          popup?.[0]?.[0]?.destroy();
          component?.destroy();
        },
      };
    },
    char: "@",
  },
}),
  1. Add tippy.js is already available via tippy (install if needed):
bash
bun add tippy.js
  • [ ] Step 3: Install tippy.js dependency
bash
cd frontend && bun add tippy.js
  • [ ] Step 4: Commit
bash
git add frontend/src/components/editor/TipTapEditor.tsx frontend/src/components/editor/MentionList.tsx frontend/bun.lock
git commit -m "feat: add @mention autocomplete to TipTap editor"

Task 5: Verify All Features

  • [ ] Step 1: Run type check
bash
cd frontend && bun run lint
  • [ ] Step 2: Run build
bash
cd frontend && bun run build
  • [ ] Step 3: Fix any type or build errors

  • [ ] Step 4: Commit fixes if needed

bash
git commit -m "fix: resolve type and build errors for UX core features"

Strum — Documentação.