Tims-Chat/files_wcf/js/Bastelstu.be/Chat/Ui/MessageStream.js

349 lines
10 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-20
*
* 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([ '../Helper'
, 'WoltLabSuite/Core/Date/Util'
, 'WoltLabSuite/Core/Dom/Change/Listener'
, 'WoltLabSuite/Core/Language'
, 'WoltLabSuite/Core/User'
, 'WoltLabSuite/Core/Dom/Traverse'
, '../DataStructure/EventEmitter'
, '../DataStructure/RedBlackTree/Tree'
], function (Helper, DateUtil, DomChangeListener, Language, User, DOMTraverse, EventEmitter, Tree) {
"use strict";
const enableAutoscroll = Symbol('enableAutoscroll')
const DEPENDENCIES = [ ]
class MessageStream {
constructor() {
this.stream = elById('chatMessageStream')
this.scrollContainer = elBySel('.scrollContainer', this.stream)
this[enableAutoscroll] = true
this.lastScrollPosition = undefined
this.nodeMap = new WeakMap()
this.positions = new Tree()
}
get enableAutoscroll() {
return this[enableAutoscroll]
}
set enableAutoscroll(value) {
this[enableAutoscroll] = value
if (this[enableAutoscroll]) {
this.scrollToBottom()
}
}
bootstrap() {
this.scrollContainer.addEventListener('copy', this.onCopy.bind(this))
this.scrollContainer.addEventListener('scroll', Helper.throttle(this.onScroll, 100, this), { passive: true })
}
getDateMarker(date) {
const dateMarker = elCreate('li')
dateMarker.classList.add('dateMarker')
const time = elCreate('time')
time.innerText = DateUtil.formatDate(date)
time.setAttribute('datetime', DateUtil.format(date, 'Y-m-d'))
dateMarker.appendChild(time)
return dateMarker
}
onDifferentDays(a, b) {
return DateUtil.format(a, 'Y-m-d') !== DateUtil.format(b, 'Y-m-d')
}
ingest(messages) {
let scrollTopBefore = this.enableAutoscroll ? 0 : this.scrollContainer.scrollTop
let prependedHeight = 0
const ul = elBySel('ul', this.scrollContainer)
const first = ul.firstElementChild
const ingested = messages.map((function (item) {
let currentScrollHeight = 0
const li = elCreate('li')
// Allow messages types to not render a messages
// This can be used for status messages like ChatUpdate
let fragment
if ((fragment = item.getMessageType().render(item)) === false) return
if (fragment.querySelector(`.userMention[data-user-id="${User.userId}"]`)) li.classList.add('mentioned')
li.appendChild(fragment)
li.classList.add('chatMessageBoundary')
li.setAttribute('id', `message-${item.messageID}`)
li.dataset.objectType = item.objectType
li.dataset.userId = item.userID
if (item.isOwnMessage()) li.classList.add('own')
if (item.isDeleted) li.classList.add('tombstone')
const position = this.positions.insert(item.messageID)
if (position[1] !== undefined) {
const sibling = elById(`message-${position[1]}`)
if (!sibling) throw new Error('Unreachable')
let nodeBefore, nodeAfter
let dateMarkerBetween = false
if (position[0] === 'LEFT') {
nodeAfter = sibling
nodeBefore = sibling.previousElementSibling
if (nodeBefore && nodeBefore.classList.contains('dateMarker')) {
elRemove(nodeBefore)
nodeBefore = sibling.previousElementSibling
}
}
else if (position[0] === 'RIGHT') {
nodeBefore = sibling
nodeAfter = sibling.nextElementSibling
if (nodeAfter && nodeAfter.classList.contains('dateMarker')) {
elRemove(nodeAfter)
nodeAfter = sibling.nextElementSibling
}
}
else {
throw new Error('Unreachable')
}
const messageBefore = this.nodeMap.get(nodeBefore)
if (nodeBefore && !messageBefore) throw new Error('Unreachable')
const messageAfter = this.nodeMap.get(nodeAfter)
if (nodeAfter && !messageAfter) throw new Error('Unreachable')
if (!this.enableAutoscroll && nodeAfter) currentScrollHeight = this.scrollContainer.scrollHeight
let context = nodeAfter
if (nodeAfter) nodeAfter.classList.remove('first')
if (messageBefore) {
if (this.onDifferentDays(messageBefore.date, item.date)) {
const dateMarker = this.getDateMarker(item.date)
ul.insertBefore(dateMarker, nodeAfter)
li.classList.add('first')
}
else {
if (messageBefore.objectType !== item.objectType || !item.getMessageType().joinable(messageBefore, item)) {
li.classList.add('first')
}
}
}
else {
li.classList.add('first')
}
if (messageAfter) {
if (this.onDifferentDays(messageAfter.date, item.date)) {
const dateMarker = this.getDateMarker(messageAfter.date)
ul.insertBefore(dateMarker, nodeAfter)
context = dateMarker
nodeAfter.classList.add('first')
}
else {
if (messageAfter.objectType !== item.objectType || !item.getMessageType().joinable(item, messageAfter)) {
nodeAfter.classList.add('first')
}
}
}
ul.insertBefore(li, context);
if (!this.enableAutoscroll && nodeAfter) {
prependedHeight += this.scrollContainer.scrollHeight - currentScrollHeight
}
}
else {
li.classList.add('first')
ul.insertBefore(li, null)
}
this.nodeMap.set(li, item)
return { node: li
, message: item
}
}).bind(this));
if (ingested.some(item => item != null)) {
if (this.enableAutoscroll) {
this.scrollToBottom()
}
else {
this.stream.classList.add('activity')
this.scrollContainer.scrollTop = scrollTopBefore + prependedHeight
}
}
DomChangeListener.trigger()
this.emit('ingested', ingested)
}
scrollToBottom() {
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight
this.stream.classList.remove('activity')
}
onScroll() {
const { scrollTop, scrollHeight, clientHeight } = this.scrollContainer
const distanceFromTop = scrollTop
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
let direction = 'down'
if (this.lastScrollPosition != null && scrollTop < this.lastScrollPosition) {
direction = 'up'
}
if (direction === 'up') {
if (distanceFromBottom > 7) {
this.emit('scrollUp')
}
if (distanceFromTop <= 7) {
this.emit('reachedTop')
}
else if (distanceFromTop <= 300) {
this.emit('nearTop')
}
}
else if (direction === 'down') {
if (distanceFromTop > 7) {
this.emit('scrollDown')
}
if (distanceFromBottom <= 7) {
this.scrollToBottom()
this.emit('reachedBottom')
}
else if (distanceFromBottom <= 300) {
this.emit('nearBottom')
}
}
this.lastScrollPosition = scrollTop
}
onCopy(event) {
const selection = window.getSelection()
// Similar to selecting nothing
if (selection.isCollapsed) return
// Get the first and last node in the selection
let originalStart, start, end, originalEnd
start = originalStart = selection.getRangeAt(0).startContainer
end = originalEnd = selection.getRangeAt(selection.rangeCount - 1).endContainer
const startOffset = selection.getRangeAt(0).startOffset
const endOffset = selection.getRangeAt(selection.rangeCount - 1).endOffset
// The Traverse module needs nodes of the Element type, the selected elements could be of type Text
while (!(start instanceof Element) && start.parentNode) start = start.parentNode
while (!(end instanceof Element) && end.parentNode) end = end.parentNode
if (!start || !end) throw new Error('Unexpected error, no element nodes in selection')
// Try to find the starting li element in the selection
if (!start.id || start.id.indexOf('message-') !== 0) {
start = DOMTraverse.parentBySel(start, "li[id^='message']", this.stream)
}
// Try to find the ending li element in the selection
if (!end.id || end.id.indexOf('message-') !== 0) {
end = DOMTraverse.parentBySel(end, "li[id^='message']", this.stream)
}
// Do not select a message if we selected only a new line
if (originalStart instanceof Text && originalStart.textContent.substring(startOffset) === "") {
start = DOMTraverse.next(start)
}
// The selection went outside of the stream container, end at the last li element
if (end === null) {
end = elBySel('ul > li:last-child', this.stream)
}
// Discard the selection, we selected only whitespace between two messages
if (start === end && endOffset === 0) return
// Do not include the ending message if there is no visible selection
if (start !== end && endOffset === 0) {
end = DOMTraverse.prev(end)
}
const elements = [ ]
let next = start
do {
elements.push(next)
if (next === end) break
}
while (next = DOMTraverse.next(next))
// Only apply our custom formatting when selecting multiple or whole messages
if (elements.length === 1) {
const range = document.createRange()
range.setStart(originalStart, startOffset)
range.setEnd(originalEnd, endOffset)
if (!Helper.rangeSpansTextContent(range, start.querySelector('.chatMessage'))) return
}
try {
event.clipboardData.setData('text/plain', elements.map((el, index, arr) => {
const message = this.nodeMap.get(el)
if (el.classList.contains('dateMarker')) return `== ${el.textContent.trim()} ==`
if (!message) return
if (el.classList.contains('tombstone')) {
return `[${message.formattedTime}] ${Language.get('chat.messageType.be.bastelstu.chat.messageType.tombstone.message')}`
}
const elem = elBySel('.chatMessage', el)
let body
if (typeof (body = message.getMessageType().renderPlainText(message)) === 'undefined' || body === false) {
body = Helper.getTextContent(elem).replace(/\t+/g, '\t') // collapse multiple tabs
.replace(/ +/g, ' ') // collapse multiple spaces
.replace(/([\t ]*\n){2,}/g, '\n') // collapse line consisting of tabs, spaces and newlines
.replace(/^[\t ]+|[\t ]+$/gm, '') // remove leading and trailing whitespace per line
}
return `[${message.formattedTime}] <${message.username}> ${body.trim()}`
}).filter(x => x).join('\n'))
event.preventDefault()
}
catch (e) {
console.error('Unable to use the clipboard API')
console.error(e)
}
}
}
EventEmitter(MessageStream.prototype)
MessageStream.DEPENDENCIES = DEPENDENCIES
return MessageStream
});