From d4758159e6d1ab7475539b9615f7b9890f782dd7 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 6 Jul 2025 11:57:15 +0200 Subject: [PATCH] Link tablos and chat + enhance UX --- ui/src/App.tsx | 12 +- ui/src/components/ChannelPreview.tsx | 210 +++++++++++++++++++++++++++ ui/src/hooks/channel.ts | 17 +++ ui/src/pages/chat.tsx | 50 ++++++- ui/src/pages/tablo.tsx | 19 ++- ui/src/providers/ChatProvider.tsx | 5 +- 6 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 ui/src/components/ChannelPreview.tsx create mode 100644 ui/src/hooks/channel.ts diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1b121e7..cc1c1c4 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -83,7 +83,17 @@ export const App = () => { element={ - + {(client) => } + + + } + /> + + + {(client) => } } diff --git a/ui/src/components/ChannelPreview.tsx b/ui/src/components/ChannelPreview.tsx new file mode 100644 index 0000000..6d50179 --- /dev/null +++ b/ui/src/components/ChannelPreview.tsx @@ -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 ( +
+ {/* Avatar with online status */} +
+ + {isOnline && ( + } + /> + )} + +
+ + {/* Channel info */} +
+
+

+ {displayTitle} +

+ {timestamp && ( + + {formatTimestamp(timestamp)} + + )} +
+ +
+

+ {latestMessagePreview} +

+ + {/* Unread count badge */} + {unreadCount > 0 && ( +
+ + {unreadCount > 99 ? "99+" : unreadCount} + +
+ )} +
+
+ + {/* Active indicator */} + {isActive && ( +
+ )} +
+ ); +} diff --git a/ui/src/hooks/channel.ts b/ui/src/hooks/channel.ts new file mode 100644 index 0000000..f62d8a4 --- /dev/null +++ b/ui/src/hooks/channel.ts @@ -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(null); + useEffect(() => { + if (channelId) { + const channel = client.channel("messaging", channelId); + channel.watch(); + setChannel(channel); + } + }, [channelId, client]); + return channel; +}; diff --git a/ui/src/pages/chat.tsx b/ui/src/pages/chat.tsx index f624cb4..fca3a2f 100644 --- a/ui/src/pages/chat.tsx +++ b/ui/src/pages/chat.tsx @@ -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 ( + + + + + + + + ); + } + return ( -
-
- +
+
+ ( + + )} + />
-
+
diff --git a/ui/src/pages/tablo.tsx b/ui/src/pages/tablo.tsx index fb29b74..1f7e17c 100644 --- a/ui/src/pages/tablo.tsx +++ b/ui/src/pages/tablo.tsx @@ -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); }} > {item.name} diff --git a/ui/src/providers/ChatProvider.tsx b/ui/src/providers/ChatProvider.tsx index c9ed980..86edb02 100644 --- a/ui/src/providers/ChatProvider.tsx +++ b/ui/src/providers/ChatProvider.tsx @@ -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 ( - {children} + {children(client)} ); }