pages235/src/chat.js

528 lines
16 KiB
JavaScript

//@ts-check
import { repeat } from 'lit/directives/repeat.js'
import { classMap } from 'lit/directives/class-map.js'
import { LitElement, html, css } from 'lit'
import { isCypress } from './utils'
import { getBuiltinCommandsList, tryHandleBuiltinCommand } from './builtinCommands'
import { options } from './optionsStorage'
import { activeModalStack, hideCurrentModal, showModal, miscUiState, notification } from './globalState'
import { formatMessage } from './botUtils'
import { getColorShadow, messageFormatStylesMap } from './react/MessageFormatted'
/**
* @typedef {{parts: import('./botUtils').MessageFormatPart[], id, fading?, faded}} Message
*/
class ChatBox extends LitElement {
static get styles () {
return css`
div.chat-wrapper { /* increase specificity */
position: fixed;
z-index: 10;
padding-left: calc(env(safe-area-inset-left) / 2);
padding-right: calc(env(safe-area-inset-right, 4px) / 2);
}
.chat-messages-wrapper {
bottom: 40px;
padding: 4px;
padding-left: 0;
max-height: var(--chatHeight);
width: var(--chatWidth);
transform-origin: bottom left;
transform: scale(var(--chatScale));
pointer-events: none;
}
.chat-input-wrapper {
bottom: 1px;
width: calc(100% - 3px);
position: fixed;
left: 1px;
box-sizing: border-box;
background-color: rgba(0, 0, 0, 0);
}
.chat-input {
box-sizing: border-box;
width: 100%;
}
.chat-completions {
position: absolute;
/* position this bottom on top of parent */
top: 0;
left: 0;
transform: translateY(-100%);
/* width: 150px; */
display: flex;
padding: 0 2px; // input padding
width: 100%;
}
.input-mobile .chat-completions {
transform: none;
top: 15px; // input height
}
.chat-completions-pad-text {
pointer-events: none;
white-space: pre;
opacity: 0;
overflow: hidden;
}
.chat-completions-items {
background-color: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
/* justify-content: flex-end; */
/* probably would be better to replace with margin, not sure */
padding: 2px;
max-height: 100px;
overflow: auto;
/* hide ugly scrollbars in firefox */
scrollbar-width: none;
}
/* unsupported by firefox */
::-webkit-scrollbar {
width: 5px;
background-color: rgb(24, 24, 24);
}
::-webkit-scrollbar-thumb {
background-color: rgb(50, 50, 50);
}
.chat-completions-items > div {
cursor: pointer;
}
.chat-completions-items > div:hover {
text-shadow: 0px 0px 6px white;
}
.input-mobile .chat-completions-items {
justify-content: flex-start;
}
.input-mobile {
top: 1px;
}
.input-mobile #chatinput {
height: 20px;
}
.display-mobile {
top: 40px;
}
.chat, .chat-input {
color: white;
font-size: 10px;
margin: 0px;
line-height: 100%;
text-shadow: 1px 1px 0px #3f3f3f;
font-family: mojangles, minecraft, monospace;
max-height: var(--chatHeight);
}
.chat {
pointer-events: none;
overflow: hidden;
width: 100%;
scrollbar-width: thin;
}
.chat.opened {
pointer-events: auto;
overflow-y: auto;
}
input[type=text], #chatinput {
background-color: rgba(0, 0, 0, 0.5);
border: 1px solid rgba(0, 0, 0, 0);
outline: none;
pointer-events: auto;
/* styles reset */
padding-top: 1px;
padding-bottom: 1px;
padding-left: 2px;
padding-right: 2px;
height: 15px;
}
.chat-mobile-hidden {
width: 8px;
height: 0;
position: absolute;
display: block !important;
opacity: 0;
pointer-events: none;
}
.chat-mobile-hidden:nth-last-child(1) {
height: 8px;
}
#chatinput:focus {
border-color: white;
}
.chat-message {
padding-left: 4px;
background-color: rgba(0, 0, 0, 0.5);
list-style: none;
word-break: break-all;
}
.chat-message-fadeout {
opacity: 1;
transition: all 3s;
}
.chat-message-fade {
opacity: 0;
}
.chat-message-faded {
transition: none !important;
}
.chat.opened .chat-message {
opacity: 1 !important;
transition: none !important;
}
.chat-message-part {
}
`
}
static get properties () {
return {
messages: {
type: Array
},
completionItems: {
type: Array
},
completePadText: {
type: String
}
}
}
constructor () {
super()
this.chatHistoryPos = 0
this.chatHistory = JSON.parse(window.sessionStorage.chatHistory || '[]')
this.completePadText = ''
this.messagesLimit = 200
/** @type {string[]} */
this.completionItemsSource = []
/** @type {string[]} */
this.completionItems = []
this.completeRequestValue = ''
/** @type {Message[]} */
this.messages = [{
parts: [
{
text: 'Welcome to prismarine-web-client! Chat appears here.',
}
],
id: 0,
fading: true,
faded: true,
}]
}
enableChat (initialText = '') {
if (this.inChat) {
hideCurrentModal()
return
}
notification.show = false
// @ts-expect-error
const chat = this.shadowRoot.getElementById('chat-messages')
/** @type {HTMLInputElement} */
// @ts-expect-error
const chatInput = this.shadowRoot.getElementById('chatinput')
showModal(this)
// Show extended chat history
chat.style.maxHeight = 'var(--chatHeight)'
chat.scrollTop = chat.scrollHeight // Stay bottom of the list
// handle / and other snippets
this.updateInputValue(initialText)
this.chatHistoryPos = this.chatHistory.length
// to show
this.requestUpdate()
setTimeout(() => {
// after component update display
chatInput.focus()
})
}
get inChat () {
return activeModalStack.some(m => m.elem === this)
}
/**
* @param {import('minecraft-protocol').Client} client
*/
init (client) {
// @ts-expect-error
const chat = this.shadowRoot.getElementById('chat-messages')
/** @type {HTMLInputElement} */
// @ts-expect-error
const chatInput = this.shadowRoot.getElementById('chatinput')
/** @type {any} */
this.chatInput = chatInput
// Show chat
chat.style.display = 'block'
let savedCurrentValue
// Chat events
document.addEventListener('keydown', e => {
if (activeModalStack.at(-1)?.elem !== this) return
if (e.code === 'ArrowUp') {
if (this.chatHistoryPos === 0) return
if (this.chatHistoryPos === this.chatHistory.length) {
savedCurrentValue = chatInput.value
}
this.updateInputValue(this.chatHistory[--this.chatHistoryPos] || '')
} else if (e.code === 'ArrowDown') {
if (this.chatHistoryPos === this.chatHistory.length) return
this.updateInputValue(this.chatHistory[++this.chatHistoryPos] || savedCurrentValue || '')
}
})
document.addEventListener('keypress', e => {
if (!this.inChat && activeModalStack.length === 0) {
return false
}
if (!this.inChat) return
e.stopPropagation()
if (e.code === 'Enter') {
const message = chatInput.value
if (message) {
this.chatHistory.push(message)
window.sessionStorage.chatHistory = JSON.stringify(this.chatHistory)
const builtinHandled = tryHandleBuiltinCommand(message)
if (!builtinHandled) {
bot.chat(message)
}
}
hideCurrentModal()
}
})
this.hide = () => {
this.completionItems = []
// Clear chat input
chatInput.value = ''
// Unfocus it
chatInput.blur()
// Hide extended chat history
chat.style.maxHeight = 'var(--chatHeight)'
chat.scrollTop = chat.scrollHeight // Stay bottom of the list
this.requestUpdate()
return 'custom' // custom hide
}
this.hide()
bot.on('message', (fullmessage) => {
const parts = formatMessage(fullmessage)
const lastId = this.messages.at(-1)?.id ?? 0
this.messages = [...this.messages.slice(-this.messagesLimit), {
parts,
id: lastId + 1,
fading: false,
faded: false
}]
/** @type {any} */
const message = this.messages.at(-1)
chat.scrollTop = chat.scrollHeight // Stay bottom of the list
// fading
setTimeout(() => {
message.fading = true
this.requestUpdate()
setTimeout(() => {
message.faded = true
this.requestUpdate()
}, 3000)
}, 5000)
})
// todo support hover content below, {action: 'show_text', contents: {text}}, and some other types
// todo remove
window.dummyMessage = () => {
client.emit('chat', {
message: '{"color":"yellow","translate":"multiplayer.player.joined","with":[{"insertion":"pviewer672","clickEvent":{"action":"suggest_command","value":"/tell pviewer672 "},"hoverEvent":{"action":"show_entity","contents":{"type":"minecraft:player","id":"ecd0eeb1-625e-3fea-b16e-cb449dcfa434","name":{"text":"pviewer672"}}},"text":"pviewer672"}]}',
position: 1,
sender: '00000000-0000-0000-0000-000000000000',
})
}
// window.dummyMessage()
chatInput.addEventListener('input', (e) => {
const completeValue = this.getCompleteValue()
this.completePadText = completeValue === '/' ? '' : completeValue
if (this.completeRequestValue === completeValue) {
/** @type {any} */
const lastWord = chatInput.value.split(' ').at(-1)
this.completionItems = this.completionItemsSource.filter(i => {
const compareableParts = i.split(/[_:]/)
return compareableParts.some(compareablePart => compareablePart.startsWith(lastWord))
})
return
}
this.completeRequestValue = ''
this.completionItems = []
this.completionItemsSource = []
if (options.autoRequestCompletions && this.getCompleteValue() === '/') {
void this.fetchCompletion()
}
})
chatInput.addEventListener('keydown', (e) => {
if (e.code === 'Tab') {
if (this.completionItems.length) {
this.acceptComplete(this.completionItems[0])
} else {
void this.fetchCompletion(chatInput.value)
}
e.preventDefault()
}
if (e.code === 'Space' && options.autoRequestCompletions && chatInput.value.startsWith('/')) {
// alternative we could just simply use keyup, but only with keydown we can display suggestions popup as soon as possible
void this.fetchCompletion(this.getCompleteValue(chatInput.value + ' '))
}
})
}
getCompleteValue (value = this.chatInput.value) {
const valueParts = value.split(' ')
const lastLength = valueParts.at(-1).length
const completeValue = lastLength ? value.slice(0, -lastLength) : value
if (valueParts.length === 1 && value.startsWith('/')) return '/'
return completeValue
}
async fetchCompletion (value = this.getCompleteValue()) {
this.completionItemsSource = []
this.completionItems = []
this.completeRequestValue = value
let items = await bot.tabComplete(value, true, true)
if (typeof items[0] === 'object') {
// @ts-expect-error
if (items[0].match) items = items.map(i => i.match)
}
if (value !== this.completeRequestValue) return
if (this.completeRequestValue === '/') {
if (!items[0].startsWith('/')) {
// normalize
items = items.map(item => `/${item}`)
}
if (localServer) {
items = [...items, ...getBuiltinCommandsList()]
}
}
this.completionItems = items
this.completionItemsSource = items
}
renderMessagePart (/** @type {import('./botUtils').MessageFormatPart} */{ bold, color, italic, strikethrough, text, underlined }) {
const colorF = (color) => {
return color.trim().startsWith('#') ? `color:${color}` : messageFormatStylesMap[color] ?? undefined
}
/** @type {string[]} */
// @ts-expect-error
const applyStyles = [
color ? colorF(color.toLowerCase()) + `; text-shadow: 1px 1px 0px ${getColorShadow(colorF(color.toLowerCase()).replace('color:', ''))}` : messageFormatStylesMap.white,
italic && messageFormatStylesMap.italic,
bold && messageFormatStylesMap.bold,
italic && messageFormatStylesMap.italic,
underlined && messageFormatStylesMap.underlined,
strikethrough && messageFormatStylesMap.strikethrough
].filter(Boolean)
return html`
<span
class="chat-message-part"
style="${applyStyles.join(';')}"
>${text}</span>
`
}
renderMessage (/** @type {Message} */message) {
const classes = {
'chat-message-fadeout': message.fading,
'chat-message-fade': message.fading,
'chat-message-faded': message.faded,
'chat-message': true
}
return html`
<li class=${classMap(classes)}>
${message.parts.map(msg => this.renderMessagePart(msg))}
</li>
`
}
updateInputValue (value) {
/** @type {any} */
const { chatInput } = this
chatInput.value = value
chatInput.dispatchEvent(new Event('input'))
setTimeout(() => {
chatInput.setSelectionRange(value.length, value.length)
}, 0)
}
auxInputFocus (fireKey) {
document.dispatchEvent(new KeyboardEvent('keydown', { code: fireKey }))
this.chatInput.focus()
}
acceptComplete (item) {
const base = this.completeRequestValue === '/' ? '' : this.getCompleteValue()
this.updateInputValue(base + item)
// would be cool but disabled because some comands don't need args (like ping)
// // trigger next tab complete
// this.chatInput.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space' }))
this.chatInput.focus()
}
render () {
return html`
<div class="chat-wrapper chat-messages-wrapper ${miscUiState.currentTouch ? 'display-mobile' : ''}">
<div class="chat ${this.inChat ? 'opened' : ''}" id="chat-messages">
<!-- its to hide player joined at random timings, todo add chat tests as well -->
${repeat(isCypress() ? [] : this.messages, (m) => m.id, (m) => this.renderMessage(m))}
</div>
</div>
<div class="chat-wrapper chat-input-wrapper ${miscUiState.currentTouch ? 'input-mobile' : ''}" style="display: ${this.inChat ? 'block' : 'none'}">
<div class="chat-input">
${this.completionItems.length ? html`
<div class="chat-completions">
<div class="chat-completions-pad-text">${this.completePadText}</div>
<div class="chat-completions-items">
${repeat(this.completionItems, (i) => i, (i) => html`<div @click=${() => this.acceptComplete(i)}>${i}</div>`)}
</div>
</div>
` : ''}
<input type="text" class="chat-mobile-hidden" id="chatinput-next-command" spellcheck="false" autocomplete="off" @focus=${() => {
this.auxInputFocus('ArrowUp')
}}></input>
<input type="text" class="chat-input" id="chatinput" spellcheck="false" autocomplete="off" aria-autocomplete="both"></input>
<input type="text" class="chat-mobile-hidden" id="chatinput-prev-command" spellcheck="false" autocomplete="off" @focus=${() => {
this.auxInputFocus('ArrowDown')
}}></input>
</div>
</div>
`
}
}
window.customElements.define('chat-box', ChatBox)