638 lines
18 KiB
QML
638 lines
18 KiB
QML
// Copyright Mirage authors & contributors <https://github.com/mirukana/mirage>
|
|
// SPDX-License-Identifier: LGPL-3.0-or-later
|
|
|
|
import QtQuick 2.12
|
|
import CppUtils 0.1
|
|
|
|
QtObject {
|
|
enum Media { Page, File, Image, Video, Audio }
|
|
|
|
property QtObject theme: null
|
|
property bool keyboardFlicking: false
|
|
|
|
readonly property var imageExtensions: [
|
|
"bmp", "gif", "jpg", "jpeg", "png", "pbm", "pgm", "ppm", "xbm", "xpm",
|
|
"tiff", "webp", "svg",
|
|
]
|
|
|
|
readonly property var videoExtensions: [
|
|
"3gp", "avi", "flv", "m4p", "m4v", "mkv", "mov", "mp4",
|
|
"mpeg", "mpg", "ogv", "qt", "vob", "webm", "wmv", "yuv",
|
|
]
|
|
|
|
readonly property var audioExtensions: [
|
|
"pcm", "wav", "raw", "aiff", "flac", "m4a", "tta", "aac", "mp3",
|
|
"ogg", "oga", "opus",
|
|
]
|
|
|
|
function makeObject(urlComponent, parent=null, properties={},
|
|
callback=null) {
|
|
let comp = urlComponent
|
|
|
|
if (! Qt.isQtObject(urlComponent)) {
|
|
// It's an url or path string to a component
|
|
comp = Qt.createComponent(urlComponent, Component.Asynchronous)
|
|
}
|
|
|
|
let ready = false
|
|
|
|
comp.statusChanged.connect(status => {
|
|
if ([Component.Null, Component.Error].includes(status)) {
|
|
console.error("Failed creating component: ", comp.errorString())
|
|
|
|
} else if (! ready && status === Component.Ready) {
|
|
const incu = comp.incubateObject(
|
|
parent, properties, Qt.Asynchronous,
|
|
)
|
|
|
|
if (incu.status === Component.Ready) {
|
|
if (callback) callback(incu.object)
|
|
ready = true
|
|
return
|
|
}
|
|
|
|
incu.onStatusChanged = (istatus) => {
|
|
if (incu.status === Component.Error) {
|
|
console.error("Failed incubating object: ",
|
|
incu.errorString())
|
|
|
|
} else if (istatus === Component.Ready &&
|
|
callback && ! ready) {
|
|
if (callback) callback(incu.object)
|
|
ready = true
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
if (comp.status === Component.Ready) comp.statusChanged(comp.status)
|
|
}
|
|
|
|
|
|
function makePopup(urlComponent, parent, properties={}, callback=null,
|
|
autoDestruct=true) {
|
|
makeObject(urlComponent, parent, properties, (popup) => {
|
|
popup.open()
|
|
if (autoDestruct) popup.closed.connect(() => { popup.destroy() })
|
|
if (callback) callback(popup)
|
|
})
|
|
}
|
|
|
|
|
|
function sum(array) {
|
|
if (array.length < 1) return 0
|
|
return array.reduce((a, b) => (isNaN(a) ? 0 : a) + (isNaN(b) ? 0 : b))
|
|
}
|
|
|
|
|
|
function range(startOrEnd, end=null, ) {
|
|
// range(3) → [0, 1, 2, 3]
|
|
// range(3, 6) → [3, 4, 5, 6]
|
|
// range(3, -1) → [3, 2, 1, 0, -1]
|
|
|
|
const numbers = []
|
|
let realStart = end ? startOrEnd : 0
|
|
let realEnd = end ? end : startOrEnd
|
|
|
|
if (realEnd < realStart)
|
|
for (let i = realStart; i >= realEnd; i--)
|
|
numbers.push(i)
|
|
else
|
|
for (let i = realStart; i <= realEnd; i++)
|
|
numbers.push(i)
|
|
|
|
return numbers
|
|
}
|
|
|
|
|
|
function chunk(array, chunkSize) {
|
|
const chunks = []
|
|
|
|
for (let i = 0; i < array.length; i += chunkSize) {
|
|
chunks.push(array.slice(i, i + chunkSize))
|
|
}
|
|
|
|
return chunks
|
|
}
|
|
|
|
|
|
function isEmptyObject(obj) {
|
|
return Object.entries(obj).length === 0 && obj.constructor === Object
|
|
}
|
|
|
|
|
|
function objectUpdate(current, update) {
|
|
return Object.assign({}, current, update)
|
|
}
|
|
|
|
|
|
function objectUpdateRecursive(current, update) {
|
|
for (const key of Object.keys(update)) {
|
|
if ((key in current) && typeof(current[key]) === "object" &&
|
|
typeof(update[key]) === "object") {
|
|
objectUpdateRecursive(current[key], update[key])
|
|
} else {
|
|
current[key] = update[key]
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function numberWrapAt(num, max) {
|
|
return num < 0 ? max + (num % max) : (num % max)
|
|
}
|
|
|
|
|
|
function hsluv(hue, saturation, lightness, alpha=1.0) {
|
|
return CppUtils.hsluv(hue, saturation, lightness, alpha)
|
|
}
|
|
|
|
|
|
function hueFrom(string) {
|
|
// Calculate and return a unique hue between 0 and 360 for the string
|
|
let hue = 0
|
|
for (let i = 0; i < string.length; i++) {
|
|
hue += string.charCodeAt(i) * 99
|
|
}
|
|
return hue % 360
|
|
}
|
|
|
|
|
|
function nameColor(name, dim=false) {
|
|
return hsluv(
|
|
hueFrom(name),
|
|
|
|
dim ?
|
|
theme.controls.displayName.dimSaturation :
|
|
theme.controls.displayName.saturation,
|
|
|
|
dim ?
|
|
theme.controls.displayName.dimLightness :
|
|
theme.controls.displayName.lightness,
|
|
)
|
|
}
|
|
|
|
|
|
function coloredNameHtml(name, userId, displayText=null, dim=false) {
|
|
// substring: remove leading @
|
|
return `<font color="${nameColor(name || userId.substring(1), dim)}">`+
|
|
escapeHtml(displayText || name || userId) +
|
|
"</font>"
|
|
}
|
|
|
|
|
|
function escapeHtml(text) {
|
|
// Replace special HTML characters by encoded alternatives
|
|
return text.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'")
|
|
}
|
|
|
|
|
|
function stripHtmlTags(text) {
|
|
// XXX: Potentially unsafe!
|
|
return text.replace(/<\/?[^>]+(>|$)/g, "")
|
|
}
|
|
|
|
|
|
function plain2Html(text) {
|
|
// Escape html, convert `\n` into `<br>` tags and `\t` into four spaces
|
|
return escapeHtml(text).replace(/\n/g, "<br>")
|
|
.replace(/\t/g, " " * 4)
|
|
}
|
|
|
|
|
|
function htmlColorize(text, color) {
|
|
return `<font color="${color}">${text}</font>`
|
|
}
|
|
|
|
|
|
function processedEventText(ev) {
|
|
const type = ev.event_type
|
|
const unknownMsg = type === "RoomMessageUnknown"
|
|
const sender = coloredNameHtml(ev.sender_name, ev.sender_id)
|
|
|
|
if (type === "RoomMessageEmote")
|
|
return ev.content.match(/^\s*<(p|h[1-6])>/) ?
|
|
ev.content.replace(/(^\s*<(p|h[1-6])>)/, `$1${sender} `) :
|
|
sender + " " + ev.content
|
|
|
|
if (type.startsWith("RoomMessage") && ! unknownMsg)
|
|
return ev.content
|
|
|
|
if (type.startsWith("RoomEncrypted"))
|
|
return ev.content
|
|
|
|
if (type === "RedactedEvent") {
|
|
// FIXME: this can generate an "argument missing" warning because
|
|
// QML first gets notified of the event type change, *then* of the
|
|
// content change.
|
|
let content = qsTr(escapeHtml(ev.content)).arg(sender)
|
|
|
|
if (ev.content.includes("%2"))
|
|
content = content.arg(coloredNameHtml(
|
|
ev.redacter_name, ev.redacter_id, "", true,
|
|
))
|
|
|
|
return qsTr(
|
|
`<font color="${theme.chat.message.redactedBody}">` +
|
|
content +
|
|
"</font>"
|
|
)
|
|
}
|
|
|
|
if (ev.content.includes("%2")) {
|
|
const target = coloredNameHtml(ev.target_name, ev.target_id)
|
|
return qsTr(ev.content).arg(sender).arg(target)
|
|
}
|
|
|
|
return qsTr(ev.content).arg(sender)
|
|
}
|
|
|
|
function filterMatches(filter, text) {
|
|
if (! filter) return true
|
|
|
|
const filter_lower = filter.toLowerCase()
|
|
|
|
if (filter_lower === filter) {
|
|
// Consider case only if filter isn't all lowercase (smart case)
|
|
filter = filter_lower
|
|
text = text.toLowerCase()
|
|
}
|
|
|
|
for (const word of filter.split(" ")) {
|
|
if (word && ! text.includes(word)) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
|
|
function filterMatchesAny(filter, ...texts) {
|
|
for (const text of texts) {
|
|
if (filterMatches(filter, text)) return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
|
|
function fitSize(minWidth, minHeight, width, height, maxWidth, maxHeight) {
|
|
if (width >= height) {
|
|
const new_width = Math.min(Math.max(width, minWidth), maxWidth)
|
|
return Qt.size(new_width, height / (width / new_width))
|
|
}
|
|
|
|
const new_height = Math.min(Math.max(height, minHeight), maxHeight)
|
|
return Qt.size(width / (height / new_height), new_height)
|
|
}
|
|
|
|
|
|
function minutesBetween(date1, date2) {
|
|
return ((date2 - date1) / 1000) / 60
|
|
}
|
|
|
|
|
|
function dateIsDay(date, dayDate) {
|
|
return date.getDate() === dayDate.getDate() &&
|
|
date.getMonth() === dayDate.getMonth() &&
|
|
date.getFullYear() === dayDate.getFullYear()
|
|
}
|
|
|
|
|
|
function dateIsToday(date) {
|
|
return dateIsDay(date, new Date())
|
|
}
|
|
|
|
|
|
function dateIsYesterday(date) {
|
|
const yesterday = new Date()
|
|
yesterday.setDate(yesterday.getDate() - 1)
|
|
return dateIsDay(date, yesterday)
|
|
}
|
|
|
|
|
|
function formatTime(time, seconds=true) {
|
|
return Qt.formatTime(
|
|
time,
|
|
|
|
Qt.locale().timeFormat(
|
|
seconds ? Locale.LongFormat : Locale.NarrowFormat
|
|
).replace(/\./g, ":").replace(/ t$/, "")
|
|
// en_DK.UTF-8 locale wrongfully gives "." separators;
|
|
// also remove the timezone at the end
|
|
)
|
|
}
|
|
|
|
|
|
function smartFormatDate(date) {
|
|
return (
|
|
date < new Date(1) ?
|
|
"" :
|
|
|
|
// e.g. "03:24"
|
|
dateIsToday(date) ?
|
|
formatTime(date, false) :
|
|
|
|
// e.g. "5 Dec"
|
|
date.getFullYear() === new Date().getFullYear() ?
|
|
Qt.formatDate(date, "d MMM") :
|
|
|
|
// e.g. "Jan 2020"
|
|
Qt.formatDate(date, "MMM yyyy")
|
|
)
|
|
}
|
|
|
|
|
|
function formatRelativeTime(milliseconds, shortForm=true) {
|
|
const seconds = Math.floor(milliseconds / 1000)
|
|
|
|
if (shortForm) {
|
|
return (
|
|
seconds < 60 ?
|
|
qsTr("%1s").arg(seconds) :
|
|
|
|
seconds < 60 * 60 ?
|
|
qsTr("%1mn").arg(Math.floor(seconds / 60)) :
|
|
|
|
seconds < 60 * 60 * 24 ?
|
|
qsTr("%1h").arg(Math.floor(seconds / 60 / 60)) :
|
|
|
|
seconds < 60 * 60 * 24 * 30 ?
|
|
qsTr("%1d").arg(Math.floor(seconds / 60 / 60 / 24)) :
|
|
|
|
seconds < 60 * 60 * 24 * 30 * 12 ?
|
|
qsTr("%1mo").arg(Math.floor(seconds / 60 / 60 / 24 / 30)) :
|
|
|
|
qsTr("%1y").arg(Math.floor(seconds / 60 / 60 / 24 / 30 / 12))
|
|
)
|
|
}
|
|
|
|
return (
|
|
seconds < 60 ?
|
|
qsTr("%1 seconds").arg(seconds) :
|
|
|
|
seconds < 60 * 60 ?
|
|
qsTr("%1 minutes").arg(Math.floor(seconds / 60)) :
|
|
|
|
seconds < 60 * 60 * 24 ?
|
|
qsTr("%1 hours").arg(Math.floor(seconds / 60 / 60)) :
|
|
|
|
seconds < 60 * 60 * 24 * 30 ?
|
|
qsTr("%1 days").arg(Math.floor(seconds / 60 / 60 / 24)) :
|
|
|
|
seconds < 60 * 60 * 24 * 30 * 12 ?
|
|
qsTr("%1 months").arg(Math.floor(seconds / 60 / 60 / 24 / 30)) :
|
|
|
|
qsTr("%1 years").arg(Math.floor(seconds / 60 / 60 / 24 / 30 / 12))
|
|
)
|
|
}
|
|
|
|
|
|
function formatDuration(milliseconds) {
|
|
const totalSeconds = milliseconds / 1000
|
|
|
|
const hours = Math.floor(totalSeconds / 3600)
|
|
let minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
let seconds = Math.floor(totalSeconds % 60)
|
|
|
|
if (seconds < 10) seconds = `0${seconds}`
|
|
if (hours < 1) return `${minutes}:${seconds}`
|
|
|
|
if (minutes < 10) minutes = `0${minutes}`
|
|
return `${hours}:${minutes}:${seconds}`
|
|
}
|
|
|
|
|
|
function round(floatNumber, decimalDigits=2) {
|
|
return parseFloat(floatNumber.toFixed(decimalDigits))
|
|
}
|
|
|
|
|
|
function commaAndJoin(array) {
|
|
if (array.length === 0) return ""
|
|
if (array.length === 1) return array[0]
|
|
|
|
return qsTr("%1 and %2")
|
|
.arg(array.slice(0, -1).join(qsTr(", ")))
|
|
.arg(array.slice(-1)[0])
|
|
}
|
|
|
|
|
|
function flickPages(flickable, pages, horizontal=false, multiplier=8) {
|
|
// Adapt velocity and deceleration for the number of pages to flick.
|
|
// If this is a repeated flicking, flick faster than a single flick.
|
|
if (! flickable.interactive) return
|
|
|
|
keyboardFlicking = true
|
|
|
|
const futureVelocity =
|
|
(horizontal ? -flickable.width : -flickable.height) * pages
|
|
|
|
const currentVelocity =
|
|
horizontal ?
|
|
-flickable.horizontalVelocity :
|
|
-flickable.verticalVelocity
|
|
|
|
const goFaster =
|
|
(futureVelocity < 0 && currentVelocity < futureVelocity / 2) ||
|
|
(futureVelocity > 0 && currentVelocity > futureVelocity / 2)
|
|
|
|
const magicNumber = 2.5
|
|
const normalDecel = flickable.flickDeceleration
|
|
const normalMaxSpeed = flickable.maximumFlickVelocity
|
|
const fastMultiply =
|
|
pages && multiplier / (1 - Math.log10(Math.abs(pages)))
|
|
|
|
flickable.maximumFlickVelocity = 5000
|
|
|
|
|
|
flickable.flickDeceleration = Math.max(
|
|
goFaster ? normalDecel : -Infinity,
|
|
Math.abs(normalDecel * magicNumber * pages),
|
|
)
|
|
|
|
const flick =
|
|
futureVelocity * magicNumber * (goFaster ? fastMultiply : 1)
|
|
|
|
horizontal ? flickable.flick(flick, 0) : flickable.flick(0, flick)
|
|
|
|
flickable.maximumFlickVelocity = normalMaxSpeed
|
|
flickable.flickDeceleration = normalDecel
|
|
|
|
keyboardFlicking = false
|
|
}
|
|
|
|
|
|
function flickToTop(flickable) {
|
|
if (! flickable.interactive) return
|
|
if (flickable.visibleArea.yPosition < 0) return
|
|
|
|
flickable.contentY = flickable.originY
|
|
flickable.flick(0, 1000) // Force the delegates to load and bounce
|
|
}
|
|
|
|
|
|
function flickToBottom(flickable) {
|
|
if (! flickable.interactive) return
|
|
if (flickable.visibleArea.yPosition < 0) return
|
|
|
|
flickable.contentY =
|
|
flickable.originY + flickable.contentHeight - flickable.height
|
|
|
|
flickable.flick(0, -1000)
|
|
}
|
|
|
|
|
|
function urlFileName(url) {
|
|
return url.toString().split("/").slice(-1)[0].split("?")[0]
|
|
}
|
|
|
|
|
|
function urlExtension(url) {
|
|
return urlFileName(url).split(".").slice(-1)[0]
|
|
}
|
|
|
|
|
|
function getLinkType(url) {
|
|
const ext = urlExtension(url).toLowerCase()
|
|
|
|
return (
|
|
imageExtensions.includes(ext) ? Utils.Media.Image :
|
|
videoExtensions.includes(ext) ? Utils.Media.Video :
|
|
audioExtensions.includes(ext) ? Utils.Media.Audio :
|
|
Utils.Media.Page
|
|
)
|
|
}
|
|
|
|
|
|
function sumChildrenImplicitWidths(visibleChildren, spacing=0) {
|
|
let sum = 0
|
|
|
|
for (let i = 0; i < visibleChildren.length; i++) {
|
|
const item = visibleChildren[i]
|
|
|
|
if (item)
|
|
sum += (item.width > 0 ? item.implicitWidth : 0) + spacing
|
|
}
|
|
|
|
return sum
|
|
}
|
|
|
|
|
|
function getWordAtPosition(text, position) {
|
|
// getWordAtPosition("foo bar", 1) → {word: "foo", start: 0, end: 2}
|
|
let seen = -1
|
|
|
|
for (var word of text.split(/(\s+)/)) {
|
|
var start = seen + 1
|
|
seen += word.length
|
|
if (seen >= position) return {word, start, end: seen}
|
|
}
|
|
|
|
return {word, start, end: seen}
|
|
}
|
|
|
|
|
|
function getClassPathRegex(obj) {
|
|
const regexParts = []
|
|
let parent = obj
|
|
|
|
while (parent) {
|
|
if (! parent.ntheme || ! parent.ntheme.classes.length) {
|
|
parent = parent.parent
|
|
continue
|
|
}
|
|
|
|
const names = []
|
|
const end = regexParts.length ? "\\.)?" : ")"
|
|
|
|
for (let i = 0; i < parent.ntheme.classes.length; i++)
|
|
names.push(parent.ntheme.classes[i].name)
|
|
|
|
regexParts.push("(" + names.join("|") + end)
|
|
parent = parent.parent
|
|
}
|
|
|
|
return new RegExp("^" + regexParts.reverse().join("") + "$")
|
|
}
|
|
|
|
|
|
function formatPushRuleName(userId, rule) {
|
|
// rule: item from ModelStore.get(<userId>, "pushrules")
|
|
|
|
const roomColor = theme.colors.accentText
|
|
const room = ModelStore.get(userId, "rooms").find(rule.rule_id)
|
|
|
|
const text =
|
|
rule.rule_id === ".m.rule.master" ?
|
|
qsTr("Any message") :
|
|
|
|
rule.rule_id === ".m.rule.suppress_notices" ?
|
|
qsTr("Messages sent by bots") :
|
|
|
|
rule.rule_id === ".m.rule.invite_for_me" ?
|
|
qsTr("Received room invites") :
|
|
|
|
rule.rule_id === ".m.rule.member_event" ?
|
|
qsTr("Membership, name & avatar changes") :
|
|
|
|
rule.rule_id === ".m.rule.contains_display_name" ?
|
|
qsTr("Messages containing my display name") :
|
|
|
|
rule.rule_id === ".m.rule.tombstone" ?
|
|
qsTr("Room migration alerts") :
|
|
|
|
rule.rule_id === ".m.rule.reaction" ?
|
|
qsTr("Emoji reactions") :
|
|
|
|
rule.rule_id === ".m.rule.roomnotif" ?
|
|
qsTr("Messages containing %1").arg(
|
|
htmlColorize("@room", roomColor),
|
|
) :
|
|
|
|
rule.rule_id === ".m.rule.contains_user_name" ?
|
|
qsTr("Contains %1").arg(coloredNameHtml(
|
|
"", userId, userId.split(":")[0].substring(1),
|
|
)):
|
|
|
|
rule.rule_id === ".m.rule.call" ?
|
|
qsTr("Incoming audio calls") :
|
|
|
|
rule.rule_id === ".m.rule.encrypted_room_one_to_one" ?
|
|
qsTr("Encrypted 1-to-1 messages") :
|
|
|
|
rule.rule_id === ".m.rule.room_one_to_one" ?
|
|
qsTr("Unencrypted 1-to-1 messages") :
|
|
|
|
rule.rule_id === ".m.rule.message" ?
|
|
qsTr("Unencrypted group messages") :
|
|
|
|
rule.rule_id === ".m.rule.encrypted" ?
|
|
qsTr("Encrypted group messages") :
|
|
|
|
rule.rule_id === ".im.vector.jitsi" ?
|
|
qsTr("Incoming Jitsi calls") :
|
|
|
|
rule.kind === "content" ?
|
|
qsTr('Contains "%1"').arg(rule.pattern) :
|
|
|
|
rule.kind === "sender" ?
|
|
coloredNameHtml("", rule.rule_id) :
|
|
|
|
room && room.display_name && rule.kind !== "room" ?
|
|
qsTr("Messages in room %1").arg(
|
|
htmlColorize(escapeHtml(room.display_name), roomColor)
|
|
) :
|
|
|
|
room && room.display_name ?
|
|
escapeHtml(room.display_name) :
|
|
|
|
escapeHtml(rule.rule_id)
|
|
|
|
return rule.enabled ? text : qsTr("%1 (disabled rule)").arg(text)
|
|
}
|
|
}
|