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:
parent
14688afdeb
commit
b7a6798cad
8 changed files with 39 additions and 41 deletions
|
|
@ -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,
|
||||
};
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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> = {
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
Loading…
Reference in a new issue