mirror of
https://github.com/wbbaddons/Tims-Chat.git
synced 2024-11-01 14:20:07 +00:00
399 lines
11 KiB
JavaScript
399 lines
11 KiB
JavaScript
/*
|
|
* Copyright (c) 2010-2021 Tim Düsterhus.
|
|
*
|
|
* Use of this software is governed by the Business Source License
|
|
* included in the LICENSE file.
|
|
*
|
|
* Change Date: 2025-03-05
|
|
*
|
|
* 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
|
|
})
|