Link tablos and chat + enhance UX
This commit is contained in:
parent
c61dbe1271
commit
d4758159e6
6 changed files with 301 additions and 12 deletions
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
210
ui/src/components/ChannelPreview.tsx
Normal file
210
ui/src/components/ChannelPreview.tsx
Normal 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
17
ui/src/hooks/channel.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue