Link tablos and chat + enhance UX

This commit is contained in:
Arthur Belleville 2025-07-06 11:57:15 +02:00
parent c61dbe1271
commit d4758159e6
No known key found for this signature in database
6 changed files with 301 additions and 12 deletions

View file

@ -83,7 +83,17 @@ export const App = () => {
element={
<Layout>
<ChatProvider>
<ChatPage />
{(client) => <ChatPage client={client} />}
</ChatProvider>
</Layout>
}
/>
<Route
path="chat/:channelId"
element={
<Layout>
<ChatProvider>
{(client) => <ChatPage client={client} />}
</ChatProvider>
</Layout>
}

View file

@ -0,0 +1,210 @@
import { Avatar, AvatarBadge } from "@ui/ui-library/avatar";
import { Badge } from "@ui/ui-library/badge";
import { AvailableIcon } from "@ui/ui-library/icons";
import { ReactNode } from "react";
import { Channel } from "stream-chat";
import { twMerge } from "tailwind-merge";
interface ChannelPreviewProps {
channel: Channel;
displayTitle: string | undefined;
activeChannel?: Channel;
setActiveChannel?: (channel: Channel) => void;
unreadCount?: number;
latestMessagePreview?: ReactNode;
className?: string;
}
function formatTimestamp(timestamp: string | Date): string {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "now";
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
if (days < 7) return `${days}d`;
return date.toLocaleDateString();
}
function getChannelName(
displayTitle: string | undefined,
channel: Channel
): string {
if (displayTitle) return displayTitle;
if (channel.data?.config?.name) return channel.data.config.name;
// For direct messages, show other participant names
const members = Object.values(channel.state?.members || {});
const otherMembers = members.filter(
// @ts-expect-error TODO: fix this
(member) => member.user?.id !== channel.data?.config?.created_by?.id
);
if (otherMembers.length === 1) {
return otherMembers[0].user?.name || otherMembers[0].user?.id || "Unknown";
} else if (otherMembers.length > 1) {
return otherMembers
.map((member) => member.user?.name || member.user?.id)
.join(", ");
}
return "Channel";
}
function getChannelAvatar(
displayTitle: string | undefined,
channel: Channel
): {
src?: string;
alt: string;
} {
// @ts-expect-error TODO: fix this
if (channel.data?.config?.image) {
return {
// @ts-expect-error TODO: fix this
src: channel.data.config.image,
alt: getChannelName(displayTitle, channel),
};
}
// For direct messages, use the first other participant's avatar
const members = Object.values(channel.state?.members || {});
const otherMembers = members.filter(
// @ts-expect-error TODO: fix this
(member) => member.user?.id !== channel.data?.config?.created_by?.id
);
if (otherMembers.length > 0) {
const firstMember = otherMembers[0];
return {
src: firstMember.user?.image,
alt: firstMember.user?.name || firstMember.user?.id || "User",
};
}
return { alt: getChannelName(displayTitle, channel) };
}
// function getLastMessagePreview(lastMessage?: StreamMessage): string {
// if (!lastMessage) return "No messages yet";
// if (lastMessage.deleted_at) return "Message deleted";
// if (lastMessage.text) {
// return lastMessage.text.length > 50
// ? lastMessage.text.substring(0, 50) + "..."
// : lastMessage.text;
// }
// if (lastMessage.attachments?.length && lastMessage.attachments.length > 0) {
// const attachment = lastMessage.attachments[0];
// if (attachment.type === "image") return "📷 Image";
// if (attachment.type === "video") return "🎥 Video";
// if (attachment.type === "file") return "📎 File";
// }
// return "Message";
// }
function isUserOnline(channel: Channel): boolean {
const members = Object.values(channel.state?.members || {});
const otherMembers = members.filter(
// @ts-expect-error TODO: fix this
(member) => member.user?.id !== channel.data?.config?.created_by?.id
);
return otherMembers.some((member) => member.user?.online);
}
export function ChannelPreview({
displayTitle,
channel,
activeChannel,
setActiveChannel,
unreadCount = 0,
latestMessagePreview,
className,
}: ChannelPreviewProps) {
const isActive = activeChannel?.id === channel.id;
const avatar = getChannelAvatar(displayTitle, channel);
const isOnline = isUserOnline(channel);
const timestamp = channel.data?.created_at;
const handleClick = () => {
setActiveChannel?.(channel);
};
return (
<div
className={twMerge(
"group relative flex items-center gap-3 p-3 cursor-pointer transition-all duration-200 hover:bg-gray-50 dark:hover:bg-gray-800/50 border-b border-gray-100 dark:border-gray-800",
isActive &&
"bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800",
className
)}
onClick={handleClick}
>
{/* Avatar with online status */}
<div className="relative">
<Avatar
src={avatar.src}
alt={avatar.alt}
className="size-12 ring-2 ring-transparent group-hover:ring-gray-200 dark:group-hover:ring-gray-700"
>
{isOnline && (
<AvatarBadge
badge={<AvailableIcon className="size-3 text-green-500" />}
/>
)}
</Avatar>
</div>
{/* Channel info */}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<h3
className={twMerge(
"font-medium text-gray-900 dark:text-gray-100 truncate",
isActive && "text-blue-600 dark:text-blue-400"
)}
>
{displayTitle}
</h3>
{timestamp && (
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2 flex-shrink-0">
{formatTimestamp(timestamp)}
</span>
)}
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{latestMessagePreview}
</p>
{/* Unread count badge */}
{unreadCount > 0 && (
<div className="ml-2 flex-shrink-0">
<Badge
variant="in-review"
className="text-xs min-w-[20px] h-5 px-2 py-0 flex items-center justify-center"
>
{unreadCount > 99 ? "99+" : unreadCount}
</Badge>
</div>
)}
</div>
</div>
{/* Active indicator */}
{isActive && (
<div className="absolute left-0 top-0 bottom-0 w-1 bg-blue-500 dark:bg-blue-400 rounded-r-full" />
)}
</div>
);
}

17
ui/src/hooks/channel.ts Normal file
View file

@ -0,0 +1,17 @@
import { useEffect, useState } from "react";
import { Channel, StreamChat } from "stream-chat";
export const useChannel = (
client: StreamChat,
channelId: string | undefined
) => {
const [channel, setChannel] = useState<Channel | null>(null);
useEffect(() => {
if (channelId) {
const channel = client.channel("messaging", channelId);
channel.watch();
setChannel(channel);
}
}, [channelId, client]);
return channel;
};

View file

@ -7,16 +7,56 @@ import {
Channel,
} from "stream-chat-react";
import { useUser } from "@ui/providers/UserStoreProvider";
import { ChannelPreview } from "@ui/components/ChannelPreview";
import { useParams } from "react-router-dom";
import { StreamChat } from "stream-chat";
import { useChannel } from "@ui/hooks/channel";
export function ChatPage() {
export function ChatPage({ client }: { client: StreamChat }) {
const user = useUser();
const filters = { members: { $in: [user.id] }, type: "messaging" };
const { channelId } = useParams();
const channel = useChannel(client, channelId);
if (channel) {
return (
<Channel channel={channel}>
<Window>
<ChannelHeader />
<MessageList />
<MessageInput />
</Window>
</Channel>
);
}
return (
<div className="flex h-screen">
<div className="w-1/3 border-r">
<ChannelList filters={filters} />
<div className="flex h-screen bg-gray-50 dark:bg-gray-900">
<div className="w-1/3 border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
<ChannelList
filters={filters}
setActiveChannelOnMount
Preview={({
displayTitle,
channel,
activeChannel,
setActiveChannel,
unread,
latestMessagePreview,
}) => (
<ChannelPreview
displayTitle={displayTitle}
channel={channel}
activeChannel={activeChannel}
setActiveChannel={setActiveChannel}
unreadCount={unread}
latestMessagePreview={latestMessagePreview}
/>
)}
/>
</div>
<div className="flex-1">
<div className="flex-1 bg-white dark:bg-gray-800">
<Channel>
<Window>
<ChannelHeader />

View file

@ -18,6 +18,7 @@ import {
} from "@ui/hooks/tablos";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { TabloInsert, TabloUpdate, UserTablo } from "@ui/types/tablos.types";
import { useNavigate } from "react-router-dom";
type FilterOption = {
id: "all" | "owned" | "invited";
@ -39,6 +40,7 @@ export const TabloPage = () => {
const [filterType, setFilterType] = useState<"all" | "owned" | "invited">(
"all"
);
const navigate = useNavigate();
const { data: tablos, isLoading, error } = useTablosList();
const createTabloMutation = useCreateTablo();
@ -56,9 +58,18 @@ export const TabloPage = () => {
});
const menuItems = [
{ name: "Conversations" },
{ name: "Planning" },
{ name: "Notes" },
{
name: "Conversations",
action: (tabloId: string) => navigate(`/chat/${tabloId}`),
},
{
name: "Planning",
action: (tabloId: string) => navigate(`/tablo/${tabloId}/planning`),
},
{
name: "Notes",
action: (tabloId: string) => navigate(`/tablo/${tabloId}/notes`),
},
];
const openCreateModal = () => {
@ -431,7 +442,7 @@ export const TabloPage = () => {
className="w-full px-4 py-2 text-left text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={(e) => {
e.stopPropagation();
console.log(`${item.name} clicked for ${tablo.name}`);
item.action(tablo.id);
}}
>
<span>{item.name}</span>

View file

@ -1,11 +1,12 @@
import { Chat, useCreateChatClient } from "stream-chat-react";
import { useUser } from "./UserStoreProvider";
import { LoadingSpinner } from "@ui/components/LoadingSpinner";
import { StreamChat } from "stream-chat";
export default function ChatProvider({
children,
}: {
children: React.ReactNode;
children: (client: StreamChat) => React.ReactNode;
}) {
const apiKey = import.meta.env.VITE_STREAM_CHAT_API_KEY as string;
const user = useUser();
@ -41,7 +42,7 @@ export default function ChatProvider({
return (
<Chat client={client} theme="team light">
{children}
{children(client)}
</Chat>
);
}