💩(frontend) integrate chat into sidepanel and revamp UI
Properly integrate chat into the sidepanel to improve UX and avoid disruptions. Implement initial styling based on Google Meet's design, with plans for future enhancements. Some details remain to be refined, such as preserving newline characters in the message formatter. This substantial commit refactors and cleans up a significant legacy component. Chat notifications will be addressed in a separate PR. Note: While this is a large commit, it represents a major improvement in user experience (in my opinion).
This commit is contained in:
committed by
aleb_the_flash
parent
998382020d
commit
a84b76170d
@@ -1,11 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
formatChatMessageLinks,
|
||||
LiveKitRoom,
|
||||
type LocalUserChoices,
|
||||
} from '@livekit/components-react'
|
||||
import { LiveKitRoom, type LocalUserChoices } from '@livekit/components-react'
|
||||
import { Room, RoomOptions } from 'livekit-client'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
@@ -104,7 +100,7 @@ export const Conference = ({
|
||||
audio={userConfig.audioEnabled}
|
||||
video={userConfig.videoEnabled}
|
||||
>
|
||||
<VideoConference chatMessageFormatter={formatChatMessageLinks} />
|
||||
<VideoConference />
|
||||
{showInviteDialog && (
|
||||
<InviteDialog
|
||||
isOpen={showInviteDialog}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ParticipantsList } from './controls/Participants/ParticipantsList'
|
||||
import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
|
||||
import { ReactNode } from 'react'
|
||||
import { Effects } from './Effects'
|
||||
import { Chat } from '../prefabs/Chat'
|
||||
|
||||
type StyledSidePanelProps = {
|
||||
title: string
|
||||
@@ -81,12 +82,19 @@ const StyledSidePanel = ({
|
||||
)
|
||||
|
||||
type PanelProps = {
|
||||
isOpen: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
isOpen: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const Panel = ({ isOpen, children }: PanelProps) => (
|
||||
<div style={{ display: isOpen ? 'block' : 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
display: isOpen ? 'inherit' : 'none',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
flexGrow: 1,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@@ -95,7 +103,8 @@ export const SidePanel = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const sidePanel = layoutSnap.sidePanel
|
||||
|
||||
const { isParticipantsOpen, isEffectsOpen } = useWidgetInteraction()
|
||||
const { isParticipantsOpen, isEffectsOpen, isChatOpen } =
|
||||
useWidgetInteraction()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
|
||||
return (
|
||||
@@ -107,16 +116,15 @@ export const SidePanel = () => {
|
||||
})}
|
||||
isClosed={!sidePanel}
|
||||
>
|
||||
<Panel
|
||||
isOpen={isParticipantsOpen}
|
||||
>
|
||||
<Panel isOpen={isParticipantsOpen}>
|
||||
<ParticipantsList />
|
||||
</Panel>
|
||||
<Panel
|
||||
isOpen={isEffectsOpen}
|
||||
>
|
||||
<Panel isOpen={isEffectsOpen}>
|
||||
<Effects />
|
||||
</Panel>
|
||||
<Panel isOpen={isChatOpen}>
|
||||
<Chat />
|
||||
</Panel>
|
||||
</StyledSidePanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ReceivedChatMessage } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Text } from '@/primitives'
|
||||
import { MessageFormatter } from '@livekit/components-react'
|
||||
|
||||
export interface ChatEntryProps extends React.HTMLAttributes<HTMLLIElement> {
|
||||
entry: ReceivedChatMessage
|
||||
hideMetadata?: boolean
|
||||
messageFormatter?: MessageFormatter
|
||||
}
|
||||
|
||||
export const ChatEntry: (
|
||||
props: ChatEntryProps & React.RefAttributes<HTMLLIElement>
|
||||
) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
|
||||
HTMLLIElement,
|
||||
ChatEntryProps
|
||||
>(function ChatEntry(
|
||||
{ entry, hideMetadata = false, messageFormatter, ...props }: ChatEntryProps,
|
||||
ref
|
||||
) {
|
||||
// Fixme - Livekit messageFormatter strips '\n' char
|
||||
const formattedMessage = React.useMemo(() => {
|
||||
return messageFormatter ? messageFormatter(entry.message) : entry.message
|
||||
}, [entry.message, messageFormatter])
|
||||
const time = new Date(entry.timestamp)
|
||||
const locale = navigator ? navigator.language : 'en-US'
|
||||
|
||||
return (
|
||||
<li
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
ref={ref}
|
||||
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||
data-lk-message-origin={entry.from?.isLocal ? 'local' : 'remote'}
|
||||
{...props}
|
||||
>
|
||||
{!hideMetadata && (
|
||||
<span
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '0.5rem',
|
||||
})}
|
||||
>
|
||||
<Text bold={true} variant="sm">
|
||||
{entry.from?.name ?? entry.from?.identity}
|
||||
</Text>
|
||||
<Text variant="sm">
|
||||
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||
</Text>
|
||||
</span>
|
||||
)}
|
||||
<Text
|
||||
variant="sm"
|
||||
margin={false}
|
||||
className={css({
|
||||
'& .lk-chat-link': {
|
||||
color: 'blue',
|
||||
textDecoration: 'underline',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{formattedMessage}
|
||||
</Text>
|
||||
</li>
|
||||
)
|
||||
})
|
||||
@@ -92,7 +92,6 @@ export const ChatInput = ({
|
||||
}}
|
||||
rows={rows || 1}
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
border: 'none',
|
||||
resize: 'none',
|
||||
height: 'auto',
|
||||
@@ -101,6 +100,7 @@ export const ChatInput = ({
|
||||
padding: '7px 10px',
|
||||
overflowY: 'hidden',
|
||||
}}
|
||||
placeholderStyle={'strong'}
|
||||
spellCheck={false}
|
||||
maxLength={500}
|
||||
aria-label={t('textArea.label')}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiChat1Line } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useWidgetInteraction } from '../../hooks/useWidgetInteraction'
|
||||
import { chatStore } from '@/stores/chat'
|
||||
|
||||
export const ChatToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat' })
|
||||
|
||||
const { isChatOpen, unreadMessages, toggleChat } = useWidgetInteraction()
|
||||
const chatSnap = useSnapshot(chatStore)
|
||||
|
||||
const { isChatOpen, toggleChat } = useWidgetInteraction()
|
||||
const tooltipLabel = isChatOpen ? 'open' : 'closed'
|
||||
|
||||
return (
|
||||
@@ -28,7 +32,7 @@ export const ChatToggle = () => {
|
||||
>
|
||||
<RiChat1Line />
|
||||
</ToggleButton>
|
||||
{!!unreadMessages && (
|
||||
{!!chatSnap.unreadMessages && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
import { useLayoutContext } from '@livekit/components-react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export enum SidePanel {
|
||||
PARTICIPANTS = 'participants',
|
||||
EFFECTS = 'effects',
|
||||
CHAT = 'chat',
|
||||
}
|
||||
|
||||
export const useWidgetInteraction = () => {
|
||||
const { dispatch, state } = useLayoutContext().widget
|
||||
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const sidePanel = layoutSnap.sidePanel
|
||||
|
||||
const isParticipantsOpen = sidePanel == SidePanel.PARTICIPANTS
|
||||
const isEffectsOpen = sidePanel == SidePanel.EFFECTS
|
||||
const isChatOpen = sidePanel == SidePanel.CHAT
|
||||
|
||||
const toggleParticipants = () => {
|
||||
if (dispatch && state?.showChat) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
layoutStore.sidePanel = isParticipantsOpen ? null : SidePanel.PARTICIPANTS
|
||||
}
|
||||
|
||||
const toggleChat = () => {
|
||||
if (isParticipantsOpen || isEffectsOpen) {
|
||||
layoutStore.sidePanel = null
|
||||
}
|
||||
if (dispatch) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
layoutStore.sidePanel = isChatOpen ? null : SidePanel.CHAT
|
||||
}
|
||||
|
||||
const toggleEffects = () => {
|
||||
if (dispatch && state?.showChat) {
|
||||
dispatch({ msg: 'toggle_chat' })
|
||||
}
|
||||
layoutStore.sidePanel = isEffectsOpen ? null : SidePanel.EFFECTS
|
||||
}
|
||||
|
||||
@@ -43,8 +31,7 @@ export const useWidgetInteraction = () => {
|
||||
toggleParticipants,
|
||||
toggleChat,
|
||||
toggleEffects,
|
||||
isChatOpen: state?.showChat,
|
||||
unreadMessages: state?.unreadMessages,
|
||||
isChatOpen,
|
||||
isParticipantsOpen,
|
||||
isEffectsOpen,
|
||||
}
|
||||
|
||||
@@ -1,42 +1,40 @@
|
||||
import type { ChatMessage, ChatOptions } from '@livekit/components-core'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
ChatCloseIcon,
|
||||
ChatEntry,
|
||||
ChatToggle,
|
||||
MessageFormatter,
|
||||
formatChatMessageLinks,
|
||||
useChat,
|
||||
useMaybeLayoutContext,
|
||||
useParticipants,
|
||||
} from '@livekit/components-react'
|
||||
import { cloneSingleChild } from '@/features/rooms/utils/cloneSingleChild'
|
||||
import { ChatInput } from '@/features/rooms/livekit/components/chat/Input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { chatStore } from '@/stores/chat'
|
||||
import { Div, Text } from '@/primitives'
|
||||
import { ChatInput } from '../components/chat/Input'
|
||||
import { ChatEntry } from '../components/chat/Entry'
|
||||
import { useWidgetInteraction } from '../hooks/useWidgetInteraction'
|
||||
|
||||
/** @public */
|
||||
export interface ChatProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
ChatOptions {
|
||||
messageFormatter?: MessageFormatter
|
||||
}
|
||||
ChatOptions {}
|
||||
|
||||
/**
|
||||
* The Chat component adds a basis chat functionality to the LiveKit room. The messages are distributed to all participants
|
||||
* in the room. Only users who are in the room at the time of dispatch will receive the message.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <LiveKitRoom>
|
||||
* <Chat />
|
||||
* </LiveKitRoom>
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function Chat({ messageFormatter, ...props }: ChatProps) {
|
||||
export function Chat({ ...props }: ChatProps) {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||
|
||||
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
const ulRef = React.useRef<HTMLUListElement>(null)
|
||||
|
||||
const { send, chatMessages, isSending } = useChat()
|
||||
|
||||
const layoutContext = useMaybeLayoutContext()
|
||||
const { isChatOpen } = useWidgetInteraction()
|
||||
const chatSnap = useSnapshot(chatStore)
|
||||
|
||||
// Use useParticipants hook to trigger a re-render when the participant list changes.
|
||||
const participants = useParticipants()
|
||||
|
||||
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||
|
||||
async function handleSubmit(text: string) {
|
||||
@@ -46,22 +44,21 @@ export function Chat({ messageFormatter, ...props }: ChatProps) {
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ulRef) {
|
||||
if (chatMessages.length > 0 && ulRef.current) {
|
||||
ulRef.current?.scrollTo({ top: ulRef.current.scrollHeight })
|
||||
}
|
||||
}, [ulRef, chatMessages])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!layoutContext || chatMessages.length === 0) {
|
||||
if (chatMessages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
layoutContext.widget.state?.showChat &&
|
||||
chatMessages.length > 0 &&
|
||||
isChatOpen &&
|
||||
lastReadMsgAt.current !== chatMessages[chatMessages.length - 1]?.timestamp
|
||||
) {
|
||||
lastReadMsgAt.current = chatMessages[chatMessages.length - 1]?.timestamp
|
||||
chatStore.unreadMessages = 0
|
||||
return
|
||||
}
|
||||
|
||||
@@ -69,55 +66,69 @@ export function Chat({ messageFormatter, ...props }: ChatProps) {
|
||||
(msg) => !lastReadMsgAt.current || msg.timestamp > lastReadMsgAt.current
|
||||
).length
|
||||
|
||||
const { widget } = layoutContext
|
||||
if (
|
||||
unreadMessageCount > 0 &&
|
||||
widget.state?.unreadMessages !== unreadMessageCount
|
||||
chatSnap.unreadMessages !== unreadMessageCount
|
||||
) {
|
||||
widget.dispatch?.({ msg: 'unread_msg', count: unreadMessageCount })
|
||||
chatStore.unreadMessages = unreadMessageCount
|
||||
}
|
||||
}, [chatMessages, layoutContext, layoutContext?.widget])
|
||||
}, [chatMessages, chatSnap.unreadMessages, isChatOpen])
|
||||
|
||||
const renderedMessages = React.useMemo(() => {
|
||||
return chatMessages.map((msg, idx, allMsg) => {
|
||||
const hideMetadata =
|
||||
idx >= 1 &&
|
||||
msg.timestamp - allMsg[idx - 1].timestamp < 60_000 &&
|
||||
allMsg[idx - 1].from === msg.from
|
||||
|
||||
return (
|
||||
<ChatEntry
|
||||
key={msg.id ?? idx}
|
||||
hideMetadata={hideMetadata}
|
||||
entry={msg}
|
||||
messageFormatter={formatChatMessageLinks}
|
||||
/>
|
||||
)
|
||||
})
|
||||
// This ensures that the chat message list is updated to reflect any changes in participant information.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatMessages, participants])
|
||||
|
||||
return (
|
||||
<div {...props} className="lk-chat">
|
||||
<div className="lk-chat-header">
|
||||
Messages
|
||||
<ChatToggle className="lk-close-button">
|
||||
<ChatCloseIcon />
|
||||
</ChatToggle>
|
||||
</div>
|
||||
|
||||
<ul className="lk-list lk-chat-messages" ref={ulRef}>
|
||||
{props.children
|
||||
? chatMessages.map((msg, idx) =>
|
||||
cloneSingleChild(props.children, {
|
||||
entry: msg,
|
||||
key: msg.id ?? idx,
|
||||
messageFormatter,
|
||||
})
|
||||
)
|
||||
: chatMessages.map((msg, idx, allMsg) => {
|
||||
const hideName = idx >= 1 && allMsg[idx - 1].from === msg.from
|
||||
// If the time delta between two messages is bigger than 60s show timestamp.
|
||||
const hideTimestamp =
|
||||
idx >= 1 && msg.timestamp - allMsg[idx - 1].timestamp < 60_000
|
||||
|
||||
return (
|
||||
<ChatEntry
|
||||
key={msg.id ?? idx}
|
||||
hideName={hideName}
|
||||
hideTimestamp={hideName === false ? false : hideTimestamp} // If we show the name always show the timestamp as well.
|
||||
entry={msg}
|
||||
messageFormatter={messageFormatter}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<Div
|
||||
display={'flex'}
|
||||
padding={'0 1.5rem'}
|
||||
flexGrow={1}
|
||||
flexDirection={'column'}
|
||||
minHeight={0}
|
||||
{...props}
|
||||
>
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
padding: '0.75rem',
|
||||
backgroundColor: '#f3f4f6',
|
||||
borderRadius: 4,
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{t('disclaimer')}
|
||||
</Text>
|
||||
<Div
|
||||
flexGrow={1}
|
||||
flexDirection={'column'}
|
||||
minHeight={0}
|
||||
overflowY="scroll"
|
||||
>
|
||||
<ul className="lk-list lk-chat-messages" ref={ulRef}>
|
||||
{renderedMessages}
|
||||
</ul>
|
||||
</Div>
|
||||
<ChatInput
|
||||
inputRef={inputRef}
|
||||
onSubmit={(e) => handleSubmit(e)}
|
||||
isSending={isSending}
|
||||
/>
|
||||
</div>
|
||||
</Div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type {
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
@@ -17,7 +15,6 @@ import {
|
||||
GridLayout,
|
||||
LayoutContextProvider,
|
||||
RoomAudioRenderer,
|
||||
MessageFormatter,
|
||||
usePinnedTracks,
|
||||
useTracks,
|
||||
useCreateLayoutContext,
|
||||
@@ -50,7 +47,6 @@ const LayoutWrapper = styled(
|
||||
*/
|
||||
export interface VideoConferenceProps
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
chatMessageFormatter?: MessageFormatter
|
||||
/** @alpha */
|
||||
SettingsComponent?: React.ComponentType
|
||||
}
|
||||
@@ -73,10 +69,7 @@ export interface VideoConferenceProps
|
||||
* ```
|
||||
* @public
|
||||
*/
|
||||
export function VideoConference({
|
||||
chatMessageFormatter,
|
||||
...props
|
||||
}: VideoConferenceProps) {
|
||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
const lastAutoFocusedScreenShareTrack =
|
||||
React.useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import React from 'react'
|
||||
import clsx from 'clsx'
|
||||
|
||||
/** @internal function from LiveKit*/
|
||||
export function cloneSingleChild(
|
||||
children: React.ReactNode | React.ReactNode[],
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props?: Record<string, any>,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
key?: any
|
||||
) {
|
||||
return React.Children.map(children, (child) => {
|
||||
// Checking isValidElement is the safe way and avoids a typescript
|
||||
// error too.
|
||||
if (React.isValidElement(child) && React.Children.only(children)) {
|
||||
if (child.props.class) {
|
||||
// make sure we retain classnames of both passed props and child
|
||||
props ??= {}
|
||||
props.class = clsx(child.props.class, props.class)
|
||||
props.style = { ...child.props.style, ...props.style }
|
||||
}
|
||||
return React.cloneElement(child, { ...props, key })
|
||||
}
|
||||
return child
|
||||
})
|
||||
}
|
||||
@@ -97,14 +97,19 @@
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "",
|
||||
"effects": ""
|
||||
"effects": "",
|
||||
"chat": ""
|
||||
},
|
||||
"content": {
|
||||
"participants": "",
|
||||
"effects": ""
|
||||
"effects": "",
|
||||
"chat": ""
|
||||
},
|
||||
"closeButton": ""
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": ""
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "",
|
||||
"contributors": "",
|
||||
|
||||
@@ -95,14 +95,19 @@
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effects"
|
||||
"effects": "Effects",
|
||||
"chat": "Messages in the chat"
|
||||
},
|
||||
"content": {
|
||||
"participants": "participants",
|
||||
"effects": "effects"
|
||||
"effects": "effects",
|
||||
"chat": "messages"
|
||||
},
|
||||
"closeButton": "Hide {{content}}"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "The messages are visible to participants only at the time they are sent. All messages are deleted at the end of the call."
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "In room",
|
||||
"you": "You",
|
||||
|
||||
@@ -95,14 +95,19 @@
|
||||
"sidePanel": {
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
"effects": "Effets"
|
||||
"effects": "Effets",
|
||||
"chat": "Messages dans l'appel"
|
||||
},
|
||||
"content": {
|
||||
"participants": "les participants",
|
||||
"effects": "les effets"
|
||||
"effects": "les effets",
|
||||
"chat": "les messages"
|
||||
},
|
||||
"closeButton": "Masquer {{content}}"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "Les messages sont visibles par les participants uniquement au moment de\nleur envoi. Tous les messages sont supprimés à la fin de l'appel."
|
||||
},
|
||||
"participants": {
|
||||
"subheading": "Dans la réunion",
|
||||
"you": "Vous",
|
||||
|
||||
@@ -15,4 +15,13 @@ export const TextArea = styled(RACTextArea, {
|
||||
borderRadius: 4,
|
||||
transition: 'all 200ms',
|
||||
},
|
||||
variants: {
|
||||
placeholderStyle: {
|
||||
strong: {
|
||||
_placeholder: {
|
||||
color: 'black',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
9
src/frontend/src/stores/chat.ts
Normal file
9
src/frontend/src/stores/chat.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { proxy } from 'valtio'
|
||||
|
||||
type State = {
|
||||
unreadMessages: number
|
||||
}
|
||||
|
||||
export const chatStore = proxy<State>({
|
||||
unreadMessages: 0,
|
||||
})
|
||||
Reference in New Issue
Block a user