mirror of
https://github.com/wbbaddons/Tims-Chat.git
synced 2024-10-31 14:10:08 +00:00
456 lines
14 KiB
JavaScript
456 lines
14 KiB
JavaScript
/*
|
||
* Copyright (c) 2010-2020 Tim Düsterhus.
|
||
*
|
||
* Use of this software is governed by the Business Source License
|
||
* included in the LICENSE file.
|
||
*
|
||
* Change Date: 2024-10-31
|
||
*
|
||
* On the date above, in accordance with the Business Source
|
||
* License, use of this software will be governed by version 2
|
||
* or later of the General Public License.
|
||
*/
|
||
|
||
define([ './Chat/console'
|
||
, 'Bastelstu.be/bottle'
|
||
, 'Bastelstu.be/_Push'
|
||
, 'WoltLabSuite/Core/Core'
|
||
, 'WoltLabSuite/Core/Language'
|
||
, 'WoltLabSuite/Core/Timer/Repeating'
|
||
, 'WoltLabSuite/Core/User'
|
||
, './Chat/Autocompleter'
|
||
, './Chat/CommandHandler'
|
||
, './Chat/DataStructure/Throttle'
|
||
, './Chat/Message'
|
||
, './Chat/Messenger'
|
||
, './Chat/ParseError'
|
||
, './Chat/ProfileStore'
|
||
, './Chat/Room'
|
||
, './Chat/Template'
|
||
, './Chat/Ui/Attachment/Upload'
|
||
, './Chat/Ui/AutoAway'
|
||
, './Chat/Ui/Chat'
|
||
, './Chat/Ui/ConnectionWarning'
|
||
, './Chat/Ui/ErrorDialog'
|
||
, './Chat/Ui/Input'
|
||
, './Chat/Ui/Input/Autocompleter'
|
||
, './Chat/Ui/MessageStream'
|
||
, './Chat/Ui/MessageActions/Delete'
|
||
, './Chat/Ui/Mobile'
|
||
, './Chat/Ui/Notification'
|
||
, './Chat/Ui/ReadMarker'
|
||
, './Chat/Ui/Settings'
|
||
, './Chat/Ui/Topic'
|
||
, './Chat/Ui/UserActionDropdownHandler'
|
||
, './Chat/Ui/UserList'
|
||
], function (console, Bottle, Push, Core, Language, RepeatingTimer, CoreUser, Autocompleter,
|
||
CommandHandler, Throttle, Message, Messenger, ParseError, ProfileStore, Room, Template, UiAttachmentUpload, UiAutoAway, Ui,
|
||
UiConnectionWarning, ErrorDialog, UiInput, UiInputAutocompleter, UiMessageStream, UiMessageActionDelete, UiMobile, UiNotification,
|
||
UiReadMarker, UiSettings, UiTopic, UiUserActionDropdownHandler, UiUserList) {
|
||
"use strict";
|
||
|
||
class Chat {
|
||
constructor(roomID, config) {
|
||
console.debug('Chat.constructor', 'Constructing …')
|
||
|
||
this.config = config
|
||
|
||
this.sessionID = Core.getUuid()
|
||
|
||
// Setup Bottle containers
|
||
this.bottle = new Bottle()
|
||
this.bottle.value('bottle', this.bottle)
|
||
this.bottle.value('config', config)
|
||
this.bottle.constant('sessionID', this.sessionID)
|
||
this.bottle.constant('roomID', roomID)
|
||
|
||
// Register chat components
|
||
this.service('Autocompleter', Autocompleter)
|
||
this.service('CommandHandler', CommandHandler)
|
||
this.service('Messenger', Messenger)
|
||
this.service('ProfileStore', ProfileStore)
|
||
this.service('Room', Room)
|
||
|
||
// Register UI components
|
||
this.service('Ui', Ui)
|
||
this.service('UiAutoAway', UiAutoAway)
|
||
this.service('UiConnectionWarning', UiConnectionWarning)
|
||
this.service('UiInput', UiInput)
|
||
this.service('UiInputAutocompleter', UiInputAutocompleter)
|
||
this.service('UiMessageActionDelete', UiMessageActionDelete)
|
||
this.service('UiMessageStream', UiMessageStream)
|
||
this.service('UiMobile', UiMobile)
|
||
this.service('UiNotification', UiNotification)
|
||
this.service('UiReadMarker', UiReadMarker)
|
||
this.service('UiSettings', UiSettings)
|
||
this.service('UiTopic', UiTopic)
|
||
this.service('UiUserActionDropdownHandler', UiUserActionDropdownHandler)
|
||
this.service('UiUserList', UiUserList)
|
||
this.service('UiAttachmentUpload', UiAttachmentUpload)
|
||
|
||
// Register Models
|
||
this.bottle.instanceFactory('Message', (container, m) => {
|
||
return new Message(container.MessageType, m)
|
||
})
|
||
|
||
// Register Templates
|
||
const selector = [ '[type="x-text/template"]'
|
||
, '[data-application="be.bastelstu.chat"]'
|
||
, '[data-template-name]'
|
||
].join('')
|
||
|
||
const templates = elBySelAll(selector)
|
||
|
||
Array.prototype.forEach.call(templates, (function (template) {
|
||
this.bottle.factory(`Template.${template.dataset.templateName}`, function (container) {
|
||
const includeNames = (template.dataset.templateIncludes || '').split(/ /).filter(item => item !== "")
|
||
const includes = { }
|
||
includeNames.forEach(item => includes[item] = container[item])
|
||
|
||
return new Template(template.textContent, includes)
|
||
})
|
||
}).bind(this))
|
||
|
||
// Register MessageTypes
|
||
Object.entries(this.config.messageTypes)
|
||
.forEach(([ objectType, messageType ]) => {
|
||
const MessageType = require(messageType.module)
|
||
|
||
this.bottle.factory(`MessageType.${objectType.replace(/\./g, '-')}`, _ => {
|
||
const deps = this.bottle.digest(MessageType.DEPENDENCIES || [])
|
||
|
||
return new MessageType(...deps, objectType)
|
||
})
|
||
})
|
||
|
||
// Register Commands
|
||
Object.values(this.config.commands).forEach(command => {
|
||
const Command = require(command.module)
|
||
|
||
this.bottle.factory(`Command.${command.package.replace(/\./g, '-')}:${command.identifier}`, _ => {
|
||
const deps = this.bottle.digest(Command.DEPENDENCIES || [])
|
||
|
||
return new Command(...deps, command)
|
||
})
|
||
})
|
||
this.bottle.constant('Trigger', new Map(Object.entries(this.config.triggers).map(([ trigger, commandID ]) => {
|
||
const command = this.config.commands[commandID]
|
||
const key = [ command.package, command.identifier ]
|
||
return [ trigger, key ]
|
||
})))
|
||
|
||
// Register Settings
|
||
Array.from(elBySelAll('#chatQuickSettingsNavigation .button[data-module]')).forEach(item => {
|
||
const Button = require(item.dataset.module)
|
||
|
||
this.bottle.instanceFactory(`UiSettingsButton.${item.dataset.module.replace(/\./g, '-')}`, (_, element) => {
|
||
const deps = this.bottle.digest(Button.DEPENDENCIES || [])
|
||
return new Button(element, ...deps)
|
||
})
|
||
})
|
||
|
||
this.knows = { from: undefined
|
||
, to: undefined
|
||
}
|
||
|
||
this.processMessagesThrottled = Throttle(this.processMessages.bind(this))
|
||
this.queuedMessages = [ ]
|
||
this.messageSinks = new Set()
|
||
|
||
this.pullTimer = undefined
|
||
this.pullUserListTimer = undefined
|
||
this.pushConnected = false
|
||
|
||
this.firstFailure = null
|
||
}
|
||
|
||
service(name, _constructor, args = [ ]) {
|
||
this.bottle.factory(name, _ => {
|
||
const deps = this.bottle.digest(_constructor.DEPENDENCIES || [ ])
|
||
|
||
return new _constructor(...deps, ...args)
|
||
})
|
||
}
|
||
|
||
async bootstrap() {
|
||
console.debug('Chat.bootstrap', 'Initializing …')
|
||
|
||
this.ui = this.bottle.container.Ui
|
||
this.ui.bootstrap()
|
||
|
||
this.bottle.container.UiInput.on('submit', this.onSubmit.bind(this))
|
||
this.bottle.container.UiInput.on('autocomplete', this.onAutocomplete.bind(this))
|
||
|
||
await this.bottle.container.Room.join()
|
||
|
||
// Bind unload event to leave the Chat
|
||
window.addEventListener('unload', this.bottle.container.Room.leave.bind(this.bottle.container.Room, true))
|
||
document.addEventListener('visibilitychange', _ => {
|
||
this.processMessagesThrottled.setDelay(document.hidden ? 10000 : 125)
|
||
})
|
||
|
||
this.pullTimer = new RepeatingTimer(Throttle(this.pullMessages.bind(this)), this.config.reloadTime * 1e3)
|
||
|
||
Push.onConnect(_ => {
|
||
console.debug('Chat.bootstrap', 'Push connected')
|
||
this.pushConnected = true
|
||
this.pullTimer.setDelta(30e3)
|
||
})
|
||
.catch(error => { console.debug(error) })
|
||
|
||
Push.onDisconnect(_ => {
|
||
console.debug('Chat.bootstrap', 'Push disconnected')
|
||
this.pushConnected = false
|
||
this.pullTimer.setDelta(this.config.reloadTime * 1e3)
|
||
})
|
||
.catch(error => { console.debug(error) })
|
||
|
||
Push.onMessage('be.bastelstu.chat.message', this.pullMessages.bind(this))
|
||
.catch(error => { console.debug(error) })
|
||
|
||
// Fetch user list every 60 seconds
|
||
// This acts as a safety net: It should be kept current by messages whenever possible.
|
||
this.pullUserListTimer = new RepeatingTimer(this.updateUsers.bind(this), 60e3)
|
||
|
||
this.registerMessageSink(this.bottle.container.UiMessageStream)
|
||
this.registerMessageSink(this.bottle.container.UiNotification)
|
||
this.registerMessageSink(this.bottle.container.UiAutoAway)
|
||
|
||
await Promise.all([ this.pullMessages()
|
||
, this.updateUsers()
|
||
, this.bottle.container.ProfileStore.ensureUsersByIDs([ CoreUser.userId ])
|
||
])
|
||
|
||
return this
|
||
}
|
||
|
||
registerMessageSink(sink) {
|
||
if (typeof sink.ingest !== 'function') {
|
||
throw new Error('The given sink does not provide a .ingest function.')
|
||
}
|
||
|
||
this.messageSinks.add(sink)
|
||
}
|
||
|
||
unregisterMessageSink(sink) {
|
||
this.messageSinks.delete(sink)
|
||
}
|
||
|
||
hcf(err = undefined) {
|
||
console.debug('Chat.hcf', 'Gotcha! FIRE was caught! FIRE’s data was newly added to the POKéDEX.', err)
|
||
|
||
this.pullTimer.stop()
|
||
this.pullUserListTimer.stop()
|
||
|
||
new ErrorDialog(Language.get('chat.error.hcf', { err }))
|
||
}
|
||
|
||
async onSubmit(event) {
|
||
const input = event.target
|
||
const value = input.getText()
|
||
|
||
console.debug('Chat.onSubmit', `Pushing message: ${value}`)
|
||
|
||
// Clear message input
|
||
input.insertText('', { append: false })
|
||
|
||
this.markAsBack()
|
||
|
||
let [ trigger, parameterString ] = this.bottle.container.CommandHandler.splitCommand(value)
|
||
let command = null
|
||
if (trigger === null) {
|
||
command = this.bottle.container.CommandHandler.getCommandByIdentifier('be.bastelstu.chat', 'plain')
|
||
}
|
||
else {
|
||
command = this.bottle.container.CommandHandler.getCommandByTrigger(trigger)
|
||
}
|
||
|
||
if (command === null) {
|
||
this.ui.input.inputError(Language.get('chat.error.triggerNotFound', { trigger }))
|
||
return
|
||
}
|
||
|
||
try {
|
||
let parameters
|
||
try {
|
||
parameters = this.bottle.container.CommandHandler.applyCommand(command, parameterString)
|
||
}
|
||
catch (e) {
|
||
if (e instanceof ParseError) {
|
||
e = new Error(Language.get('chat.error.invalidParameters', { data: e.data }))
|
||
}
|
||
throw e
|
||
}
|
||
|
||
const payload = { commandID: command.id
|
||
, parameters
|
||
}
|
||
|
||
try {
|
||
await this.bottle.container.Messenger.push(payload)
|
||
this.ui.input.hideInputError()
|
||
}
|
||
catch (error) {
|
||
let seriousError = true
|
||
if (error.returnValues && error.returnValues.fieldName === 'message' && (error.returnValues.realErrorMessage || error.returnValues.errorType)) {
|
||
this.ui.input.inputError(error.returnValues.realErrorMessage || error.returnValues.errorType)
|
||
seriousError = false
|
||
}
|
||
else {
|
||
this.ui.input.inputError(error.message)
|
||
}
|
||
|
||
if (seriousError) {
|
||
this.handleError(error)
|
||
}
|
||
}
|
||
|
||
// We assume that a running push server will push us our own message
|
||
if (!this.pushConnected) {
|
||
this.pullMessages()
|
||
}
|
||
|
||
console.debug('Chat.onSubmit', `Done`)
|
||
}
|
||
catch (e) {
|
||
this.ui.input.inputError(e.message)
|
||
}
|
||
}
|
||
|
||
async markAsBack() {
|
||
try {
|
||
if (this.bottle.container.ProfileStore.getSelf().away == null) return
|
||
console.debug('Chat.markAsBack', `Marking as back`)
|
||
|
||
const command = this.bottle.container.CommandHandler.getCommandByIdentifier('be.bastelstu.chat', 'back')
|
||
return this.bottle.container.Messenger.push({ commandID: command.id, parameters: { } })
|
||
}
|
||
catch (err) {
|
||
console.error('Chat.markAsBack', err)
|
||
}
|
||
}
|
||
|
||
onAutocomplete(event) {
|
||
const input = event.target
|
||
const value = input.getText(true)
|
||
|
||
console.debug('Chat.onAutocomplete', `Autocompleting message: ${value}`)
|
||
|
||
const result = this.bottle.container.Autocompleter.autocomplete(value)
|
||
const completions = []
|
||
for (const item of result) {
|
||
completions.push(item)
|
||
if (completions.length == 5) break
|
||
}
|
||
|
||
this.ui.autocompleter.sendCompletions(completions)
|
||
}
|
||
|
||
async pullMessages() {
|
||
console.debug('Chat.pullMessages', `Pulling new messages, starting at ${this.knows.to ? this.knows.to + 1 : ''}`)
|
||
|
||
let payload
|
||
try {
|
||
if (this.knows.to === undefined) {
|
||
payload = await this.bottle.container.Messenger.pull()
|
||
}
|
||
else {
|
||
payload = await this.bottle.container.Messenger.pull(this.knows.to + 1)
|
||
}
|
||
}
|
||
catch (e) {
|
||
this.handleError(e)
|
||
return
|
||
}
|
||
|
||
console.debug('Chat.pullMessages', `Handling result: `, payload)
|
||
const start = (performance ? performance : Date).now()
|
||
this.ui.connectionWarning.hide()
|
||
this.firstFailure = null
|
||
|
||
// Null range: No messages satisfy the constraints
|
||
if (payload.from > payload.to) {
|
||
const end = (performance ? performance : Date).now()
|
||
console.debug('Chat.pullMessages', `took ${(end - start) / 1000}s`)
|
||
return
|
||
}
|
||
|
||
let messages = payload.messages
|
||
|
||
if (this.knows.from !== undefined && this.knows.to !== undefined) {
|
||
messages = messages.filter((message) => {
|
||
return !(this.knows.from <= message.messageID && message.messageID <= this.knows.to)
|
||
})
|
||
}
|
||
|
||
if (this.knows.from === undefined || payload.from < this.knows.from) this.knows.from = payload.from
|
||
if (this.knows.to === undefined || payload.to > this.knows.to) this.knows.to = payload.to
|
||
|
||
this.queuedMessages.push(messages)
|
||
const end = (performance ? performance : Date).now()
|
||
console.debug('Chat.pullMessages', `took ${(end - start) / 1000}s`)
|
||
|
||
this.processMessagesThrottled()
|
||
}
|
||
|
||
handleError(error) {
|
||
if (this.firstFailure === null) {
|
||
console.error('Chat.handleError', `Request failed, 30 seconds until shutdown`)
|
||
this.firstFailure = Date.now()
|
||
this.ui.connectionWarning.show()
|
||
}
|
||
|
||
console.debugException(error)
|
||
|
||
if ((Date.now() - this.firstFailure) >= 30e3) {
|
||
console.error('Chat.handleError', ' Failures for 30 seconds, aborting')
|
||
|
||
this.hcf(error)
|
||
}
|
||
}
|
||
|
||
async processMessages() {
|
||
console.debug('Chat.processMessages', 'Processing messages')
|
||
const start = (performance ? performance : Date).now()
|
||
const messages = [ ].concat(...this.queuedMessages)
|
||
this.queuedMessages = []
|
||
|
||
if (messages.length === 0) return
|
||
|
||
await Promise.all(messages.map(async (message) => {
|
||
this.bottle.container.ProfileStore.pushLastActivity(message.userID)
|
||
|
||
return message.getMessageType().preProcess(message)
|
||
}))
|
||
|
||
const updateUserList = messages.some((message) => {
|
||
return message.getMessageType().shouldUpdateUserList(message)
|
||
})
|
||
|
||
if (updateUserList) {
|
||
this.updateUsers()
|
||
}
|
||
|
||
await this.bottle.container.ProfileStore.ensureUsersByIDs([ ].concat(...messages.map(message => message.getMessageType().getReferencedUsers(message))))
|
||
|
||
messages.forEach((message) => {
|
||
message.getMessageType().preRender(message)
|
||
})
|
||
|
||
this.messageSinks.forEach(sink => sink.ingest(messages))
|
||
const end = (performance ? performance : Date).now()
|
||
console.debug('Chat.processMessages', `took ${(end - start) / 1000}s`)
|
||
}
|
||
|
||
async updateUsers() {
|
||
console.debug('Chat.updateUsers')
|
||
|
||
const users = await this.bottle.container.Room.getUsers()
|
||
await this.bottle.container.ProfileStore.ensureUsersByIDs(users.map(user => user.userID))
|
||
this.ui.userList.render(users)
|
||
}
|
||
}
|
||
|
||
return Chat
|
||
});
|