* wip

* wip 2

* wip

* Copy types from bot

* wip

* Send and display messages

* wip: shadow

* Copy styles from design screenshot

* Fix "create new thread" button

* Fix switching threads

* wip

* Scrollback

* Reactions

* Fix issues with chat input sizing

* Update changelog

* Fix bugs; add scroll to thread list

* Use types from backend definitions

* Update git commit of chat backend; remove stray file path

* hotfix: fix "edit thread" shortcut on macos

* Address issues

* Extract chat header to separate component

* Begin matching appearance with Figma

* Show reaction bar on hover

* fix small scrollbar appear next to the message input

* Disallow sending empty messages

* Add chat URL to config - production URL will be added soon

* add production chat url

* fix linters

---------

Co-authored-by: Paweł Buchowski <pawel.buchowski@enso.org>
This commit is contained in:
somebody1234 2023-07-18 21:23:41 +10:00 committed by GitHub
parent 67f7d33801
commit 457d0986b6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 1880 additions and 46 deletions

View File

@ -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)

View File

@ -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]+)*$)/`
// =======================================

View File

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="12" fill="black" fill-opacity="0.22" />
<g opacity="0.66">
<rect x="16.2426" y="17.6569" width="14" height="2" transform="rotate(-135 16.2426 17.6569)" fill="white" />
<rect x="6.34302" y="16.2426" width="14" height="2" transform="rotate(-45 6.34302 16.2426)" fill="white" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 438 B

View File

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.9635 5.5547L8.83205 11.7519C8.43623 12.3457 7.56377 12.3457 7.16795 11.7519L3.03647 5.5547C2.59343 4.89015 3.06982 4 3.86852 4L12.1315 4C12.9302 4 13.4066 4.89015 12.9635 5.5547Z"
fill="#484848" />
</svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@ -38,10 +38,16 @@
<link rel="stylesheet" href="/docsStyle.css" />
<script type="module" src="/index.js" defer></script>
<script type="module" src="/run.js" defer></script>
<script
src="https://cdn.jsdelivr.net/npm/@twemoji/api@14.1.2/dist/twemoji.min.js"
integrity="sha384-D6GSzpW7fMH86ilu73eB95ipkfeXcMPoOGVst/L04yqSSe+RTUY0jXcuEIZk0wrT"
crossorigin="anonymous"
></script>
</head>
<body>
<div id="enso-dashboard" class="enso-dashboard"></div>
<div id="root"></div>
<div id="enso-chat" class="enso-chat"></div>
<noscript>
This page requires JavaScript to run. Please enable it in your browser.
</noscript>

View File

@ -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",

View File

@ -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<react.SetStateAction<number>>] {
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)

View File

@ -30,19 +30,33 @@ const API_URLS = {
production: newtype.asNewtype<ApiUrl>('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<ChatUrl>('ws://localhost:8082'),
// TODO[sb]: Insert the actual URL of the production chat bot here.
production: newtype.asNewtype<ChatUrl>('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<string, 'ApiUrl'>
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'>

View File

@ -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<chat.ReactionSymbol>
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 (
<div className={`inline-block bg-white rounded-full m-1 ${className ?? ''}`}>
{REACTION_EMOJIS.map(emoji => (
<button
key={emoji}
onClick={() => {
if (selectedReactions.has(emoji)) {
doRemoveReaction(emoji)
} else {
doReact(emoji)
}
}}
// FIXME: Grayscale has the wrong lightness
className={`rounded-full hover:bg-gray-200 m-1 p-1 ${
selectedReactions.has(emoji) ? '' : 'opacity-70 grayscale hover:grayscale-0'
}`}
>
<Twemoji key={emoji} emoji={emoji} size={REACTION_BUTTON_SIZE} />
</button>
))}
</div>
)
}
// =================
// === 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 (
<div>
{reactions.map(reaction => (
<Twemoji key={reaction} emoji={reaction} size={REACTION_SIZE} />
))}
</div>
)
}
}
// ===================
// === 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 (
<div
className="mx-4 my-2"
onMouseEnter={() => {
setIsHovered(true)
}}
onMouseLeave={() => {
setIsHovered(false)
}}
>
<div className="flex">
<img
crossOrigin="anonymous"
src={message.avatar ?? DefaultUserIcon}
className="rounded-full h-8 w-8 my-1"
/>
<div className="mx-2 leading-5">
<div className="font-bold">{message.name}</div>
<div className="text-opacity-50 text-primary">
{dateTime.formatDateTimeChatFriendly(new Date(message.timestamp))}
</div>
</div>
</div>
<div className="whitespace-pre-wrap">
{message.content}
<Reactions reactions={reactions} />
</div>
{shouldShowReactionBar && (
<ReactionBar
doReact={doReact}
doRemoveReaction={doRemoveReaction}
selectedReactions={new Set(message.reactions)}
/>
)}
{message.isStaffMessage && !shouldShowReactionBar && isHovered && (
<div className="relative h-0 py-1 -my-1">
<ReactionBar
doReact={doReact}
doRemoveReaction={doRemoveReaction}
selectedReactions={new Set(message.reactions)}
className="absolute shadow-soft"
/>
</div>
)}
</div>
)
}
// ==================
// === ChatHeader ===
// ==================
/** Props for a {@Link ChatHeader}. */
interface InternalChatHeaderProps {
threads: chat.ThreadData[]
setThreads: React.Dispatch<React.SetStateAction<chat.ThreadData[]>>
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<HTMLInputElement>(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 (
<>
<div className="flex text-sm font-semibold mx-4 mt-2">
<button className="flex grow items-center" onClick={toggleThreadListVisibility}>
<img
className={`transition-transform duration-300 ${
isThreadListVisible ? '-rotate-180' : ''
}`}
src={TriangleDownIcon}
/>
{/* Spacing. */}
<div className="w-2" />
<div className="grow">
<input
type="text"
ref={titleInputRef}
defaultValue={threadTitle}
className="bg-transparent w-full leading-6"
onClick={event => {
event.stopPropagation()
}}
onKeyDown={event => {
switch (event.key) {
case 'Escape': {
event.currentTarget.value = threadTitle
break
}
case 'Enter': {
event.currentTarget.blur()
break
}
}
}}
onBlur={event => {
const newTitle = event.currentTarget.value
setThreadTitle(newTitle)
if (threadId != null) {
setThreads(oldThreads =>
oldThreads.map(thread =>
thread.id !== threadId
? thread
: { ...thread, title: newTitle }
)
)
sendMessage({
type: chat.ChatMessageDataType.renameThread,
title: newTitle,
threadId: threadId,
})
}
}}
/>
</div>
</button>
<button className="mx-1" onClick={doClose}>
<img src={CloseLargeIcon} />
</button>
</div>
<div className="relative text-sm font-semibold">
<div
className={`grid absolute w-full bg-ide-bg shadow-soft clip-path-bottom-shadow overflow-hidden transition-grid-template-rows z-10 ${
isThreadListVisible ? 'grid-rows-1fr' : 'grid-rows-0fr'
}`}
>
<div className="min-h-0 max-h-70 overflow-y-auto">
{threads.map(thread => (
<div
key={thread.id}
className={`flex p-1 ${
thread.id === threadId
? 'cursor-default bg-gray-350'
: 'cursor-pointer hover:bg-gray-300'
}`}
onClick={event => {
event.stopPropagation()
if (thread.id !== threadId) {
switchThread(thread.id)
setIsThreadListVisible(false)
}
}}
>
<div className="w-8 text-center">
{/* {thread.hasUnreadMessages ? '(!) ' : ''} */}
</div>
<div>{thread.title}</div>
</div>
))}
</div>
</div>
</div>
</>
)
}
// ============
// === 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<chat.ThreadData[]>([])
const [messages, setMessages] = React.useState<ChatDisplayMessage[]>([])
const [threadId, setThreadId] = React.useState<chat.ThreadId | null>(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<number | null>(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<HTMLTextAreaElement>(null!)
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const messagesRef = React.useRef<HTMLDivElement>(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<chat.MessageId>(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(
<div
style={{ right }}
className="text-xs-mini text-chat flex flex-col fixed top-0 right-0 h-screen bg-ide-bg border-ide-bg-dark border-l-2 w-83.5 py-1 z-10"
>
<ChatHeader
threads={threads}
setThreads={setThreads}
threadId={threadId}
threadTitle={threadTitle}
setThreadTitle={setThreadTitle}
switchThread={switchThread}
sendMessage={sendMessage}
doClose={doClose}
/>
<div
ref={messagesRef}
className="flex-1 overflow-scroll"
onScroll={event => {
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 => (
<ChatMessage
key={message.id}
message={message}
reactions={[]}
doReact={reaction => {
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
}
/>
))}
</div>
<div className="rounded-2xl bg-white p-1 mx-2 my-1">
<form onSubmit={sendCurrentMessage}>
<div>
<textarea
ref={messageInputRef}
rows={1}
autoFocus
required
placeholder="Type your message ..."
className="w-full rounded-lg resize-none p-1"
onKeyDown={event => {
switch (event.key) {
case 'Enter': {
// If the shift key is not pressed, submit the form.
// If the shift key is pressed, keep the default
// behavior of adding a newline.
if (!event.shiftKey) {
event.preventDefault()
event.currentTarget.form?.requestSubmit()
}
}
}
}}
onInput={event => {
const element = event.currentTarget
element.style.height = '0px'
element.style.height =
`min(${MAX_MESSAGE_INPUT_LINES}lh,` +
`${element.scrollHeight + 1}px)`
const newIsReplyEnabled = NON_WHITESPACE_CHARACTER_REGEX.test(
element.value
)
if (newIsReplyEnabled !== isReplyEnabled) {
setIsReplyEnabled(newIsReplyEnabled)
}
}}
/>
<div className="flex">
<button
type="button"
disabled={!isReplyEnabled}
className={`text-xxs text-white rounded-full grow text-left px-1.5 py-1 ${
isReplyEnabled ? 'bg-gray-400' : 'bg-gray-300'
}`}
onClick={event => {
sendCurrentMessage(event, true)
}}
>
New question? Click to start a new thread!
</button>
{/* Spacing. */}
<div className="w-0.5" />
<button
type="submit"
disabled={!isReplyEnabled}
className={`text-white bg-blue-600 rounded-full px-1.5 py-1 ${
isReplyEnabled ? '' : 'opacity-50'
}`}
>
Reply!
</button>
</div>
</div>
</form>
</div>
{!isPaidUser && (
<button
className="text-left leading-5 rounded-2xl bg-call-to-action text-white p-2 mx-2 my-1"
onClick={upgradeToPro}
>
Click here to upgrade to Enso Pro and get access to high-priority, live
support!
</button>
)}
</div>,
container
)
}
}
export default Chat

View File

@ -30,6 +30,7 @@ import * as backendProvider from '../../providers/backend'
import * as loggerProvider from '../../providers/logger'
import * as modalProvider from '../../providers/modal'
import Chat, * as chat from './chat'
import PermissionDisplay, * as permissionDisplay from './permissionDisplay'
import ProjectActionButton, * as projectActionButton from './projectActionButton'
import ContextMenu from './contextMenu'
@ -289,6 +290,8 @@ function Dashboard(props: DashboardProps) {
null
)
const [query, setQuery] = React.useState('')
const [isHelpChatOpen, setIsHelpChatOpen] = React.useState(false)
const [isHelpChatVisible, setIsHelpChatVisible] = React.useState(false)
const [loadingProjectManagerDidFail, setLoadingProjectManagerDidFail] = React.useState(false)
const [directoryId, setDirectoryId] = React.useState(
session.organization != null ? rootDirectoryId(session.organization.id) : null
@ -365,6 +368,22 @@ function Dashboard(props: DashboardProps) {
}
}, [doRefresh])
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 (isHelpChatOpen) {
setIsHelpChatVisible(true)
} else {
handle = window.setTimeout(() => {
setIsHelpChatVisible(false)
}, chat.ANIMATION_DURATION_MS)
}
return () => {
clearTimeout(handle)
}
}, [isHelpChatOpen])
React.useEffect(() => {
const onProjectManagerLoadingFailed = () => {
setLoadingProjectManagerDidFail(true)
@ -1145,6 +1164,8 @@ function Dashboard(props: DashboardProps) {
supportsLocalBackend={supportsLocalBackend}
projectName={project?.name ?? null}
tab={tab}
isHelpChatOpen={isHelpChatOpen}
setIsHelpChatOpen={setIsHelpChatOpen}
toggleTab={() => {
if (project && tab === Tab.dashboard) {
switchToIdeTab()
@ -1702,6 +1723,15 @@ function Dashboard(props: DashboardProps) {
)}
{/* This should be just `{modal}`, however TypeScript incorrectly throws an error. */}
{project && <Ide project={project} appRunner={appRunner} />}
{/* `session.accessToken` MUST be present in order for the `Chat` component to work. */}
{isHelpChatVisible && session.accessToken != null && (
<Chat
isOpen={isHelpChatOpen}
doClose={() => {
setIsHelpChatOpen(false)
}}
/>
)}
{modal && <>{modal}</>}
</div>
)

View File

@ -28,6 +28,8 @@ export interface TopBarProps {
tab: dashboard.Tab
toggleTab: () => void
setBackendType: (backendType: backendModule.BackendType) => void
isHelpChatOpen: boolean
setIsHelpChatOpen: (isHelpChatOpen: boolean) => void
query: string
setQuery: (value: string) => void
}
@ -35,8 +37,17 @@ export interface TopBarProps {
/** The {@link TopBarProps.setQuery} parameter is used to communicate with the parent component,
* because `searchVal` may change parent component's project list. */
function TopBar(props: TopBarProps) {
const { supportsLocalBackend, projectName, tab, toggleTab, setBackendType, query, setQuery } =
props
const {
supportsLocalBackend,
projectName,
tab,
toggleTab,
setBackendType,
isHelpChatOpen,
setIsHelpChatOpen,
query,
setQuery,
} = props
const [isUserMenuVisible, setIsUserMenuVisible] = React.useState(false)
const { modal } = modalProvider.useModal()
const { setModal, unsetModal } = modalProvider.useSetModal()
@ -125,17 +136,20 @@ function TopBar(props: TopBarProps) {
className="flex-1 mx-2 bg-transparent"
/>
</div>
<a
href="https://discord.gg/enso"
target="_blank"
rel="noreferrer"
className="flex items-center bg-help rounded-full px-2.5 text-white mx-2"
>
<span className="whitespace-nowrap">help chat</span>
<div className="ml-2">
<img src={SpeechBubbleIcon} />
<div className="grow" />
{!isHelpChatOpen && (
<div
className="flex cursor-pointer items-center bg-help rounded-full px-2.5 text-white mx-2"
onClick={() => {
setIsHelpChatOpen(true)
}}
>
<span className="whitespace-nowrap">help chat</span>
<div className="ml-2">
<img src={SpeechBubbleIcon} />
</div>
</div>
</a>
)}
{/* User profile and menu. */}
<div className="transform w-8">
<div

View File

@ -0,0 +1,54 @@
/** @file A wrapper for a twemoji image. */
import * as React from 'react'
// =================
// === Constants ===
// =================
/** The base of hexadecimal numbers. */
const HEXADECIMAL = 16
// ===============
// === Twemoji ===
// ===============
/** Props for a {@link Twemoji}. */
export interface TwemojiProps {
emoji: string
}
// Only accepts strings that are two code points - for example, emojis.
/** Returns the input type if it consists of two codepoints. Otherwise, it returns
* an error message. */
type MustBeLength2String<T extends string> = T extends `${string}${string}${infer Rest}`
? Rest extends ''
? T
: 'Error: string must have a length of 2'
: 'Error: string must have a length of 2'
/** Props for a {@link Twemoji}, but with extra validation. */
interface InternalValidTwemojiProps<T extends string> {
emoji: MustBeLength2String<T>
size: number
}
/** Serves a Twemoji image from the JSDelivr CDN. */
function Twemoji<T extends string>(props: InternalValidTwemojiProps<T>) {
const { emoji, size } = props
// This is safe as the that the string is required to be non-empty by the type of `props`.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const imagePath = emoji.codePointAt(0)!.toString(HEXADECIMAL)
return (
<img
src={`https://cdn.jsdelivr.net/npm/twemoji@latest/2/svg/${imagePath}.svg`}
crossOrigin="anonymous"
height={size}
width={size}
alt={emoji}
/>
)
}
export default Twemoji

View File

@ -1,6 +1,13 @@
/** @file Utilities for manipulating and displaying dates and times */
import * as newtype from '../newtype'
// =================
// === Constants ===
// =================
/** The number of hours in half a day. This is used to get the number of hours for AM/PM time. */
const HALF_DAY_HOURS = 12
// ================
// === DateTime ===
// ================
@ -18,6 +25,23 @@ export function formatDateTime(date: Date) {
return `${year}-${month}-${dayOfMonth}, ${hour}:${minute}`
}
// TODO[sb]: Is this DD/MM/YYYY or MM/DD/YYYY?
/** Formats date time into the preferred chat-frienly format: `DD/MM/YYYY, hh:mm PM`. */
export function formatDateTimeChatFriendly(date: Date) {
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const dayOfMonth = date.getDate().toString().padStart(2, '0')
let hourRaw = date.getHours()
let amOrPm = 'AM'
if (hourRaw > HALF_DAY_HOURS) {
hourRaw -= HALF_DAY_HOURS
amOrPm = 'PM'
}
const hour = hourRaw.toString().padStart(2, '0')
const minute = date.getMinutes().toString().padStart(2, '0')
return `${dayOfMonth}/${month}/${year} ${hour}:${minute} ${amOrPm}`
}
/** Formats a {@link Date} as a {@link Rfc3339DateTime} */
export function toRfc3339(date: Date) {
return newtype.asNewtype<Rfc3339DateTime>(date.toISOString())

View File

@ -126,6 +126,9 @@ export function useEvent<T>(): [event: T | null, dispatchEvent: (value: T | null
// === useDebugState ===
// `console.*` is allowed because this is for debugging purposes only.
/* eslint-disable no-restricted-properties */
/** A modified `useState` that logs the old and new values when `setState` is called. */
export function useDebugState<T>(
initialState: T | (() => T),
@ -138,29 +141,23 @@ export function useDebugState<T>(
const setState = React.useCallback(
(valueOrUpdater: React.SetStateAction<T>, source?: string) => {
const fullDescription = `${description}${source != null ? ` from '${source}'` : ''}`
if (typeof valueOrUpdater === 'function') {
// This is UNSAFE, however React makes the same assumption.
// eslint-disable-next-line no-restricted-syntax
const updater = valueOrUpdater as (prevState: T) => T
rawSetState(oldState => {
rawSetState(oldState => {
const newState =
typeof valueOrUpdater === 'function'
? // This is UNSAFE when `T` is itself a function type,
// however React makes the same assumption.
// eslint-disable-next-line no-restricted-syntax
(valueOrUpdater as (prevState: T) => T)(oldState)
: valueOrUpdater
if (!Object.is(oldState, newState)) {
console.group(description)
console.trace(description)
console.log(`Old ${fullDescription}:`, oldState)
const newState = updater(oldState)
console.log(`New ${fullDescription}:`, newState)
console.groupEnd()
return newState
})
} else {
rawSetState(oldState => {
if (!Object.is(oldState, valueOrUpdater)) {
console.group(description)
console.log(`Old ${fullDescription}:`, oldState)
console.log(`New ${fullDescription}:`, valueOrUpdater)
console.groupEnd()
}
return valueOrUpdater
})
}
}
return newState
})
},
[description]
)
@ -194,6 +191,8 @@ function useMonitorDependencies(
oldDependenciesRef.current = dependencies
}
/* eslint-enable no-restricted-properties */
// === useDebugEffect ===
/** A modified `useEffect` that logs the old and new values of changed dependencies. */

View File

@ -25,6 +25,12 @@ interface NewtypeVariant<TypeName extends string> {
* `a: string = asNewtype<Newtype<string, 'Name'>>(b)` successfully typechecks. */
export type Newtype<T, TypeName extends string> = NewtypeVariant<TypeName> & T
/** Extracts the original type out of a {@link Newtype}.
* Its only use is in {@link asNewtype}. */
type UnNewtype<T extends Newtype<unknown, string>> = T extends infer U & NewtypeVariant<T['_$type']>
? U
: NotNewtype & Omit<T, '_$type'>
/** An interface that matches a type if and only if it is not a newtype. */
interface NotNewtype {
// eslint-disable-next-line @typescript-eslint/naming-convention
@ -32,9 +38,7 @@ interface NotNewtype {
}
/** Converts a value that is not a newtype, to a value that is a newtype. */
export function asNewtype<T extends Newtype<unknown, string>>(
s: NotNewtype & Omit<T, '_$type'>
): T {
export function asNewtype<T extends Newtype<unknown, string>>(s: UnNewtype<T>): T {
// This cast is unsafe.
// `T` has an extra property `_$type` which is used purely for typechecking
// and does not exist at runtime.

View File

@ -1,4 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;700&display=swap");
@import url("https://fonts.googleapis.com/css2?family=M+PLUS+1:wght@500;600;700&display=swap");
body {
margin: 0;
@ -6,7 +6,8 @@ body {
/* These styles MUST still be copied
* as `.enso-dashboard body` and `.enso-dashboard html` make no sense. */
.enso-dashboard {
.enso-dashboard,
.enso-chat {
line-height: 1.5;
-webkit-text-size-adjust: 100%;
-moz-tab-size: 4;
@ -16,7 +17,8 @@ body {
font-feature-settings: normal;
}
.enso-dashboard *:focus {
.enso-dashboard *:focus,
.enso-chat *:focus {
outline: none !important;
}
@ -48,7 +50,8 @@ body {
background: rgba(0, 0, 0, 0);
}
.enso-dashboard {
.enso-dashboard,
.enso-chat {
@tailwind base;
@tailwind components;
@tailwind utilities;
@ -90,6 +93,15 @@ body {
);
}
.clip-path-bottom-shadow {
clip-path: polygon(
0 0,
100% 0,
100% calc(100% + 100vh),
0 calc(100% + 100vh)
);
}
.dasharray-5 {
stroke-dasharray: calc(12 * 0.05 * 6.2832) calc(12 * 6.2832);
}

View File

@ -21,6 +21,9 @@ export const theme = {
// Should be `#3e515fe5`, but `bg-opacity` does not work with RGBA.
/** The default color of all text. */
primary: '#52636f',
chat: '#484848',
'ide-bg': '#ebeef1',
'ide-bg-dark': '#d0d3d6',
// Should be `#3e515f14`, but `bg-opacity` does not work with RGBA.
label: '#f0f1f3',
help: '#3f68ce',
@ -34,14 +37,19 @@ export const theme = {
'perm-docs-write': '#2db1c3',
// Should be `#3e515f14`, but `bg-opacity` does not work with RGBA.
'perm-none': '#f0f1f3',
'call-to-action': '#fa6c08',
'gray-350': '#b7bcc5',
},
flexGrow: {
2: '2',
},
fontSize: {
'xs-mini': '0.71875rem',
vs: '0.8125rem',
},
spacing: {
'83.5': '20.875rem',
'70': '17.5rem',
'10lh': '10lh',
'140': '35rem',
},
@ -81,11 +89,16 @@ inset 0 -36px 51px -51px #00000014`,
transitionProperty: {
width: 'width',
'stroke-dasharray': 'stroke-dasharray',
'grid-template-rows': 'grid-template-rows',
},
transitionDuration: {
'5000': '5000ms',
'90000': '90000ms',
},
gridTemplateRows: {
'0fr': '0fr',
'1fr': '1fr',
},
gridTemplateColumns: {
'fill-60': 'repeat(auto-fill, minmax(15rem, 1fr))',
},

View File

@ -1,4 +1,5 @@
/** @file File watch and compile service. */
import * as module from 'node:module'
import * as path from 'node:path'
import * as url from 'node:url'
@ -32,12 +33,13 @@ OPTS.write = false
OPTS.loader['.html'] = 'copy'
OPTS.pure.splice(OPTS.pure.indexOf('assert'), 1)
;(OPTS.inject = OPTS.inject ?? []).push(path.resolve(THIS_PATH, '..', '..', 'debugGlobals.ts'))
const REQUIRE = module.default.createRequire(import.meta.url)
OPTS.plugins.push({
name: 'react-dom-profiling',
setup: build => {
build.onResolve({ filter: /^react-dom$/ }, args => {
if (args.kind === 'import-statement') {
return { path: 'react-dom/profiling' }
return { path: REQUIRE.resolve('react-dom/profiling') }
} else {
return
}

View File

@ -441,6 +441,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",
@ -3380,6 +3381,110 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/@discordjs/builders": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.6.3.tgz",
"integrity": "sha512-CTCh8NqED3iecTNuiz49mwSsrc2iQb4d0MjMdmS/8pb69Y4IlzJ/DIy/p5GFlgOrFbNO2WzMHkWKQSiJ3VNXaw==",
"dev": true,
"dependencies": {
"@discordjs/formatters": "^0.3.1",
"@discordjs/util": "^0.3.1",
"@sapphire/shapeshift": "^3.8.2",
"discord-api-types": "^0.37.41",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.3",
"tslib": "^2.5.0"
},
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.1.tgz",
"integrity": "sha512-aWEc9DCf3TMDe9iaJoOnO2+JVAjeRNuRxPZQA6GVvBf+Z3gqUuWYBy2NWh4+5CLYq5uoc3MOvUQ5H5m8CJBqOA==",
"dev": true,
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.1.tgz",
"integrity": "sha512-M7X4IGiSeh4znwcRGcs+49B5tBkNDn4k5bmhxJDAUhRxRHTiFAOTVUNQ6yAKySu5jZTnCbSvTYHW3w0rAzV1MA==",
"dev": true,
"dependencies": {
"discord-api-types": "^0.37.41"
},
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/@discordjs/rest": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-1.7.1.tgz",
"integrity": "sha512-Ofa9UqT0U45G/eX86cURQnX7gzOJLG2oC28VhIk/G6IliYgQF7jFByBJEykPSHE4MxPhqCleYvmsrtfKh1nYmQ==",
"dev": true,
"dependencies": {
"@discordjs/collection": "^1.5.1",
"@discordjs/util": "^0.3.0",
"@sapphire/async-queue": "^1.5.0",
"@sapphire/snowflake": "^3.4.2",
"discord-api-types": "^0.37.41",
"file-type": "^18.3.0",
"tslib": "^2.5.0",
"undici": "^5.22.0"
},
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/@discordjs/rest/node_modules/file-type": {
"version": "18.5.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-18.5.0.tgz",
"integrity": "sha512-yvpl5U868+V6PqXHMmsESpg6unQ5GfnPssl4dxdJudBrr9qy7Fddt7EVX1VLlddFfe8Gj9N7goCZH22FXuSQXQ==",
"dev": true,
"dependencies": {
"readable-web-to-node-stream": "^3.0.2",
"strtok3": "^7.0.0",
"token-types": "^5.0.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
}
},
"node_modules/@discordjs/util": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz",
"integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==",
"dev": true,
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/@discordjs/ws": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-0.8.3.tgz",
"integrity": "sha512-hcYtppanjHecbdNyCKQNH2I4RP9UrphDgmRgLYrATEQF1oo4sYSve7ZmGsBEXSzH72MO2tBPdWSThunbxUVk0g==",
"dev": true,
"dependencies": {
"@discordjs/collection": "^1.5.1",
"@discordjs/rest": "^1.7.1",
"@discordjs/util": "^0.3.1",
"@sapphire/async-queue": "^1.5.0",
"@types/ws": "^8.5.4",
"@vladfrangu/async_event_emitter": "^2.2.1",
"discord-api-types": "^0.37.41",
"tslib": "^2.5.0",
"ws": "^8.13.0"
},
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/@electron/get": {
"version": "2.0.2",
"dev": true,
@ -4689,6 +4794,40 @@
"node": ">=14"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.0.tgz",
"integrity": "sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==",
"dev": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.2.tgz",
"integrity": "sha512-YRbCXWy969oGIdqR/wha62eX8GNHsvyYi0Rfd4rNW6tSVVa8p0ELiMEuOH/k8rgtvRoM+EMV7Csqz77YdwiDpA==",
"dev": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz",
"integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==",
"dev": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sideway/address": {
"version": "4.1.4",
"license": "BSD-3-Clause",
@ -4750,6 +4889,12 @@
"node": ">=10"
}
},
"node_modules/@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"dev": true
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"dev": true,
@ -5337,6 +5482,16 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.2.2.tgz",
"integrity": "sha512-HIzRG7sy88UZjBJamssEczH5q7t5+axva19UbZLO6u0ySbYPrwzWiXBcC0WuHyhKKoeCyneH+FvYzKQq/zTtkQ==",
"dev": true,
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/7zip-bin": {
"version": "5.1.1",
"dev": true,
@ -6015,6 +6170,17 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/better-sqlite3": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.4.0.tgz",
"integrity": "sha512-NmsNW1CQvqMszu/CFAJ3pLct6NEFlNfuGM6vw72KHkjOD1UDnL96XNN1BMQc1hiHo8vE2GbOWQYIpZ+YM5wrZw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.0"
}
},
"node_modules/bignumber.js": {
"version": "2.4.0",
"dev": true,
@ -6031,6 +6197,15 @@
"node": ">=8"
}
},
"node_modules/bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dev": true,
"dependencies": {
"file-uri-to-path": "1.0.0"
}
},
"node_modules/bl": {
"version": "4.1.0",
"license": "MIT",
@ -6214,6 +6389,18 @@
"version": "1.1.2",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dev": true,
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.0.0",
"license": "MIT",
@ -7237,6 +7424,37 @@
"node": ">=8"
}
},
"node_modules/discord-api-types": {
"version": "0.37.47",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.47.tgz",
"integrity": "sha512-rNif8IAv6duS2z47BMXq/V9kkrLfkAoiwpFY3sLxxbyKprk065zqf3HLTg4bEoxRSmi+Lhc7yqGDrG8C3j8GFA==",
"dev": true
},
"node_modules/discord.js": {
"version": "14.11.0",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.11.0.tgz",
"integrity": "sha512-CkueWYFQ28U38YPR8HgsBR/QT35oPpMbEsTNM30Fs8loBIhnA4s70AwQEoy6JvLcpWWJO7GY0y2BUzZmuBMepQ==",
"dev": true,
"dependencies": {
"@discordjs/builders": "^1.6.3",
"@discordjs/collection": "^1.5.1",
"@discordjs/formatters": "^0.3.1",
"@discordjs/rest": "^1.7.1",
"@discordjs/util": "^0.3.1",
"@discordjs/ws": "^0.8.3",
"@sapphire/snowflake": "^3.4.2",
"@types/ws": "^8.5.4",
"discord-api-types": "^0.37.41",
"fast-deep-equal": "^3.1.3",
"lodash.snakecase": "^4.1.1",
"tslib": "^2.5.0",
"undici": "^5.22.0",
"ws": "^8.13.0"
},
"engines": {
"node": ">=16.9.0"
}
},
"node_modules/dlv": {
"version": "1.1.3",
"dev": true,
@ -7432,6 +7650,18 @@
"resolved": "lib/dashboard/src/authentication",
"link": true
},
"node_modules/enso-chat": {
"name": "enso-support",
"version": "1.0.0",
"resolved": "git+ssh://git@github.com/enso-org/enso-bot.git#3b76888ec6bd3579016e70ef83ba282714aec47d",
"dev": true,
"license": "MIT",
"dependencies": {
"better-sqlite3": "^8.4.0",
"discord.js": "^14.11.0",
"ws": "^8.13.0"
}
},
"node_modules/enso-common": {
"resolved": "lib/common",
"link": true
@ -8478,6 +8708,12 @@
"node": ">=0.10.0"
}
},
"node_modules/file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"dev": true
},
"node_modules/filelist": {
"version": "1.0.4",
"dev": true,
@ -10674,6 +10910,12 @@
"version": "4.6.2",
"license": "MIT"
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"dev": true
},
"node_modules/lodash.throttle": {
"version": "4.1.1",
"license": "MIT",
@ -12551,6 +12793,19 @@
"node": ">=8"
}
},
"node_modules/peek-readable": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz",
"integrity": "sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==",
"dev": true,
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/pend": {
"version": "1.2.0",
"dev": true,
@ -13362,6 +13617,22 @@
"node": ">= 6"
}
},
"node_modules/readable-web-to-node-stream": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
"integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
"dev": true,
"dependencies": {
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"dev": true,
@ -14552,6 +14823,15 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"dev": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"license": "MIT",
@ -14698,6 +14978,23 @@
"license": "MIT",
"peer": true
},
"node_modules/strtok3": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.0.0.tgz",
"integrity": "sha512-pQ+V+nYQdC5H3Q7qBZAz/MO6lwGhoC2gOAjuouGf/VO0m7vQRh8QNMl2Uf6SwAtzZ9bOw3UIeBukEGNJl5dtXQ==",
"dev": true,
"dependencies": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^5.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/sudo-prompt": {
"version": "9.2.1",
"license": "MIT",
@ -15122,6 +15419,23 @@
"node": ">=0.6"
}
},
"node_modules/token-types": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz",
"integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==",
"dev": true,
"dependencies": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/tough-cookie": {
"version": "2.5.0",
"dev": true,
@ -15146,6 +15460,12 @@
"utf8-byte-length": "^1.0.1"
}
},
"node_modules/ts-mixer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz",
"integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==",
"dev": true
},
"node_modules/ts-node": {
"version": "10.9.1",
"dev": true,
@ -15195,8 +15515,9 @@
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.4.1",
"license": "0BSD"
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
},
"node_modules/tsutils": {
"version": "3.21.0",
@ -15344,6 +15665,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/undici": {
"version": "5.22.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz",
"integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==",
"dev": true,
"dependencies": {
"busboy": "^1.6.0"
},
"engines": {
"node": ">=14.0"
}
},
"node_modules/unfetch": {
"version": "4.2.0",
"license": "MIT"
@ -15823,6 +16156,27 @@
"typedarray-to-buffer": "^3.1.5"
}
},
"node_modules/ws": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"dev": true,
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xdg-basedir": {
"version": "4.0.0",
"dev": true,
@ -17910,6 +18264,88 @@
"ajv-keywords": "^3.4.1"
}
},
"@discordjs/builders": {
"version": "1.6.3",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.6.3.tgz",
"integrity": "sha512-CTCh8NqED3iecTNuiz49mwSsrc2iQb4d0MjMdmS/8pb69Y4IlzJ/DIy/p5GFlgOrFbNO2WzMHkWKQSiJ3VNXaw==",
"dev": true,
"requires": {
"@discordjs/formatters": "^0.3.1",
"@discordjs/util": "^0.3.1",
"@sapphire/shapeshift": "^3.8.2",
"discord-api-types": "^0.37.41",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.3",
"tslib": "^2.5.0"
}
},
"@discordjs/collection": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.1.tgz",
"integrity": "sha512-aWEc9DCf3TMDe9iaJoOnO2+JVAjeRNuRxPZQA6GVvBf+Z3gqUuWYBy2NWh4+5CLYq5uoc3MOvUQ5H5m8CJBqOA==",
"dev": true
},
"@discordjs/formatters": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.3.1.tgz",
"integrity": "sha512-M7X4IGiSeh4znwcRGcs+49B5tBkNDn4k5bmhxJDAUhRxRHTiFAOTVUNQ6yAKySu5jZTnCbSvTYHW3w0rAzV1MA==",
"dev": true,
"requires": {
"discord-api-types": "^0.37.41"
}
},
"@discordjs/rest": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-1.7.1.tgz",
"integrity": "sha512-Ofa9UqT0U45G/eX86cURQnX7gzOJLG2oC28VhIk/G6IliYgQF7jFByBJEykPSHE4MxPhqCleYvmsrtfKh1nYmQ==",
"dev": true,
"requires": {
"@discordjs/collection": "^1.5.1",
"@discordjs/util": "^0.3.0",
"@sapphire/async-queue": "^1.5.0",
"@sapphire/snowflake": "^3.4.2",
"discord-api-types": "^0.37.41",
"file-type": "^18.3.0",
"tslib": "^2.5.0",
"undici": "^5.22.0"
},
"dependencies": {
"file-type": {
"version": "18.5.0",
"resolved": "https://registry.npmjs.org/file-type/-/file-type-18.5.0.tgz",
"integrity": "sha512-yvpl5U868+V6PqXHMmsESpg6unQ5GfnPssl4dxdJudBrr9qy7Fddt7EVX1VLlddFfe8Gj9N7goCZH22FXuSQXQ==",
"dev": true,
"requires": {
"readable-web-to-node-stream": "^3.0.2",
"strtok3": "^7.0.0",
"token-types": "^5.0.1"
}
}
}
},
"@discordjs/util": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-0.3.1.tgz",
"integrity": "sha512-HxXKYKg7vohx2/OupUN/4Sd02Ev3PBJ5q0gtjdcvXb0ErCva8jNHWfe/v5sU3UKjIB/uxOhc+TDOnhqffj9pRA==",
"dev": true
},
"@discordjs/ws": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-0.8.3.tgz",
"integrity": "sha512-hcYtppanjHecbdNyCKQNH2I4RP9UrphDgmRgLYrATEQF1oo4sYSve7ZmGsBEXSzH72MO2tBPdWSThunbxUVk0g==",
"dev": true,
"requires": {
"@discordjs/collection": "^1.5.1",
"@discordjs/rest": "^1.7.1",
"@discordjs/util": "^0.3.1",
"@sapphire/async-queue": "^1.5.0",
"@types/ws": "^8.5.4",
"@vladfrangu/async_event_emitter": "^2.2.1",
"discord-api-types": "^0.37.41",
"tslib": "^2.5.0",
"ws": "^8.13.0"
}
},
"@electron/get": {
"version": "2.0.2",
"dev": true,
@ -18729,6 +19165,28 @@
"@remix-run/router": {
"version": "1.3.3"
},
"@sapphire/async-queue": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.0.tgz",
"integrity": "sha512-JkLdIsP8fPAdh9ZZjrbHWR/+mZj0wvKS5ICibcLrRI1j84UmLMshx5n9QmL8b95d4onJ2xxiyugTgSAX7AalmA==",
"dev": true
},
"@sapphire/shapeshift": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.2.tgz",
"integrity": "sha512-YRbCXWy969oGIdqR/wha62eX8GNHsvyYi0Rfd4rNW6tSVVa8p0ELiMEuOH/k8rgtvRoM+EMV7Csqz77YdwiDpA==",
"dev": true,
"requires": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
}
},
"@sapphire/snowflake": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.1.tgz",
"integrity": "sha512-BxcYGzgEsdlG0dKAyOm0ehLGm2CafIrfQTZGWgkfKYbj+pNNsorZ7EotuZukc2MT70E0UbppVbtpBrqpzVzjNA==",
"dev": true
},
"@sideway/address": {
"version": "4.1.4",
"peer": true,
@ -18773,6 +19231,12 @@
"defer-to-connect": "^2.0.0"
}
},
"@tokenizer/token": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
"dev": true
},
"@tootallnate/once": {
"version": "2.0.0",
"dev": true
@ -19170,6 +19634,12 @@
"eslint-visitor-keys": "^3.3.0"
}
},
"@vladfrangu/async_event_emitter": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.2.2.tgz",
"integrity": "sha512-HIzRG7sy88UZjBJamssEczH5q7t5+axva19UbZLO6u0ySbYPrwzWiXBcC0WuHyhKKoeCyneH+FvYzKQq/zTtkQ==",
"dev": true
},
"7zip-bin": {
"version": "5.1.1",
"dev": true
@ -19610,6 +20080,16 @@
"tweetnacl": "^0.14.3"
}
},
"better-sqlite3": {
"version": "8.4.0",
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-8.4.0.tgz",
"integrity": "sha512-NmsNW1CQvqMszu/CFAJ3pLct6NEFlNfuGM6vw72KHkjOD1UDnL96XNN1BMQc1hiHo8vE2GbOWQYIpZ+YM5wrZw==",
"dev": true,
"requires": {
"bindings": "^1.5.0",
"prebuild-install": "^7.1.0"
}
},
"bignumber.js": {
"version": "2.4.0",
"dev": true
@ -19618,6 +20098,15 @@
"version": "2.2.0",
"dev": true
},
"bindings": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
"integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
"dev": true,
"requires": {
"file-uri-to-path": "1.0.0"
}
},
"bl": {
"version": "4.1.0",
"requires": {
@ -19733,6 +20222,15 @@
"buffer-from": {
"version": "1.1.2"
},
"busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dev": true,
"requires": {
"streamsearch": "^1.1.0"
}
},
"bytes": {
"version": "3.0.0",
"peer": true
@ -20377,6 +20875,34 @@
"path-type": "^4.0.0"
}
},
"discord-api-types": {
"version": "0.37.47",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.47.tgz",
"integrity": "sha512-rNif8IAv6duS2z47BMXq/V9kkrLfkAoiwpFY3sLxxbyKprk065zqf3HLTg4bEoxRSmi+Lhc7yqGDrG8C3j8GFA==",
"dev": true
},
"discord.js": {
"version": "14.11.0",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.11.0.tgz",
"integrity": "sha512-CkueWYFQ28U38YPR8HgsBR/QT35oPpMbEsTNM30Fs8loBIhnA4s70AwQEoy6JvLcpWWJO7GY0y2BUzZmuBMepQ==",
"dev": true,
"requires": {
"@discordjs/builders": "^1.6.3",
"@discordjs/collection": "^1.5.1",
"@discordjs/formatters": "^0.3.1",
"@discordjs/rest": "^1.7.1",
"@discordjs/util": "^0.3.1",
"@discordjs/ws": "^0.8.3",
"@sapphire/snowflake": "^3.4.2",
"@types/ws": "^8.5.4",
"discord-api-types": "^0.37.41",
"fast-deep-equal": "^3.1.3",
"lodash.snakecase": "^4.1.1",
"tslib": "^2.5.0",
"undici": "^5.22.0",
"ws": "^8.13.0"
}
},
"dlv": {
"version": "1.1.3",
"dev": true
@ -20770,6 +21296,16 @@
"typescript": "^4.9.3"
}
},
"enso-chat": {
"version": "git+ssh://git@github.com/enso-org/enso-bot.git#3b76888ec6bd3579016e70ef83ba282714aec47d",
"dev": true,
"from": "enso-chat@git://github.com/enso-org/enso-bot#wip/sb/initial-implementation",
"requires": {
"better-sqlite3": "^8.4.0",
"discord.js": "^14.11.0",
"ws": "^8.13.0"
}
},
"enso-common": {
"version": "file:lib/common"
},
@ -20823,6 +21359,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",
"esbuild": "^0.17.15",
"esbuild-plugin-time": "^1.0.0",
@ -21562,6 +22099,12 @@
"version": "3.9.0",
"dev": true
},
"file-uri-to-path": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
"dev": true
},
"filelist": {
"version": "1.0.4",
"dev": true,
@ -22960,6 +23503,12 @@
"lodash.merge": {
"version": "4.6.2"
},
"lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"dev": true
},
"lodash.throttle": {
"version": "4.1.1",
"peer": true
@ -24267,6 +24816,12 @@
"version": "4.0.0",
"dev": true
},
"peek-readable": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-5.0.0.tgz",
"integrity": "sha512-YtCKvLUOvwtMGmrniQPdO7MwPjgkFBtFIrmfSbYmYuq3tKDV/mcfAhBth1+C3ru7uXIZasc/pHnb+YDYNkkj4A==",
"dev": true
},
"pend": {
"version": "1.2.0",
"dev": true
@ -24765,6 +25320,15 @@
"util-deprecate": "^1.0.1"
}
},
"readable-web-to-node-stream": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/readable-web-to-node-stream/-/readable-web-to-node-stream-3.0.2.tgz",
"integrity": "sha512-ePeK6cc1EcKLEhJFt/AebMCLL+GgSKhuygrZ/GLaKZYEecIgIECf4UaUuaByiGtzckwR4ain9VzUh95T1exYGw==",
"dev": true,
"requires": {
"readable-stream": "^3.6.0"
}
},
"readdirp": {
"version": "3.6.0",
"dev": true,
@ -25566,6 +26130,12 @@
"stream-to": "~0.2.0"
}
},
"streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"dev": true
},
"string_decoder": {
"version": "1.3.0",
"requires": {
@ -25650,6 +26220,16 @@
"version": "1.0.5",
"peer": true
},
"strtok3": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-7.0.0.tgz",
"integrity": "sha512-pQ+V+nYQdC5H3Q7qBZAz/MO6lwGhoC2gOAjuouGf/VO0m7vQRh8QNMl2Uf6SwAtzZ9bOw3UIeBukEGNJl5dtXQ==",
"dev": true,
"requires": {
"@tokenizer/token": "^0.3.0",
"peek-readable": "^5.0.0"
}
},
"sudo-prompt": {
"version": "9.2.1",
"peer": true
@ -25950,6 +26530,16 @@
"version": "1.0.1",
"peer": true
},
"token-types": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/token-types/-/token-types-5.0.1.tgz",
"integrity": "sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==",
"dev": true,
"requires": {
"@tokenizer/token": "^0.3.0",
"ieee754": "^1.2.1"
}
},
"tough-cookie": {
"version": "2.5.0",
"dev": true,
@ -25968,6 +26558,12 @@
"utf8-byte-length": "^1.0.1"
}
},
"ts-mixer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz",
"integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==",
"dev": true
},
"ts-node": {
"version": "10.9.1",
"dev": true,
@ -25993,7 +26589,9 @@
"version": "3.3.0"
},
"tslib": {
"version": "2.4.1"
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.0.tgz",
"integrity": "sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA=="
},
"tsutils": {
"version": "3.21.0",
@ -26086,6 +26684,15 @@
"which-boxed-primitive": "^1.0.2"
}
},
"undici": {
"version": "5.22.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.22.1.tgz",
"integrity": "sha512-Ji2IJhFXZY0x/0tVBXeQwgPlLWw13GVzpsWPQ3rV50IFMMof2I55PZZxtm4P6iNq+L5znYN9nSTAq0ZyE6lSJw==",
"dev": true,
"requires": {
"busboy": "^1.6.0"
}
},
"unfetch": {
"version": "4.2.0"
},
@ -26407,6 +27014,13 @@
"typedarray-to-buffer": "^3.1.5"
}
},
"ws": {
"version": "8.13.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.13.0.tgz",
"integrity": "sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==",
"dev": true,
"requires": {}
},
"xdg-basedir": {
"version": "4.0.0",
"dev": true