Merge branch '536-download-button-doesn-t-show-at-all-on-narrow-screens' into 'dev'

Lots of fixes to "media threads"

See merge request keanuapp/keanuapp-weblite!264
This commit is contained in:
N Pex 2023-11-06 15:28:26 +00:00
commit ed1342065b
23 changed files with 867 additions and 333 deletions

109
package-lock.json generated
View file

@ -29,7 +29,7 @@
"linkify-html": "^4.1.0",
"linkifyjs": "^4.1.0",
"material-design-icons-iconfont": "^6.1",
"matrix-js-sdk": "^23.4.0",
"matrix-js-sdk": "^29.1.0",
"md-gum-polyfill": "^1.0.0",
"mic-recorder-to-mp3": "^2.2.2",
"path-browserify": "^1.0.1",
@ -1844,10 +1844,10 @@
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-js": {
"version": "0.1.0-alpha.4",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.4.tgz",
"integrity": "sha512-mdaDKrw3P5ZVCpq0ioW0pV6ihviDEbS8ZH36kpt9stLKHwwDSopPogE6CkQhi0B1jn1yBUtOYi32mBV/zcOR7g==",
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-2.2.0.tgz",
"integrity": "sha512-txmvaTiZpVV0/kWCRcE7tZvRESCEc1ynLJDVh9OUsFlaXfl13c7qdD3E6IJEJ8YiPMIn+PHogdfBZsO84reaMg==",
"engines": {
"node": ">= 10"
}
@ -2066,9 +2066,9 @@
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
},
"node_modules/@types/events": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.2.tgz",
"integrity": "sha512-v4Mr60wJuF069iZZCdY5DKhfj0l6eXNJtbSM/oMDNdRLoBEUsktmKnswkz0X3OAic5W8Qy/YU6owKE4A66Y46A=="
},
"node_modules/@types/express": {
"version": "4.17.14",
@ -5968,6 +5968,11 @@
"node": "*"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"node_modules/css-declaration-sorter": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz",
@ -9263,6 +9268,11 @@
"setimmediate": "^1.0.5"
}
},
"node_modules/jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -9946,31 +9956,33 @@
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA=="
},
"node_modules/matrix-js-sdk": {
"version": "23.4.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-23.4.0.tgz",
"integrity": "sha512-3gHT6IrDYBkFYzaZM052uZXv1WFGoN+q83rTvmTpMtxZYrwosLHb6Y/w/Lfl26y1N1gTDdyQ0Vd3NpSycKmpJA==",
"version": "29.1.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-29.1.0.tgz",
"integrity": "sha512-nF+ACFioDltGCf2KFfXK7QoJ70Ytnzm4Jse2UI+BDXeR9WCjtKefXJtboN2rmU4MFmLCTHcnBTmu6yig67YUqw==",
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.3",
"@matrix-org/matrix-sdk-crypto-wasm": "^2.0.0",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
"jwt-decode": "^3.1.2",
"loglevel": "^1.7.1",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.0.0",
"matrix-widget-api": "^1.6.0",
"oidc-client-ts": "^2.2.4",
"p-retry": "4",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
"uuid": "9"
},
"engines": {
"node": ">=16.0.0"
"node": ">=18.0.0"
}
},
"node_modules/matrix-widget-api": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.2.0.tgz",
"integrity": "sha512-BkBTREtXjCUM3Kx4UBgDmKoz39w7AXfIjBIC/jISBdcJkg8upFUhpIy+zUrSCIrmfO2Ke8LOsSoFoQkOyhqGxQ==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.6.0.tgz",
"integrity": "sha512-VXIJyAZ/WnBmT4C7ePqevgMYGneKMCP/0JuCOqntSsaNlCRHJvwvTxmqUU+ufOpzIF5gYNyIrAjbgrEbK3iqJQ==",
"dependencies": {
"@types/events": "^3.0.0",
"events": "^3.2.0"
@ -10746,6 +10758,18 @@
"resolved": "https://registry.npmjs.org/octal/-/octal-1.0.0.tgz",
"integrity": "sha512-nnda7W8d+A3vEIY+UrDQzzboPf1vhs4JYVhff5CDkq9QNoZY7Xrxeo/htox37j9dZf7yNHevZzqtejWgy1vCqQ=="
},
"node_modules/oidc-client-ts": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.4.0.tgz",
"integrity": "sha512-WijhkTrlXK2VvgGoakWJiBdfIsVGz6CFzgjNNqZU1hPKV2kyeEaJgLs7RwuiSp2WhLfWBQuLvr2SxVlZnk3N1w==",
"dependencies": {
"crypto-js": "^4.2.0",
"jwt-decode": "^3.1.2"
},
"engines": {
"node": ">=12.13.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -17608,10 +17632,10 @@
"integrity": "sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A==",
"dev": true
},
"@matrix-org/matrix-sdk-crypto-js": {
"version": "0.1.0-alpha.4",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.4.tgz",
"integrity": "sha512-mdaDKrw3P5ZVCpq0ioW0pV6ihviDEbS8ZH36kpt9stLKHwwDSopPogE6CkQhi0B1jn1yBUtOYi32mBV/zcOR7g=="
"@matrix-org/matrix-sdk-crypto-wasm": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-2.2.0.tgz",
"integrity": "sha512-txmvaTiZpVV0/kWCRcE7tZvRESCEc1ynLJDVh9OUsFlaXfl13c7qdD3E6IJEJ8YiPMIn+PHogdfBZsO84reaMg=="
},
"@matrix-org/olm": {
"version": "3.2.12",
@ -17803,9 +17827,9 @@
"integrity": "sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ=="
},
"@types/events": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz",
"integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g=="
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.2.tgz",
"integrity": "sha512-v4Mr60wJuF069iZZCdY5DKhfj0l6eXNJtbSM/oMDNdRLoBEUsktmKnswkz0X3OAic5W8Qy/YU6owKE4A66Y46A=="
},
"@types/express": {
"version": "4.17.14",
@ -20893,6 +20917,11 @@
"randomfill": "^1.0.3"
}
},
"crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
},
"css-declaration-sorter": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz",
@ -23451,6 +23480,11 @@
"setimmediate": "^1.0.5"
}
},
"jwt-decode": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-3.1.2.tgz",
"integrity": "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="
},
"kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
@ -24033,18 +24067,20 @@
"integrity": "sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA=="
},
"matrix-js-sdk": {
"version": "23.4.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-23.4.0.tgz",
"integrity": "sha512-3gHT6IrDYBkFYzaZM052uZXv1WFGoN+q83rTvmTpMtxZYrwosLHb6Y/w/Lfl26y1N1gTDdyQ0Vd3NpSycKmpJA==",
"version": "29.1.0",
"resolved": "https://registry.npmjs.org/matrix-js-sdk/-/matrix-js-sdk-29.1.0.tgz",
"integrity": "sha512-nF+ACFioDltGCf2KFfXK7QoJ70Ytnzm4Jse2UI+BDXeR9WCjtKefXJtboN2rmU4MFmLCTHcnBTmu6yig67YUqw==",
"requires": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-js": "^0.1.0-alpha.3",
"@matrix-org/matrix-sdk-crypto-wasm": "^2.0.0",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
"jwt-decode": "^3.1.2",
"loglevel": "^1.7.1",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.0.0",
"matrix-widget-api": "^1.6.0",
"oidc-client-ts": "^2.2.4",
"p-retry": "4",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
@ -24052,9 +24088,9 @@
}
},
"matrix-widget-api": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.2.0.tgz",
"integrity": "sha512-BkBTREtXjCUM3Kx4UBgDmKoz39w7AXfIjBIC/jISBdcJkg8upFUhpIy+zUrSCIrmfO2Ke8LOsSoFoQkOyhqGxQ==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/matrix-widget-api/-/matrix-widget-api-1.6.0.tgz",
"integrity": "sha512-VXIJyAZ/WnBmT4C7ePqevgMYGneKMCP/0JuCOqntSsaNlCRHJvwvTxmqUU+ufOpzIF5gYNyIrAjbgrEbK3iqJQ==",
"requires": {
"@types/events": "^3.0.0",
"events": "^3.2.0"
@ -24693,6 +24729,15 @@
"resolved": "https://registry.npmjs.org/octal/-/octal-1.0.0.tgz",
"integrity": "sha512-nnda7W8d+A3vEIY+UrDQzzboPf1vhs4JYVhff5CDkq9QNoZY7Xrxeo/htox37j9dZf7yNHevZzqtejWgy1vCqQ=="
},
"oidc-client-ts": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-2.4.0.tgz",
"integrity": "sha512-WijhkTrlXK2VvgGoakWJiBdfIsVGz6CFzgjNNqZU1hPKV2kyeEaJgLs7RwuiSp2WhLfWBQuLvr2SxVlZnk3N1w==",
"requires": {
"crypto-js": "^4.2.0",
"jwt-decode": "^3.1.2"
}
},
"on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",

View file

@ -30,7 +30,7 @@
"linkify-html": "^4.1.0",
"linkifyjs": "^4.1.0",
"material-design-icons-iconfont": "^6.1",
"matrix-js-sdk": "^23.4.0",
"matrix-js-sdk": "^29.1.0",
"md-gum-polyfill": "^1.0.0",
"mic-recorder-to-mp3": "^2.2.2",
"path-browserify": "^1.0.1",

View file

@ -1,27 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" id="favicon" href="<%= BASE_URL %>favicon.ico">
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" />
<link rel="icon" id="favicon" href="<%= BASE_URL %>favicon.ico" />
<title><%= htmlWebpackPlugin.options.title %></title>
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-72x72.png" sizes="72x72">
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-96x96.png" sizes="96x96">
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-128x128.png" sizes="128x128">
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-144x144.png" sizes="144x144">
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-152x152.png" sizes="152x152">
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-192x192.png" sizes="192x192">
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-384x384.png" sizes="384x384">
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-512x512.png" sizes="512x512">
<link rel="manifest" href="<%= BASE_URL %>manifest.json">
<meta name="apple-mobile-web-app-capable" content="yes" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-72x72.png" sizes="72x72" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-96x96.png" sizes="96x96" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-128x128.png" sizes="128x128" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-144x144.png" sizes="144x144" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-152x152.png" sizes="152x152" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-192x192.png" sizes="192x192" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-384x384.png" sizes="384x384" />
<link rel="apple-touch-icon" href="<%= BASE_URL %>icons/icon-512x512.png" sizes="512x512" />
<link rel="manifest" href="<%= BASE_URL %>manifest.json" />
<script src="./lottie-player.js"></script>
<style>
#loader {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 20;
background-color: rgba(255, 255, 255, 1);
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
<strong
>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please
enable it to continue.</strong
>
</noscript>
<div id="app"></div>
<div id="app">
<!-- To get rid of blank white screen, show loading indicator before Vue app is initialized -->
<!-- This loader will be replaced by the Vue component on mounted -->
<div id="loader">
<lottie-player autoplay loop mode="normal" src="./loader.json" style="width: 128px"> </lottie-player>
</div>
</div>
<!-- built files will be auto injected -->
</body>
</html>

1
public/loader.json Normal file

File diff suppressed because one or more lines are too long

77
public/lottie-player.js Normal file

File diff suppressed because one or more lines are too long

View file

@ -228,7 +228,7 @@ body {
}
@media #{map-get($display-breakpoints, 'sm-and-down')} {
margin-top: 72px;
//margin-top: 72px;
margin-bottom: 70px;
}
}
@ -1332,6 +1332,10 @@ body {
}
}
.invisible {
opacity: 0;
}
.new-room {
font-family: "Inter", sans-serif;
font-size: 16px !important;

View file

@ -62,7 +62,9 @@
"user_was_banned_you": "You were kicked and banned from the chat.",
"user_joined": "{user} joined the chat",
"user_left": "{user} left the chat",
"someone": "Someone",
"user_said": "{user} said:",
"sent_media": "Sent {count} media items.",
"file_prefix": "File: ",
"edited": "(edited)",
"download_progress": "{percentage}% downloaded",

View file

@ -32,7 +32,7 @@
:attachments="currentFileInputs"
/>
<div v-if="!useVoiceMode && !useFileModeNonAdmin" class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer"
<div v-if="!useVoiceMode && !useFileModeNonAdmin" :class="{'chat-content': true, 'flex-grow-1': true, 'flex-shrink-1': true, 'invisible': !initialLoadDone}" ref="chatContainer"
v-on:scroll="onScroll" @click="closeContextMenusIfOpen">
<div ref="messageOperationsStrut" class="message-operations-strut">
<message-operations ref="messageOperations" :style="opStyle" :emojis="recentEmojis" v-on:close="
@ -42,8 +42,8 @@
v-on:addreply="addReply(selectedEvent)" v-on:edit="edit(selectedEvent)" v-on:redact="redact(selectedEvent)"
v-on:download="download(selectedEvent)" v-on:more="
isEmojiQuickReaction= true
showMoreMessageOperations($event)
" :originalEvent="selectedEvent" />
showMoreMessageOperations({event: selectedEvent, anchor: $event.anchor})
" :originalEvent="selectedEvent" :timelineSet="timelineSet" />
</div>
<div ref="avatarOperationsStrut" class="avatar-operations-strut">
@ -87,6 +87,7 @@
isEmojiQuickReaction = true
showMoreMessageOperations({event: event, anchor: $event.anchor})
"
v-on:layout-change="onLayoutChange"
/>
<!-- <div v-if="debugging" style="user-select:text">EventID: {{ event.getId() }}</div> -->
<!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}</div> -->
@ -114,6 +115,7 @@
<div v-if="replyToContentType === 'm.text'" class="reply-text" :title="replyToEvent.getContent().body">
{{ replyToEvent.getContent().body | latestReply }}
</div>
<div v-if="replyToContentType === 'm.thread'">{{ replyToThreadMessage }}</div>
<div v-if="replyToContentType === 'm.image'">{{ $t("message.reply_image") }}</div>
<div v-if="replyToContentType === 'm.audio'">{{ $t("message.reply_audio_message") }}</div>
<div v-if="replyToContentType === 'm.video'">{{ $t("message.reply_video") }}</div>
@ -533,7 +535,7 @@ export default {
if (contentArr[0] === "") {
contentArr.shift();
}
return contentArr[0].replace(/^> (<.*> )?/g, "");
return (contentArr && contentArr.length > 0) ? contentArr[0].replace(/^> (<.*> )?/g, "") : "";
},
},
@ -792,6 +794,18 @@ export default {
}
}
return "";
},
/**
* If we are replying to a (media) thread, this is the hint we show when replying.
*/
replyToThreadMessage() {
if (this.replyToEvent && this.timelineSet) {
return this.$t("message.sent_media", {count: this.timelineSet.relations
.getAllChildEventsForEvent(this.replyToEvent.getId())
.filter((e) => util.downloadableTypes().includes(e.getContent().msgtype)).length});
}
return "";
}
},
@ -800,6 +814,8 @@ export default {
immediate: true,
handler(value, oldValue) {
if (value && !oldValue) {
this.events.filter(event => (event.threadRootId && !event.parentThread)).forEach(event => this.setParentThread(event));
this.events.filter(event => (event.replyEventId && !event.replyEvent)).forEach(event => this.setReplyToEvent(event));
console.log("Loading finished!");
}
}
@ -842,7 +858,7 @@ export default {
}
});
} else {
this.initialLoadDone = true;
this.setInitialLoadDone();
return; // no room
}
},
@ -889,6 +905,15 @@ export default {
},
methods: {
/**
* Set initialLoadDone to 'true'. First process all events, setting threadParent and replyEvent if needed.
*/
setInitialLoadDone() {
this.events.filter(event => (event.threadRootId && !event.parentThread)).forEach(event => this.setParentThread(event));
this.events.filter(event => (event.replyEventId && !event.replyEvent)).forEach(event => this.setReplyToEvent(event));
this.initialLoadDone = true;
console.log("Loading finished!");
},
windowNotificationPermission,
onNotificationDialog() {
if(this.windowNotificationPermission() === 'denied') {
@ -943,9 +968,23 @@ export default {
console.log("ERROR " + err);
})
.finally(() => {
self.initialLoadDone = true;
if (initialEventId && !this.showCreatedRoomWelcomeHeader) {
self.scrollToEvent(initialEventId);
// const [timelineEvents, threadedEvents, unknownRelations] =
// this.room.partitionThreadedEvents(self.events);
// this.$matrix.matrixClient.processAggregatedTimelineEvents(this.room, timelineEvents);
// //room.addEventsToTimeline(timelineEvents, true, room.getLiveTimeline());
// this.$matrix.matrixClient.processThreadEvents(this.room, threadedEvents, true);
// unknownRelations.forEach((event) => this.room.relations.aggregateChildEvent(event));
this.setInitialLoadDone();
if (initialEventId && !this.showCreatedRoomWelcomeHeader) {
const event = this.room.findEventById(initialEventId);
this.$nextTick(() => {
if (event && event.parentThread) {
self.scrollToEvent(event.parentThread.getId());
} else {
self.scrollToEvent(initialEventId);
}
});
} else if (this.showCreatedRoomWelcomeHeader || this.showDirectChatWelcomeHeader) {
self.onScroll();
}
@ -960,7 +999,7 @@ export default {
} else {
// Error. Done loading.
this.events = this.timelineWindow.getEvents();
this.initialLoadDone = true;
this.setInitialLoadDone();
}
})
.finally(() => {
@ -1094,12 +1133,83 @@ export default {
this.restartRRTimer();
},
setParentThread(event) {
const parentEvent = this.timelineSet.findEventById(event.threadRootId) || this.room.findEventById(event.threadRootId);
if (parentEvent) {
Vue.set(parentEvent, "isMxThread", true);
Vue.set(event, "parentThread", parentEvent);
} else {
// Try to load from server.
this.$matrix.matrixClient.getEventTimeline(this.timelineSet, event.threadRootId).then((tl) => {
if (tl) {
const parentEvent = tl.getEvents().find((e) => e.getId() === event.threadRootId);
if (parentEvent) {
this.events = this.timelineWindow.getEvents();
const fn = () => {
Vue.set(parentEvent, "isMxThread", true);
Vue.set(event, "parentThread", parentEvent);
};
if (this.initialLoadDone) {
const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
const element = document.querySelector(sel);
if (element) {
this.onLayoutChange(fn, element);
} else {
fn();
}
} else {
fn();
}
}
}
});
}
},
setReplyToEvent(event) {
const parentEvent = this.timelineSet.findEventById(event.replyEventId) || this.room.findEventById(event.replyEventId);
if (parentEvent) {
Vue.set(event, "replyEvent", parentEvent);
} else {
// Try to load from server.
this.$matrix.matrixClient.getEventTimeline(this.timelineSet, event.replyEventId)
.then((tl) => {
if (tl) {
const parentEvent = tl.getEvents().find((e) => e.getId() === event.replyEventId);
if (parentEvent) {
this.events = this.timelineWindow.getEvents();
const fn = () => {Vue.set(event, "replyEvent", parentEvent);};
if (this.initialLoadDone) {
const sel = "[eventId=\"" + parentEvent.getId() + "\"]";
const element = document.querySelector(sel);
if (element) {
this.onLayoutChange(fn, element);
} else {
fn();
}
} else {
fn();
}
}
}
}).catch(e => console.error(e));
}
},
onEvent(event) {
//console.log("OnEvent", JSON.stringify(event));
if (event.getRoomId() !== this.roomId) {
return; // Not for this room
}
if (this.initialLoadDone && event.threadRootId && !event.parentThread) {
this.setParentThread(event);
}
if (this.initialLoadDone && event.replyEventId && !event.replyEvent) {
this.setReplyToEvent(event);
}
const loadingDone = this.initialLoadDone;
this.$matrix.matrixClient.decryptEventIfNeeded(event, {});
@ -1107,7 +1217,7 @@ export default {
this.paginateBackIfNeeded();
}
if (loadingDone && event.forwardLooking && !event.isRelation()) {
if (loadingDone && event.forwardLooking && (!event.isRelation() || event.isMxThread || event.threadRootId || event.parentThread )) {
// If we are at bottom, scroll to see new events...
var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll
const container = this.chatContainer;
@ -1315,6 +1425,28 @@ export default {
this.cancelSendAttachment();
},
/**
* Called by message components that need to change their layout. This will avoid "jumping" in the UI, because
* we remember scroll position, apply the layout change, then restore the scroll.
* NOTE: we use "parentElement" below, because it is expected to be called with "element" set to the message component
* and the message component in turn being wrapped by a "message-wrapper" element (see html above).
* @param {} action A function that performs desired layout changes.
* @param {*} element Root element for the chat message.
*/
onLayoutChange(action, element) {
if (!element || !element.parentElemen || this.useVoiceMode || this.useFileModeNonAdmin) {
action();
return
}
const container = this.chatContainer;
this.scrollPosition.prepareFor(element.parentElement.offsetTop >= container.scrollTop ? "down" : "up");
action();
this.$nextTick(() => {
// restore scroll position!
this.scrollPosition.restore();
});
},
handleScrolledToTop() {
if (
this.timelineWindow &&
@ -1379,9 +1511,18 @@ export default {
const container = this.chatContainer;
const ref = this.$refs[eventId];
if (container && ref) {
const targetY = container.clientHeight / 2;
const sourceY = ref[0].offsetTop;
container.scrollTo(0, sourceY - targetY);
const parent = container.getBoundingClientRect();
const item = ref[0].getBoundingClientRect();
let offsetY = (parent.bottom - parent.top) / 2;
if (ref[0].clientHeight > offsetY) {
offsetY = Math.max(0, (parent.bottom - parent.top) - ref[0].clientHeight);
}
const targetY = parent.top + offsetY;
const currentY = item.top;
const y = container.scrollTop + (currentY - targetY);
this.$nextTick(() => {
container.scrollTo(0, y);
});
}
},
@ -1433,7 +1574,11 @@ export default {
addReply(event) {
this.replyToEvent = event;
this.$refs.messageInput.focus();
this.replyToContentType = event.getContent().msgtype || 'm.poll';
if (event.parentThread || event.isThreadRoot || event.isMxThread) {
this.replyToContentType = 'm.thread';
} else {
this.replyToContentType = event.getContent().msgtype || 'm.poll';
}
this.setReplyToImage(event);
},
@ -1455,7 +1600,12 @@ export default {
},
download(event) {
util.download(this.$matrix.matrixClient, event);
if ((event.isThreadRoot || event.isMxThread) && this.timelineSet) {
const children = this.timelineSet.relations.getAllChildEventsForEvent(event.getId()).filter(e => util.downloadableTypes().includes(e.getContent().msgtype));
children.forEach(child => util.download(this.$matrix.matrixClient, child));
} else {
util.download(this.$matrix.matrixClient, event);
}
},
cancelEditReply() {

View file

@ -1,6 +1,6 @@
<template>
<div class="chat-root">
<div class="chat-root d-flex flex-column" ref="exportRoot">
<div class="chat-root export d-flex flex-column" ref="exportRoot">
<!-- Header-->
<v-container fluid class="chat-header flex-grow-0 flex-shrink-0">
<v-row class="chat-header-row flex-nowrap">
@ -18,18 +18,17 @@
<div class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer">
<div v-for="(event, index) in events" :key="event.getId()" :eventId="event.getId()">
<!-- DAY Marker, shown for every new day in the timeline -->
<div v-if="showDayMarkerBeforeEvent(event)" class="day-marker"><div class="line"></div><div class="text">{{ dayForEvent(event) }}</div><div class="line"></div></div>
<div v-if="showDayMarkerBeforeEvent(event)" class="day-marker">
<div class="line"></div>
<div class="text">{{ dayForEvent(event) }}</div>
<div class="line"></div>
</div>
<div v-if="!event.isRelation() && !event.isRedacted() && !event.isRedaction()" :ref="event.getId()">
<div class="message-wrapper">
<component
:is="componentForEvent(event, true)"
:room="room"
:originalEvent="event"
:nextEvent="events[index + 1]"
:timelineSet="timelineSet"
ref="exportedEvent"
/>
<component :is="componentForEvent(event, true)" :room="room" :originalEvent="event"
:nextEvent="events[index + 1]" :timelineSet="timelineSet" :componentFn="componentForEventForExport"
ref="exportedEvent" v-on:layout-change="onLayoutChange" />
<!-- <div v-if="debugging" style="user-select:text">EventID: {{ event.getId() }}</div> -->
<!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}</div> -->
</div>
@ -54,6 +53,7 @@
</template>
<script>
import Vue from "vue";
import MessageIncomingText from "./messages/MessageIncomingText.vue";
import MessageIncomingFile from "./messages/MessageIncomingFile.vue";
import MessageIncomingImage from "./messages/MessageIncomingImage.vue";
@ -98,6 +98,7 @@ import util from "../plugins/utils";
import JSZip from "jszip";
import { saveAs } from "file-saver";
import { EventTimelineSet } from "matrix-js-sdk";
import axios from 'axios';
export default {
name: "RoomExport",
@ -146,7 +147,7 @@ export default {
props: {
room: {
type: Object,
default: function() {
default: function () {
return null;
},
},
@ -181,6 +182,9 @@ export default {
},
},
methods: {
componentForEventForExport(event) {
return this.componentForEvent(event, true);
},
cancelExport() {
this.cancelled = true;
},
@ -254,6 +258,21 @@ export default {
this.timelineSet.addEventsToTimeline(events.reverse(), true, this.timelineSet.getLiveTimeline(), "");
this.events = events;
// Need to set thread root events and replyEvents so stuff is rendered correctly.
this.events.filter(event => (event.threadRootId && !event.parentThread)).forEach(event => {
const parentEvent = this.timelineSet.findEventById(event.threadRootId) || this.room.findEventById(event.threadRootId);
if (parentEvent) {
Vue.set(parentEvent, "isMxThread", true);
Vue.set(event, "parentThread", parentEvent);
}
});
this.events.filter(event => (event.replyEventId && !event.replyEvent)).forEach(event => {
const parentEvent = this.timelineSet.findEventById(event.replyEventId) || this.room.findEventById(event.replyEventId);
if (parentEvent) {
Vue.set(event, "replyEvent", parentEvent);
}
});
// Wait a tick so UI is updated.
return new Promise((resolve, ignoredReject) => {
this.$nextTick(() => {
@ -264,129 +283,192 @@ export default {
.then(() => {
// UI updated, start processing events
zip = new JSZip();
var avatarFolder = zip.folder("avatars");
var imageFolder = zip.folder("images");
var audioFolder = zip.folder("audio");
var videoFolder = zip.folder("video");
var downloadPromises = [];
let components = this.$refs.exportedEvent;
for (const comp of components) {
let componentClass = comp.$vnode.tag.split("-").reverse()[0];
switch (componentClass) {
case "MessageIncomingImageExport":
case "MessageOutgoingImageExport":
// TODO - maybe consider what media to download based on the file size we already have?
// info = comp.event.getContent().info;
// if (info && info.size && currentMediaSize + info.size > maxMediaSize) {
// // No need to even download.
// console.log("Dont download!");
// continue;
// }
for (const parentComp of components) {
let childComponents = [parentComp];
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
return new Promise((resolve, ignoredReject) => {
let mime = blob.type;
var extension = ".png";
switch (mime) {
case "image/jpeg":
case "image/jpg":
extension = ".jpg";
break;
case "image/gif":
extension = ".gif";
// Some components, i.e. the media threads, have subcomponents
// that we want to export. So pickup subcomponents here as well.
if (parentComp.$refs && parentComp.$refs.exportedEvent) {
if (Array.isArray(parentComp.$refs.exportedEvent)) {
for (const child of parentComp.$refs.exportedEvent) {
childComponents.push(child);
}
} else {
childComponents.push(parentComp.$refs.exportedEvent);
}
}
for (const comp of childComponents) {
// Avatars need downloading?
if (comp.$el) {
const avatars = comp.$el.getElementsByClassName("v-avatar");
if (avatars && avatars.length > 0) {
const member = this.room.getMember(comp.event.getSender());
if (member) {
const fileName = comp.event.getSender() + ".png";
const setSource = (fileName) => {
for (let avatarIndex = 0; avatarIndex < avatars.length; avatarIndex++) {
const avatarElement = avatars[avatarIndex];
const images = avatarElement.getElementsByTagName("img");
for (let imageIndex = 0; imageIndex < images.length; imageIndex++) {
const img = images[imageIndex];
img.onerror = undefined;
img.src = './avatars/' + fileName;
}
}
}
if (!avatarFolder.file(fileName)) {
const url = member.getAvatarUrl(this.$matrix.matrixClient.getHomeserverUrl(), 40, 40, "scale", true);
if (url) {
avatarFolder.file(fileName, "empty");
downloadPromises.push(
axios.get(url, {
responseType: 'blob'
})
.then(result => {
if (result.data) {
avatarFolder.file(fileName, result.data);
setSource(fileName);
}
})
.catch(err => {
console.error("Download error: ", err);
avatarFolder.remove(fileName);
}));
}
} else {
setSource(fileName);
}
}
}
}
let componentClass = comp.$vnode.tag.split("-").reverse()[0];
switch (componentClass) {
case "MessageIncomingImageExport":
case "MessageOutgoingImageExport":
// TODO - maybe consider what media to download based on the file size we already have?
// info = comp.event.getContent().info;
// if (info && info.size && currentMediaSize + info.size > maxMediaSize) {
// // No need to even download.
// console.log("Dont download!");
// continue;
// }
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
return new Promise((resolve, ignoredReject) => {
let mime = blob.type;
var extension = ".png";
switch (mime) {
case "image/jpeg":
case "image/jpg":
extension = ".jpg";
break;
case "image/gif":
extension = ".gif";
}
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
let fileName = comp.event.getId() + extension;
imageFolder.file(fileName, blob); // TODO calc bytes
let blobUrl = URL.createObjectURL(blob);
comp.src = blobUrl;
this.$nextTick(() => {
// Update source
let elements = comp.$el.getElementsByClassName("v-image__image");
let element = elements && elements[0];
if (element) {
element.style.backgroundImage = 'url("./images/' + fileName + '")';
element.classList.remove("v-image__image--preload");
}
URL.revokeObjectURL(blobUrl); // Give the blob back
this.processedEvents += 1;
resolve(true);
});
}
});
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
case "MessageIncomingAudioExport":
case "MessageOutgoingAudioExport":
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
let fileName = comp.event.getId() + extension;
imageFolder.file(fileName, blob); // TODO calc bytes
let blobUrl = URL.createObjectURL(blob);
comp.src = blobUrl;
this.$nextTick(() => {
// Update source
let elements = comp.$el.getElementsByClassName("v-image__image");
return new Promise((resolve, ignoredReject) => {
//let mime = blob.type;
var extension = ".mp3";
let fileName = comp.event.getId() + extension;
audioFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.$el.getElementsByTagName("audio");
let element = elements && elements[0];
if (element) {
element.style.backgroundImage = 'url("./images/' + fileName + '")';
element.classList.remove("v-image__image--preload");
element.src = "./audio/" + fileName;
}
URL.revokeObjectURL(blobUrl); // Give the blob back
this.processedEvents += 1;
resolve(true);
});
}
});
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
case "MessageIncomingAudioExport":
case "MessageOutgoingAudioExport":
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
return new Promise((resolve, ignoredReject) => {
//let mime = blob.type;
var extension = ".mp3";
let fileName = comp.event.getId() + extension;
audioFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.$el.getElementsByTagName("audio");
let element = elements && elements[0];
if (element) {
element.src = "./audio/" + fileName;
}
this.processedEvents += 1;
resolve(true);
});
}
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
case "MessageIncomingVideoExport":
case "MessageOutgoingVideoExport":
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
return new Promise((resolve, ignoredReject) => {
//let mime = blob.type;
var extension = ".mp4";
let fileName = comp.event.getId() + extension;
videoFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.$el.getElementsByTagName("video");
let element = elements && elements[0];
if (element) {
element.src = "./video/" + fileName;
}
this.processedEvents += 1;
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
case "MessageIncomingVideoExport":
case "MessageOutgoingVideoExport":
downloadPromises.push(
util
.getAttachment(this.$matrix.matrixClient, comp.event, null, true)
.then((blob) => {
if (currentMediaSize + blob.size <= maxMediaSize) {
currentMediaSize += blob.size;
return new Promise((resolve, ignoredReject) => {
//let mime = blob.type;
var extension = ".mp4";
let fileName = comp.event.getId() + extension;
videoFolder.file(fileName, blob); // TODO calc bytes
let elements = comp.$el.getElementsByTagName("video");
let element = elements && elements[0];
if (element) {
element.src = "./video/" + fileName;
}
this.processedEvents += 1;
resolve(true);
});
}
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
default:
this.processedEvents += 1;
break;
resolve(true);
});
}
})
.catch((ignoredErr) => {
this.processedEvents += 1;
})
);
break;
default:
this.processedEvents += 1;
break;
}
}
}
return Promise.all(downloadPromises);
@ -410,7 +492,7 @@ export default {
}
doc +=
"</head><body><div class='v-application v-application--is-ltr theme--light' style='height:100%;overflow-y:auto'>";
const getCssRules = function(el) {
const getCssRules = function (el) {
if (el.classList.contains("op-button")) {
el.innerHTML = "";
} else {
@ -441,6 +523,30 @@ export default {
this.$emit("close");
});
},
onLayoutChange(action, ignoredelement) {
action();
},
},
};
</script>
<style lang="scss">
.chat-root.export {
.messageIn-thread, .messageOut-thread {
/** For media threads, hide all duplicated metadata, like
sender, sender avatar, time, quick reactions etc. They are
shown for the root thread event */
.messageIn {
margin-left: 50px !important;
}
.messageOut {
margin-right: 50px !important;
}
.messageIn, .messageOut {
.quick-reaction-container, .senderAndTime, .avatar {
display: none;
}
}
}
}
</style>

View file

@ -18,9 +18,11 @@ import MessageOutgoingThread from "./messages/MessageOutgoingThread.vue";
import MessageIncomingImageExport from "./messages/export/MessageIncomingImageExport";
import MessageIncomingAudioExport from "./messages/export/MessageIncomingAudioExport";
import MessageIncomingVideoExport from "./messages/export/MessageIncomingVideoExport";
import MessageIncomingThreadExport from "./messages/export/MessageIncomingThreadExport";
import MessageOutgoingImageExport from "./messages/export/MessageOutgoingImageExport";
import MessageOutgoingAudioExport from "./messages/export/MessageOutgoingAudioExport";
import MessageOutgoingVideoExport from "./messages/export/MessageOutgoingVideoExport";
import MessageOutgoingThreadExport from "./messages/export/MessageOutgoingThreadExport";
import ContactJoin from "./messages/ContactJoin.vue";
import ContactLeave from "./messages/ContactLeave.vue";
import ContactInvited from "./messages/ContactInvited.vue";
@ -159,9 +161,9 @@ export default {
case "m.room.message":
if (event.getSender() != this.$matrix.currentUserId) {
if (event.isThreadRoot || event.isThread) {
if (event.isMxThread) {
// Incoming thread, e.g. a file drop!
return MessageIncomingThread;
return isForExport ? MessageIncomingThreadExport : MessageIncomingThread;
}
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable
@ -193,9 +195,9 @@ export default {
}
return MessageIncomingText;
} else {
if (event.isThreadRoot || event.isThread) {
if (event.isMxThread) {
// Outgoing thread
return MessageOutgoingThread;
return isForExport ? MessageOutgoingThreadExport : MessageOutgoingThread;
}
if (event.getContent().msgtype == "m.image") {
// For SVG, make downloadable

View file

@ -8,7 +8,7 @@
</div>
</div>
<v-avatar class="avatar" ref="avatar" size="32" color="#ededed" @click.stop="otherAvatarClicked($refs.avatar.$el)">
<img v-if="messageEventAvatar(event)" :src="messageEventAvatar(event)" />
<img v-if="messageEventAvatar(event)" :src="messageEventAvatar(event)" onerror="this.style.display='none'" />
<span v-else class="white--text headline">{{
eventSenderDisplayName(event).substring(0, 1).toUpperCase()
}}</span>
@ -20,7 +20,7 @@
<v-icon>more_vert</v-icon>
</v-btn>
</div>
<QuickReactions :event="event" :timelineSet="timelineSet" v-on="$listeners"/>
<QuickReactions :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<SeenBy :room="room" :event="event"/>
</div>
</template>

View file

@ -2,14 +2,13 @@
<message-incoming v-bind="{...$props, ...$attrs}" v-on="$listeners">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<span>{{ $t('message.file_prefix') }}</span>
<span

View file

@ -2,14 +2,13 @@
<message-incoming v-bind="{...$props, ...$attrs}" v-on="$listeners">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<i v-if="event.isRedacted()" class="deleted-text">
<v-icon :color="this.senderIsAdminOrModerator(this.event)?'white':''" size="small">block</v-icon>

View file

@ -2,14 +2,13 @@
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners" v-if="items.length > 1">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<v-container fluid class="imageCollection">
<v-row wrap>
@ -51,54 +50,46 @@ export default {
return {
items: [],
showItem: null,
thread: null,
}
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.processThread();
},
beforeDestroy() {
this.thread = null;
},
watch: {
thread: {
handler(newValue, oldValue) {
if (oldValue) {
oldValue.off('Relations.add', this.onAddRelation);
}
if (newValue) {
newValue.on('Relations.add', this.onAddRelation);
}
this.processThread();
},
immediate: true
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
methods: {
onAddRelation() {
this.processThread();
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
onItemClick(event) {
this.showItem = event.item;
},
processThread() {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId()).map(e => {
let ret = {
event: e,
src: null,
};
ret.promise =
util
.getThumbnail(this.$matrix.matrixClient, e, 100, 100)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
return ret;
});
this.$emit('layout-change', () => {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = {
event: e,
src: null,
};
ret.promise =
util
.getThumbnail(this.$matrix.matrixClient, e, 100, 100)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
return ret;
});
}, this.$el);
},
layoutedItems() {
if (!this.items || this.items.length == 0) { return [] }

View file

@ -24,7 +24,7 @@
<img v-if="userAvatar" :src="userAvatar" />
<span v-else class="white--text headline">{{ userAvatarLetter }}</span>
</v-avatar>
<QuickReactions :event="event" :timelineSet="timelineSet" v-on="$listeners"/>
<QuickReactions :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<SeenBy :room="room" :event="event"/>
</div>
</template>

View file

@ -2,15 +2,14 @@
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<span>{{ $t('message.file_prefix') }}</span>
<span

View file

@ -2,9 +2,7 @@
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', {user: inReplyToSender || "Someone"}) }}
</div>
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"

View file

@ -2,12 +2,14 @@
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners" v-if="items.length > 1">
<div class="bubble">
<div class="original-message" v-if="inReplyToText">
<div class="original-message-sender">
{{ $t('message.user_said', { user: inReplyToSender || "Someone" }) }}
</div>
<div class="original-message-text" v-html="linkify($sanitize(inReplyToText))" />
<div class="original-message-sender">{{ inReplyToSender }}</div>
<div
class="original-message-text"
v-html="linkify($sanitize(inReplyToText))"
/>
</div>
<div class="message">
<v-container fluid class="imageCollection">
<v-row wrap>
@ -49,54 +51,46 @@ export default {
return {
items: [],
showItem: null,
thread: null,
}
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.processThread();
},
beforeDestroy() {
this.thread = null;
},
watch: {
thread: {
handler(newValue, oldValue) {
if (oldValue) {
oldValue.off('Relations.add', this.onAddRelation);
}
if (newValue) {
newValue.on('Relations.add', this.onAddRelation);
}
this.processThread();
},
immediate: true
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
methods: {
onAddRelation() {
this.processThread();
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
onItemClick(event) {
this.showItem = event.item;
},
processThread() {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId()).map(e => {
let ret = {
event: e,
src: null,
};
ret.promise =
util
.getThumbnail(this.$matrix.matrixClient, e, 100, 100)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
return ret;
});
this.$emit('layout-change', () => {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = {
event: e,
src: null,
};
ret.promise =
util
.getThumbnail(this.$matrix.matrixClient, e, 100, 100)
.then((url) => {
ret.src = url;
})
.catch((err) => {
console.log("Failed to fetch thumbnail: ", err);
});
return ret;
});
}, this.$el);
},
layoutedItems() {
if (!this.items || this.items.length == 0) { return [] }

View file

@ -0,0 +1,59 @@
<template>
<message-incoming class="messageIn-thread" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<component v-for="item in items" :is="componentFn(item.event)" :originalEvent="item.event" :key="item.event.getId()"
v-bind="{ ...$props }" v-on="$listeners" ref="exportedEvent" />
</message-incoming>
</template>
<script>
import MessageIncoming from "../MessageIncoming.vue";
import messageMixin from "./../messageMixin";
import util from "../../../plugins/utils";
export default {
extends: MessageIncoming,
components: { MessageIncoming },
mixins: [messageMixin],
data() {
return {
items: [],
}
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
processThread() {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = {
event: e,
src: null,
};
return ret;
});
},
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
</style>

View file

@ -0,0 +1,59 @@
<template>
<message-outgoing class="messageOut-thread" v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<component v-for="item in items" :is="componentFn(item.event)" :originalEvent="item.event" :key="item.event.getId()"
v-bind="{ ...$props }" v-on="$listeners" ref="exportedEvent" />
</message-outgoing>
</template>
<script>
import MessageOutgoing from "../MessageOutgoing.vue";
import messageMixin from "./../messageMixin";
import util from "../../../plugins/utils";
export default {
extends: MessageOutgoing,
components: { MessageOutgoing },
mixins: [messageMixin],
data() {
return {
items: [],
}
},
mounted() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
if (!this.thread) {
this.event.on("Event.relationsCreated", this.onRelationsCreated);
}
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
methods: {
onRelationsCreated() {
this.thread = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), "m.thread", "m.room.message");
this.event.off("Event.relationsCreated", this.onRelationsCreated);
},
processThread() {
this.items = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId())
.filter(e => util.downloadableTypes().includes(e.getContent().msgtype))
.map(e => {
let ret = {
event: e,
src: null,
};
return ret;
});
},
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>
<style lang="scss" scoped>
.bubble {
width: 100%;
}
</style>

View file

@ -2,7 +2,7 @@ import QuickReactions from "./QuickReactions.vue";
import * as linkify from 'linkifyjs';
import linkifyHtml from 'linkify-html';
import utils from "../../plugins/utils"
import Vue from "vue";
import util from "../../plugins/utils";
linkify.options.defaults.className = "link";
linkify.options.defaults.target = { url: "_blank" };
@ -40,51 +40,22 @@ export default {
type: Function,
default: function () {
return () => {};
}
},
},
},
data() {
return {
event: {},
inReplyToEvent: null,
inReplyToSender: null,
utils
thread: null,
utils,
};
},
mounted() {
const relatesTo = this.validEvent && this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
// Can we find the original message?
const originalEventId = relatesTo["m.in_reply_to"].event_id;
if (originalEventId && this.timelineSet) {
const originalEvent = this.timelineSet.findEventById(originalEventId);
if (originalEvent) {
this.inReplyToEvent = originalEvent;
this.inReplyToSender = this.eventSenderDisplayName(originalEvent);
}
}
}
},
beforeUnmount() {
if (this.validEvent) {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
}
beforeDestroy() {
this.thread = null;
},
watch: {
event: {
immediate: true,
handler(newValue, oldValue) {
if (oldValue && oldValue.getId) {
oldValue.off("Event.relationsCreated", this.onRelationsCreated);
}
if (newValue && newValue.getId) {
newValue.on("Event.relationsCreated", this.onRelationsCreated);
if (newValue.isThreadRoot) {
Vue.set(newValue, "isThread", true);
}
}
}
},
originalEvent: {
immediate: true,
handler(originalEvent, ignoredOldValue) {
@ -96,7 +67,19 @@ export default {
});
}
},
}
},
thread: {
handler(newValue, oldValue) {
if (oldValue) {
oldValue.off("Relations.add", this.onAddRelation);
}
if (newValue) {
newValue.on("Relations.add", this.onAddRelation);
}
this.processThread();
},
immediate: true,
},
},
computed: {
/**
@ -107,6 +90,16 @@ export default {
return this.event && Object.keys(this.event).length !== 0;
},
/**
* If this is a thread event, we return the root here, so all reactions will land on the root event.
*/
eventForReactions() {
if (this.event.parentThread) {
return this.event.parentThread;
}
return this.event;
},
incoming() {
return this.event && this.event.getSender() != this.$matrix.currentUserId;
},
@ -123,11 +116,34 @@ export default {
return true;
},
inReplyToSender() {
const originalEvent = this.validEvent && this.event.replyEvent;
if (originalEvent) {
const sender = this.eventSenderDisplayName(originalEvent);
if (originalEvent.isThreadRoot || originalEvent.isMxThread) {
return sender || this.$t("message.someone");
} else {
return this.$t("message.user_said", { user: sender || this.$t("message.someone") });
}
}
return null;
},
inReplyToEvent() {
return this.validEvent && this.event.replyEvent;
},
inReplyToText() {
const relatesTo = this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
if (this.inReplyToEvent && (this.inReplyToEvent.isThreadRoot || this.inReplyToEvent.isMxThread)) {
const children = this.timelineSet.relations
.getAllChildEventsForEvent(this.inReplyToEvent.getId())
.filter((e) => util.downloadableTypes().includes(e.getContent().msgtype));
return this.$t("message.sent_media", { count: children.length });
}
const content = this.event.getContent();
if ('body' in content) {
if ("body" in content) {
const lines = content.body.split("\n").reverse() || [];
while (lines.length && !lines[0].startsWith("> ")) lines.shift();
// Reply fallback has a blank line after it, so remove it to prevent leading newline
@ -137,12 +153,10 @@ export default {
return text;
}
}
if (this.inReplyToEvent) {
var c = this.inReplyToEvent.getContent();
return c.body;
}
// We don't have the original text (at the moment at least)
return this.$t("fallbacks.original_text");
}
@ -153,7 +167,7 @@ export default {
const relatesTo = this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
const content = this.event.getContent();
if ('body' in content) {
if ("body" in content) {
// Remove the new text and strip "> " from the old original text
const lines = content.body.split("\n");
while (lines.length && lines[0].startsWith("> ")) lines.shift();
@ -190,10 +204,9 @@ export default {
},
},
methods: {
onRelationsCreated(relationType, ignoredEventType) {
if (relationType === "m.thread") {
Vue.set(this.event, "isThread", true);
}
onAddRelation() {
console.error("onAddRelation");
this.processThread();
},
ownAvatarClicked() {
this.$emit("own-avatar-clicked", { event: this.event });
@ -308,5 +321,10 @@ export default {
linkify(text) {
return linkifyHtml(text);
},
/**
* Override this to handle updates to (the) message thread.
*/
processThread() {},
},
};

View file

@ -1,3 +1,4 @@
import util from "../../plugins/utils";
export default {
computed: {
@ -5,8 +6,12 @@ export default {
return !this.incoming && this.event.getContent().msgtype == "m.text";
},
isDownloadable() {
if ((this.event.isThreadRoot || this.event.isMxThread) && this.timelineSet) {
const children = this.timelineSet.relations.getAllChildEventsForEvent(this.event.getId()).filter(e => util.downloadableTypes().includes(e.getContent().msgtype));
return children.length > 0;
}
const msgtype = this.event.getContent().msgtype;
return ['m.video','m.audio','m.image','m.file'].includes(msgtype);
return util.downloadableTypes().includes(msgtype);
},
isRedactable() {
const room = this.$matrix.matrixClient.getRoom(this.event.getRoomId());

View file

@ -299,10 +299,8 @@ class Util {
if (err && err.name == "UnknownDeviceError") {
console.log("Unknown devices. Mark as known before retrying.");
var setAsKnownPromises = [];
for (var user of Object.keys(err.devices)) {
const userDevices = err.devices[user];
for (var deviceId of Object.keys(userDevices)) {
const deviceInfo = userDevices[deviceId];
err.devices.forEach((userDevices, user) => {
userDevices.forEach((deviceInfo, deviceId) => {
if (!deviceInfo.known) {
setAsKnownPromises.push(
matrixClient.setDeviceKnown(
@ -312,9 +310,9 @@ class Util {
)
);
}
}
}
Promise.all(setAsKnownPromises)
});
});
return Promise.all(setAsKnownPromises)
.then(() => {
// All devices now marked as "known", try to resend
let event = err.event;
@ -499,7 +497,6 @@ class Util {
// Have we changed our local view mode of this room?
const tags = room.tags;
if (tags && tags["ui_options"]) {
console.error("We have a tag!");
if (tags["ui_options"]["voice_mode"] === 1) {
return ROOM_TYPE_VOICE_MODE;
} else if (tags["ui_options"]["file_mode"] === 1) {
@ -897,6 +894,10 @@ class Util {
});
}
downloadableTypes() {
return ['m.video','m.audio','m.image','m.file'];
}
download(matrixClient, event) {
this
.getAttachment(matrixClient, event)