{tabIds.map((tabId, index) => {
diff --git a/frontend/app/tab/workspaceswitcher.less b/frontend/app/tab/workspaceswitcher.less
new file mode 100644
index 000000000..4a21a398b
--- /dev/null
+++ b/frontend/app/tab/workspaceswitcher.less
@@ -0,0 +1,211 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.workspace-switcher-button {
+ display: flex;
+ height: 26px;
+ padding: 0px 12px;
+ justify-content: flex-end;
+ align-items: center;
+ gap: 12px;
+ border-radius: 6px;
+ background: rgba(255, 255, 255, 0.07);
+ margin-top: 6px;
+ margin-right: 13px;
+ box-sizing: border-box;
+
+ .workspace-icon {
+ width: 15px;
+ height: 15px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+}
+
+.icon-left,
+.icon-right {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+}
+
+.divider {
+ width: 1px;
+ height: 20px;
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.scrollable {
+ max-height: 400px;
+ width: 100%;
+}
+
+.workspace-switcher-content {
+ min-height: auto;
+ display: flex;
+ width: 256px;
+ padding: 0;
+ flex-direction: column;
+ align-items: center;
+ border-radius: 8px;
+ border: 0.5px solid rgba(255, 255, 255, 0.1);
+ background-color: rgb(35, 35, 35);
+ box-shadow: 0px 8px 24px 0px rgba(0, 0, 0, 0.8);
+
+ .title {
+ color: #fff;
+ font-size: 12px;
+ line-height: 19px;
+ font-weight: 600;
+ margin-bottom: 5px;
+ width: 100%;
+ padding: 6px 8px 0px;
+ }
+
+ .expandable-menu-item {
+ margin: 3px 8px;
+ }
+
+ .expandable-menu-item-group {
+ margin: 0 8px;
+
+ &:last-child {
+ margin-bottom: 4px;
+ border-bottom-left-radius: 8px;
+ border-bottom-right-radius: 8px;
+ }
+
+ .expandable-menu-item {
+ margin: 0;
+ }
+ }
+
+ .expandable-menu-item-group {
+ border: 1px solid transparent;
+ border-radius: 4px;
+
+ .menu-group-title-wrapper {
+ display: flex;
+ width: 100%;
+ padding: 5px 8px;
+ border-radius: 4px;
+ }
+
+ &.open:not(:first-child) {
+ background-color: rgb(30, 30, 30);
+ border: 1px solid rgb(41, 41, 41);
+ }
+
+ &.is-active {
+ .expandable-menu-item-group-title:hover {
+ background-color: transparent;
+ }
+ }
+ }
+
+ .expandable-menu-item,
+ .expandable-menu-item-group-title {
+ color: #fff;
+ font-size: 12px;
+ line-height: 19px;
+ padding: 5px 8px;
+
+ .content {
+ width: 100%;
+ }
+ }
+
+ .expandable-menu-item-group-title {
+ height: 29px;
+ padding: 0;
+
+ .left-icon {
+ font-size: 14px;
+ }
+ }
+
+ .color-icon-selector {
+ .input {
+ margin: 5px 0 10px;
+ }
+
+ .color-selector {
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ minmax(15px, 15px)
+ ); // Ensures each color circle has a fixed 14px size
+ grid-gap: 18.5px; // Space between items
+ justify-content: center;
+ align-items: center;
+ margin-top: 5px;
+
+ .color-circle {
+ width: 15px;
+ height: 15px;
+ border-radius: 50%;
+ cursor: pointer;
+ position: relative;
+
+ // Border offset outward
+ &:before {
+ content: "";
+ position: absolute;
+ top: -3px;
+ left: -3px;
+ right: -3px;
+ bottom: -3px;
+ border-radius: 50%;
+ border: 1px solid transparent;
+ }
+
+ &.selected:before {
+ border-color: white; // Highlight for the selected circle
+ }
+ }
+ }
+
+ .icon-selector {
+ display: grid;
+ grid-template-columns: repeat(
+ auto-fit,
+ minmax(16px, 16px)
+ ); // Ensures each color circle has a fixed 14px size
+ grid-column-gap: 17.5px; // Space between items
+ grid-row-gap: 13px; // Space between items
+ justify-content: center;
+ align-items: center;
+ margin-top: 15px;
+
+ .icon-item {
+ font-size: 15px;
+ color: #666;
+ cursor: pointer;
+ transition: color 0.3s ease;
+
+ &.selected {
+ color: white;
+ }
+
+ &:hover {
+ color: #fff;
+ }
+ }
+ }
+
+ .delete-ws-btn-wrapper {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 10px;
+ }
+ }
+
+ .actions {
+ width: 100%;
+ padding: 3px 0;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ }
+}
diff --git a/frontend/app/tab/workspaceswitcher.tsx b/frontend/app/tab/workspaceswitcher.tsx
new file mode 100644
index 000000000..a3c06b5be
--- /dev/null
+++ b/frontend/app/tab/workspaceswitcher.tsx
@@ -0,0 +1,431 @@
+// Copyright 2024, Command Line
+// SPDX-License-Identifier: Apache-2.0
+
+import { Button } from "@/element/button";
+import {
+ ExpandableMenu,
+ ExpandableMenuItem,
+ ExpandableMenuItemData,
+ ExpandableMenuItemGroup,
+ ExpandableMenuItemGroupTitle,
+ ExpandableMenuItemGroupTitleType,
+ ExpandableMenuItemLeftElement,
+ ExpandableMenuItemRightElement,
+} from "@/element/expandablemenu";
+import { Input } from "@/element/input";
+import { Popover, PopoverButton, PopoverContent } from "@/element/popover";
+import { makeIconClass } from "@/util/util";
+import clsx from "clsx";
+import { colord } from "colord";
+import { atom, useAtom } from "jotai";
+import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
+import { memo, useEffect, useRef } from "react";
+import WorkspaceSVG from "../asset/workspace.svg";
+
+import "./workspaceswitcher.less";
+
+interface ColorSelectorProps {
+ colors: string[];
+ selectedColor?: string;
+ onSelect: (color: string) => void;
+ className?: string;
+}
+
+const ColorSelector = memo(({ colors, selectedColor, onSelect, className }: ColorSelectorProps) => {
+ const handleColorClick = (color: string) => {
+ onSelect(color);
+ };
+
+ return (
+
+ {colors.map((color) => (
+
handleColorClick(color)}
+ />
+ ))}
+
+ );
+});
+
+interface IconSelectorProps {
+ icons: string[];
+ selectedIcon?: string;
+ onSelect: (icon: string) => void;
+ className?: string;
+}
+
+const IconSelector = memo(({ icons, selectedIcon, onSelect, className }: IconSelectorProps) => {
+ const handleIconClick = (icon: string) => {
+ onSelect(icon);
+ };
+
+ return (
+
+ {icons.map((icon) => {
+ const iconClass = makeIconClass(icon, false);
+ return (
+ handleIconClick(icon)}
+ />
+ );
+ })}
+
+ );
+});
+
+interface ColorAndIconSelectorProps {
+ title: string;
+ icon: string;
+ color: string;
+ focusInput: boolean;
+ onTitleChange: (newTitle: string) => void;
+ onColorChange: (newColor: string) => void;
+ onIconChange: (newIcon: string) => void;
+ onDeleteWorkspace: () => void;
+}
+const ColorAndIconSelector = memo(
+ ({
+ title,
+ icon,
+ color,
+ focusInput,
+ onTitleChange,
+ onColorChange,
+ onIconChange,
+ onDeleteWorkspace,
+ }: ColorAndIconSelectorProps) => {
+ const inputRef = useRef
(null);
+
+ useEffect(() => {
+ if (focusInput && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [focusInput]);
+
+ return (
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+interface WorkspaceDataType {
+ id: string;
+ icon: string;
+ label: string;
+ color: string;
+ isActive: boolean;
+}
+
+// Define the global Jotai atom for menuData
+const workspaceData: WorkspaceDataType[] = [
+ {
+ id: "596e76eb-d87d-425e-9f6e-1519069ee446",
+ icon: "",
+ label: "Default",
+ color: "",
+ isActive: false,
+ },
+ {
+ id: "596e76eb-d87d-425e-9f6e-1519069ee447",
+ icon: "shield-cat",
+ label: "Cat Space",
+ color: "#e91e63",
+ isActive: true,
+ },
+ {
+ id: "596e76eb-d87d-425e-9f6e-1519069ee448",
+ icon: "paw-simple",
+ label: "Bear Space",
+ color: "#ffc107",
+ isActive: false,
+ },
+];
+
+export const menuDataAtom = atom(workspaceData);
+
+const WorkspaceSwitcher = () => {
+ const [menuData, setMenuData] = useAtom(menuDataAtom);
+
+ const handleTitleChange = (id: string, newTitle: string) => {
+ // This should call a service
+ setMenuData((prevMenuData) =>
+ prevMenuData.map((item) => {
+ if (item.id === id) {
+ return {
+ ...item,
+ label: newTitle,
+ };
+ }
+ return item;
+ })
+ );
+ };
+
+ const handleColorChange = (id: string, newColor: string) => {
+ // This should call a service
+ setMenuData((prevMenuData) =>
+ prevMenuData.map((item) => {
+ if (item.id === id) {
+ return {
+ ...item,
+ color: newColor,
+ };
+ }
+ return item;
+ })
+ );
+ };
+
+ const handleIconChange = (id: string, newIcon: string) => {
+ // This should call a service
+ setMenuData((prevMenuData) =>
+ prevMenuData.map((item) => {
+ if (item.id === id) {
+ return {
+ ...item,
+ icon: newIcon,
+ };
+ }
+ return item;
+ })
+ );
+ };
+
+ const setActiveWorkspace = (id: string) => {
+ // This should call a service
+ setMenuData((prevMenuData) =>
+ prevMenuData.map((item) => {
+ if (item.id === id) {
+ return {
+ ...item,
+ isActive: true,
+ };
+ }
+ return {
+ ...item,
+ isActive: false,
+ };
+ })
+ );
+ };
+
+ const handleAddNewWorkspace = () => {
+ // This should call a service
+ const id = `group-${Math.random().toString(36).substr(2, 9)}`;
+ setMenuData((prevMenuData) => {
+ const updatedMenuData = prevMenuData.map((item) => ({
+ ...item,
+ isActive: false,
+ }));
+
+ const newWorkspace = {
+ id,
+ icon: "circle",
+ label: "New Workspace",
+ color: "#8bc34a",
+ isActive: true,
+ };
+
+ return [...updatedMenuData, newWorkspace];
+ });
+ };
+
+ const handleDeleteWorkspace = (id: string) => {
+ console.log("got here!!!");
+ // This should call a service
+ setMenuData((prevMenuData) => {
+ const updatedMenuData = prevMenuData.filter((item) => item.id !== id);
+ console.log("updatedMenuData", updatedMenuData);
+
+ const isAnyActive = updatedMenuData.some((item) => item.isActive);
+
+ if (!isAnyActive && updatedMenuData.length > 0) {
+ updatedMenuData[0] = { ...updatedMenuData[0], isActive: true };
+ }
+
+ return updatedMenuData;
+ });
+ };
+
+ const activeWorkspace = menuData.find((workspace) => workspace.isActive);
+
+ const data = menuData.map((item): ExpandableMenuItemData => {
+ const { id, icon, label, color, isActive } = item;
+ const title: ExpandableMenuItemGroupTitleType = { label };
+ const leftElement = icon ? (
+
+ ) : null;
+ title.leftElement = leftElement;
+ title.rightElement = isActive ? : null;
+
+ if (label === "Default") {
+ return {
+ id,
+ type: "group",
+ title: {
+ leftElement: ,
+ label: "Default",
+ rightElement: isActive ? : null,
+ },
+ };
+ }
+ return {
+ id,
+ type: "group",
+ title,
+ isOpen: isActive,
+ children: [
+ {
+ type: "item",
+ content: ({ isOpen }: { isOpen: boolean }) => (
+ handleTitleChange(id, title)}
+ onColorChange={(color) => handleColorChange(id, color)}
+ onIconChange={(icon) => handleIconChange(id, icon)}
+ onDeleteWorkspace={() => handleDeleteWorkspace(id)}
+ />
+ ),
+ },
+ ],
+ };
+ });
+
+ const modWorkspaceColor =
+ activeWorkspace.label === "Default"
+ ? "rgba(0, 0, 0, .2)"
+ : colord(activeWorkspace.color).alpha(0.1).toRgbString();
+
+ const renderExpandableMenu = (menuItems: ExpandableMenuItemData[], parentIsOpen?: boolean) => {
+ return menuItems.map((item, index) => {
+ if (item.type === "item") {
+ let contentElement;
+ if (typeof item.content === "function") {
+ contentElement = item.content({ isOpen: parentIsOpen });
+ } else {
+ contentElement = item.content;
+ }
+ return (
+
+ {item.leftElement && (
+ {item.leftElement}
+ )}
+ {contentElement}
+ {item.rightElement && (
+ {item.rightElement}
+ )}
+
+ );
+ } else if (item.type === "group") {
+ return (
+
+ setActiveWorkspace(item.id)}>
+
+ {item.title.leftElement && (
+
+ {item.title.leftElement}
+
+ )}
+
{item.title.label}
+ {item.title.rightElement && (
+
+ {item.title.rightElement}
+
+ )}
+
+
+ {item.children && item.children.length > 0 && renderExpandableMenu(item.children, item.isOpen)}
+
+ );
+ }
+ return null;
+ });
+ };
+
+ let workspaceIcon = (
+
+ );
+ if (activeWorkspace.label == "Default") {
+ workspaceIcon = ;
+ }
+
+ return (
+
+
+ {workspaceIcon}
+ {/*
+
+
+ */}
+
+
+ Switch workspace
+
+
+ {renderExpandableMenu(data)}
+
+
+
+
+
handleAddNewWorkspace()}>
+
+
+
+ New workspace
+
+
+
+
+ );
+};
+
+export { WorkspaceSwitcher };
diff --git a/frontend/app/view/chat/channels.less b/frontend/app/view/chat/channels.less
new file mode 100644
index 000000000..eed93867b
--- /dev/null
+++ b/frontend/app/view/chat/channels.less
@@ -0,0 +1,30 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.channel-list {
+ width: 180px;
+ background-color: rgba(255, 255, 255, 0.025);
+ overflow-x: hidden;
+ padding: 5px;
+
+ .menu-item-button {
+ padding: 5px 7px;
+ }
+
+ .nested-list {
+ padding-left: 10px;
+ }
+
+ .menu-item-text {
+ color: rgb(from var(--main-text-color) r g b / 0.7);
+ }
+
+ .has-children .menu-item-text {
+ font-size: 14px;
+ }
+
+ .is-open .menu-item-text {
+ color: var(--main-text-color);
+ font-weight: bold;
+ }
+}
diff --git a/frontend/app/view/chat/channels.tsx b/frontend/app/view/chat/channels.tsx
new file mode 100644
index 000000000..90b59415d
--- /dev/null
+++ b/frontend/app/view/chat/channels.tsx
@@ -0,0 +1,13 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { CollapsibleMenu } from "@/app/element/collapsiblemenu";
+import { memo } from "react";
+
+import "./channels.less";
+
+const Channels = memo(({ channels }: { channels: MenuItem[] }) => {
+ return ;
+});
+
+export { Channels };
diff --git a/frontend/app/view/chat/chat.less b/frontend/app/view/chat/chat.less
new file mode 100644
index 000000000..0e7d49fb4
--- /dev/null
+++ b/frontend/app/view/chat/chat.less
@@ -0,0 +1,31 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.chat-view {
+ display: flex;
+ flex-direction: row;
+ width: 100%;
+ height: 100%;
+
+ .chat-section {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+
+ .message-wrapper {
+ flex-grow: 1; // Make the ChatMessages take up available height
+ display: flex;
+ flex-direction: column;
+ overflow: hidden; // Ensure content doesn't overflow
+ }
+
+ .chatbox {
+ display: flex;
+ align-items: center;
+
+ textarea {
+ border: none;
+ }
+ }
+ }
+}
diff --git a/frontend/app/view/chat/chat.tsx b/frontend/app/view/chat/chat.tsx
new file mode 100644
index 000000000..28cff735e
--- /dev/null
+++ b/frontend/app/view/chat/chat.tsx
@@ -0,0 +1,70 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { ChatMessage, ChatMessages } from "@/app/view/chat/chatmessages";
+import { UserStatus } from "@/app/view/chat/userlist";
+import * as jotai from "jotai";
+import { memo } from "react";
+import { Channels } from "./channels";
+import { ChatBox } from "./chatbox";
+import { channels, messages, users } from "./data";
+import { UserList } from "./userlist";
+
+import "./chat.less";
+
+class ChatModel {
+ viewType: string;
+ channels: MenuItem[];
+ users: UserStatus[];
+ messagesAtom: jotai.PrimitiveAtom;
+
+ constructor(blockId: string) {
+ this.viewType = "chat";
+ this.channels = channels;
+ this.users = users;
+ this.messagesAtom = jotai.atom(messages);
+ }
+
+ addMessageAtom = jotai.atom(null, (get, set, newMessage: ChatMessage) => {
+ const currentMessages = get(this.messagesAtom);
+ set(this.messagesAtom, [...currentMessages, newMessage]);
+ });
+}
+
+function makeChatModel(blockId: string): ChatModel {
+ return new ChatModel(blockId);
+}
+
+interface ChatProps {
+ model: ChatModel;
+}
+
+const Chat = memo(({ model }: ChatProps) => {
+ const { channels, users } = model;
+ const messages = jotai.useAtomValue(model.messagesAtom);
+ const [, appendMessage] = jotai.useAtom(model.addMessageAtom);
+
+ const handleSendMessage = (message: string) => {
+ const newMessage: ChatMessage = {
+ id: `${Date.now()}`,
+ username: "currentUser",
+ message: message,
+ };
+ appendMessage(newMessage);
+ };
+
+ return (
+
+
+
+
+
+
+
handleSendMessage(message)} />
+
+
+
+ );
+});
+
+export { Chat, ChatModel, makeChatModel };
diff --git a/frontend/app/view/chat/chatbox.tsx b/frontend/app/view/chat/chatbox.tsx
new file mode 100644
index 000000000..882637b7f
--- /dev/null
+++ b/frontend/app/view/chat/chatbox.tsx
@@ -0,0 +1,78 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { EmojiPalette, type EmojiItem } from "@/app/element/emojipalette";
+import { InputGroup } from "@/app/element/input";
+import { MultiLineInput } from "@/app/element/multilineinput";
+import * as keyutil from "@/util/keyutil";
+import React, { memo, useRef, useState } from "react";
+import { throttle } from "throttle-debounce";
+
+interface ChatBoxProps {
+ onSendMessage: (message: string) => void;
+}
+
+const ChatBox = memo(({ onSendMessage }: ChatBoxProps) => {
+ const [message, setMessage] = useState("");
+ const multiLineInputRef = useRef(null);
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ setMessage(e.target.value);
+ };
+
+ const handleKeyDown = (waveEvent: WaveKeyboardEvent): boolean => {
+ if (keyutil.checkKeyPressed(waveEvent, "Enter") && !waveEvent.shift && message.trim() !== "") {
+ onSendMessage(message);
+ setMessage("");
+ return true;
+ }
+ return false;
+ };
+
+ const handleEmojiSelect = (emojiItem: EmojiItem) => {
+ if (multiLineInputRef.current) {
+ const { selectionStart, selectionEnd } = multiLineInputRef.current;
+ const currentValue = multiLineInputRef.current.value;
+
+ // Insert emoji at the current cursor position
+ const newValue =
+ currentValue.substring(0, selectionStart) + emojiItem.emoji + currentValue.substring(selectionEnd);
+
+ // Update the message state and textarea value
+ setMessage(newValue);
+
+ // Set the textarea value manually
+ multiLineInputRef.current.value = newValue;
+
+ // Move cursor after the inserted emoji
+ const cursorPosition = selectionStart + emojiItem.emoji.length;
+
+ // Use setTimeout to ensure the cursor positioning happens after rendering the new value
+ throttle(0, () => {
+ if (multiLineInputRef.current) {
+ multiLineInputRef.current.selectionStart = multiLineInputRef.current.selectionEnd = cursorPosition;
+ multiLineInputRef.current.focus(); // Make sure the textarea remains focused
+ }
+ })();
+
+ // Trigger onChange manually
+ multiLineInputRef.current.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ };
+
+ return (
+
+ keyutil.keydownWrapper(handleKeyDown)(e)}
+ placeholder="Type a message..."
+ />
+
+
+ );
+});
+
+export { ChatBox };
diff --git a/frontend/app/view/chat/chatmessages.less b/frontend/app/view/chat/chatmessages.less
new file mode 100644
index 000000000..7321fc764
--- /dev/null
+++ b/frontend/app/view/chat/chatmessages.less
@@ -0,0 +1,51 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.chat-messages {
+ height: 100%;
+ width: 100%;
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.chat-message {
+ display: flex;
+ align-items: flex-start;
+ align-items: center;
+ margin-bottom: 8px;
+ font-size: 1em;
+ line-height: 1.4;
+}
+
+.chat-user-icon {
+ height: 1em; /* Make user icon height match the text height */
+ width: 1em; /* Keep the icon proportional */
+ border-radius: 50%;
+ margin-right: 8px;
+}
+
+.chat-username {
+ font-weight: bold;
+ margin-right: 4px;
+ line-height: 1.4; /* Ensure alignment with the first line of the message */
+}
+
+.chat-text {
+ display: flex;
+ align-items: flex-start;
+ flex-wrap: wrap;
+}
+
+.chat-text img {
+ height: 1em; /* Make inline images (rendered via markdown) match the text height */
+ width: auto; /* Keep the aspect ratio of images */
+ margin: 0 4px;
+ display: inline;
+}
+
+.chat-emoji {
+ margin: 0 2px;
+ font-size: 1em; /* Match emoji size with the text height */
+}
diff --git a/frontend/app/view/chat/chatmessages.stories.tsx b/frontend/app/view/chat/chatmessages.stories.tsx
new file mode 100644
index 000000000..1979c99e5
--- /dev/null
+++ b/frontend/app/view/chat/chatmessages.stories.tsx
@@ -0,0 +1,90 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { Meta, StoryObj } from "@storybook/react";
+import { ChatMessages } from "./chatmessages";
+import "./chatmessages.less";
+
+export interface ChatMessage {
+ id: string;
+ username: string;
+ message: string;
+ color?: string;
+ userIcon?: string;
+ messageIcon?: string;
+}
+
+const meta = {
+ title: "Elements/ChatMessages",
+ component: ChatMessages,
+ args: {
+ messages: [
+ {
+ id: "1",
+ username: "User1",
+ message: "Hello everyone! ๐",
+ color: "#ff4500",
+ userIcon: "https://via.placeholder.com/50",
+ },
+ {
+ id: "2",
+ username: "User2",
+ message: "Check this out: ![cool icon](https://via.placeholder.com/20)",
+ color: "#1e90ff",
+ },
+ {
+ id: "3",
+ username: "User3",
+ message: "This is a simple text message without icons.",
+ color: "#32cd32",
+ userIcon: "https://via.placeholder.com/50",
+ },
+ {
+ id: "4",
+ username: "User4",
+ message: "๐ ๐ Great job!",
+ color: "#ff6347",
+ },
+ {
+ id: "5",
+ username: "User5",
+ message: "Look at this cool icon: Isn't it awesome? ![cool icon](https://via.placeholder.com/20)",
+ color: "#8a2be2",
+ userIcon: "https://via.placeholder.com/50",
+ },
+ ],
+ },
+ argTypes: {
+ messages: {
+ description: "Array of chat messages to be displayed",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Messages: Story = {
+ render: (args) => (
+
+
+
+ ),
+};
+
+export const ScrollableMessages: Story = {
+ render: (args) => (
+
+
+
+ ),
+ args: {
+ messages: Array.from({ length: 50 }, (_, i) => ({
+ id: `${i + 1}`,
+ username: `User${i + 1}`,
+ message: `This is message number ${i + 1}.`,
+ color: i % 2 === 0 ? "#ff6347" : "#1e90ff",
+ userIcon: "https://via.placeholder.com/50",
+ })),
+ },
+};
diff --git a/frontend/app/view/chat/chatmessages.tsx b/frontend/app/view/chat/chatmessages.tsx
new file mode 100644
index 000000000..7afa61280
--- /dev/null
+++ b/frontend/app/view/chat/chatmessages.tsx
@@ -0,0 +1,60 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import { Markdown } from "@/app/element/markdown";
+import clsx from "clsx";
+import { OverlayScrollbarsComponent } from "overlayscrollbars-react";
+import { memo, useEffect, useRef } from "react";
+
+import "./chatmessages.less";
+
+export interface ChatMessage {
+ id: string;
+ username: string;
+ message: string;
+ color?: string;
+ userIcon?: string;
+}
+
+interface ChatMessagesProps {
+ messages: ChatMessage[];
+ className?: string;
+}
+
+const ChatMessages = memo(({ messages, className }: ChatMessagesProps) => {
+ const messagesEndRef = useRef(null);
+ const overlayScrollRef = useRef(null);
+
+ const scrollToBottom = () => {
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
+ };
+
+ useEffect(() => {
+ // scrollToBottom();
+ }, [messages]);
+
+ return (
+
+ {messages.map(({ id, username, message, color, userIcon }) => (
+
+ {userIcon &&
}
+
+ {username}:
+
+
+
+
+
+ ))}
+
+
+ );
+});
+
+ChatMessages.displayName = "ChatMessages";
+
+export { ChatMessages };
diff --git a/frontend/app/view/chat/data.tsx b/frontend/app/view/chat/data.tsx
new file mode 100644
index 000000000..16428441a
--- /dev/null
+++ b/frontend/app/view/chat/data.tsx
@@ -0,0 +1,225 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+import { ChatMessage } from "@/app/view/chat/chatmessages";
+import { UserStatus } from "@/app/view/chat/userlist";
+
+export const channels: MenuItem[] = [
+ {
+ label: "Aurora Streams",
+ icon: "#",
+ onClick: () => console.log("Aurora Streams clicked"),
+ },
+ {
+ label: "Crimson Oasis",
+ onClick: () => console.log("Crimson Oasis clicked"),
+ subItems: [
+ {
+ label: "Golden Dunes",
+ icon: "#",
+ onClick: () => console.log("Golden Dunes clicked"),
+ },
+ {
+ label: "Emerald Springs",
+ icon: "#",
+ onClick: () => console.log("Emerald Springs clicked"),
+ },
+ {
+ label: "Ruby Cascades",
+ icon: "#",
+ onClick: () => console.log("Ruby Cascades clicked"),
+ },
+ {
+ label: "Sapphire Falls",
+ icon: "#",
+ onClick: () => console.log("Sapphire Falls clicked"),
+ },
+ ],
+ },
+ {
+ label: "Velvet Horizon",
+ onClick: () => console.log("Velvet Horizon clicked"),
+ subItems: [
+ {
+ label: "Amber Skies",
+ icon: "#",
+ onClick: () => console.log("Amber Skies clicked"),
+ },
+ ],
+ },
+ {
+ label: "Mystic Meadows",
+ icon: "#",
+ onClick: () => console.log("Mystic Meadows clicked"),
+ },
+ {
+ label: "Celestial Grove",
+ icon: "#",
+ onClick: () => console.log("Celestial Grove clicked"),
+ },
+ {
+ label: "Twilight Whisper",
+ icon: "#",
+ onClick: () => console.log("Twilight Whisper clicked"),
+ },
+ {
+ label: "Starlit Haven",
+ onClick: () => console.log("Starlit Haven clicked"),
+ subItems: [
+ {
+ label: "Moonlit Trail",
+ icon: "#",
+ onClick: () => console.log("Moonlit Trail clicked"),
+ },
+ ],
+ },
+ {
+ label: "Silver Mist",
+ icon: "#",
+ onClick: () => console.log("Silver Mist clicked"),
+ },
+ {
+ label: "Eclipse Haven",
+ onClick: () => console.log("Eclipse Haven clicked"),
+ subItems: [
+ {
+ label: "Obsidian Wave",
+ icon: "#",
+ onClick: () => console.log("Obsidian Wave clicked"),
+ },
+ {
+ label: "Ivory Shore",
+ icon: "#",
+ onClick: () => console.log("Ivory Shore clicked"),
+ },
+ {
+ label: "Azure Tide",
+ icon: "#",
+ onClick: () => console.log("Azure Tide clicked"),
+ },
+ ],
+ },
+ {
+ label: "Dragon's Peak",
+ icon: "#",
+ onClick: () => console.log("Dragon's Peak clicked"),
+ },
+ {
+ label: "Seraph's Wing",
+ icon: "#",
+ onClick: () => console.log("Seraph's Wing clicked"),
+ },
+ {
+ label: "Frozen Abyss",
+ icon: "#",
+ onClick: () => console.log("Frozen Abyss clicked"),
+ },
+ {
+ label: "Radiant Blossom",
+ icon: "#",
+ onClick: () => console.log("Radiant Blossom clicked"),
+ },
+ {
+ label: "Whispering Pines",
+ icon: "#",
+ onClick: () => console.log("Whispering Pines clicked"),
+ subItems: [
+ {
+ label: "Cedar Haven",
+ icon: "#",
+ onClick: () => console.log("Cedar Haven clicked"),
+ },
+ ],
+ },
+ {
+ label: "Scarlet Veil",
+ icon: "#",
+ onClick: () => console.log("Scarlet Veil clicked"),
+ },
+ {
+ label: "Onyx Spire",
+ icon: "#",
+ onClick: () => console.log("Onyx Spire clicked"),
+ },
+ {
+ label: "Violet Enclave",
+ onClick: () => console.log("Violet Enclave clicked"),
+ subItems: [
+ {
+ label: "Indigo Haven",
+ icon: "#",
+ onClick: () => console.log("Indigo Haven clicked"),
+ },
+ {
+ label: "Amethyst Hollow",
+ icon: "#",
+ onClick: () => console.log("Amethyst Hollow clicked"),
+ },
+ {
+ label: "Crimson Glow",
+ icon: "#",
+ onClick: () => console.log("Crimson Glow clicked"),
+ },
+ ],
+ },
+];
+
+export const users: UserStatus[] = [
+ {
+ label: "John Doe",
+ status: "online",
+ avatarUrl: "https://via.placeholder.com/50",
+ onClick: () => console.log("John Doe clicked"),
+ },
+ {
+ label: "Jane Smith",
+ status: "busy",
+ onClick: () => console.log("Jane Smith clicked"),
+ },
+ {
+ label: "Robert Brown",
+ status: "away",
+ avatarUrl: "https://via.placeholder.com/50",
+ onClick: () => console.log("Robert Brown clicked"),
+ },
+ {
+ label: "Alice Lambert",
+ status: "offline",
+ onClick: () => console.log("Alice Lambert clicked"),
+ },
+];
+
+export const messages: ChatMessage[] = [
+ {
+ id: "1",
+ username: "User1",
+ message: "Hello everyone! ๐",
+ color: "#ff4500",
+ userIcon: "https://via.placeholder.com/50",
+ },
+ {
+ id: "2",
+ username: "User2",
+ message: "Check this out: ![cool icon](https://via.placeholder.com/20)",
+ color: "#1e90ff",
+ },
+ {
+ id: "3",
+ username: "User3",
+ message: "This is a simple text message without icons.",
+ color: "#32cd32",
+ userIcon: "https://via.placeholder.com/50",
+ },
+ {
+ id: "4",
+ username: "User4",
+ message: "๐ ๐ Great job!",
+ color: "#ff6347",
+ },
+ {
+ id: "5",
+ username: "User5",
+ message: "Look at this cool icon: Isn't it awesome? ![cool icon](https://via.placeholder.com/20)",
+ color: "#8a2be2",
+ userIcon: "https://via.placeholder.com/50",
+ },
+];
diff --git a/frontend/app/view/chat/userlist.less b/frontend/app/view/chat/userlist.less
new file mode 100644
index 000000000..0e909c5ce
--- /dev/null
+++ b/frontend/app/view/chat/userlist.less
@@ -0,0 +1,36 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+.user-list {
+ display: flex;
+ flex-direction: column;
+ padding: 10px;
+ max-width: 250px;
+ background-color: rgba(255, 255, 255, 0.025);
+ padding: 5px;
+}
+
+.user-status-item {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ margin-bottom: 6px;
+ cursor: pointer;
+ transition: background-color 0.3s ease;
+}
+
+.user-status-item:hover {
+ background-color: var(--button-grey-hover-bg);
+ border-radius: 4px;
+}
+
+.user-status-icon {
+ margin-right: 10px;
+ display: flex;
+ align-items: center;
+}
+
+.user-status-text {
+ font-size: 1em;
+ font-weight: bold;
+}
diff --git a/frontend/app/view/chat/userlist.stories.tsx b/frontend/app/view/chat/userlist.stories.tsx
new file mode 100644
index 000000000..025d9b380
--- /dev/null
+++ b/frontend/app/view/chat/userlist.stories.tsx
@@ -0,0 +1,58 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import type { Meta, StoryObj } from "@storybook/react";
+import { UserList } from "./userlist";
+
+import "./userlist.less";
+
+export interface UserStatus {
+ text: string;
+ status: "online" | "busy" | "away" | "offline";
+ onClick: () => void;
+}
+
+const meta = {
+ title: "Elements/UserList",
+ component: UserList,
+ args: {
+ users: [
+ {
+ label: "John Doe",
+ status: "online",
+ onClick: () => console.log("John Doe clicked"),
+ },
+ {
+ label: "Jane Smith",
+ status: "busy",
+ onClick: () => console.log("Jane Smith clicked"),
+ },
+ {
+ label: "Robert Brown",
+ status: "away",
+ onClick: () => console.log("Robert Brown clicked"),
+ },
+ {
+ label: "Alice Lambert",
+ status: "offline",
+ onClick: () => console.log("Alice Lambert clicked"),
+ },
+ ],
+ },
+ argTypes: {
+ users: {
+ description: "Array of user statuses to be displayed",
+ },
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ render: (args) => (
+
+
+
+ ),
+};
diff --git a/frontend/app/view/chat/userlist.tsx b/frontend/app/view/chat/userlist.tsx
new file mode 100644
index 000000000..925bb8c97
--- /dev/null
+++ b/frontend/app/view/chat/userlist.tsx
@@ -0,0 +1,38 @@
+// Copyright 2024, Command Line Inc.
+// SPDX-License-Identifier: Apache-2.0
+
+import clsx from "clsx";
+import { memo } from "react";
+import { Avatar } from "../../element/avatar";
+import "./userlist.less";
+
+export interface UserStatus {
+ label: string;
+ status: "online" | "busy" | "away" | "offline";
+ onClick: () => void;
+ avatarUrl?: string;
+}
+
+interface UserListProps {
+ users: UserStatus[];
+ className?: string;
+}
+
+const UserList = memo(({ users, className }: UserListProps) => {
+ return (
+
+ {users.map(({ label, status, onClick, avatarUrl }, index) => (
+
+ ))}
+
+ );
+});
+
+UserList.displayName = "UserList";
+
+export { UserList };
diff --git a/frontend/types/custom.d.ts b/frontend/types/custom.d.ts
index 209405d49..a855c7dad 100644
--- a/frontend/types/custom.d.ts
+++ b/frontend/types/custom.d.ts
@@ -243,6 +243,7 @@ declare global {
type MenuItem = {
label: string;
+ icon?: string | React.ReactNode;
subItems?: MenuItem[];
onClick?: (e: React.MouseEvent) => void;
};
diff --git a/package.json b/package.json
index 8d1f060b3..c3390fa87 100644
--- a/package.json
+++ b/package.json
@@ -98,6 +98,7 @@
"base64-js": "^1.5.1",
"clsx": "^2.1.1",
"color": "^4.2.3",
+ "colord": "^2.9.3",
"css-tree": "^3.0.0",
"dayjs": "^1.11.13",
"debug": "^4.3.7",
diff --git a/yarn.lock b/yarn.lock
index 79a3eae44..6f97792cd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4302,6 +4302,13 @@ __metadata:
languageName: node
linkType: hard
+"colord@npm:^2.9.3":
+ version: 2.9.3
+ resolution: "colord@npm:2.9.3"
+ checksum: 10c0/9699e956894d8996b28c686afe8988720785f476f59335c80ce852ded76ab3ebe252703aec53d9bef54f6219aea6b960fb3d9a8300058a1d0c0d4026460cd110
+ languageName: node
+ linkType: hard
+
"colorspace@npm:1.1.x":
version: 1.1.4
resolution: "colorspace@npm:1.1.4"
@@ -11717,6 +11724,7 @@ __metadata:
base64-js: "npm:^1.5.1"
clsx: "npm:^2.1.1"
color: "npm:^4.2.3"
+ colord: "npm:^2.9.3"
css-tree: "npm:^3.0.0"
dayjs: "npm:^1.11.13"
debug: "npm:^4.3.7"