fix(chat): normalize REST messages, full-width layout, and UI cleanup

- Normalize snake_case REST API responses to camelCase in useChat so
  messages are correctly attributed after page reload
- Remove max-w-3xl from chat-ui messages and composer for full-width
- Remove discussion header/border so chat fills the tablo section
- Remove plan badge pills from settings page header
- Use org logo in NavigationBar avatar instead of first-letter fallback
- Persist plan announcement in localStorage instead of sessionStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arthur Belleville 2026-04-14 22:10:12 +02:00
parent 14688afdeb
commit b7a6798cad
No known key found for this signature in database
8 changed files with 39 additions and 41 deletions

View file

@ -14,9 +14,6 @@ interface ChatMessage {
createdAt: string;
clientId: string;
optimistic?: boolean;
// REST API returns snake_case fields from DB, WS messages use camelCase
user_id?: string;
created_at?: string;
}
interface Member {
@ -69,16 +66,14 @@ export function ChatMessages({
const chatMessages = useMemo<ChatMessageData[]>(
() =>
messages.map((msg) => {
const userId = msg.userId || msg.user_id || "";
const createdAt = msg.createdAt || msg.created_at || "";
const member = membersById.get(userId);
const member = membersById.get(msg.userId);
return {
id: msg.id,
senderId: userId,
senderId: msg.userId,
senderName: member?.name ?? t("defaultUserName"),
senderAvatar: member?.avatar_url ?? undefined,
text: msg.text,
timestamp: new Date(createdAt),
timestamp: new Date(msg.createdAt),
status: msg.optimistic ? "sending" : undefined,
};
}),

View file

@ -163,6 +163,12 @@ export function UserMenuPopover({ isCollapsed }: { isCollapsed: boolean }) {
)}
>
<Avatar className="size-7">
{organizationData?.organization?.logo_url && (
<AvatarImage
src={`https://assets.xtablo.com/org-icons/${organizationData.organization.id}/icon-192.png`}
alt={organizationData.organization.name ?? "Organization"}
/>
)}
<AvatarFallback>
{organizationData?.organization?.name?.charAt(0).toUpperCase() ?? "O"}
</AvatarFallback>

View file

@ -32,11 +32,11 @@ export function PlanAnnouncement() {
const { active_subscription_plan } = organizationData;
if (!active_subscription_plan) return;
const lastAnnouncedPlan = sessionStorage.getItem(PLAN_ANNOUNCED_KEY);
const lastAnnouncedPlan = localStorage.getItem(PLAN_ANNOUNCED_KEY);
if (lastAnnouncedPlan === active_subscription_plan) return;
hasAnnounced.current = true;
sessionStorage.setItem(PLAN_ANNOUNCED_KEY, active_subscription_plan);
localStorage.setItem(PLAN_ANNOUNCED_KEY, active_subscription_plan);
const label = PLAN_LABELS[active_subscription_plan];
if (!label) return;

View file

@ -1,19 +1,16 @@
import { UserTablo } from "@xtablo/shared/types/tablos.types";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useChat } from "../hooks/useChat";
import { useTabloMembers } from "../hooks/tablos";
import { useUser } from "../providers/UserStoreProvider";
import { ChatMessages } from "./ChatMessages";
import { TabloHeaderActions } from "./TabloHeaderActions";
interface TabloDiscussionSectionProps {
tablo: UserTablo;
isAdmin: boolean;
}
export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectionProps) => {
const { t } = useTranslation("chat");
export const TabloDiscussionSection = ({ tablo }: TabloDiscussionSectionProps) => {
const user = useUser();
const {
messages,
@ -36,15 +33,7 @@ export const TabloDiscussionSection = ({ tablo, isAdmin }: TabloDiscussionSectio
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex justify-between items-start mb-4 shrink-0">
<div>
<h1 className="text-3xl font-bold text-foreground">{t("discussionTitle")}</h1>
<p className="text-muted-foreground mt-1">{t("discussionSubtitle")}</p>
</div>
<TabloHeaderActions tablo={tablo} isAdmin={isAdmin} />
</div>
<div className="flex-1 bg-card rounded-lg border border-border overflow-hidden min-h-0">
<div className="flex-1 overflow-hidden min-h-0">
<ChatMessages
messages={messages}
currentUserId={user.id}

View file

@ -11,6 +11,25 @@ interface ChatMessage {
optimistic?: boolean;
}
/** Raw shape returned by the REST API (snake_case from PostgREST). */
interface RawApiMessage {
id: string;
user_id: string;
text: string;
created_at: string;
client_id?: string;
}
function normalizeMessage(raw: RawApiMessage): ChatMessage {
return {
id: raw.id,
userId: raw.user_id,
text: raw.text,
createdAt: raw.created_at,
clientId: raw.client_id ?? "",
};
}
type ServerMessage =
| { type: "message.new"; id: string; userId: string; text: string; createdAt: string; clientId: string }
| { type: "typing"; userId: string; isTyping: boolean }
@ -49,15 +68,16 @@ export function useChat(channelId: string | undefined) {
if (!res.ok) return;
const data = await res.json() as { messages: ChatMessage[]; hasMore: boolean };
const data = await res.json() as { messages: RawApiMessage[]; hasMore: boolean };
const normalized = data.messages.map(normalizeMessage);
setHasMoreMessages(data.hasMore);
if (before) {
// Prepend older messages
setMessages((prev) => [...data.messages, ...prev]);
setMessages((prev) => [...normalized, ...prev]);
} else {
// Initial load
setMessages(data.messages);
setMessages(normalized);
}
}, [channelId, token]);

View file

@ -25,7 +25,6 @@ import { TypographyH3, TypographyMuted, TypographySmall } from "@xtablo/ui/compo
import { CameraIcon, CookieIcon, Loader2Icon, Trash2Icon, UploadIcon } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@xtablo/ui/components/badge";
import { LanguageSelector } from "../components/LanguageSelector";
import { SubscriptionCard } from "../components/SubscriptionCard";
import { useIntroduction } from "../hooks/intros";
@ -243,17 +242,6 @@ export default function SettingsPage() {
<TypographyMuted>{t("settings:subtitle")}</TypographyMuted>
</div>
<div className="flex flex-wrap items-center gap-2 mt-2">
{organizationData?.active_subscription_plan === "annual" && (
<Badge className="bg-linear-to-r from-purple-500 to-blue-500 text-white border-transparent text-xs">
Founder
</Badge>
)}
{organizationData?.active_subscription_plan === "team" && (
<Badge color="indigo">Teams</Badge>
)}
{organizationData?.active_subscription_plan === "solo" && (
<Badge color="zinc">Solo</Badge>
)}
<LanguageSelector />
</div>
</div>

View file

@ -500,7 +500,7 @@ export const TabloDetailsPage = () => {
</div>
{/* ── Tab content ─────────────────────────────────────────────────── */}
<div className={cn("px-4 sm:px-6 pt-6 pb-8", activeSection === "discussion" && "flex flex-col flex-1 min-h-0")}>
<div className={cn("px-4 sm:px-6 pt-6 pb-8", activeSection === "discussion" && "flex flex-col flex-1 min-h-0 !px-0 !pt-0 !pb-0")}>
{activeSection === "overview" &&
(() => {
const overviewBlocks: Record<OverviewBlockId, React.ReactNode> = {

View file

@ -1017,7 +1017,7 @@ function ChatMessages({
role="log"
aria-live="polite"
>
<div className="mx-auto w-full max-w-3xl">
<div className="w-full">
{items.map((item, i) => {
switch (item.type) {
case "date":
@ -1286,7 +1286,7 @@ function ChatComposer({
{/* Composer body — frosted glass */}
<div className="border-t border-border bg-card px-3 py-2 backdrop-blur-[20px] backdrop-saturate-[180%]">
<div className="mx-auto max-w-3xl">
<div>
{/* Input row */}
<div className="flex items-end gap-2">
{/* + button with attachment popout */}