diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f80354f77..866e507b9f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,9 @@ visualization size by dragging its right and bottom borders. Visualization width also follows the node's width, and visualizations are aligned to the left side of the node. +- [Help chat][7151]. The link to the Discord server is replaced with a chat + bridge to the Discord server. This is intended to have the chat visible at the + same time as the IDE, so that help can be much more interactive. [5910]: https://github.com/enso-org/enso/pull/5910 [6279]: https://github.com/enso-org/enso/pull/6279 @@ -215,6 +218,7 @@ [7028]: https://github.com/enso-org/enso/pull/7028 [7014]: https://github.com/enso-org/enso/pull/7014 [7146]: https://github.com/enso-org/enso/pull/7146 +[7151]: https://github.com/enso-org/enso/pull/7151 [7164]: https://github.com/enso-org/enso/pull/7164 #### EnsoGL (rendering engine) diff --git a/app/ide-desktop/eslint.config.js b/app/ide-desktop/eslint.config.js index be4aa78ec6b..12108e0f8cc 100644 --- a/app/ide-desktop/eslint.config.js +++ b/app/ide-desktop/eslint.config.js @@ -40,7 +40,7 @@ const STRING_LITERAL = ':matches(Literal[raw=/^["\']/], TemplateLiteral)' const JSX = ':matches(JSXElement, JSXFragment)' const NOT_PASCAL_CASE = '/^(?!_?([A-Z][a-z0-9]*)+$)/' const NOT_CAMEL_CASE = '/^(?!_?[a-z][a-z0-9*]*([A-Z0-9][a-z0-9]*)*$)(?!React$)/' -const WHITELISTED_CONSTANTS = 'logger|.+Context' +const WHITELISTED_CONSTANTS = 'logger|.+Context|interpolationFunction.+' const NOT_CONSTANT_CASE = `/^(?!${WHITELISTED_CONSTANTS}$|_?[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$)/` // ======================================= diff --git a/app/ide-desktop/lib/assets/close_large.svg b/app/ide-desktop/lib/assets/close_large.svg new file mode 100644 index 00000000000..03e9a809df1 --- /dev/null +++ b/app/ide-desktop/lib/assets/close_large.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/ide-desktop/lib/assets/triangle_down.svg b/app/ide-desktop/lib/assets/triangle_down.svg new file mode 100644 index 00000000000..1e84ce1e4d3 --- /dev/null +++ b/app/ide-desktop/lib/assets/triangle_down.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/app/ide-desktop/lib/content/src/index.html b/app/ide-desktop/lib/content/src/index.html index 0799963293a..87bdfb12d80 100644 --- a/app/ide-desktop/lib/content/src/index.html +++ b/app/ide-desktop/lib/content/src/index.html @@ -38,10 +38,16 @@ +
+
diff --git a/app/ide-desktop/lib/dashboard/package.json b/app/ide-desktop/lib/dashboard/package.json index 612efd50f8a..c098dc61dbb 100644 --- a/app/ide-desktop/lib/dashboard/package.json +++ b/app/ide-desktop/lib/dashboard/package.json @@ -26,6 +26,7 @@ "@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/parser": "^5.49.0", "enso-authentication": "^1.0.0", + "enso-chat": "git://github.com/enso-org/enso-bot#wip/sb/initial-implementation", "enso-content": "^1.0.0", "eslint": "^8.32.0", "eslint-plugin-jsdoc": "^39.6.8", diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/animations.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/animations.tsx new file mode 100644 index 00000000000..41607153b76 --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/animations.tsx @@ -0,0 +1,171 @@ +/** @file Functions to manually animate values over time. + * This is useful if the values need to be known before paint. + * + * See MDN for information on the easing functions defined in this module: + * https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function */ +import * as react from 'react' + +// ================= +// === Constants === +// ================= +/** The number of times the segment from 0 to 1 will be bisected to find the x-value for + * a cubic bezier curve. */ +const CUBIC_BEZIER_BISECTIONS = 10 + +/** Accepts a parameter containing the actual progress as a fraction between 0 and 1 inclusive, + * and returns the fraction. */ +export type InterpolationFunction = (progress: number) => number + +/** Interpolates between two values over time */ +export function useInterpolateOverTime( + interpolationFunction: InterpolationFunction, + durationMs: number, + initialValue = 0 +): [value: number, setTargetValue: react.Dispatch>] { + const [value, setValue] = react.useState(initialValue) + const [startValue, setStartValue] = react.useState(initialValue) + const [endValue, setEndValue] = react.useState(initialValue) + + react.useEffect(() => { + setStartValue(value) + const startTime = Number(new Date()) + let isRunning = true + const onTick = () => { + const fraction = Math.min((Number(new Date()) - startTime) / durationMs, 1) + if (isRunning && fraction < 1) { + setValue(startValue + (endValue - startValue) * interpolationFunction(fraction)) + requestAnimationFrame(onTick) + } else { + setValue(endValue) + setStartValue(endValue) + } + } + requestAnimationFrame(onTick) + return () => { + isRunning = false + } + }, [endValue]) + + return [value, setEndValue] +} + +/** Equivalent to the CSS easing function `linear(a, b, c, ...)`. + * + * `interpolationFunctionLinear()` is equivalent to `interpolationFunctionLinear(0, 1)`. + * + * Does not support percentages to control time spent on a specific line segment, unlike the CSS + * `linear(0, 0.25 75%, 1)` */ +export function interpolationFunctionLinear(...points: number[]): InterpolationFunction { + if (points.length === 0) { + return progress => progress + } else { + const length = points.length + return progress => { + const effectiveIndex = progress * length + // The following are guaranteed to be non-null, as `progress` is guaranteed + // to be between 0 and 1. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const start = points[Math.floor(effectiveIndex)]! + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const end = points[Math.ceil(effectiveIndex)]! + const progressThroughEffectiveIndex = effectiveIndex % 1 + return (end - start) * progressThroughEffectiveIndex + } + } +} + +/** Defines a cubic BΓ©zier curve with control points `(0, 0)`, `(x1, y1)`, `(x2, y2)`, + * and `(1, 1)`. + * + * Equivalent to the CSS easing function `cubic-bezier(x1, y1, x2, y2)` */ +export function interpolationFunctionCubicBezier( + x1: number, + y1: number, + x2: number, + y2: number +): InterpolationFunction { + return progress => { + let minimum = 0 + let maximum = 1 + for (let i = 0; i < CUBIC_BEZIER_BISECTIONS; ++i) { + const t = (minimum + maximum) / 2 + const u = 1 - t + // See here for the source of the explicit form: + // https://en.wikipedia.org/wiki/B%C3%A9zier_curve#Cubic_B%C3%A9zier_curves + // `x0 = 0` and `x3 = 1` have been substituted in. + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + const estimatedProgress = 3 * u * t * (u * x1 + t * x2) + t * t * t + if (estimatedProgress > progress) { + maximum = t + } else { + minimum = t + } + } + const t = (minimum + maximum) / 2 + const u = 1 - t + // Uses the same formula as above, but calculating `y` instead of `x`. + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + return 3 * u * t * (u * y1 + t * y2) + t * t * t + } +} + +// Magic numbers are allowable as these definitions are taken straight from the spec. +/* eslint-disable @typescript-eslint/no-magic-numbers */ + +/** Equivalent to the CSS easing function `ease`, which is itself equivalent to + * `cubic-bezier(0.25, 0.1, 0.25, 1.0)`. */ +export const interpolationFunctionEase = interpolationFunctionCubicBezier(0.25, 0.1, 0.25, 1.0) + +/** Equivalent to the CSS easing function `ease-in`, which is itself equivalent to + * `cubic-bezier(0.42, 0.0, 1.0, 1.0)`. */ +export const interpolationFunctionEaseIn = interpolationFunctionCubicBezier(0.42, 0.0, 1.0, 1.0) + +/** Equivalent to the CSS easing function `ease-in-out`, which is itself equivalent to + * `cubic-bezier(0.42, 0.0, 0.58, 1.0)`. */ +export const interpolationFunctionEaseInOut = interpolationFunctionCubicBezier(0.42, 0.0, 0.58, 1.0) + +/** Equivalent to the CSS easing function `ease-out`, which is itself equivalent to + * `cubic-bezier(0.0, 0.0, 0.58, 1.0)`. */ +export const interpolationFunctionEaseOut = interpolationFunctionCubicBezier(0.0, 0.0, 0.58, 1.0) + +/* eslint-enable @typescript-eslint/no-magic-numbers */ + +/** Determines which sides should have a "jump" - a step that lasts for zero time, effectively + * making the anmiation skip that end point. */ +export enum StepJumpSides { + start = 'jump-start', + end = 'jump-end', + both = 'jump-both', + none = 'jump-none', +} + +/** Equivalent to the CSS easing function `steps(stepCount, jumpSides)`. */ +export function interpolationFunctionSteps( + stepCount: number, + jumpSides: StepJumpSides +): InterpolationFunction { + switch (jumpSides) { + case StepJumpSides.start: { + return progress => Math.ceil(progress * stepCount) / stepCount + } + case StepJumpSides.end: { + return progress => Math.floor(progress * stepCount) / stepCount + } + case StepJumpSides.both: { + const stepCountPlusOne = stepCount + 1 + return progress => Math.ceil(progress * stepCount) / stepCountPlusOne + } + case StepJumpSides.none: { + const stepCountMinusOne = stepCount - 1 + return progress => Math.min(1, Math.floor(progress * stepCount) / stepCountMinusOne) + } + } +} + +/** Equivalent to the CSS easing function `step-start`, which is itself equivalent to + * `steps(1, jump-start)`. */ +export const interpolationFunctionStepStart = interpolationFunctionSteps(1, StepJumpSides.start) + +/** Equivalent to the CSS easing function `step-end`, which is itself equivalent to + * `steps(1, jump-end)`. */ +export const interpolationFunctionStepEnd = interpolationFunctionSteps(1, StepJumpSides.end) diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts b/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts index b994b6d0052..1bf6fb651cc 100644 --- a/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/config.ts @@ -30,19 +30,33 @@ const API_URLS = { production: newtype.asNewtype('https://7aqkn3tnbc.execute-api.eu-west-1.amazonaws.com'), } +/** + * All possible Help Chat endpoint URLs, sorted by environment. + * + * In development mode, the chat bot will need to be run locally: + * https://github.com/enso-org/enso-bot */ +const CHAT_URLS = { + development: newtype.asNewtype('ws://localhost:8082'), + // TODO[sb]: Insert the actual URL of the production chat bot here. + production: newtype.asNewtype('wss://chat.cloud.enso.org'), +} + /** All possible configuration options, sorted by environment. */ const CONFIGS = { npekin: { cloudRedirect: CLOUD_REDIRECTS.development, apiUrl: API_URLS.npekin, + chatUrl: CHAT_URLS.development, } satisfies Config, pbuchu: { cloudRedirect: CLOUD_REDIRECTS.development, apiUrl: API_URLS.pbuchu, + chatUrl: CHAT_URLS.development, } satisfies Config, production: { cloudRedirect: CLOUD_REDIRECTS.production, apiUrl: API_URLS.production, + chatUrl: CHAT_URLS.production, } satisfies Config, } /** Export the configuration that is currently in use. */ @@ -54,10 +68,14 @@ export const ACTIVE_CONFIG: Config = CONFIGS[ENVIRONMENT] /** Interface defining the configuration options that we expect to provide for the Dashboard. */ export interface Config { - /** URL used as the OAuth redirect when running in the cloud app. */ + /** URL of the OAuth redirect when running in the cloud app. + * + * The desktop app redirects to a static deep link, so it does not have to be configured. */ cloudRedirect: auth.OAuthRedirect - /** URL used as the base URL for requests to our Cloud API backend. */ + /** Base URL for requests to our Cloud API backend. */ apiUrl: ApiUrl + /** URL to the websocket endpoint of the Help Chat. */ + chatUrl: ChatUrl } // =================== @@ -73,4 +91,7 @@ export type Environment = 'npekin' | 'pbuchu' | 'production' // =========== /** Base URL for requests to our Cloud API backend. */ -type ApiUrl = newtype.Newtype +type ApiUrl = newtype.Newtype<`http://${string}` | `https://${string}`, 'ApiUrl'> + +/** URL to the websocket endpoint of the Help Chat. */ +type ChatUrl = newtype.Newtype<`ws://${string}` | `wss://${string}`, 'ChatUrl'> diff --git a/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/chat.tsx b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/chat.tsx new file mode 100644 index 00000000000..b832e8d8c8e --- /dev/null +++ b/app/ide-desktop/lib/dashboard/src/authentication/src/dashboard/components/chat.tsx @@ -0,0 +1,853 @@ +/** @file A WebSocket-based chat directly to official support on the official Discord server. */ +import * as React from 'react' +import * as reactDom from 'react-dom' +import toast from 'react-hot-toast' + +import CloseLargeIcon from 'enso-assets/close_large.svg' +import DefaultUserIcon from 'enso-assets/default_user.svg' +import TriangleDownIcon from 'enso-assets/triangle_down.svg' + +import * as chat from 'enso-chat/chat' + +import * as animations from '../../animations' +import * as authProvider from '../../authentication/providers/auth' +import * as config from '../../config' +import * as dateTime from '../dateTime' +import * as loggerProvider from '../../providers/logger' +import * as newtype from '../../newtype' + +import Twemoji from './twemoji' + +// ================= +// === Constants === +// ================= + +// TODO[sb]: Consider associating a project with a thread +// (and providing a button to jump to the relevant project). +// The project shouldn't be jumped to automatically, since it may take a long time +// to switch projects, and undo history may be lost. + +const HELP_CHAT_ID = 'enso-chat' +export const ANIMATION_DURATION_MS = 200 +const WIDTH_PX = 336 +/** The size (both width and height) of each reaction button. */ +const REACTION_BUTTON_SIZE = 20 +/** The size (both width and height) of each reaction on a message. */ +const REACTION_SIZE = 16 +/** The list of reaction emojis, in order. */ +const REACTION_EMOJIS: chat.ReactionSymbol[] = ['❀️', 'πŸ‘', 'πŸ‘Ž', 'πŸ˜€', 'πŸ™', 'πŸ‘€', 'πŸŽ‰'] +/** The initial title of the thread. */ +const DEFAULT_THREAD_TITLE = 'New chat thread' +/** A {@link RegExp} matching any non-whitespace character. */ +const NON_WHITESPACE_CHARACTER_REGEX = /\S/ +/** A {@link RegExp} matching auto-generated thread names. */ +const AUTOGENERATED_THREAD_TITLE_REGEX = /^New chat thread (\d+)$/ +/** The maximum number of lines to show in the message input, past which a scrollbar is shown. */ +const MAX_MESSAGE_INPUT_LINES = 10 +/** The maximum number of messages to fetch when opening a new thread. + * This SHOULD be the same limit as the chat backend (the maximum number of messages sent in + * `serverThread` events). */ +const MAX_MESSAGE_HISTORY = 25 + +// ========================== +// === ChatDisplayMessage === +// ========================== + +/** Information needed to display a chat message. */ +interface ChatDisplayMessage { + id: chat.MessageId + /** If `true`, this is a message from the staff to the user. + * If `false`, this is a message from the user to the staff. */ + isStaffMessage: boolean + avatar: string | null + /** Name of the author of the message. */ + name: string + content: string + reactions: chat.ReactionSymbol[] + /** Given in milliseconds since the unix epoch. */ + timestamp: number + /** Given in milliseconds since the unix epoch. */ + editedTimestamp: number | null +} + +// ========================== +// === makeNewThreadTitle === +// ========================== + +/** Returns an auto-generated thread title. */ +function makeNewThreadTitle(threads: chat.ThreadData[]) { + const threadTitleNumbers = threads + .map(thread => thread.title.match(AUTOGENERATED_THREAD_TITLE_REGEX)) + .flatMap(match => (match != null ? parseInt(match[1] ?? '0', 10) : [])) + return `${DEFAULT_THREAD_TITLE} ${Math.max(0, ...threadTitleNumbers) + 1}` +} + +// =================== +// === ReactionBar === +// =================== + +/** Props for a {@link ReactionBar}. */ +export interface ReactionBarProps { + selectedReactions: Set + doReact: (reaction: chat.ReactionSymbol) => void + doRemoveReaction: (reaction: chat.ReactionSymbol) => void + className?: string +} + +/** A list of emoji reactions to choose from. */ +function ReactionBar(props: ReactionBarProps) { + const { selectedReactions, doReact, doRemoveReaction, className } = props + + return ( +
+ {REACTION_EMOJIS.map(emoji => ( + + ))} +
+ ) +} + +// ================= +// === Reactions === +// ================= + +/** Props for a {@link Reactions}. */ +export interface ReactionsProps { + reactions: chat.ReactionSymbol[] +} + +/** A list of emoji reactions that have been on a message. */ +function Reactions(props: ReactionsProps) { + const { reactions } = props + + if (reactions.length === 0) { + return null + } else { + return ( +
+ {reactions.map(reaction => ( + + ))} +
+ ) + } +} + +// =================== +// === ChatMessage === +// =================== + +/** Props for a {@link ChatMessage}. */ +export interface ChatMessageProps { + message: ChatDisplayMessage + reactions: chat.ReactionSymbol[] + shouldShowReactionBar: boolean + doReact: (reaction: chat.ReactionSymbol) => void + doRemoveReaction: (reaction: chat.ReactionSymbol) => void +} + +/** A chat message, including user info, sent date, and reactions (if any). */ +function ChatMessage(props: ChatMessageProps) { + const { message, reactions, shouldShowReactionBar, doReact, doRemoveReaction } = props + const [isHovered, setIsHovered] = React.useState(false) + return ( +
{ + setIsHovered(true) + }} + onMouseLeave={() => { + setIsHovered(false) + }} + > +
+ +
+
{message.name}
+
+ {dateTime.formatDateTimeChatFriendly(new Date(message.timestamp))} +
+
+
+
+ {message.content} + +
+ {shouldShowReactionBar && ( + + )} + {message.isStaffMessage && !shouldShowReactionBar && isHovered && ( +
+ +
+ )} +
+ ) +} + +// ================== +// === ChatHeader === +// ================== + +/** Props for a {@Link ChatHeader}. */ +interface InternalChatHeaderProps { + threads: chat.ThreadData[] + setThreads: React.Dispatch> + threadId: chat.ThreadId | null + threadTitle: string + setThreadTitle: (threadTitle: string) => void + switchThread: (threadId: chat.ThreadId) => void + sendMessage: (message: chat.ChatClientMessageData) => void + doClose: () => void +} + +/** The header bar for a {@link Chat}. Includes the title, close button, and threads list. */ +function ChatHeader(props: InternalChatHeaderProps) { + const { + threads, + setThreads, + threadId, + threadTitle, + setThreadTitle, + switchThread, + sendMessage, + doClose, + } = props + const [isThreadListVisible, setIsThreadListVisible] = React.useState(false) + + // These will never be `null` as their values are set immediately. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const titleInputRef = React.useRef(null!) + + React.useEffect(() => { + titleInputRef.current.value = threadTitle + }, [threadTitle]) + + React.useEffect(() => { + const onClick = () => { + setIsThreadListVisible(false) + } + document.addEventListener('click', onClick) + return () => { + document.removeEventListener('click', onClick) + } + }, []) + + const toggleThreadListVisibility = React.useCallback((event: React.SyntheticEvent) => { + event.stopPropagation() + setIsThreadListVisible(visible => !visible) + }, []) + + return ( + <> +
+ + +
+
+
+
+ {threads.map(thread => ( +
{ + event.stopPropagation() + if (thread.id !== threadId) { + switchThread(thread.id) + setIsThreadListVisible(false) + } + }} + > +
+ {/* {thread.hasUnreadMessages ? '(!) ' : ''} */} +
+
{thread.title}
+
+ ))} +
+
+
+ + ) +} + +// ============ +// === Chat === +// ============ + +/** Props for a {@link Chat}. */ +export interface ChatProps { + /** This should only be false when the panel is closing. */ + isOpen: boolean + doClose: () => void +} + +/** Chat sidebar. */ +function Chat(props: ChatProps) { + const { isOpen, doClose } = props + const { accessToken: rawAccessToken } = authProvider.useNonPartialUserSession() + const logger = loggerProvider.useLogger() + + /** This is SAFE, because this component is only rendered when `accessToken` is present. + * See `dashboard.tsx` for its sole usage. */ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const accessToken = rawAccessToken! + + const [isPaidUser, setIsPaidUser] = React.useState(true) + const [isReplyEnabled, setIsReplyEnabled] = React.useState(false) + // `true` if and only if scrollback was triggered for the current thread. + const [shouldIgnoreMessageLimit, setShouldIgnoreMessageLimit] = React.useState(false) + const [isAtBeginning, setIsAtBeginning] = React.useState(false) + const [threads, setThreads] = React.useState([]) + const [messages, setMessages] = React.useState([]) + const [threadId, setThreadId] = React.useState(null) + const [threadTitle, setThreadTitle] = React.useState(DEFAULT_THREAD_TITLE) + const [isAtTop, setIsAtTop] = React.useState(false) + const [isAtBottom, setIsAtBottom] = React.useState(true) + const [messagesHeightBeforeMessageHistory, setMessagesHeightBeforeMessageHistory] = + React.useState(null) + // TODO: proper URL + const [websocket] = React.useState(() => new WebSocket(config.ACTIVE_CONFIG.chatUrl)) + const [right, setTargetRight] = animations.useInterpolateOverTime( + animations.interpolationFunctionEaseInOut, + ANIMATION_DURATION_MS, + -WIDTH_PX + ) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const messageInputRef = React.useRef(null!) + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const messagesRef = React.useRef(null!) + + React.useEffect(() => { + setIsPaidUser(false) + }, []) + + React.useEffect(() => { + return () => { + websocket.close() + } + }, [websocket]) + + React.useLayoutEffect(() => { + const element = messagesRef.current + if (isAtTop && messagesHeightBeforeMessageHistory != null) { + element.scrollTop = element.scrollHeight - messagesHeightBeforeMessageHistory + setMessagesHeightBeforeMessageHistory(null) + } else if (isAtBottom) { + element.scrollTop = element.scrollHeight - element.clientHeight + } + // Auto-scroll MUST only happen when the message list changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages]) + + const sendMessage = React.useCallback( + (message: chat.ChatClientMessageData) => { + websocket.send(JSON.stringify(message)) + }, + [/* should never change */ websocket] + ) + + React.useEffect(() => { + const onMessage = (data: MessageEvent) => { + if (typeof data.data !== 'string') { + logger.error('Chat cannot handle binary messages.') + } else { + // This is SAFE, as the format of server messages is known. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const message: chat.ChatServerMessageData = JSON.parse(data.data) + switch (message.type) { + case chat.ChatMessageDataType.serverThreads: { + setThreads(message.threads) + break + } + case chat.ChatMessageDataType.serverThread: { + if (!threads.some(thread => thread.id === message.id)) { + setThreads(oldThreads => { + const newThread = { + id: message.id, + title: message.title, + hasUnreadMessages: false, + } + if (!oldThreads.some(thread => thread.id === message.id)) { + return [...oldThreads, newThread] + } else { + return oldThreads.map(oldThread => + oldThread.id === newThread.id ? newThread : oldThread + ) + } + }) + } + setShouldIgnoreMessageLimit(false) + setThreadId(message.id) + setThreadTitle(message.title) + setIsAtBeginning(message.isAtBeginning) + const newMessages = message.messages.flatMap(innerMessage => { + switch (innerMessage.type) { + case chat.ChatMessageDataType.serverMessage: { + const displayMessage: ChatDisplayMessage = { + id: innerMessage.id, + isStaffMessage: true, + content: innerMessage.content, + reactions: innerMessage.reactions, + avatar: innerMessage.authorAvatar, + name: innerMessage.authorName, + timestamp: innerMessage.timestamp, + editedTimestamp: innerMessage.editedTimestamp, + } + return displayMessage + } + case chat.ChatMessageDataType.serverReplayedMessage: { + const displayMessage: ChatDisplayMessage = { + id: innerMessage.id, + isStaffMessage: false, + content: innerMessage.content, + reactions: [], + avatar: null, + name: 'Me', + timestamp: innerMessage.timestamp, + editedTimestamp: null, + } + return displayMessage + } + } + }) + switch (message.requestType) { + case chat.ChatMessageDataType.historyBefore: { + setMessages(oldMessages => [...newMessages, ...oldMessages]) + break + } + default: { + setMessages(newMessages) + break + } + } + break + } + case chat.ChatMessageDataType.serverMessage: { + const newMessage: ChatDisplayMessage = { + id: message.id, + isStaffMessage: true, + avatar: message.authorAvatar, + name: message.authorName, + content: message.content, + reactions: [], + timestamp: message.timestamp, + editedTimestamp: null, + } + setMessages(oldMessages => { + const newMessages = [...oldMessages, newMessage] + return shouldIgnoreMessageLimit + ? newMessages + : newMessages.slice(-MAX_MESSAGE_HISTORY) + }) + break + } + case chat.ChatMessageDataType.serverEditedMessage: { + setMessages( + messages.map(otherMessage => { + if (otherMessage.id !== message.id) { + return otherMessage + } else { + return { + ...otherMessage, + content: message.content, + editedTimestamp: message.timestamp, + } + } + }) + ) + break + } + case chat.ChatMessageDataType.serverReplayedMessage: { + // This message is only sent as part of the `serverThread` message and + // can safely be ignored. + break + } + } + } + } + const onOpen = () => { + sendMessage({ + type: chat.ChatMessageDataType.authenticate, + accessToken, + }) + } + websocket.addEventListener('message', onMessage) + websocket.addEventListener('open', onOpen) + return () => { + websocket.removeEventListener('message', onMessage) + websocket.removeEventListener('open', onOpen) + } + }, [ + websocket, + shouldIgnoreMessageLimit, + logger, + threads, + messages, + accessToken, + /* should never change */ sendMessage, + ]) + + const container = document.getElementById(HELP_CHAT_ID) + + React.useEffect(() => { + // The types come from a third-party API and cannot be changed. + // eslint-disable-next-line no-restricted-syntax + let handle: number | undefined + if (container != null) { + if (isOpen) { + container.style.display = '' + setTargetRight(0) + } else { + setTargetRight(-WIDTH_PX) + handle = window.setTimeout(() => { + container.style.display = 'none' + }, ANIMATION_DURATION_MS) + } + } + return () => { + clearTimeout(handle) + } + }, [isOpen, container, setTargetRight]) + + const switchThread = React.useCallback( + (newThreadId: chat.ThreadId) => { + const threadData = threads.find(thread => thread.id === newThreadId) + if (threadData == null) { + const message = `Unknown thread id '${newThreadId}'.` + toast.error(message) + logger.error(message) + } else { + sendMessage({ + type: chat.ChatMessageDataType.switchThread, + threadId: newThreadId, + }) + } + }, + [threads, /* should never change */ sendMessage, /* should never change */ logger] + ) + + const sendCurrentMessage = React.useCallback( + (event: React.SyntheticEvent, createNewThread?: boolean) => { + event.preventDefault() + const element = messageInputRef.current + const content = element.value + if (NON_WHITESPACE_CHARACTER_REGEX.test(content)) { + setIsReplyEnabled(false) + element.value = '' + element.style.height = '0px' + element.style.height = `${element.scrollHeight}px` + const newMessage: ChatDisplayMessage = { + // This MUST be unique. + id: newtype.asNewtype(String(Number(new Date()))), + isStaffMessage: false, + avatar: null, + name: 'Me', + content, + reactions: [], + timestamp: Number(new Date()), + editedTimestamp: null, + } + if (threadId == null || createNewThread === true) { + const newThreadTitle = + threadId == null ? threadTitle : makeNewThreadTitle(threads) + sendMessage({ + type: chat.ChatMessageDataType.newThread, + title: newThreadTitle, + content, + }) + setThreadId(null) + setThreadTitle(newThreadTitle) + setMessages([newMessage]) + } else { + sendMessage({ + type: chat.ChatMessageDataType.message, + threadId, + content, + }) + setMessages(oldMessages => { + const newMessages = [...oldMessages, newMessage] + return shouldIgnoreMessageLimit + ? newMessages + : newMessages.slice(-MAX_MESSAGE_HISTORY) + }) + } + } + }, + [ + threads, + threadId, + threadTitle, + shouldIgnoreMessageLimit, + /* should never change */ sendMessage, + ] + ) + + const upgradeToPro = () => { + // TODO: + } + + if (container == null) { + logger.error('Chat container not found.') + return null + } else { + // This should be `findLast`, but that requires ES2023. + const lastStaffMessage = [...messages].reverse().find(message => message.isStaffMessage) + + return reactDom.createPortal( +
+ +
{ + const element = event.currentTarget + const isNowAtTop = element.scrollTop === 0 + const isNowAtBottom = + element.scrollTop + element.clientHeight === element.scrollHeight + const firstMessage = messages[0] + if (isNowAtTop && !isAtBeginning && firstMessage != null) { + setShouldIgnoreMessageLimit(true) + sendMessage({ + type: chat.ChatMessageDataType.historyBefore, + messageId: firstMessage.id, + }) + setMessagesHeightBeforeMessageHistory(element.scrollHeight) + } + if (isNowAtTop !== isAtTop) { + setIsAtTop(isNowAtTop) + } + if (isNowAtBottom !== isAtBottom) { + setIsAtBottom(isNowAtBottom) + } + }} + > + {messages.map(message => ( + { + sendMessage({ + type: chat.ChatMessageDataType.reaction, + messageId: message.id, + reaction, + }) + setMessages(oldMessages => + oldMessages.map(oldMessage => + oldMessage.id === message.id + ? { + ...message, + reactions: [...oldMessage.reactions, reaction], + } + : oldMessage + ) + ) + }} + doRemoveReaction={reaction => { + sendMessage({ + type: chat.ChatMessageDataType.removeReaction, + messageId: message.id, + reaction, + }) + setMessages(oldMessages => + oldMessages.map(oldMessage => + oldMessage.id === message.id + ? { + ...message, + reactions: oldMessage.reactions.filter( + oldReaction => oldReaction !== reaction + ), + } + : oldMessage + ) + ) + }} + shouldShowReactionBar={ + message === lastStaffMessage || message.reactions.length !== 0 + } + /> + ))} +
+
+
+
+