Merge branch 'dev'
This commit is contained in:
commit
a59f98902c
53 changed files with 2398 additions and 755 deletions
|
|
@ -50,7 +50,7 @@ The app loads runtime configutation from the server at "./config.json" and merge
|
|||
The following values can be set via the config file:
|
||||
|
||||
* **logo** - An url or base64-encoded image data url that represents the app logotype.
|
||||
* **accentColor** - The accent color of the app UI.
|
||||
* **accentColor** - The accent color of the app UI. Use a HTML-style color value string, like "#ff0080".
|
||||
* **show_status_messages** - Whether to show only user joins/leaves and display name updates, or the full range of room status updates. Possible values are "never" (only the above), "moderators" (moderators will see all status updates) or "always" (everyone will see all status updates). Defaults to "always".
|
||||
|
||||
|
||||
|
|
|
|||
97
package-lock.json
generated
97
package-lock.json
generated
|
|
@ -10,7 +10,7 @@
|
|||
"dependencies": {
|
||||
"@matrix-org/olm": "^3.2.12",
|
||||
"aes-js": "^3.1.2",
|
||||
"axios": "^0.21.0",
|
||||
"axios": "^1.4.0",
|
||||
"browserify-fs": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"clean-insights-sdk": "^2.4",
|
||||
|
|
@ -4097,6 +4097,11 @@
|
|||
"lodash": "^4.17.14"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"node_modules/at-least-node": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||
|
|
@ -4153,11 +4158,13 @@
|
|||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "0.21.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
|
||||
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
|
||||
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.0"
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-eslint": {
|
||||
|
|
@ -5497,6 +5504,17 @@
|
|||
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
|
|
@ -6378,6 +6396,14 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delegate": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
|
||||
|
|
@ -7869,6 +7895,19 @@
|
|||
"resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz",
|
||||
"integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg=="
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
|
|
@ -11929,6 +11968,11 @@
|
|||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"node_modules/prr": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
|
||||
|
|
@ -19358,6 +19402,11 @@
|
|||
"lodash": "^4.17.14"
|
||||
}
|
||||
},
|
||||
"asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
|
||||
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
|
||||
},
|
||||
"at-least-node": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
|
||||
|
|
@ -19386,11 +19435,13 @@
|
|||
}
|
||||
},
|
||||
"axios": {
|
||||
"version": "0.21.4",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
|
||||
"integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
|
||||
"integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
|
||||
"requires": {
|
||||
"follow-redirects": "^1.14.0"
|
||||
"follow-redirects": "^1.15.0",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"babel-eslint": {
|
||||
|
|
@ -20464,6 +20515,14 @@
|
|||
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
|
||||
"dev": true
|
||||
},
|
||||
"combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"requires": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
|
|
@ -21145,6 +21204,11 @@
|
|||
"isobject": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
|
||||
},
|
||||
"delegate": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/delegate/-/delegate-3.2.0.tgz",
|
||||
|
|
@ -22325,6 +22389,16 @@
|
|||
"resolved": "https://registry.npmjs.org/foreach/-/foreach-2.0.6.tgz",
|
||||
"integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg=="
|
||||
},
|
||||
"form-data": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
|
||||
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
|
||||
"requires": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"mime-types": "^2.1.12"
|
||||
}
|
||||
},
|
||||
"forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
|
|
@ -25482,6 +25556,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"proxy-from-env": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||
},
|
||||
"prr": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"dependencies": {
|
||||
"@matrix-org/olm": "^3.2.12",
|
||||
"aes-js": "^3.1.2",
|
||||
"axios": "^0.21.0",
|
||||
"axios": "^1.4.0",
|
||||
"browserify-fs": "^1.0.0",
|
||||
"buffer": "^6.0.3",
|
||||
"clean-insights-sdk": "^2.4",
|
||||
|
|
|
|||
47
src/App.vue
47
src/App.vue
|
|
@ -31,6 +31,7 @@
|
|||
|
||||
<script>
|
||||
import stickers from "./plugins/stickers";
|
||||
import logoMixin from "./components/logoMixin";
|
||||
|
||||
export default {
|
||||
name: "App",
|
||||
|
|
@ -41,6 +42,7 @@ export default {
|
|||
availableJsonTranslation: null
|
||||
}
|
||||
},
|
||||
mixins: [logoMixin],
|
||||
beforeMount() {
|
||||
this.setDefaultLanguage();
|
||||
},
|
||||
|
|
@ -111,9 +113,43 @@ export default {
|
|||
|
||||
// Set language
|
||||
this.$i18n.locale = this.$store.state.language || "en";
|
||||
},
|
||||
showNotification() {
|
||||
if(document.visibilityState === "visible") {
|
||||
return;
|
||||
}
|
||||
const title = this.$t('notification.title');
|
||||
const notification = new Notification(title, {icon: this.logotype});
|
||||
notification.onclick = () => {
|
||||
notification.close();
|
||||
window.parent.focus();
|
||||
}
|
||||
},
|
||||
requestAndShowPermission(notificationCount) {
|
||||
Notification.requestPermission(function (permission) {
|
||||
if(notificationCount > 0 && permission === "granted") {
|
||||
this.showNotification();
|
||||
}
|
||||
});
|
||||
},
|
||||
requestNotificationPermission(notificationCount) {
|
||||
if ('Notification' in window) {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if(notificationCount > 0 && permission === 'granted') {
|
||||
this.showNotification();
|
||||
} else if(permission === "default") {
|
||||
this.requestAndShowPermission(notificationCount);
|
||||
} else {
|
||||
this.requestAndShowPermission(notificationCount);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
notificationCount() {
|
||||
return this.$matrix.notificationCount
|
||||
},
|
||||
currentUser() {
|
||||
return this.$store.state.auth.user;
|
||||
},
|
||||
|
|
@ -126,8 +162,8 @@ export default {
|
|||
},
|
||||
title() {
|
||||
var title = this.appName;
|
||||
if (this.$matrix.notificationCount > 0) {
|
||||
title += " [" + this.$matrix.notificationCount + "]";
|
||||
if (this.notificationCount > 0) {
|
||||
title += " [" + this.notificationCount + "]";
|
||||
}
|
||||
if (this.$route.meta.title) {
|
||||
title += " - " + this.$route.meta.title;
|
||||
|
|
@ -182,6 +218,11 @@ export default {
|
|||
},
|
||||
immediate: true,
|
||||
},
|
||||
notificationCount: {
|
||||
handler(nCount) {
|
||||
this.requestNotificationPermission(nCount)
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
@ -195,7 +236,7 @@ export default {
|
|||
}
|
||||
|
||||
#app {
|
||||
background-color: $app-background;
|
||||
background-color: var(--v-app-background);
|
||||
}
|
||||
|
||||
.main {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"productLink": "letsconvene.im",
|
||||
"defaultServer": "https://neo.keanu.im",
|
||||
"identityServer_unset": "",
|
||||
"registrationToken_unset": "",
|
||||
"rtl": false,
|
||||
"accentColor_unset": "",
|
||||
"logo_unset": "",
|
||||
|
|
@ -44,6 +45,7 @@
|
|||
}
|
||||
],
|
||||
"experimental_voice_mode": true,
|
||||
"experimental_file_mode": true,
|
||||
"experimental_read_only_room": true,
|
||||
"experimental_public_room": true,
|
||||
"show_status_messages": "never"
|
||||
|
|
|
|||
|
|
@ -1,16 +1,19 @@
|
|||
@import "~vuetify/src/styles/settings/_variables.scss";
|
||||
@import "@/assets/css/main.scss";
|
||||
@import "@/assets/css/vendors/v-emoji-picker";
|
||||
@import "@/assets/css/filedrop.scss";
|
||||
|
||||
$admin-bg: black;
|
||||
$admin-fg: white;
|
||||
|
||||
body {
|
||||
--v-app-background: $app-background;
|
||||
--v-background-color: white;
|
||||
--v-foreground-color: black;
|
||||
--v-secondary-color: #242424;
|
||||
--v-divider-color: #eeeeee;
|
||||
&.dark {
|
||||
--v-app-background: black;
|
||||
--v-background-color: black;
|
||||
--v-foreground-color: white;
|
||||
--v-secondary-color: #c0c0c0;
|
||||
|
|
@ -101,22 +104,17 @@ body {
|
|||
}
|
||||
|
||||
.notification-alert {
|
||||
display: inline-block;
|
||||
background-color: $alert-bg-color;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 2px;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
.icon-circle {
|
||||
color: $alert-bg-color;
|
||||
}
|
||||
&.popup-open::after {
|
||||
top: 20px;
|
||||
top: 35px;
|
||||
color: #246bfd;
|
||||
}
|
||||
.missed-items-popup {
|
||||
position: absolute;
|
||||
bottom: -17px;
|
||||
left: -20px;
|
||||
left: -175px;
|
||||
transform: translateY(100%);
|
||||
background: #246bfd;
|
||||
border-radius: 8px;
|
||||
|
|
@ -125,8 +123,10 @@ body {
|
|||
padding: 22px 18px 23px 18px;
|
||||
z-index: 300;
|
||||
user-select: none;
|
||||
width: 300px;
|
||||
justify-content: space-between;
|
||||
.text {
|
||||
white-space: nowrap;
|
||||
white-space: break-spaces;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
|
|
@ -161,7 +161,12 @@ body {
|
|||
backdrop-filter: blur(2px);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media #{map-get($display-breakpoints, 'sm-and-down')} {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-root {
|
||||
|
|
@ -206,7 +211,6 @@ body {
|
|||
@media #{map-get($display-breakpoints, 'sm-and-down')} {
|
||||
margin-top: 72px;
|
||||
margin-bottom: 70px;
|
||||
z-index: 9;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -791,6 +795,7 @@ body {
|
|||
|
||||
.room-name-inline {
|
||||
text-align: start;
|
||||
min-width: 75px;
|
||||
}
|
||||
|
||||
.room-name.no-upper {
|
||||
|
|
@ -953,30 +958,6 @@ body {
|
|||
font-size: 16 * $chat-text-size !important;
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px !important;
|
||||
.qr {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
.link {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 16px;
|
||||
text-decoration: underline;
|
||||
color: #3d6eff;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
.link-copied-in-place .v-btn__content {
|
||||
font-family: "Inter", sans-serif !important;
|
||||
font-size: 12px !important;
|
||||
text-transform: none !important;
|
||||
color: #3d6eff;
|
||||
}
|
||||
|
||||
.filled-button {
|
||||
@media #{map-get($display-breakpoints, 'sm-and-up')} {
|
||||
min-width: 180px !important;
|
||||
|
|
@ -1077,7 +1058,7 @@ body {
|
|||
}
|
||||
|
||||
.profile {
|
||||
background-color: white;
|
||||
background-color: #e8e8e8;
|
||||
height: 100%;
|
||||
.chat-header {
|
||||
background-color: transparent;
|
||||
|
|
@ -1286,7 +1267,7 @@ body {
|
|||
.option-warning {
|
||||
background: linear-gradient(0deg, #FFF3F3, #FFF3F3), #FFFBED;
|
||||
border-radius: 8px;
|
||||
padding: 18px;
|
||||
padding: 18px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
|
|
@ -1294,7 +1275,7 @@ body {
|
|||
line-height: 17px;
|
||||
.v-icon {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1433,6 +1414,14 @@ body {
|
|||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
margin-left: -8px !important;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
.reaction-emoji {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
font-size: 17px;
|
||||
}
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
|
|
@ -1452,6 +1441,9 @@ body {
|
|||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
.clap-button {
|
||||
font-size: 24px;
|
||||
}
|
||||
.mic-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
|
@ -1539,4 +1531,4 @@ body {
|
|||
right: 20px;
|
||||
bottom: 20px;
|
||||
position: absolute;
|
||||
}
|
||||
}
|
||||
360
src/assets/css/filedrop.scss
Normal file
360
src/assets/css/filedrop.scss
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
$large-button-height: 48px;
|
||||
$small-button-height: 36px;
|
||||
|
||||
.file-drop-root {
|
||||
$hiliteColor: #4642f1;
|
||||
font-family: "Inter", sans-serif;
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin: 0;
|
||||
background-color: var(--v-background-color);
|
||||
color: var(--v-foreground-color);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
|
||||
.file-drop-title {
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 11.54 * $chat-text-size;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 700;
|
||||
line-height: 140%;
|
||||
letter-spacing: 0.34px;
|
||||
text-transform: uppercase;
|
||||
margin-top: 13px;
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
.background {
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
background-color: #181719;
|
||||
&.drop-target {
|
||||
background-color: #383739;
|
||||
}
|
||||
border-radius: 19px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.file-format-info {
|
||||
opacity: 0.6;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 11 * $chat-text-size;
|
||||
font-family: "Inter", sans-serif;
|
||||
line-height: 117%;
|
||||
letter-spacing: 0.4px;
|
||||
margin-top: 13px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 11.54 * $chat-text-size;
|
||||
line-height: 140%;
|
||||
color: white;
|
||||
background-color: $hiliteColor !important;
|
||||
border-radius: $small-button-height / 2;
|
||||
min-height: 0;
|
||||
height: $small-button-height !important;
|
||||
margin-top: $chat-standard-padding-xs;
|
||||
margin-bottom: $chat-standard-padding-xs;
|
||||
&.large {
|
||||
padding: 16px 23px;
|
||||
height: $large-button-height;
|
||||
border-radius: $large-button-height / 2;
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
color: rgba(white, 80%) !important;
|
||||
}
|
||||
textarea::placeholder {
|
||||
color: rgba(white, 80%) !important;
|
||||
}
|
||||
|
||||
.attachment-wrapper {
|
||||
width: 100%;
|
||||
flex: 0 0 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.file-drop-current-item {
|
||||
width: 100%;
|
||||
height: 70%;
|
||||
background-color: #181719;
|
||||
&.drop-target {
|
||||
background-color: #383739;
|
||||
}
|
||||
border-radius: 19px;
|
||||
overflow: hidden;
|
||||
.v-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.filename {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.file-drop-thumbnail-container {
|
||||
width: 100%;
|
||||
padding: 13px 20px 15px 20px;
|
||||
height: 74px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
.file-drop-thumbnail {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
background-color: #242424;
|
||||
border: 2px solid white;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
&.current {
|
||||
border: 2px solid #4642f1;
|
||||
}
|
||||
&.noborder {
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.v-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
margin-right: 8px;
|
||||
|
||||
.add,
|
||||
.remove {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.v-icon {
|
||||
width: 14px;
|
||||
height: 15.75px;
|
||||
}
|
||||
}
|
||||
.remove {
|
||||
// Slight background to make visible
|
||||
background-color: rgba(black, 0.2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-drop-section {
|
||||
margin-top: 20px;
|
||||
padding: 16px 18px;
|
||||
background-color: #181719;
|
||||
border-radius: 19px;
|
||||
}
|
||||
|
||||
.file-drop-input-container,
|
||||
.file-drop-sending-input-container,
|
||||
.file-drop-sent-input-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 20%;
|
||||
background-color: #181719;
|
||||
border-radius: 19px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
.input-area-text {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
margin-bottom: 50px;
|
||||
padding: 16px 18px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-weight: 300;
|
||||
}
|
||||
.v-btn {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeInStackItem {
|
||||
from {opacity: 0;}
|
||||
to {opacity: 1;}
|
||||
}
|
||||
|
||||
// Sending
|
||||
//
|
||||
.file-drop-sent-stack {
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.no-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
div {
|
||||
position: absolute;
|
||||
}
|
||||
.file-drop-stack-item {
|
||||
transform: rotate(-4.4deg);
|
||||
}
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 21 * $chat-text-size;
|
||||
font-family: "Poppins", sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.34px;
|
||||
}
|
||||
.items-sent {
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
div, .v-icon {
|
||||
position: absolute;
|
||||
}
|
||||
.v-icon, .v-icon__component {
|
||||
width: 30%;
|
||||
height: 30%;
|
||||
}
|
||||
}
|
||||
.file-drop-stack-item {
|
||||
background: #3a3a3c;
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
.v-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
&.direct {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
&.animated {
|
||||
animation-name: fadeInStackItem;
|
||||
animation-fill-mode: both;
|
||||
animation-duration: 1.5s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-drop-sending-container {
|
||||
width: 100%;
|
||||
padding: 13px 0px 15px 0px;
|
||||
height: 50%;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
white-space: nowrap;
|
||||
text-align: start;
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
|
||||
.file-drop-sending-item {
|
||||
width: 100%;
|
||||
height: 64px;
|
||||
overflow: hidden;
|
||||
background-color: #242424;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(0deg, #26242b 0%, #26242b 100%), #fff;
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
.v-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex: 0 0 48px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.filename {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
left: 8px;
|
||||
font-size: 0.7em;
|
||||
}
|
||||
.v-progress-linear {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.file-drop-cancel {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: green !important;
|
||||
background: #2e2e3b;
|
||||
border-radius: 8.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.file-drop-sending-input-container {
|
||||
.v-btn {
|
||||
.v-progress-circular {
|
||||
margin-left: 8px;
|
||||
}
|
||||
background: linear-gradient(0deg, #000 0%, #000 100%), #4642f1;
|
||||
}
|
||||
}
|
||||
|
||||
.file-drop-files-sent {
|
||||
width: 100%;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 21 * $chat-text-size;
|
||||
font-family: "Poppins", sans-serif;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.34px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.file-drop-sent-input-container {
|
||||
background-color: transparent;
|
||||
.v-btn {
|
||||
right: unset;
|
||||
left: 8px;
|
||||
background: linear-gradient(0deg, #000 0%, #000 100%), #4642f1;
|
||||
}
|
||||
}
|
||||
}
|
||||
15
src/assets/icons/ic_check_circle.vue
Normal file
15
src/assets/icons/ic_check_circle.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<template>
|
||||
<svg width="59" height="60" viewBox="0 0 59 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Group 3826">
|
||||
<g id="Group 3800">
|
||||
<g id="Group 3799">
|
||||
<circle id="Ellipse 352" cx="29.5" cy="29.5127" r="28.5" fill="#4642F1" stroke="white"
|
||||
stroke-width="2" />
|
||||
<path id="Vector" fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M22.8132 29.1473L26.8315 33.8176L38.5723 19.8068C39.4513 18.8601 40.8326 19.8699 40.1419 20.9428L28.5896 38.677C27.7106 39.813 26.5177 39.9392 25.5131 38.8032L19.674 31.7978C18.5439 30.1569 21.432 27.8217 22.8132 29.1471V29.1473Z"
|
||||
fill="white" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
7
src/assets/icons/ic_lock.vue
Normal file
7
src/assets/icons/ic_lock.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg width="18" height="22" viewBox="0 0 18 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M16.0247 8.80006H15.4908V6.42878C15.4908 2.88379 12.5789 0 9.00064 0C5.42053 0 2.50904 2.88379 2.50904 6.42878V8.80047H1.97615C0.88518 8.80047 0 9.67678 0 10.7572V20.0423C0 21.1231 0.884952 22 1.97615 22H16.0248C17.1153 22 18 21.1232 18 20.0423L17.9999 10.7568C17.9999 9.67638 17.1151 8.80006 16.025 8.80006H16.0247ZM9.82754 15.5262V16.7406C9.82754 16.9215 9.6795 17.0687 9.4959 17.0687H8.50277C8.32063 17.0687 8.17185 16.9216 8.17185 16.7406V15.5262C7.7193 15.2498 7.41503 14.7589 7.41503 14.193C7.41503 13.3265 8.12456 12.6242 9.0001 12.6242C9.87461 12.6242 10.5841 13.3265 10.5841 14.193C10.584 14.7589 10.2795 15.2498 9.82754 15.5262ZM12.6451 8.80006H5.35551V6.42878C5.35551 4.43786 6.99007 2.81942 9.00073 2.81942C11.0097 2.81942 12.6451 4.43763 12.6451 6.42878V8.80006Z"
|
||||
fill="white" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/assets/icons/ic_trash.vue
Normal file
7
src/assets/icons/ic_trash.vue
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<template>
|
||||
<svg width="14" height="16" viewBox="0 0 14 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M1.22395 5.24998L2.19384 14.7971C2.24872 15.3374 2.7326 15.75 3.31161 15.75H10.688C11.267 15.75 11.7509 15.3374 11.8058 14.7971L12.7757 5.24998H1.22395ZM4.75998 2.09999H1.11163C0.816497 2.09999 0.534245 2.20968 0.325379 2.40503C0.117003 2.60085 0 2.86547 0 3.14213V3.68282C0 3.9595 0.116999 4.2241 0.325379 4.41991C0.534255 4.61526 0.81652 4.72495 1.11163 4.72495H12.8884C13.1835 4.72495 13.4658 4.61527 13.6746 4.41991C13.883 4.2241 14 3.95948 14 3.68282V3.14213C14 2.86545 13.883 2.60084 13.6746 2.40503C13.4657 2.20968 13.1835 2.09999 12.8884 2.09999H9.24002V1.3766C9.24002 0.616408 8.58251 0 7.77162 0H6.22825C5.41736 0 4.75985 0.616408 4.75985 1.3766L4.75998 2.09999Z"
|
||||
fill="white" />
|
||||
</svg>
|
||||
</template>
|
||||
BIN
src/assets/sounds/clapping.mp3
Normal file
BIN
src/assets/sounds/clapping.mp3
Normal file
Binary file not shown.
|
|
@ -29,7 +29,7 @@
|
|||
"join_invite": "ཁ་སྣོན་བྱས་པའི་གྲོགས་ཁོ་ན།",
|
||||
"permissions": "ནང་འཛུལ་གྱི་ཆོག་མཆན་ཁག",
|
||||
"created_by": "{user} བཟོས།",
|
||||
"copy_link": "གདན་ཞུ་འབྲེལ་ཐག་པར་བཤུས་རྒྱོབས།",
|
||||
"copy_invite_link": "གདན་ཞུ་འབྲེལ་ཐག་པར་བཤུས་རྒྱོབས།",
|
||||
"scan_code": "བཤེར་རིས་བཤེར་ཏེ་ཁ་བརྡ་ཁང་དུ་འཛུལ།",
|
||||
"user_admin": "དོ་དམ་པ།",
|
||||
"experimental_features": "ཚོད་ལྟའི་ཁྱད་ཆོས་ཁག",
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@
|
|||
"permissions": "Beitrittsberechtigungen",
|
||||
"join_invite": "Nur hinzugefügte Personen",
|
||||
"join_public": "Jeder mit einem Link",
|
||||
"copy_link": "Link zur Einladung kopieren",
|
||||
"copy_invite_link": "Link zur Einladung kopieren",
|
||||
"link_copied": "Link kopiert!",
|
||||
"purge": "Raum löschen",
|
||||
"members": "Mitglieder",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,13 @@
|
|||
"show_less": "Show less",
|
||||
"show_more": "Show more",
|
||||
"add_reaction": "Add reaction",
|
||||
"click_to_remove": "Click to remove"
|
||||
"click_to_remove": "Click to remove",
|
||||
"time": {
|
||||
"recently": "just now",
|
||||
"minutes": "1 minute ago | {n} minutes ago",
|
||||
"hours": "1 hour ago | {n} hours ago",
|
||||
"days": "1 day ago | {n} days ago"
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"start_private_chat": "Private chat with this user",
|
||||
|
|
@ -90,7 +96,12 @@
|
|||
"incoming_message_deleted_text": "This message was deleted.",
|
||||
"not_allowed_to_send": "Only admins and moderators are allowed to send to the room",
|
||||
"reaction_count_more": "{reactionCount} more",
|
||||
"seen_by": "Seen by no members | Seen by 1 member | Seen by {count} members"
|
||||
"seen_by_count": "Seen by no members | Seen by 1 member | Seen by {count} members",
|
||||
"seen_by": "Seen by",
|
||||
"file": "File",
|
||||
"files": "Files",
|
||||
"images": "Images",
|
||||
"send_attachements_dialog_title": "Do you want to send following attachments ?"
|
||||
},
|
||||
"room": {
|
||||
"invitations": "You have no invitations | You have 1 invitation | You have {count} invitations",
|
||||
|
|
@ -164,7 +175,10 @@
|
|||
"send_verification": "Send verification email",
|
||||
"sent_verification": "An email has been sent to {email}. Please use your regular email client to verify the address.",
|
||||
"resend_verification": "Resend verification email",
|
||||
"email_not_valid": "Email address not valid"
|
||||
"email_not_valid": "Email address not valid",
|
||||
"registration_token": "Please enter registration token",
|
||||
"send_token": "Send token",
|
||||
"token_not_valid": "Invalid token"
|
||||
},
|
||||
"profile": {
|
||||
"title": "My Profile",
|
||||
|
|
@ -206,7 +220,8 @@
|
|||
"status_logging_in": "Logging in...",
|
||||
"status_joining": "Joining room...",
|
||||
"join_failed": "Failed to join room.",
|
||||
"choose_name": "Choose a name to use"
|
||||
"choose_name": "Choose a name to use",
|
||||
"you_have_been_banned": "You have been banned from this room."
|
||||
},
|
||||
"invite": {
|
||||
"title": "Add Friends",
|
||||
|
|
@ -249,7 +264,8 @@
|
|||
"permissions": "Join Permissions",
|
||||
"join_invite": "Only People Added",
|
||||
"join_public": "Anyone with a link",
|
||||
"copy_link": "Copy invite link",
|
||||
"copy_invite_link": "Copy invite link",
|
||||
"copy_link": "Copy link",
|
||||
"link_copied": "Link copied!",
|
||||
"purge": "Delete room",
|
||||
"members": "Members",
|
||||
|
|
@ -264,13 +280,19 @@
|
|||
"user_admin": "Administrator",
|
||||
"user_moderator": "Moderator",
|
||||
"experimental_features": "Experimental Features",
|
||||
"room_type": "Room type",
|
||||
"room_type_default": "Default",
|
||||
"voice_mode": "Voice mode",
|
||||
"voice_mode_info": "Switches the chat interface to a 'listen and record' mode",
|
||||
"file_mode": "File mode",
|
||||
"file_mode_info": "Switches the chat interface to a 'file drop' mode",
|
||||
"download_chat": "Download chat",
|
||||
"read_only_room": "Read only room",
|
||||
"read_only_room_info": "Only admins and moderators are allowed to send to the room",
|
||||
"make_public": "Make Public",
|
||||
"make_public_warning": "warning: Full message history will be visible to new participants"
|
||||
"make_public_warning": "warning: Full message history will be visible to new participants",
|
||||
"contact_link": "My Contact Link",
|
||||
"contact_link_desc": "Share your contact link. When opened, a direct message will be started with you."
|
||||
},
|
||||
"room_info_sheet": {
|
||||
"this_room": "This room",
|
||||
|
|
@ -329,5 +351,34 @@
|
|||
"fetched_n_of_total_events": "Fetched {count} of {total} events",
|
||||
"processed_n_of_total_events": "Processed media for {count} of {total} events",
|
||||
"export_filename": "Exported chat {date}"
|
||||
},
|
||||
"notification": {
|
||||
"title": "New message received"
|
||||
},
|
||||
"emoji": {
|
||||
"search": "Search...",
|
||||
"categories": {
|
||||
"activity": "Activity",
|
||||
"flags": "Flags",
|
||||
"foods": "Foods",
|
||||
"frequently": "Frequently used",
|
||||
"objects": "Objects",
|
||||
"nature": "Nature",
|
||||
"peoples": "Peoples",
|
||||
"symbols": "Symbols",
|
||||
"places": "Places"
|
||||
}
|
||||
},
|
||||
"file_mode": {
|
||||
"choose_files": "Choose files",
|
||||
"any_file_format_accepted": "Any file format is accepted",
|
||||
"secure_file_send": "secure file send",
|
||||
"add_a_message": "Add a message",
|
||||
"sending_progress": "Sending...",
|
||||
"sending": "Sending",
|
||||
"files_sent":"1 file sent! | {count} files sent!",
|
||||
"files_sent_with_note":"1 file sent with a note! | {count} files sent with a note!",
|
||||
"return_to_home": "Return to home",
|
||||
"files": "Files"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"leave_room": "Salir",
|
||||
"view_profile": "Vista",
|
||||
"identity_temporary": "Tu identidad {displayName} es temporal. Puedes cambiar tu nombre o establecer una contraseña para conservarla.",
|
||||
"copy_link": "Copiar el enlace de invitación",
|
||||
"copy_invite_link": "Copiar el enlace de invitación",
|
||||
"scan_code": "Escanear para unirse a la sala"
|
||||
},
|
||||
"purge_room": {
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@
|
|||
"permissions": "Autorisations d’adhésion",
|
||||
"join_invite": "Seules les personnes ajoutées",
|
||||
"join_public": "Quiconque avec un lien",
|
||||
"copy_link": "Copier le lien d’invitation",
|
||||
"copy_invite_link": "Copier le lien d’invitation",
|
||||
"link_copied": "Lien copié !",
|
||||
"purge": "Supprimer le salon",
|
||||
"user_you": "{user} (vous)",
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@
|
|||
"leave_room": "Lascia",
|
||||
"scan_code": "Scansiona per entrare nella stanza",
|
||||
"version_info": "Realizzato da Guardian Project. Versione: {version}",
|
||||
"copy_link": "Copia il collegamento di invito"
|
||||
"copy_invite_link": "Copia il collegamento di invito"
|
||||
},
|
||||
"voice_recorder": {
|
||||
"failed_to_record": "Impossibile registrare l’audio",
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@
|
|||
"members": "Medlemmer",
|
||||
"purge": "Slett rom",
|
||||
"link_copied": "Lenke kopiert.",
|
||||
"copy_link": "Kopier invitasjonslenke",
|
||||
"copy_invite_link": "Kopier invitasjonslenke",
|
||||
"created_by": "Opprettet av {user}",
|
||||
"title": "Romdetaljer"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@
|
|||
"permissions": "Permissões de ingresso",
|
||||
"join_invite": "Somente pessoas adicionadas",
|
||||
"join_public": "Qualquer pessoa com um link",
|
||||
"copy_link": "Copie o link do convite",
|
||||
"copy_invite_link": "Copie o link do convite",
|
||||
"link_copied": "Link copiado!",
|
||||
"purge": "Excluir a sala",
|
||||
"members": "Membros",
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
"members": "Membri",
|
||||
"purge": "Ștergeți camera",
|
||||
"link_copied": "Link copiat!",
|
||||
"copy_link": "Copiați link-ul de invitație",
|
||||
"copy_invite_link": "Copiați link-ul de invitație",
|
||||
"join_public": "Oricine are un link",
|
||||
"join_invite": "Numai persoane adăugate",
|
||||
"permissions": "Permisiuni de aderare",
|
||||
|
|
|
|||
|
|
@ -30,36 +30,20 @@
|
|||
"edited": "تەھرىرلەندى",
|
||||
"file_prefix": "ھۆججەت ",
|
||||
"user_said": "{user} سۆزلىدى:",
|
||||
"user_left": "قوللانغۇچى مۇنازىرىدىن چېكىندى",
|
||||
"user_joined": "قوللانغۇچى مۇنازىرىغا قاتناشتى",
|
||||
"user_was_invited": "قوللانغۇچى مۇنازىرە ئۆيىگە تەكلىپ قىلىندى...",
|
||||
"user_encrypted_room": "قوللانغۇچى مۇنازىرە-خانىنى سىفىرلاشتۇردى",
|
||||
"user_changed_room_avatar": "قوللانغۇچى مۇنازىرە-خانىدىكى كۆرىنىشىنى ئۆزگەرتتى",
|
||||
"user_changed_avatar": "قوللانغۇچى كۆرىنىشىنى ئۆزگەرتتى",
|
||||
"user_changed_display_name": "قوللانغۇچىنىڭ ئىسمى«يېڭى ئىسىمغا» ئۆزگەرتىلدى",
|
||||
"user_aliased_room": "مۇنازىرە ئۆيىنىڭ ئىسمى ئۆزگەرتىلدى",
|
||||
"user_created_room": "يېڭى مۇنازىرە ئۆيى قۇرۇلدى",
|
||||
"you": "سىز",
|
||||
"room_powerlevel_change": "{user} دەرىجىسىنى ئۆزگەرتىش {changes}",
|
||||
"user_changed_guest_access_open": "قوللانغۇچى ئەزالارنىڭ مۇنازىرەخانىغا قوشۇلىشىغا رۇخسەت قىلدى",
|
||||
"user_changed_guest_access_closed": "قوللانغۇچى ئەزالارنىڭ مۇنازىرەخانىغا قوشۇلۇشتىن رەت قىلىندى",
|
||||
"user_powerlevel_change_from_to": "قوللانغۇچى بۇرۇنقى دەرىجىسىدىن يېڭى دەرىجىسىگە كۆتۈرىلدى",
|
||||
"scale_image": "كىچىكلەنمە رەسىم",
|
||||
"users_are_typing": "ئەزالىرى يېزىۋاتىدۇ {count}",
|
||||
"user_is_typing": "قوللانغۇچى يېزىۋاتىدۇ",
|
||||
"your_message": "ئۇچۇرىڭىز ...",
|
||||
"replying_to": "{user}",
|
||||
"unread_messages": "ئوقۇلمىغان ئۇچۇرلار",
|
||||
"user_changed_room_topic": "قوللانغۇچى مۇنازىرەخانىنىڭ تېمىسىنى ئۆزگەرتتى",
|
||||
"user_changed_room_name": "قوللانغۇچى مۇنازىرەخانىنىڭ ئىسمىنى ئۆزگەرتتى",
|
||||
"room_joinrule_public": "كۆپچىلىك",
|
||||
"room_joinrule_invite": "تەكلىپ قىلىڭ",
|
||||
"user_changed_join_rules": "قوللانغۇچى مۇنازىرەخانىنى مەلۇم تىپقا ئۆزگەرتتى",
|
||||
"room_history_joined": "ئەزالار قاتناشقاندىن باشلاپ ئوقۇغىلى بولىدۇ",
|
||||
"room_history_invited": "ئەزالار تەكلىپ قىلىنغان ۋاقىتتىن باشلاپ ئوقۇغىلى بولىدۇ",
|
||||
"room_history_shared": "مۇنازىرەخانىدىكى ھەركىم ئوقۇيالايدۇ",
|
||||
"room_history_world_readable": "ھەركىم ئوقۇيالايدۇ",
|
||||
"user_changed_room_history": "قوللانغۇچى» مۇنازىرەخانىنىڭ تارىخىنى قۇردى»"
|
||||
"room_history_world_readable": "ھەركىم ئوقۇيالايدۇ"
|
||||
},
|
||||
"language_display_name": "ئۇيغۇرچە",
|
||||
"new_room": {
|
||||
|
|
@ -160,7 +144,7 @@
|
|||
"members": "ئەزالار",
|
||||
"purge": "ئۆينى ئۆچۈرۈڭ",
|
||||
"link_copied": "ئۇلىنىش كۆچۈرۈلدى!",
|
||||
"copy_link": "تەكلىپ ئۇلانمىسىنى كۆچۈرۈڭ",
|
||||
"copy_invite_link": "تەكلىپ ئۇلانمىسىنى كۆچۈرۈڭ",
|
||||
"join_public": "ئۇلىنىشى بارلار",
|
||||
"join_invite": "پەقەت كىشىلەر قوشۇلدى",
|
||||
"permissions": "ئىجازەتكە قوشۇلۇڭ",
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@
|
|||
"join_invite": "仅添加人员",
|
||||
"permissions": "加入权限",
|
||||
"created_by": "由 {user} 创建",
|
||||
"copy_link": "复制邀请链接",
|
||||
"copy_invite_link": "复制邀请链接",
|
||||
"scan_code": "扫一扫加入聊天室",
|
||||
"user_admin": "管理员",
|
||||
"voice_mode": "语音模块",
|
||||
|
|
|
|||
|
|
@ -28,38 +28,53 @@
|
|||
}}</span>
|
||||
</v-avatar>
|
||||
</div>
|
||||
|
||||
<!-- Current emoji reactions -->
|
||||
<div class="typing-users">
|
||||
<transition-group name="list" tag="div">
|
||||
<v-avatar v-for="reaction in reactions" :key="reaction.member.userId" class="typing-user" size="32" color="grey">
|
||||
<img v-if="memberAvatar(reaction.member)" :src="memberAvatar(reaction.member)" />
|
||||
<span v-else class="white--text headline">{{
|
||||
reaction.member.name.substring(0, 1).toUpperCase()
|
||||
}}</span>
|
||||
<div class="reaction-emoji">{{ reaction.emoji }}</div>
|
||||
</v-avatar>
|
||||
</transition-group>
|
||||
</div>
|
||||
|
||||
<div v-if="currentAudioEvent" class="senderAndTime">
|
||||
<div class="sender">{{ eventSenderDisplayName(currentAudioEvent) }}</div>
|
||||
<div class="time">
|
||||
{{ formatTime(currentAudioEvent.event.origin_server_ts) }}
|
||||
{{ formatTimeAgo(currentAudioEvent.event.origin_server_ts) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="play-time">
|
||||
{{ currentTime }} / {{ totalTime }}
|
||||
</div>
|
||||
<audio ref="player" :src="src" @durationchange="updateDuration">
|
||||
{{ $t('fallbacks.audio_file') }}
|
||||
</audio>
|
||||
<div v-if="currentAudioEvent" class="auto-audio-player">
|
||||
<v-btn id="btn-rewind" @click.stop="rewind" icon>
|
||||
<v-btn id="btn-rewind" :disabled="!info || info.loading" @click.stop="rewind" icon>
|
||||
<v-icon size="28">$vuetify.icons.rewind</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-if="playing" id="btn-pause" @click.stop="pause" icon>
|
||||
<v-progress-circular v-if="info && info.loading" :value="info.loadPercent" @click.stop="pause" size="36" width="2" style="margin:26px"></v-progress-circular>
|
||||
<v-btn v-else-if="info && info.playing" id="btn-pause" @click.stop="pause" icon>
|
||||
<v-icon size="56">$vuetify.icons.pause_circle</v-icon>
|
||||
</v-btn>
|
||||
<v-btn v-else id="btn-play" @click.stop="play" icon>
|
||||
<v-icon size="56">$vuetify.icons.play_circle</v-icon>
|
||||
</v-btn>
|
||||
<v-btn id="btn-forward" @click.stop="forward" icon>
|
||||
<v-btn id="btn-forward" :disabled="!info || info.loading" @click.stop="forward" icon>
|
||||
<v-icon size="28">$vuetify.icons.forward</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div class="load-later">
|
||||
<v-btn :class="{'mic-button': true, 'dimmed': !canRecordAudio}" ref="mic_button" fab small elevation="0" v-blur
|
||||
@click.stop="micButtonClicked()">
|
||||
<v-icon color="white">mic</v-icon>
|
||||
</v-btn>
|
||||
<div style="align-self: flex-end;">
|
||||
<v-btn class="clap-button clickable" text elevation="0" v-blur @click.stop="clapButtonClicked()">👏</v-btn>
|
||||
<v-btn :class="{'mic-button': true, 'dimmed': !canRecordAudio}" ref="mic_button" fab small elevation="0" v-blur
|
||||
@click.stop="micButtonClicked()">
|
||||
<v-icon color="white">mic</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-icon class="clickable" @click="loadNext" color="white" size="28">expand_more</v-icon>
|
||||
</div>
|
||||
|
||||
|
|
@ -102,87 +117,46 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
src: null,
|
||||
REACTION_ANIMATION_TIME: 2500,
|
||||
info: null,
|
||||
currentAudioEvent: null,
|
||||
autoPlayNextEvent: false,
|
||||
currentAudioSource: null,
|
||||
player: null,
|
||||
duration: 0,
|
||||
playPercent: 0,
|
||||
playTime: 0,
|
||||
playing: false,
|
||||
analyzer: null,
|
||||
analyzerDataArray: null,
|
||||
showReadOnlyToast: false,
|
||||
reactions: [],
|
||||
updateReactionsTimer: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on('audio-playback-started', this.audioPlaybackStarted);
|
||||
this.$root.$on('audio-playback-paused', this.audioPlaybackPaused);
|
||||
this.$root.$on('audio-playback-ended', this.audioPlaybackEnded);
|
||||
this.$root.$on('audio-playback-reaction', this.audioPlaybackReaction);
|
||||
document.body.classList.add("dark");
|
||||
this.$root.$on('playback-start', this.onPlaybackStart);
|
||||
this.player = this.$refs.player;
|
||||
this.player.autoplay = false;
|
||||
this.player.addEventListener("timeupdate", this.updateProgressBar);
|
||||
this.player.addEventListener("play", () => {
|
||||
if (!this.analyser) {
|
||||
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
let audioSource = null;
|
||||
if (audioCtx) {
|
||||
audioSource = audioCtx.createMediaElementSource(this.player);
|
||||
this.analyser = audioCtx.createAnalyser();
|
||||
audioSource.connect(this.analyser);
|
||||
this.analyser.connect(audioCtx.destination);
|
||||
|
||||
this.analyser.fftSize = 128;
|
||||
const bufferLength = this.analyser.frequencyBinCount;
|
||||
this.analyzerDataArray = new Uint8Array(bufferLength);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.playing = true;
|
||||
this.updateVisualization();
|
||||
if (this.currentAudioEvent) {
|
||||
this.$emit("mark-read", this.currentAudioEvent.getId(), this.currentAudioEvent.getId());
|
||||
}
|
||||
});
|
||||
this.player.addEventListener("pause", () => {
|
||||
this.playing = false;
|
||||
this.clearVisualization();
|
||||
});
|
||||
this.player.addEventListener("ended", () => {
|
||||
this.pause();
|
||||
this.playing = false;
|
||||
this.clearVisualization();
|
||||
this.onPlaybackEnd();
|
||||
});
|
||||
this.$audioPlayer.setAutoplay(false);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.$off('audio-playback-started', this.audioPlaybackStarted);
|
||||
this.$root.$off('audio-playback-paused', this.audioPlaybackPaused);
|
||||
this.$root.$off('audio-playback-ended', this.audioPlaybackEnded);
|
||||
this.$root.$off('audio-playback-reaction', this.audioPlaybackReaction);
|
||||
document.body.classList.remove("dark");
|
||||
this.$audioPlayer.removeListener(this._uid);
|
||||
this.currentAudioEvent = null;
|
||||
this.loadAudioAttachmentSource(); // Release
|
||||
this.$root.$off('playback-start', this.onPlaybackStart);
|
||||
},
|
||||
computed: {
|
||||
canRecordAudio() {
|
||||
return !this.$matrix.currentRoomIsReadOnlyForUser && util.browserCanRecordAudio();
|
||||
},
|
||||
currentTime() {
|
||||
return util.formatDuration(this.playTime);
|
||||
return util.formatDuration(this.info ? this.info.currentTime : 0);
|
||||
},
|
||||
currentTimeMs() {
|
||||
return this.info ? this.info.currentTime : 0;
|
||||
},
|
||||
totalTime() {
|
||||
return util.formatDuration(this.duration);
|
||||
},
|
||||
playheadPercent: {
|
||||
get: function () {
|
||||
return this.playPercent;
|
||||
},
|
||||
set: function (percent) {
|
||||
if (this.player.src) {
|
||||
this.playPercent = percent;
|
||||
this.player.currentTime = (percent / 100) * this.player.duration;
|
||||
}
|
||||
},
|
||||
return util.formatDuration(this.info ? this.info.duration : 0);
|
||||
},
|
||||
recordingMembersExceptMe() {
|
||||
return this.recordingMembers.filter((member) => {
|
||||
|
|
@ -202,18 +176,14 @@ export default {
|
|||
events: {
|
||||
immediate: true,
|
||||
handler(events, ignoredOldValue) {
|
||||
console.log("Events changed", this.currentAudioEvent, this.autoPlayNextEvent);
|
||||
if (!this.currentAudioEvent || this.autoPlayNextEvent) {
|
||||
// Make sure all events are decrypted!
|
||||
const eventsBeingDecrypted = events.filter((e) => e.isBeingDecrypted());
|
||||
if (eventsBeingDecrypted.length > 0) {
|
||||
console.log("All not decrypted, wait");
|
||||
Promise.allSettled(eventsBeingDecrypted.map((e) => e.getDecryptionPromise())).then(() => {
|
||||
console.log("DONE DECRYPTING!")
|
||||
this.loadNext(this.autoPlayNextEvent && this.autoplay);
|
||||
});
|
||||
} else {
|
||||
console.log("All decrypted, load next");
|
||||
this.loadNext(this.autoPlayNextEvent && this.autoplay);
|
||||
}
|
||||
}
|
||||
|
|
@ -222,87 +192,96 @@ export default {
|
|||
currentAudioEvent: {
|
||||
immediate: true,
|
||||
handler(value, oldValue) {
|
||||
console.log("Current audio derom", value, oldValue);
|
||||
if (value && oldValue && value.getId && oldValue.getId && value.getId() === oldValue.getId()) {
|
||||
console.log("Ignoring change!!!");
|
||||
return;
|
||||
}
|
||||
if (!value || !value.getId) {
|
||||
return;
|
||||
}
|
||||
this.src = null;
|
||||
|
||||
this.clearReactions();
|
||||
|
||||
this.info = this.$audioPlayer.addListener(this._uid, value);
|
||||
|
||||
const autoPlayWasSet = this.autoPlayNextEvent;
|
||||
this.autoPlayNextEvent = false;
|
||||
|
||||
if (value.getSender() == this.$matrix.currentUserId) {
|
||||
// Sent by us. Don't autoplay if we just sent this (i.e. it is ahead of our read marker)
|
||||
if (this.room && !this.room.getReceiptsForEvent(value).includes(value.getSender())) {
|
||||
this.player.autoplay = false;
|
||||
this.$audioPlayer.setAutoplay(false);
|
||||
this.autoPlayNextEvent = autoPlayWasSet;
|
||||
}
|
||||
}
|
||||
|
||||
this.loadAudioAttachmentSource();
|
||||
this.$audioPlayer.load(value, this.timelineSet);
|
||||
}
|
||||
},
|
||||
src: {
|
||||
immediate: true,
|
||||
handler(value, ignoredOldValue) {
|
||||
console.log("Source changed to", value, ignoredOldValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
play() {
|
||||
if (this.player.src) {
|
||||
this.$root.$emit("playback-start", this);
|
||||
if (this.player.paused) {
|
||||
this.player.play();
|
||||
} else if (this.player.ended) {
|
||||
// restart
|
||||
this.player.currentTime = 0;
|
||||
this.player.play();
|
||||
}
|
||||
if (this.currentAudioEvent) {
|
||||
this.$audioPlayer.setAutoplay(false);
|
||||
this.$audioPlayer.play(this.currentAudioEvent, this.timelineSet);
|
||||
}
|
||||
},
|
||||
pause() {
|
||||
this.player.autoplay = false;
|
||||
if (this.player.src) {
|
||||
this.player.pause();
|
||||
this.$audioPlayer.setAutoplay(false);
|
||||
if (this.currentAudioEvent) {
|
||||
this.$audioPlayer.pause(this.currentAudioEvent);
|
||||
}
|
||||
},
|
||||
rewind() {
|
||||
if (this.player.src) {
|
||||
this.player.currentTime = Math.max(0, this.player.currentTime - 15);
|
||||
if (this.currentAudioEvent) {
|
||||
this.$audioPlayer.seekRelative(this.currentAudioEvent, -15000);
|
||||
}
|
||||
},
|
||||
forward() {
|
||||
if (this.player.src) {
|
||||
this.player.currentTime = Math.min(this.player.duration, this.player.currentTime + 15);
|
||||
if (this.currentAudioEvent) {
|
||||
this.$audioPlayer.seekRelative(this.currentAudioEvent, 15000);
|
||||
}
|
||||
},
|
||||
updateProgressBar() {
|
||||
if (this.player.duration > 0) {
|
||||
this.playPercent = Math.floor(
|
||||
(100 / this.player.duration) * this.player.currentTime
|
||||
);
|
||||
} else {
|
||||
this.playPercent = 0;
|
||||
audioPlaybackStarted() {
|
||||
if (!this.analyser) {
|
||||
const audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
let audioSource = null;
|
||||
if (audioCtx) {
|
||||
audioSource = audioCtx.createMediaElementSource(this.$audioPlayer.getPlayerElement());
|
||||
this.analyser = audioCtx.createAnalyser();
|
||||
audioSource.connect(this.analyser);
|
||||
this.analyser.connect(audioCtx.destination);
|
||||
|
||||
this.analyser.fftSize = 128;
|
||||
const bufferLength = this.analyser.frequencyBinCount;
|
||||
this.analyzerDataArray = new Uint8Array(bufferLength);
|
||||
}
|
||||
}
|
||||
this.playTime = 1000 * this.player.currentTime;
|
||||
},
|
||||
updateDuration() {
|
||||
this.duration = 1000 * this.player.duration;
|
||||
},
|
||||
onPlaybackStart(item) {
|
||||
this.player.autoplay = false;
|
||||
if (item != this && this.playing) {
|
||||
this.pause();
|
||||
this.updateVisualization();
|
||||
if (this.currentAudioEvent) {
|
||||
this.$emit("mark-read", this.currentAudioEvent.getId(), this.currentAudioEvent.getId());
|
||||
}
|
||||
},
|
||||
onPlaybackEnd() {
|
||||
audioPlaybackPaused() {
|
||||
this.clearVisualization();
|
||||
},
|
||||
audioPlaybackEnded() {
|
||||
this.clearVisualization();
|
||||
this.loadNext(true && this.autoplay);
|
||||
},
|
||||
audioPlaybackReaction(reaction) {
|
||||
// Play sound!
|
||||
const audio = new Audio(require("@/assets/sounds/clapping.mp3"));
|
||||
audio.volume = 0.6;
|
||||
audio.play();
|
||||
|
||||
const member = this.room.getMember(reaction.sender);
|
||||
if (member) {
|
||||
this.reactions.push(Object.assign({ addedAt: Date.now(), member: member}, reaction));
|
||||
if (!this.updateReactionsTimer) {
|
||||
this.updateReactionsTimer = setInterval(this.updateReactions, 300);
|
||||
}
|
||||
}
|
||||
},
|
||||
loadPrevious() {
|
||||
const audioMessages = this.events.filter((e) => e.getContent().msgtype === "m.audio");
|
||||
for (let i = 0; i < audioMessages.length; i++) {
|
||||
|
|
@ -332,11 +311,11 @@ export default {
|
|||
if (e.getId() === this.readMarker) {
|
||||
if (i < (audioMessages.length - 1)) {
|
||||
this.pause();
|
||||
this.player.autoplay = autoplay;
|
||||
this.$audioPlayer.setAutoplay(autoplay);
|
||||
this.currentAudioEvent = audioMessages[i + 1];
|
||||
} else {
|
||||
this.autoPlayNextEvent = true;
|
||||
this.player.autoplay = autoplay;
|
||||
this.$audioPlayer.setAutoplay(autoplay);
|
||||
this.currentAudioEvent = e;
|
||||
this.$emit("loadnext");
|
||||
}
|
||||
|
|
@ -347,7 +326,7 @@ export default {
|
|||
// No read marker found. Just use the first event here...
|
||||
if (audioMessages.length > 0) {
|
||||
this.pause();
|
||||
this.player.autoplay = autoplay;
|
||||
this.$audioPlayer.setAutoplay(autoplay);
|
||||
this.currentAudioEvent = audioMessages[0];
|
||||
}
|
||||
return;
|
||||
|
|
@ -358,11 +337,11 @@ export default {
|
|||
if (e.getId() === this.currentAudioEvent.getId()) {
|
||||
if (i < (audioMessages.length - 1)) {
|
||||
this.pause();
|
||||
this.player.autoplay = autoplay;
|
||||
this.$audioPlayer.setAutoplay(autoplay);
|
||||
this.currentAudioEvent = audioMessages[i + 1];
|
||||
} else {
|
||||
this.autoPlayNextEvent = true;
|
||||
this.player.autoplay = autoplay;
|
||||
this.$audioPlayer.setAutoplay(autoplay);
|
||||
this.$emit("loadnext");
|
||||
}
|
||||
break;
|
||||
|
|
@ -390,8 +369,7 @@ export default {
|
|||
volume.style.height = "" + w + "px";
|
||||
const color = 80 + (value * (256 - 80)) / 256;
|
||||
volume.style.backgroundColor = `rgb(${color},${color},${color})`;
|
||||
|
||||
if (this.playing) {
|
||||
if (this.info && this.info.playing) {
|
||||
requestAnimationFrame(this.updateVisualization);
|
||||
} else {
|
||||
this.clearVisualization();
|
||||
|
|
@ -404,36 +382,24 @@ export default {
|
|||
volume.style.height = "0px";
|
||||
volume.style.backgroundColor = "transparent";
|
||||
},
|
||||
loadAudioAttachmentSource() {
|
||||
console.log("loadAUto");
|
||||
if (this.src) {
|
||||
const objectUrl = this.src;
|
||||
this.src = null;
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
if (this.currentAudioEvent) {
|
||||
console.log("Will load");
|
||||
if (this.currentAudioSource) {
|
||||
this.currentAudioSource.reject("Aborted");
|
||||
}
|
||||
this.currentAudioSource =
|
||||
util
|
||||
.getAttachment(this.$matrix.matrixClient, this.currentAudioEvent, (progress) => {
|
||||
this.downloadProgress = progress;
|
||||
})
|
||||
.then((url) => {
|
||||
console.log("Loaded", url);
|
||||
this.src = url;
|
||||
this.currentAudioSource = null;
|
||||
this.$nextTick(() => {
|
||||
this.player.load();
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch attachment: ", err);
|
||||
});
|
||||
updateReactions() {
|
||||
const now = Date.now();
|
||||
this.reactions = this.reactions.filter(r => {
|
||||
return (r.addedAt + this.REACTION_ANIMATION_TIME > now);
|
||||
});
|
||||
if (this.reactions.length == 0) {
|
||||
this.clearReactions();
|
||||
}
|
||||
},
|
||||
|
||||
clearReactions() {
|
||||
if (this.updateReactionsTimer) {
|
||||
clearInterval(this.updateReactionsTimer);
|
||||
this.updateReactionsTimer = null;
|
||||
}
|
||||
this.reactions = [];
|
||||
},
|
||||
|
||||
memberAvatar(member) {
|
||||
if (member) {
|
||||
return member.getAvatarUrl(
|
||||
|
|
@ -456,6 +422,18 @@ export default {
|
|||
} else {
|
||||
this.$emit('start-recording');
|
||||
}
|
||||
},
|
||||
|
||||
clapButtonClicked() {
|
||||
if (this.currentAudioEvent) {
|
||||
this.$emit("sendclap", { event: this.currentAudioEvent, timeOffset: this.currentTimeMs })
|
||||
|
||||
// Also, play locally
|
||||
this.audioPlaybackReaction({
|
||||
sender: this.$matrix.currentUserId,
|
||||
emoji: "👏"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export default {
|
|||
bottom: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: hidden;
|
||||
z-index: 10;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.bottom-sheet-bg {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<div class="chat-root fill-height d-flex flex-column">
|
||||
<ChatHeader class="chat-header flex-grow-0 flex-shrink-0" v-on:header-click="onHeaderClick" v-on:view-room-details="viewRoomDetails" />
|
||||
<ChatHeader class="chat-header flex-grow-0 flex-shrink-0" v-on:header-click="onHeaderClick" v-on:view-room-details="viewRoomDetails" v-if="!useFileModeNonAdmin" />
|
||||
<AudioLayout ref="chatContainer" class="auto-audio-player-root" v-if="useVoiceMode" :room="room"
|
||||
:events="events" :autoplay="!showRecorder"
|
||||
:timelineSet="timelineSet"
|
||||
|
|
@ -10,12 +10,20 @@
|
|||
v-on:loadnext="handleScrolledToBottom(false)"
|
||||
v-on:loadprevious="handleScrolledToTop()"
|
||||
v-on:mark-read="sendRR"
|
||||
v-on:sendclap="sendClapReactionAtTime"
|
||||
/>
|
||||
<VoiceRecorder class="audio-layout" v-if="useVoiceMode" :micButtonRef="$refs.mic_button" :ptt="showRecorderPTT" :show="showRecorder"
|
||||
v-on:close="showRecorder = false" v-on:file="onVoiceRecording" :sendTypingIndicators="useVoiceMode" />
|
||||
|
||||
<FileDropLayout class="file-drop-root" v-if="useFileModeNonAdmin" :room="room"
|
||||
v-on:pick-file="showAttachmentPicker()"
|
||||
v-on:add-file="addAttachment($event)"
|
||||
v-on:remove-file="currentFileInputs.splice($event, 1)"
|
||||
v-on:reset="resetAttachments"
|
||||
:attachments="currentFileInputs"
|
||||
/>
|
||||
|
||||
<div v-if="!useVoiceMode" class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer"
|
||||
<div v-if="!useVoiceMode && !useFileModeNonAdmin" class="chat-content flex-grow-1 flex-shrink-1" 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="
|
||||
|
|
@ -74,7 +82,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Input area -->
|
||||
<v-container v-if="!useVoiceMode && room" fluid :class="['input-area-outer', replyToEvent ? 'reply-to' : '']">
|
||||
<v-container v-if="!useVoiceMode && !useFileModeNonAdmin && room" fluid :class="['input-area-outer', replyToEvent ? 'reply-to' : '']">
|
||||
<div :class="[replyToEvent ? 'iput-area-inner-box' : '']">
|
||||
<!-- "Scroll to end"-button -->
|
||||
<v-btn v-if="!useVoiceMode" class="scroll-to-end" v-show="showScrollToEnd" fab x-small elevation="0" color="black"
|
||||
|
|
@ -188,35 +196,52 @@
|
|||
</v-container>
|
||||
|
||||
<input ref="attachment" type="file" name="attachment" @change="handlePickedAttachment($event)"
|
||||
accept="image/*, audio/*, video/*, .pdf" class="d-none" />
|
||||
accept="image/*, audio/*, video/*, .pdf" class="d-none" multiple/>
|
||||
|
||||
<div v-if="currentImageInputPath">
|
||||
<v-dialog v-model="currentImageInputPath" class="ma-0 pa-0" :width="$vuetify.breakpoint.smAndUp ? '50%' : '85%'">
|
||||
<div v-if="currentFileInputsDialog && !useFileModeNonAdmin">
|
||||
<v-dialog v-model="currentFileInputsDialog" class="ma-0 pa-0" :width="$vuetify.breakpoint.smAndUp ? '50%' : '85%'" persistent scrollable>
|
||||
<v-card class="ma-0 pa-0">
|
||||
<v-card-text class="ma-0 pa-2">
|
||||
<v-img v-if="currentImageInput && currentImageInput.image" :aspect-ratio="1" :src="currentImageInput.image"
|
||||
contain class="current-image-input-path" />
|
||||
<div>
|
||||
file: {{ currentImageInputPath.name }}
|
||||
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
|
||||
{{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }}</span>
|
||||
<span v-else-if="currentImageInput && currentImageInput.dimensions">
|
||||
{{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }}</span>
|
||||
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
|
||||
({{ formatBytes(currentImageInput.scaledSize) }})</span>
|
||||
<span v-else> ({{ formatBytes(currentImageInputPath.size) }})</span>
|
||||
<v-switch v-if="currentImageInput && currentImageInput.scaled" :label="$t('message.scale_image')"
|
||||
v-model="currentImageInput.useScaled" />
|
||||
</div>
|
||||
<div v-if="currentSendError">{{ currentSendError }}</div>
|
||||
<div v-else>{{ currentSendProgress }}</div>
|
||||
</v-card-text>
|
||||
<v-card-title>{{ $t('message.send_attachements_dialog_title') }}</v-card-title>
|
||||
<v-divider></v-divider>
|
||||
<template v-if="imageFiles && imageFiles.length">
|
||||
<v-card-title v-if="imageFiles.length > 1"> {{ $t('message.images') }} </v-card-title>
|
||||
<v-card-text :class="{'ma-0 pa-2' : true, 'd-flex flex-wrap justify-center': imageFiles.length > 1}">
|
||||
<div :class="{'col-4': imageFiles.length > 1}" v-for="(currentImageInput, id) in imageFiles" :key="id">
|
||||
<v-img v-if="currentImageInput && currentImageInput.image" :aspect-ratio="1" :src="currentImageInput.image"
|
||||
contain class="current-image-input-path" />
|
||||
<div>
|
||||
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
|
||||
{{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }}</span>
|
||||
<span v-else-if="currentImageInput && currentImageInput.dimensions">
|
||||
{{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }}</span>
|
||||
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
|
||||
({{ formatBytes(currentImageInput.scaledSize) }})</span>
|
||||
<v-switch v-if="currentImageInput && currentImageInput.scaled" :label="$t('message.scale_image')"
|
||||
v-model="currentImageInput.useScaled" />
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</template>
|
||||
<template v-if="Array.isArray(currentFileInputs) && currentFileInputs.length">
|
||||
<v-card-title v-if="nonImageFiles.length > 1">{{ $t('message.files') }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-for="(currentImageInputPath, id) in currentFileInputs" :key="id">
|
||||
<div v-if="!currentImageInputPath.type.includes('image/')">
|
||||
<span> {{ $t('message.file') }}: {{ currentImageInputPath.name }}</span>
|
||||
<span> ({{ formatBytes(currentImageInputPath.size) }})</span>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</template>
|
||||
<v-divider></v-divider>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="primary" text @click="cancelSendAttachment" id="btn-attachment-cancel">{{
|
||||
$t("menu.cancel")
|
||||
}}</v-btn>
|
||||
<v-spacer>
|
||||
<div v-if="currentSendError">{{ currentSendError }}</div>
|
||||
<div v-else>{{ currentSendProgress }}</div>
|
||||
</v-spacer>
|
||||
<v-btn color="primary" text @click="cancelSendAttachment" id="btn-attachment-cancel">
|
||||
{{ $t("menu.cancel") }}
|
||||
</v-btn>
|
||||
<v-btn id="btn-attachment-send" color="primary" text @click="sendAttachment"
|
||||
v-if="currentSendShowSendButton" :disabled="currentSendOperation != null">{{ $t("menu.send") }}</v-btn>
|
||||
</v-card-actions>
|
||||
|
|
@ -225,7 +250,7 @@
|
|||
</div>
|
||||
|
||||
<MessageOperationsBottomSheet ref="messageOperationsSheet">
|
||||
<VEmojiPicker ref="emojiPicker" @select="emojiSelected" />
|
||||
<VEmojiPicker ref="emojiPicker" @select="emojiSelected" :i18n="i18nEmoji"/>
|
||||
</MessageOperationsBottomSheet>
|
||||
|
||||
<StickerPickerBottomSheet ref="stickerPickerSheet" v-on:selectSticker="sendSticker" />
|
||||
|
|
@ -263,7 +288,7 @@
|
|||
<script>
|
||||
import Vue from "vue";
|
||||
import { TimelineWindow, EventTimeline } from "matrix-js-sdk";
|
||||
import util from "../plugins/utils";
|
||||
import util, { ROOM_TYPE_VOICE_MODE, ROOM_TYPE_FILE_MODE } from "../plugins/utils";
|
||||
import MessageOperations from "./messages/MessageOperations.vue";
|
||||
import AvatarOperations from "./messages/AvatarOperations.vue";
|
||||
import ChatHeader from "./ChatHeader";
|
||||
|
|
@ -278,6 +303,7 @@ import ImageResize from "image-resize";
|
|||
import CreatePollDialog from "./CreatePollDialog.vue";
|
||||
import chatMixin from "./chatMixin";
|
||||
import AudioLayout from "./AudioLayout.vue";
|
||||
import FileDropLayout from "./file_mode/FileDropLayout";
|
||||
|
||||
const sizeOf = require("image-size");
|
||||
const dataUriToBuffer = require("data-uri-to-buffer");
|
||||
|
|
@ -326,7 +352,8 @@ export default {
|
|||
BottomSheet,
|
||||
AvatarOperations,
|
||||
CreatePollDialog,
|
||||
AudioLayout
|
||||
AudioLayout,
|
||||
FileDropLayout
|
||||
},
|
||||
|
||||
data() {
|
||||
|
|
@ -342,8 +369,7 @@ export default {
|
|||
timelineWindowPaginating: false,
|
||||
|
||||
scrollPosition: null,
|
||||
currentImageInput: null,
|
||||
currentImageInputPath: null,
|
||||
currentFileInputs: null,
|
||||
currentSendOperation: null,
|
||||
currentSendProgress: null,
|
||||
currentSendShowSendButton: true,
|
||||
|
|
@ -393,7 +419,21 @@ export default {
|
|||
/** Calculated style for message operations. We position the "popup" at the selected message. */
|
||||
opStyle: "",
|
||||
|
||||
isEmojiQuickReaction: true
|
||||
isEmojiQuickReaction: true,
|
||||
i18nEmoji: {
|
||||
search: this.$t("emoji.search"),
|
||||
categories: {
|
||||
Activity: this.$t("emoji.categories.activity"),
|
||||
Flags: this.$t("emoji.categories.flags"),
|
||||
Foods: this.$t("emoji.categories.foods"),
|
||||
Frequently: this.$t("emoji.categories.frequently"),
|
||||
Objects: this.$t("emoji.categories.objects"),
|
||||
Nature: this.$t("emoji.categories.nature"),
|
||||
Peoples: this.$t("emoji.categories.peoples"),
|
||||
Symbols: this.$t("emoji.categories.symbols"),
|
||||
Places: this.$t("emoji.categories.places")
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -408,6 +448,7 @@ export default {
|
|||
},
|
||||
|
||||
mounted() {
|
||||
this.$root.$on('audio-playback-ended', this.audioPlaybackEnded);
|
||||
const container = this.chatContainer;
|
||||
if (container) {
|
||||
this.scrollPosition = new ScrollPosition(container);
|
||||
|
|
@ -418,6 +459,8 @@ export default {
|
|||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.$root.$off('audio-playback-ended', this.audioPlaybackEnded);
|
||||
this.$audioPlayer.pause();
|
||||
this.stopRRTimer();
|
||||
},
|
||||
|
||||
|
|
@ -427,6 +470,23 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
nonImageFiles() {
|
||||
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => !file.type.includes("image/"))
|
||||
},
|
||||
imageFiles() {
|
||||
return this.isCurrentFileInputsAnArray && this.currentFileInputs.filter(file => file.type.includes("image/"))
|
||||
},
|
||||
isCurrentFileInputsAnArray() {
|
||||
return Array.isArray(this.currentFileInputs)
|
||||
},
|
||||
currentFileInputsDialog: {
|
||||
get() {
|
||||
return this.isCurrentFileInputsAnArray
|
||||
},
|
||||
set() {
|
||||
this.currentFileInputs = null
|
||||
}
|
||||
},
|
||||
chatContainer() {
|
||||
const container = this.$refs.chatContainer;
|
||||
if (this.useVoiceMode) {
|
||||
|
|
@ -500,19 +560,24 @@ export default {
|
|||
//
|
||||
const ref = this.selectedEvent && this.$refs[this.selectedEvent.getId()];
|
||||
var top = 0;
|
||||
var left = 0;
|
||||
var left = "unset";
|
||||
var right = "unset";
|
||||
if (ref && ref[0]) {
|
||||
if (this.showAvatarMenuAnchor) {
|
||||
var rectAnchor = this.showAvatarMenuAnchor.getBoundingClientRect();
|
||||
var rectChat = this.$refs.avatarOperationsStrut.getBoundingClientRect();
|
||||
top = rectAnchor.top - rectChat.top;
|
||||
left = rectAnchor.left - rectChat.left;
|
||||
if (this.$vuetify.rtl) {
|
||||
right = (rectAnchor.right - rectChat.right)+ "px";
|
||||
} else {
|
||||
left = (rectAnchor.left - rectChat.left) + "px";
|
||||
}
|
||||
// if (left + 250 > rectChat.right) {
|
||||
// left = rectChat.right - 250; // Pretty ugly, but we want to make sure it does not escape the screen, and we don't have the exakt width of it (yet)!
|
||||
// }
|
||||
}
|
||||
}
|
||||
return "top:" + top + "px;left:" + left + "px";
|
||||
return "top:" + top + "px;left:" + left + ";right:" + right;
|
||||
},
|
||||
canRecordAudio() {
|
||||
return util.browserCanRecordAudio();
|
||||
|
|
@ -530,9 +595,16 @@ export default {
|
|||
useVoiceMode: {
|
||||
get: function () {
|
||||
if (!this.$config.experimental_voice_mode) return false;
|
||||
return util.useVoiceMode(this.room);
|
||||
return util.roomDisplayType(this.room) === ROOM_TYPE_VOICE_MODE;
|
||||
},
|
||||
},
|
||||
useFileModeNonAdmin: {
|
||||
get: function() {
|
||||
if (!this.$config.experimental_file_mode) return false;
|
||||
return util.roomDisplayType(this.room) === ROOM_TYPE_FILE_MODE && !this.canCreatePoll; // TODO - Check user or admin
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* If we have no events and the room is encrypted, show info about this
|
||||
* to the user.
|
||||
|
|
@ -874,11 +946,26 @@ export default {
|
|||
// 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;
|
||||
if (container && container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) {
|
||||
scrollToSeeNew = true;
|
||||
if (container) {
|
||||
if (container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) {
|
||||
scrollToSeeNew = true;
|
||||
}
|
||||
}
|
||||
this.handleScrolledToBottom(scrollToSeeNew);
|
||||
}
|
||||
|
||||
// If kick or ban event, redirect to "goodbye"...
|
||||
if (event.getType() === "m.room.member" &&
|
||||
event.getStateKey() == this.$matrix.currentUserId &&
|
||||
(event.getPrevContent() || {}).membership == "join" &&
|
||||
(
|
||||
(event.getContent().membership == "leave" && event.getSender() != this.currentUserId) ||
|
||||
(event.getContent().membership == "ban" ))
|
||||
) {
|
||||
this.$store.commit("setCurrentRoomId", null);
|
||||
const wasPurged = event.getContent().reason == "Room Deleted";
|
||||
this.$navigation.push({ name: "Goodbye", params: { roomWasPurged: wasPurged } }, -1);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onUserTyping(event, member) {
|
||||
|
|
@ -928,64 +1015,61 @@ export default {
|
|||
this.$refs.attachment.click();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle picked attachment
|
||||
*/
|
||||
handlePickedAttachment(event) {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
optimizeImage(e,event,file) {
|
||||
file.image = e.target.result;
|
||||
file.dimensions = null;
|
||||
try {
|
||||
file.dimensions = sizeOf(dataUriToBuffer(e.target.result));
|
||||
|
||||
// Need to resize?
|
||||
const w = file.dimensions.width;
|
||||
const h = file.dimensions.height;
|
||||
if (w > 640 || h > 640) {
|
||||
var aspect = w / h;
|
||||
var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed());
|
||||
var newHeight = parseInt((w > h ? 640 / aspect : 640).toFixed());
|
||||
var imageResize = new ImageResize({
|
||||
format: "png",
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
outputType: "blob",
|
||||
});
|
||||
imageResize
|
||||
.play(event.target)
|
||||
.then((img) => {
|
||||
Vue.set(
|
||||
file,
|
||||
"scaled",
|
||||
new File([img], file.name, {
|
||||
type: img.type,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
);
|
||||
Vue.set(file, "useScaled", true);
|
||||
Vue.set(file, "scaledSize", img.size);
|
||||
Vue.set(file, "scaledDimensions", {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Resize failed:", err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get image dimensions: " + error);
|
||||
}
|
||||
return file
|
||||
},
|
||||
handleFileReader(event, file) {
|
||||
if (file) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const file = event.target.files[0];
|
||||
if (file.type.startsWith("image/")) {
|
||||
this.currentImageInput = {
|
||||
image: e.target.result,
|
||||
dimensions: null,
|
||||
};
|
||||
try {
|
||||
this.currentImageInput.dimensions = sizeOf(dataUriToBuffer(e.target.result));
|
||||
|
||||
// Need to resize?
|
||||
const w = this.currentImageInput.dimensions.width;
|
||||
const h = this.currentImageInput.dimensions.height;
|
||||
if (w > 640 || h > 640) {
|
||||
var aspect = w / h;
|
||||
var newWidth = parseInt((w > h ? 640 : 640 * aspect).toFixed());
|
||||
var newHeight = parseInt((w > h ? 640 / aspect : 640).toFixed());
|
||||
var imageResize = new ImageResize({
|
||||
format: "png",
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
outputType: "blob",
|
||||
});
|
||||
imageResize
|
||||
.play(event.target)
|
||||
.then((img) => {
|
||||
Vue.set(
|
||||
this.currentImageInput,
|
||||
"scaled",
|
||||
new File([img], file.name, {
|
||||
type: img.type,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
);
|
||||
Vue.set(this.currentImageInput, "useScaled", true);
|
||||
Vue.set(this.currentImageInput, "scaledSize", img.size);
|
||||
Vue.set(this.currentImageInput, "scaledDimensions", {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Resize failed:", err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get image dimensions: " + error);
|
||||
}
|
||||
this.optimizeImage(e, event, file)
|
||||
}
|
||||
console.log(this.currentImageInput);
|
||||
this.$matrix.matrixClient.getMediaConfig().then((config) => {
|
||||
this.currentImageInputPath = file;
|
||||
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, file] : [file];
|
||||
if (config["m.upload.size"] && file.size > config["m.upload.size"]) {
|
||||
this.currentSendError = this.$t("message.upload_file_too_large");
|
||||
this.currentSendShowSendButton = false;
|
||||
|
|
@ -994,9 +1078,15 @@ export default {
|
|||
}
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(event.target.files[0]);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Handle picked attachment
|
||||
*/
|
||||
handlePickedAttachment(event) {
|
||||
Object.values(event.target.files).forEach(file => this.handleFileReader(event, file));
|
||||
},
|
||||
|
||||
showStickerPicker() {
|
||||
this.$refs.stickerPickerSheet.open();
|
||||
|
|
@ -1014,41 +1104,35 @@ export default {
|
|||
});
|
||||
}
|
||||
},
|
||||
|
||||
sendAttachment(withText) {
|
||||
this.$refs.attachment.value = null;
|
||||
if (this.currentImageInputPath) {
|
||||
var inputFile = this.currentImageInputPath;
|
||||
if (this.currentImageInput && this.currentImageInput.scaled && this.currentImageInput.useScaled) {
|
||||
// Send scaled version of image instead!
|
||||
inputFile = this.currentImageInput.scaled;
|
||||
}
|
||||
if (this.isCurrentFileInputsAnArray) {
|
||||
let inputFiles = this.currentFileInputs.map(entry => {
|
||||
if (entry.scaled && entry.useScaled) {
|
||||
// Send scaled version of image instead!
|
||||
return entry.scaled;
|
||||
}
|
||||
return entry;
|
||||
})
|
||||
const promises = inputFiles.map(inputFile => util.sendImage(this.$matrix.matrixClient, this.roomId, inputFile, this.onUploadProgress));
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
this.currentSendOperation = null;
|
||||
this.currentFileInputs = null;
|
||||
this.currentSendProgress = null;
|
||||
this.currentSendOperation = util.sendImage(
|
||||
this.$matrix.matrixClient,
|
||||
this.roomId,
|
||||
inputFile,
|
||||
this.onUploadProgress
|
||||
);
|
||||
this.currentSendOperation
|
||||
.then(() => {
|
||||
this.currentSendOperation = null;
|
||||
this.currentImageInput = null;
|
||||
this.currentImageInputPath = null;
|
||||
this.currentSendProgress = null;
|
||||
if (withText) {
|
||||
this.sendMessage(withText);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError" || err === "Abort") {
|
||||
this.currentSendError = null;
|
||||
} else {
|
||||
this.currentSendError = err.LocaleString();
|
||||
}
|
||||
this.currentSendOperation = null;
|
||||
this.currentSendProgress = null;
|
||||
});
|
||||
if (withText) {
|
||||
this.sendMessage(withText);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.name === "AbortError" || err === "Abort") {
|
||||
this.currentSendError = null;
|
||||
} else {
|
||||
this.currentSendError = err.LocaleString();
|
||||
}
|
||||
this.currentSendOperation = null;
|
||||
this.currentSendProgress = null;
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1058,12 +1142,19 @@ export default {
|
|||
this.currentSendOperation.abort();
|
||||
}
|
||||
this.currentSendOperation = null;
|
||||
this.currentImageInput = null;
|
||||
this.currentImageInputPath = null;
|
||||
this.currentFileInputs = null;
|
||||
this.currentSendProgress = null;
|
||||
this.currentSendError = null;
|
||||
},
|
||||
|
||||
addAttachment(file) {
|
||||
this.handleFileReader(null, file);
|
||||
},
|
||||
|
||||
resetAttachments() {
|
||||
this.cancelSendAttachment();
|
||||
},
|
||||
|
||||
handleScrolledToTop() {
|
||||
if (
|
||||
this.timelineWindow &&
|
||||
|
|
@ -1248,6 +1339,17 @@ export default {
|
|||
this.$refs.messageOperationsSheet.close();
|
||||
},
|
||||
|
||||
sendClapReactionAtTime(e) {
|
||||
util
|
||||
.sendQuickReaction(this.$matrix.matrixClient, this.roomId, "👏", e.event, { timeOffset: e.timeOffset.toFixed(0)})
|
||||
.then(() => {
|
||||
console.log("Send clap reaction at time", e.timeOffset);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to send clap reaction:", err);
|
||||
});
|
||||
},
|
||||
|
||||
sendQuickReaction(e) {
|
||||
let previousReaction = null;
|
||||
|
||||
|
|
@ -1359,7 +1461,7 @@ export default {
|
|||
|
||||
let eventIdFirst = null;
|
||||
let eventIdLast = null;
|
||||
if (!this.useVoiceMode) {
|
||||
if (!this.useVoiceMode && !this.useFileModeNonAdmin) {
|
||||
const container = this.chatContainer;
|
||||
const elFirst = util.getFirstVisibleElement(container, (item) => item.hasAttribute("eventId"));
|
||||
const elLast = util.getLastVisibleElement(container, (item) => item.hasAttribute("eventId"));
|
||||
|
|
@ -1439,7 +1541,7 @@ export default {
|
|||
|
||||
onVoiceRecording(event) {
|
||||
this.currentSendShowSendButton = false;
|
||||
this.currentImageInputPath = event.file;
|
||||
this.currentFileInputs = Array.isArray(this.currentFileInputs) ? [...this.currentFileInputs, event.file] : [event.file];
|
||||
var text = undefined;
|
||||
if (this.currentInput && this.currentInput.length > 0) {
|
||||
text = this.currentInput;
|
||||
|
|
@ -1508,8 +1610,27 @@ export default {
|
|||
} else {
|
||||
this.showNoRecordingAvailableDialog = true;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Called when an audio message has played to the end. We listen to this so we can optionally auto-play
|
||||
* the next audio event.
|
||||
* @param matrixEvent The event that stopped playing
|
||||
*/
|
||||
audioPlaybackEnded(matrixEventId) {
|
||||
if (!this.useVoiceMode) { // Voice mode has own autoplay handling inside "AudioLayout"!
|
||||
// Auto play consecutive audio messages, either incoming or sent.
|
||||
const filteredEvents = this.filteredEvents;
|
||||
const index = filteredEvents.findIndex(e => e.getId() === matrixEventId);
|
||||
if (index >= 0 && index < (filteredEvents.length - 1)) {
|
||||
const nextEvent = filteredEvents[index + 1];
|
||||
if (nextEvent.getContent().msgtype === "m.audio") {
|
||||
// Yes, audio event!
|
||||
this.$audioPlayer.play(nextEvent, this.timelineSet);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
</div>
|
||||
<v-icon class="icon-dropdown" size="11">$vuetify.icons.ic_dropdown</v-icon>
|
||||
<div :class="{ 'notification-alert': true, 'popup-open': showMissedItemsInfo }" v-if="notifications">
|
||||
<v-icon class="icon-circle" size="11">circle</v-icon>
|
||||
<!-- MISSED ITEMS POPUP -->
|
||||
<!-- <div class="missed-items-popup-background" v-if="showMissedItemsInfo" @click.stop="setHasShownMissedItemsHint()"></div> -->
|
||||
<div class="missed-items-popup" v-if="showMissedItemsInfo" @click.stop="setHasShownMissedItemsHint()">
|
||||
|
|
@ -144,7 +145,7 @@ export default {
|
|||
this.$matrix.invites.length > 0;
|
||||
},
|
||||
notificationsText() {
|
||||
const invitationCount = this.$matrix.invites.length
|
||||
const invitationCount = this.$matrix.invites.length
|
||||
if (invitationCount > 0) {
|
||||
return this.$tc('room.invitations', invitationCount);
|
||||
}
|
||||
|
|
@ -159,7 +160,7 @@ export default {
|
|||
const roomLink = this.publicRoomLink;
|
||||
if (roomLink) {
|
||||
items.push({
|
||||
icon: '$vuetify.icons.ic_link', text: this.$t('room_info.copy_link'), handler: () => {
|
||||
icon: '$vuetify.icons.ic_link', text: this.$t('room_info.copy_invite_link'), handler: () => {
|
||||
this.$copyText(this.publicRoomLink);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
143
src/components/CopyLink.vue
Normal file
143
src/components/CopyLink.vue
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-expand-transition>
|
||||
<v-card class="ma-3" v-show="locationLink" flat>
|
||||
<v-container>
|
||||
<slot/>
|
||||
<v-row cols="12" class="qr-container ma-3">
|
||||
<v-col cols="auto">
|
||||
<canvas
|
||||
@click.stop="showFullScreenQR = true"
|
||||
ref="roomQr"
|
||||
class="qr"
|
||||
id="room-qr"
|
||||
></canvas>
|
||||
</v-col>
|
||||
<v-col align-self="center" class="public-link">
|
||||
<div class="link">{{ locationLink }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row align="center" class="mt-0 pt-0">
|
||||
<v-col align="center" class="mt-0 pt-0">
|
||||
<v-btn
|
||||
id="btn-copy-room-link"
|
||||
:color="locationLinkCopied ? '#DEE6FF' : 'black'"
|
||||
depressed
|
||||
@click.stop="copyRoomLink"
|
||||
:class="{'filled-button' : true, 'link-copied-in-place' : locationLinkCopied}"
|
||||
>{{ $t(`room_info.${locationLinkCopied ? 'link_copied' : i18nCopyLinkKey}`) }}</v-btn
|
||||
>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-card>
|
||||
</v-expand-transition>
|
||||
<QRCodePopup :show="showFullScreenQR" :message="locationLink" @close="showFullScreenQR = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import QRCode from "qrcode";
|
||||
import QRCodePopup from './QRCodePopup.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
QRCodePopup
|
||||
},
|
||||
props: {
|
||||
locationLink: {
|
||||
type: String
|
||||
},
|
||||
i18nCopyLinkKey: {
|
||||
type: String,
|
||||
default: 'copy_link'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
locationLinkCopied: false,
|
||||
showFullScreenQR: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
copyRoomLink() {
|
||||
if(this.locationLinkCopied) return
|
||||
const self = this;
|
||||
this.$copyText(this.locationLink).then(
|
||||
function (ignored) {
|
||||
// Success!
|
||||
self.locationLinkCopied = true;
|
||||
setInterval(() => {
|
||||
// Hide again
|
||||
self.locationLinkCopied = false;
|
||||
}, 3000);
|
||||
},
|
||||
function (e) {
|
||||
console.log(e);
|
||||
}
|
||||
);
|
||||
},
|
||||
updateQRCode() {
|
||||
var fullUrl = this.locationLink;
|
||||
var canvas = this.$refs.roomQr;
|
||||
if (fullUrl && canvas) {
|
||||
QRCode.toCanvas(
|
||||
canvas,
|
||||
fullUrl,
|
||||
{
|
||||
type: "image/png",
|
||||
margin: 1,
|
||||
width: 60,
|
||||
},
|
||||
function (error) {
|
||||
if (error) console.error(error);
|
||||
else console.log("success!");
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
locationLink: {
|
||||
handler() {
|
||||
this.updateQRCode();
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateQRCode();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.qr-container {
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px !important;
|
||||
.qr {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
.public-link {
|
||||
background-color: #D6D5FC33;
|
||||
border-radius: 11px;
|
||||
.link {
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 16px;
|
||||
text-decoration: underline;
|
||||
color: #3d6eff;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.link-copied-in-place .v-btn__content {
|
||||
font-family: "Inter", sans-serif !important;
|
||||
font-size: 12px !important;
|
||||
text-transform: none !important;
|
||||
color: #3d6eff;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -34,16 +34,20 @@
|
|||
</v-row>
|
||||
<v-row cols="12" align="center" justify="center">
|
||||
<v-col sm="8" align="center">
|
||||
<div class="text-left font-weight-light">{{ $t("new_room.name_room") }}</div>
|
||||
<div class="text-start font-weight-light">{{ $t("new_room.name_room") }}</div>
|
||||
<v-text-field v-model="roomName" color="black" :rules="roomNamerules" counter="50" maxlength="50"
|
||||
background-color="white" v-on:keyup.enter="$refs.topic.focus()" :disabled="step > steps.INITIAL" autofocus
|
||||
solo @update:error="updateErrorState"></v-text-field>
|
||||
<div class="text-left font-weight-light" v-show="roomName.length > 0">{{ $t("new_room.room_topic") }}</div>
|
||||
<v-text-field v-model="roomTopic" v-show="roomName.length > 0" ref="topic" color="black" background-color="white"
|
||||
v-on:keyup.enter="$refs.create.$el.focus()" :disabled="step > steps.INITIAL" solo></v-text-field>
|
||||
<div class="text-start font-weight-light" v-show="roomName.length > 0">{{ $t("new_room.room_topic") }}</div>
|
||||
<v-textarea v-model="roomTopic" v-show="roomName.length > 0" ref="topic" color="black" background-color="white"
|
||||
v-on:keydown.enter.prevent="
|
||||
() => {
|
||||
$refs.create.$el.focus()
|
||||
}
|
||||
" :disabled="step > steps.INITIAL" solo full-width auto-grow rows="1" no-resize hide-details></v-textarea>
|
||||
|
||||
<!-- Our only option right now is voice mode, so if not enabled, hide the 'options' drop down as well -->
|
||||
<template v-if="$config.experimental_voice_mode || $config.experimental_read_only_room || $config.experimental_public_room">
|
||||
<!-- Check if we have any options enabled in config -->
|
||||
<template v-if="$config.experimental_voice_mode || $config.experimental_read_only_room || $config.experimental_public_room || $config.experimental_file_mode">
|
||||
<div @click.stop="showOptions = !showOptions" v-show="roomName.length > 0" class="options clickable">
|
||||
<div>{{ $t("new_room.options") }}</div>
|
||||
<v-icon v-if="!showOptions">expand_more</v-icon>
|
||||
|
|
@ -59,13 +63,13 @@
|
|||
</v-card-text>
|
||||
<div class="option-warning" v-if="unencryptedRoom"><v-icon size="18">$vuetify.icons.ic_warning</v-icon>{{ $t("room_info.make_public_warning")}}</div>
|
||||
</v-card>
|
||||
<v-card v-if="$config.experimental_voice_mode" v-show="showOptions" class="room-option account ma-0" flat>
|
||||
|
||||
<v-card v-if="availableRoomTypes.length > 1" v-show="showOptions" class="room-option account ma-0" flat>
|
||||
<v-card-text class="with-right-label">
|
||||
<div>
|
||||
<div class="option-title">{{ $t('room_info.voice_mode') }}</div>
|
||||
<div class="option-text">{{ $t('room_info.voice_mode_info') }}</div>
|
||||
<div class="option-title">{{ $t('room_info.room_type') }}</div>
|
||||
</div>
|
||||
<v-switch v-model="useVoiceMode"></v-switch>
|
||||
<RoomTypeSelector v-model="roomType" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<v-card v-if="$config.experimental_read_only_room" v-show="showOptions" class="room-option account ma-0" flat>
|
||||
|
|
@ -102,7 +106,7 @@
|
|||
<v-container v-if="canEditProfile" class="pa-10">
|
||||
<v-row class="align-center">
|
||||
<v-col class="py-0">
|
||||
<div class="text-left font-weight-bold">{{ $t("join.choose_name") }}</div>
|
||||
<div class="text-start font-weight-bold">{{ $t("join.choose_name") }}</div>
|
||||
<v-select ref="avatar" :items="availableAvatars" cache-items outlined dense @change="selectAvatar"
|
||||
:value="availableAvatars[0]" single-line autofocus>
|
||||
<template v-slot:selection>
|
||||
|
|
@ -140,9 +144,11 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import util, { ROOM_TYPE_VOICE_MODE } from "../plugins/utils";
|
||||
import util, { ROOM_TYPE_DEFAULT } from "../plugins/utils";
|
||||
import InteractiveAuth from './InteractiveAuth.vue';
|
||||
import rememberMeMixin from "./rememberMeMixin";
|
||||
import roomTypeMixin from "./roomTypeMixin";
|
||||
import RoomTypeSelector from './RoomTypeSelector.vue';
|
||||
|
||||
const steps = Object.freeze({
|
||||
INITIAL: 0,
|
||||
|
|
@ -153,8 +159,8 @@ const steps = Object.freeze({
|
|||
|
||||
export default {
|
||||
name: "CreateRoom",
|
||||
components: { InteractiveAuth },
|
||||
mixins: [rememberMeMixin],
|
||||
components: { InteractiveAuth, RoomTypeSelector },
|
||||
mixins: [rememberMeMixin, roomTypeMixin],
|
||||
data() {
|
||||
return {
|
||||
steps,
|
||||
|
|
@ -197,8 +203,8 @@ export default {
|
|||
roomCreationErrorMsg: "",
|
||||
showOptions: false,
|
||||
unencryptedRoom: false,
|
||||
useVoiceMode: false,
|
||||
readOnlyRoom: false,
|
||||
roomType: ROOM_TYPE_DEFAULT,
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -295,7 +301,7 @@ export default {
|
|||
}
|
||||
if (room) {
|
||||
this.publicRoomLink = this.$router.getRoomLink(
|
||||
room.getCanonicalAlias() || roomId
|
||||
room.getCanonicalAlias(), roomId, room.name
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -389,9 +395,9 @@ export default {
|
|||
// Add topic
|
||||
createRoomOptions.topic = this.roomTopic;
|
||||
}
|
||||
if (this.useVoiceMode) {
|
||||
if (this.roomType != ROOM_TYPE_DEFAULT) {
|
||||
createRoomOptions.creation_content = {
|
||||
type: ROOM_TYPE_VOICE_MODE
|
||||
type: this.roomType
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -424,10 +430,7 @@ export default {
|
|||
console.log(
|
||||
"CreateRoom: Set display name to: " + this.selectedProfile.name
|
||||
);
|
||||
return this.$matrix.matrixClient.setDisplayName(
|
||||
this.selectedProfile.name,
|
||||
undefined
|
||||
);
|
||||
return this.$matrix.setUserDisplayName(this.selectedProfile.name);
|
||||
}
|
||||
}.bind(this)
|
||||
)
|
||||
|
|
@ -492,7 +495,7 @@ export default {
|
|||
this.$matrix.matrixClient,
|
||||
room_id,
|
||||
this.roomAvatarFile,
|
||||
function (p) {
|
||||
(p) => {
|
||||
if (p.total) {
|
||||
self.status = this.$t("new_room.status_avatar_total", {
|
||||
count: p.loaded || 0,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
<v-container fluid class="mt-40" v-if="step == steps.ENTER_EMAIL">
|
||||
<v-row cols="12" align="center" justify="center">
|
||||
<v-col sm="8" align="center">
|
||||
<div class="text-left font-weight-light">{{ $t("login.email") }}</div>
|
||||
<div class="text-start font-weight-light">{{ $t("login.email") }}</div>
|
||||
<v-text-field v-model="email" color="black" :rules="emailRules" type="email" maxlength="200"
|
||||
background-color="white" v-on:keyup.enter="onEmailEntered(email)" autofocus solo></v-text-field>
|
||||
<v-btn :disabled="!emailIsValid" color="black" depressed class="filled-button"
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
<v-container fluid class="mt-40" v-if="step == steps.TERMS">
|
||||
<v-row cols="12" align="center" justify="center">
|
||||
<v-col sm="8" align="center">
|
||||
<div class="text-left font-weight-light">{{ $t("login.terms") }}</div>
|
||||
<div class="text-start font-weight-light">{{ $t("login.terms") }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row cols="12" align="center" justify="center">
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
<v-row cols="12" align="center" justify="center">
|
||||
<v-col sm="8" align="center">
|
||||
<div>
|
||||
<div class="text-left font-weight-light">{{ $t("login.sent_verification", { email: this.email }) }}</div>
|
||||
<div class="text-start font-weight-light">{{ $t("login.sent_verification", { email: this.email }) }}</div>
|
||||
<v-progress-circular style="display: inline-flex" indeterminate color="primary"
|
||||
size="20"></v-progress-circular>
|
||||
</div>
|
||||
|
|
@ -61,6 +61,21 @@
|
|||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<v-container fluid class="mt-40" v-if="step == steps.ENTER_TOKEN">
|
||||
<v-row cols="12" align="center" justify="center">
|
||||
<v-col sm="8" align="center">
|
||||
<div class="text-start font-weight-light">{{ $t("login.registration_token") }}</div>
|
||||
<v-text-field v-model="token" color="black" :rules="tokenRules" type="text" maxlength="64"
|
||||
background-color="white" v-on:keyup.enter="onTokenEntered(token)" autofocus solo></v-text-field>
|
||||
<v-btn :disabled="!tokenIsValid" color="black" depressed class="filled-button"
|
||||
@click.stop="onTokenEntered(token)">
|
||||
{{ $t("login.send_token") }}
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
</v-fade-transition>
|
||||
</template>
|
||||
|
||||
|
|
@ -74,6 +89,7 @@ const steps = Object.freeze({
|
|||
EXTERNAL_AUTH: 3,
|
||||
ENTER_EMAIL: 4,
|
||||
AWAITING_EMAIL_VERIFICATION: 5,
|
||||
ENTER_TOKEN: 6,
|
||||
});
|
||||
|
||||
export default {
|
||||
|
|
@ -85,6 +101,9 @@ export default {
|
|||
emailRules: [
|
||||
v => this.validEmail(v) || this.$t("login.email_not_valid")
|
||||
],
|
||||
tokenRules: [
|
||||
v => this.validToken(v) || this.$t("login.token_not_valid")
|
||||
],
|
||||
policies: null,
|
||||
onPoliciesAccepted: () => { },
|
||||
onEmailResend: () => { },
|
||||
|
|
@ -95,6 +114,7 @@ export default {
|
|||
emailVerificationAttempt: 1,
|
||||
emailVerificationSid: null,
|
||||
emailVerificationPollTimer: null,
|
||||
token: "",
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -105,6 +125,9 @@ export default {
|
|||
emailIsValid() {
|
||||
return this.validEmail(this.email);
|
||||
},
|
||||
tokenIsValid() {
|
||||
return this.validToken(this.token);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
@ -116,6 +139,15 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
validToken(token) {
|
||||
// https://github.com/matrix-org/matrix-spec-proposals/blob/main/proposals/3231-token-authenticated-registration.md
|
||||
if (/^[A-Za-z0-9._~-]{1,64}$/.test(token)) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
registrationFlowHandler(client, authData) {
|
||||
const flows = authData.flows;
|
||||
if (!flows || flows.length == 0) {
|
||||
|
|
@ -257,6 +289,34 @@ export default {
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
case "m.login.registration_token": {
|
||||
this.step = steps.CREATING;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.$config.registrationToken) {
|
||||
// We have a token in config, use that!
|
||||
//
|
||||
const data = {
|
||||
session: authData.session,
|
||||
type: nextStage,
|
||||
token: this.$config.registrationToken
|
||||
};
|
||||
submitStageData(resolve, reject, data);
|
||||
} else {
|
||||
this.step = steps.ENTER_TOKEN;
|
||||
this.onTokenEntered = (token) => {
|
||||
this.step = steps.CREATING;
|
||||
const data = {
|
||||
session: authData.session,
|
||||
type: nextStage,
|
||||
token: token
|
||||
};
|
||||
submitStageData(resolve, reject, data);
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
this.step = steps.EXTERNAL_AUTH;
|
||||
return new Promise((resolve, reject) => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
{{ roomId && roomId.startsWith("@") ? $t("join.title_user") : $t("join.title") }}
|
||||
</div>
|
||||
<div class="join-title">
|
||||
{{ roomName }}
|
||||
{{ roomDisplayName || roomName }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -82,14 +82,15 @@
|
|||
|
||||
<interactive-auth ref="interactiveAuth" />
|
||||
|
||||
<v-btn id="btn-join" class="btn-dark" large @click.stop="handleJoin" :loading="loading" v-if="!currentUser">{{
|
||||
<v-btn id="btn-join" class="btn-dark" :disabled="room && room.selfMembership == 'ban'" large @click.stop="handleJoin" :loading="loading" v-if="!currentUser">{{
|
||||
roomId && roomId.startsWith("@") ? $t("join.enter_room_user") : $t("join.enter_room")
|
||||
}}</v-btn>
|
||||
<v-btn id="btn-join" class="btn-dark" large block @click.stop="handleJoin" :loading="loading" v-else>{{
|
||||
<v-btn id="btn-join" class="btn-dark" :disabled="room && room.selfMembership == 'ban'" large block @click.stop="handleJoin" :loading="loading" v-else>{{
|
||||
roomId && roomId.startsWith("@") ? $t("join.join_user") : $t("join.join")
|
||||
}}</v-btn>
|
||||
|
||||
<div v-if="loadingMessage" class="text-center">{{ loadingMessage }}</div>
|
||||
<div v-if="room && room.selfMembership == 'ban'" class="text-center">{{ $t("join.you_have_been_banned") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -215,6 +216,14 @@ export default {
|
|||
let activeLanguages = [...this.getLanguages()];
|
||||
return activeLanguages.filter((lang) => lang.value === this.$i18n.locale);
|
||||
},
|
||||
roomDisplayName() {
|
||||
// If there is a display name in to invite link, use that!
|
||||
try {
|
||||
return new URL(location.href).searchParams.get('roomName');
|
||||
} catch(ignoredError) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
roomId: {
|
||||
|
|
@ -363,7 +372,7 @@ export default {
|
|||
return Promise.resolve(user);
|
||||
} else {
|
||||
console.log("Join: Set display name to: " + this.selectedProfile.name);
|
||||
return this.$matrix.matrixClient.setDisplayName(this.selectedProfile.name, undefined);
|
||||
return this.$matrix.setUserDisplayName(this.selectedProfile.name);
|
||||
}
|
||||
}.bind(this)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
<v-col>
|
||||
<div class="room-name no-upper">{{ $t("login.title") }}</div>
|
||||
</v-col>
|
||||
<v-col class="text-right">
|
||||
<v-col class="text-end">
|
||||
<v-btn id="btn-close" text v-if="showCloseButton" @click.stop="$navigation.pop">
|
||||
<v-icon>close</v-icon>
|
||||
</v-btn>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@
|
|||
</v-col>
|
||||
<v-col class="flex-shrink-1 flex-grow-1">
|
||||
<div class="h1">{{ displayName }}</div>
|
||||
<div class="text-center">{{ $matrix.currentUser.user_id }}</div>
|
||||
<div class="text-center">{{ currentUserId }}</div>
|
||||
<!-- <div v-if="$matrix.currentUser.is_guest">
|
||||
{{ $t("profile.temporary_identity") }}
|
||||
</div> -->
|
||||
|
|
@ -65,11 +65,16 @@
|
|||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<copy-link :locationLink="directMessageLink" >
|
||||
<v-card-title class="h2">{{ $t("room_info.contact_link") }}</v-card-title>
|
||||
<v-card-text>{{ $t("room_info.contact_link_desc") }}</v-card-text>
|
||||
</copy-link>
|
||||
|
||||
<v-container class="mt-2 pa-5">
|
||||
<ActionRow
|
||||
@click="showEditPasswordDialog = true"
|
||||
:icon="'$vuetify.icons.password'"
|
||||
:text="$t('profile.set_password')"
|
||||
:text="$matrix.currentUser.is_guest ? $t('profile.set_password') : $t('profile.change_password')"
|
||||
/>
|
||||
<ActionRow
|
||||
@click="
|
||||
|
|
@ -93,7 +98,7 @@
|
|||
:width="$vuetify.breakpoint.smAndUp ? '940px' : '80%'"
|
||||
>
|
||||
<v-card :disabled="settingPassword">
|
||||
<v-card-title>{{ $t("profile.change_password") }}</v-card-title>
|
||||
<v-card-title>{{ $matrix.currentUser.is_guest ? $t("profile.set_password") : $t("profile.change_password") }}</v-card-title>
|
||||
<v-card-text>
|
||||
<v-text-field
|
||||
v-if="!$matrix.currentUser.is_guest"
|
||||
|
|
@ -201,6 +206,7 @@ import ActionRow from "./ActionRow.vue";
|
|||
import util from "../plugins/utils";
|
||||
import profileInfoMixin from "./profileInfoMixin";
|
||||
import LogoutRoomDialog from './LogoutRoomDialog.vue';
|
||||
import CopyLink from "./CopyLink.vue"
|
||||
|
||||
export default {
|
||||
name: "Profile",
|
||||
|
|
@ -209,6 +215,7 @@ export default {
|
|||
ActionRow,
|
||||
SelectLanguageDialog,
|
||||
LogoutRoomDialog,
|
||||
CopyLink
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -232,42 +239,12 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
user() {
|
||||
if (!this.$matrix.matrixClient) {
|
||||
return null;
|
||||
}
|
||||
return this.$matrix.matrixClient.getUser(this.$matrix.currentUserId);
|
||||
currentUserId() {
|
||||
return this.$matrix.currentUser.user_id
|
||||
},
|
||||
|
||||
displayName() {
|
||||
if (!this.user) {
|
||||
return null;
|
||||
}
|
||||
return this.user.displayName || this.user.userId;
|
||||
directMessageLink() {
|
||||
return `${window.location.origin + window.location.pathname}#/user/${this.currentUserId}`
|
||||
},
|
||||
|
||||
userAvatar() {
|
||||
if (!this.user || !this.user.avatarUrl) {
|
||||
return null;
|
||||
}
|
||||
return this.$matrix.matrixClient.mxcUrlToHttp(
|
||||
this.user.avatarUrl,
|
||||
80,
|
||||
80,
|
||||
"scale",
|
||||
true
|
||||
);
|
||||
},
|
||||
|
||||
userAvatarLetter() {
|
||||
if (!this.user) {
|
||||
return null;
|
||||
}
|
||||
return (this.user.displayName || this.user.userId.substring(1))
|
||||
.substring(0, 1)
|
||||
.toUpperCase();
|
||||
},
|
||||
|
||||
passwordsMatch() {
|
||||
return (
|
||||
!this.newPasswordHasError &&
|
||||
|
|
@ -275,7 +252,7 @@ export default {
|
|||
this.newPassword2 &&
|
||||
this.newPassword1 == this.newPassword2
|
||||
);
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -78,45 +78,7 @@
|
|||
{{ $t("room_info.created_by", { user: creator }) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-expand-transition>
|
||||
<v-container fluid class="pa-0" v-show="publicRoomLink">
|
||||
<v-row cols="12" class="qr-container ma-3">
|
||||
<v-col cols="auto">
|
||||
<canvas
|
||||
@click.stop="showFullScreenQR = true"
|
||||
ref="roomQr"
|
||||
class="qr"
|
||||
id="room-qr"
|
||||
></canvas>
|
||||
</v-col>
|
||||
<v-col align-self="center">
|
||||
<div class="link">{{ publicRoomLink }}</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row align="center" class="mt-0 pt-0">
|
||||
<v-col align="center" class="mt-0 pt-0">
|
||||
<v-btn
|
||||
v-if="publicRoomLinkCopied"
|
||||
id="btn-copy-room-link"
|
||||
color="#DEE6FF"
|
||||
depressed
|
||||
class="filled-button link-copied-in-place"
|
||||
>{{ $t("room_info.link_copied") }}</v-btn
|
||||
>
|
||||
<v-btn
|
||||
v-else
|
||||
id="btn-copy-room-link"
|
||||
color="black"
|
||||
depressed
|
||||
class="filled-button"
|
||||
@click.stop="copyRoomLink"
|
||||
>{{ $t("room_info.copy_link") }}</v-btn
|
||||
>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-expand-transition>
|
||||
<copy-link :locationLink="publicRoomLink" i18nCopyLinkKey="copy_invite_link" />
|
||||
|
||||
<v-card class="account ma-3" flat>
|
||||
<v-card-title class="h2">{{ $t("room_info.permissions") }}</v-card-title>
|
||||
|
|
@ -165,16 +127,13 @@
|
|||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card class="account ma-3" flat v-if="$config.experimental_voice_mode || canChangeReadOnly()">
|
||||
<v-card class="account ma-3" flat v-if="availableRoomTypes.length > 1 || canChangeReadOnly()">
|
||||
<v-card-title class="h2 with-right-label"><div>{{ $t("room_info.experimental_features") }}</div><div></div></v-card-title>
|
||||
<v-card-text class="with-right-label" v-if="$config.experimental_voice_mode">
|
||||
<v-card-text class="with-right-label" v-if="availableRoomTypes.length > 1">
|
||||
<div>
|
||||
<div class="option-title">{{ $t('room_info.voice_mode') }}</div>
|
||||
<div class="option-text">{{ $t('room_info.voice_mode_info') }}</div>
|
||||
<div class="option-title">{{ $t('room_info.room_type') }}</div>
|
||||
</div>
|
||||
<v-switch
|
||||
v-model="useVoiceMode"
|
||||
></v-switch>
|
||||
<RoomTypeSelector v-model="roomType" />
|
||||
</v-card-text>
|
||||
<v-card-text class="with-right-label" v-if="canChangeReadOnly()">
|
||||
<div>
|
||||
|
|
@ -287,8 +246,6 @@
|
|||
@close="showPurgeConfirmation = false"
|
||||
/>
|
||||
|
||||
<QRCodePopup :show="showFullScreenQR" :message="publicRoomLink" @close="showFullScreenQR = false" />
|
||||
|
||||
<RoomExport :room="room" v-if="exporting" v-on:close="exporting = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -299,35 +256,34 @@ import PurgeRoomDialog from "../components/PurgeRoomDialog";
|
|||
import DeviceList from "../components/DeviceList";
|
||||
import RoomExport from "../components/RoomExport";
|
||||
import RoomAvatarPicker from "../components/RoomAvatarPicker";
|
||||
import QRCode from "qrcode";
|
||||
import CopyLink from "../components/CopyLink.vue"
|
||||
import RoomTypeSelector from "./RoomTypeSelector.vue";
|
||||
import roomInfoMixin from "./roomInfoMixin";
|
||||
import QRCodePopup from './QRCodePopup.vue';
|
||||
import util from "../plugins/utils";
|
||||
import roomTypeMixin from "./roomTypeMixin";
|
||||
import util, { ROOM_TYPE_DEFAULT, ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE } from "../plugins/utils";
|
||||
|
||||
export default {
|
||||
name: "RoomInfo",
|
||||
mixins: [roomInfoMixin],
|
||||
mixins: [roomInfoMixin, roomTypeMixin],
|
||||
components: {
|
||||
LeaveRoomDialog,
|
||||
PurgeRoomDialog,
|
||||
DeviceList,
|
||||
RoomExport,
|
||||
QRCodePopup,
|
||||
RoomAvatarPicker
|
||||
RoomAvatarPicker,
|
||||
RoomTypeSelector,
|
||||
CopyLink
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
members: [],
|
||||
user: null,
|
||||
displayName: "",
|
||||
showAllMembers: false,
|
||||
showLeaveConfirmation: false,
|
||||
showPurgeConfirmation: false,
|
||||
showFullScreenQR: false,
|
||||
expandedMembers: [],
|
||||
buildVersion: "",
|
||||
updatingJoinRule: false, // Flag if we are processing update curerntly
|
||||
publicRoomLinkCopied: false,
|
||||
joinRules: [
|
||||
{
|
||||
id: "public",
|
||||
|
|
@ -348,10 +304,6 @@ export default {
|
|||
this.$matrix.on("Room.timeline", this.onEvent);
|
||||
this.updateMembers();
|
||||
this.user = this.$matrix.matrixClient.getUser(this.$matrix.currentUserId);
|
||||
this.displayName = this.user.displayName;
|
||||
|
||||
// Set QR code
|
||||
this.updateQRCode();
|
||||
|
||||
// Display build version
|
||||
const version = require("!!raw-loader!../assets/version.txt").default;
|
||||
|
|
@ -386,15 +338,26 @@ export default {
|
|||
return "";
|
||||
},
|
||||
|
||||
useVoiceMode: {
|
||||
roomType: {
|
||||
get: function () {
|
||||
return util.useVoiceMode(this.room);
|
||||
},
|
||||
set: function (audioLayout) {
|
||||
if (this.room && this.room.tags) {
|
||||
let options = this.room.tags["ui_options"] || {}
|
||||
options["voice_mode"] = (audioLayout ? 1 : 0);
|
||||
this.room.tags["ui_options"] = options;
|
||||
if (options["voice_mode"]) {
|
||||
return ROOM_TYPE_VOICE_MODE;
|
||||
} else if (options["file_mode"]) {
|
||||
return ROOM_TYPE_FILE_MODE;
|
||||
}
|
||||
}
|
||||
return ROOM_TYPE_DEFAULT;
|
||||
},
|
||||
set: function (roomType) {
|
||||
if (this.room) {
|
||||
let tags = this.room.tags || {};
|
||||
let options = tags["ui_options"] || {}
|
||||
options["voice_mode"] = (roomType == ROOM_TYPE_VOICE_MODE ? 1 : 0);
|
||||
options["file_mode"] = (roomType == ROOM_TYPE_FILE_MODE ? 1 : 0);
|
||||
tags["ui_options"] = options;
|
||||
this.room.tags = tags;
|
||||
this.$matrix.matrixClient.setRoomTag(this.room.roomId, "ui_options", options);
|
||||
}
|
||||
},
|
||||
|
|
@ -411,13 +374,9 @@ export default {
|
|||
|
||||
watch: {
|
||||
room: {
|
||||
handler(ignoredNewVal, ignoredOldVal) {
|
||||
handler() {
|
||||
console.log("RoomInfo: Current room changed");
|
||||
this.updateMembers();
|
||||
this.$nextTick(() => {
|
||||
// Wait a tick, we want to be sure that the QR canvas ref is already created!
|
||||
this.updateQRCode();
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -460,26 +419,6 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
updateQRCode() {
|
||||
var fullUrl = this.publicRoomLink;
|
||||
var canvas = this.$refs.roomQr;
|
||||
if (fullUrl && canvas) {
|
||||
QRCode.toCanvas(
|
||||
canvas,
|
||||
fullUrl,
|
||||
{
|
||||
type: "image/png",
|
||||
margin: 1,
|
||||
width: 60,
|
||||
},
|
||||
function (error) {
|
||||
if (error) console.error(error);
|
||||
else console.log("success!");
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
memberAvatar(member) {
|
||||
if (member) {
|
||||
return member.getAvatarUrl(
|
||||
|
|
@ -497,23 +436,6 @@ export default {
|
|||
this.$navigation.push({ name: "Profile" }, 1);
|
||||
},
|
||||
|
||||
copyRoomLink() {
|
||||
const self = this;
|
||||
this.$copyText(this.publicRoomLink).then(
|
||||
function (ignored) {
|
||||
// Success!
|
||||
self.publicRoomLinkCopied = true;
|
||||
setInterval(() => {
|
||||
// Hide again
|
||||
self.publicRoomLinkCopied = false;
|
||||
}, 3000);
|
||||
},
|
||||
function (e) {
|
||||
console.log(e);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
toggleMemberExpanded(member) {
|
||||
const index = this.expandedMembers.indexOf(member);
|
||||
if (index > -1) {
|
||||
|
|
@ -565,7 +487,6 @@ export default {
|
|||
})
|
||||
.finally(() => {
|
||||
this.updatingJoinRule = false;
|
||||
this.updateQRCode();
|
||||
});
|
||||
},
|
||||
startPrivateChat(userId) {
|
||||
|
|
@ -706,7 +627,7 @@ export default {
|
|||
-1
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
41
src/components/RoomTypeSelector.vue
Normal file
41
src/components/RoomTypeSelector.vue
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<template>
|
||||
<v-select outlined dense :items="availableRoomTypes"
|
||||
:value="modelValue"
|
||||
@change="$emit('update:modelValue', $event)"
|
||||
:reduce="(obj) => obj.value">
|
||||
<template v-slot:selection="{ item }">{{ item.title }}</template>
|
||||
<template v-slot:item="{ item, attrs, on }">
|
||||
<v-list-item v-on="on" v-bind="attrs" #default="{}">
|
||||
<v-list-item-content>
|
||||
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ item.description }}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import roomTypeMixin from "./roomTypeMixin";
|
||||
|
||||
export default {
|
||||
name: "RoomTypeSelector",
|
||||
mixins: [roomTypeMixin],
|
||||
model: {
|
||||
prop: "modelValue",
|
||||
event: "update:modelValue",
|
||||
},
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: function () {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "@/assets/css/chat.scss";
|
||||
</style>
|
||||
|
|
@ -453,7 +453,7 @@ export default {
|
|||
this.$emit("file", { file: this.recordedFile });
|
||||
},
|
||||
getFile(send) {
|
||||
//const duration = Date.now() - this.recordStartedAt;
|
||||
const duration = Date.now() - this.recordStartedAt;
|
||||
this.recorder
|
||||
.stop()
|
||||
.getMp3()
|
||||
|
|
@ -468,6 +468,7 @@ export default {
|
|||
lastModified: Date.now(),
|
||||
}
|
||||
);
|
||||
this.recordedFile.duration = duration;
|
||||
if (send) {
|
||||
this.send();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import MessageIncomingAudio from "./messages/MessageIncomingAudio.vue";
|
|||
import MessageIncomingVideo from "./messages/MessageIncomingVideo.vue";
|
||||
import MessageIncomingSticker from "./messages/MessageIncomingSticker.vue";
|
||||
import MessageIncomingPoll from "./messages/MessageIncomingPoll.vue";
|
||||
import MessageIncomingThread from "./messages/MessageIncomingThread.vue";
|
||||
import MessageOutgoingText from "./messages/MessageOutgoingText";
|
||||
import MessageOutgoingFile from "./messages/MessageOutgoingFile";
|
||||
import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue";
|
||||
|
|
@ -58,6 +59,7 @@ export default {
|
|||
MessageIncomingAudio,
|
||||
MessageIncomingVideo,
|
||||
MessageIncomingSticker,
|
||||
MessageIncomingThread,
|
||||
MessageOutgoingText,
|
||||
MessageOutgoingFile,
|
||||
MessageOutgoingImage,
|
||||
|
|
@ -152,7 +154,11 @@ export default {
|
|||
|
||||
case "m.room.message":
|
||||
if (event.getSender() != this.$matrix.currentUserId) {
|
||||
if (event.getContent().msgtype == "m.image") {
|
||||
if (event.isThreadRoot) {
|
||||
// Incoming thread, e.g. a file drop!
|
||||
return MessageIncomingThread;
|
||||
}
|
||||
if (event.getContent().msgtype == "m.image") {
|
||||
// For SVG, make downloadable
|
||||
if (
|
||||
event.getContent().info &&
|
||||
|
|
|
|||
319
src/components/file_mode/FileDropLayout.vue
Normal file
319
src/components/file_mode/FileDropLayout.vue
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
<template>
|
||||
<div v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
|
||||
<!-- No attachments view -->
|
||||
<template v-if="!attachments || attachments.length == 0">
|
||||
<div>
|
||||
<v-icon>$vuetify.icons.ic_lock</v-icon>
|
||||
<div class="file-drop-title">{{ $t("file_mode.secure_file_send") }}</div>
|
||||
</div>
|
||||
<div :class="{ 'background': true, 'drop-target': dropTarget }" @drop.prevent="filesDropped"
|
||||
@dragover.prevent="dropTarget = true" @dragleave.prevent="dropTarget = false"
|
||||
@dragenter.prevent="dropTarget = true">
|
||||
<v-btn @click="$emit('pick-file')" class="large">{{ $t("file_mode.choose_files") }}</v-btn>
|
||||
<div class="file-format-info">{{ $t("file_mode.any_file_format_accepted") }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ATTACHMENT SELECTION MODE -->
|
||||
<template v-if="attachments && attachments.length > 0 && status == mainStatuses.SELECTING">
|
||||
<div class="attachment-wrapper" ref="attachmentWrapper">
|
||||
<div :class="{ 'file-drop-current-item': true, 'drop-target': dropTarget }" @drop.prevent="filesDropped"
|
||||
@dragover.prevent="dropTarget = true" @dragleave.prevent="dropTarget = false"
|
||||
@dragenter.prevent="dropTarget = true">
|
||||
<v-img v-if="currentItemHasImagePreview" :src="attachments[currentItemIndex].image" />
|
||||
<div v-else class="filename">{{ attachments[currentItemIndex].name }}</div>
|
||||
</div>
|
||||
<div class="file-drop-thumbnail-container">
|
||||
<div :class="{ 'file-drop-thumbnail': true, 'clickable': true, 'current': id == currentItemIndex }"
|
||||
@click="currentItemIndex = id" v-for="(currentImageInput, id) in attachments" :key="id">
|
||||
<v-img v-if="currentImageInput && currentImageInput.image" :src="currentImageInput.image" />
|
||||
<div v-if="currentItemIndex == id" class="remove clickable" @click.stop="$emit('remove-file', id)">
|
||||
<v-icon>$vuetify.icons.ic_trash</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-drop-thumbnail noborder">
|
||||
<div class="add clickable" @click.stop="$emit('pick-file')">
|
||||
+
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-drop-input-container">
|
||||
<v-textarea ref="input" full-width solo flat auto-grow v-model="messageInput" no-resize class="input-area-text"
|
||||
rows="1" :placeholder="$t('file_mode.add_a_message')" hide-details background-color="transparent"
|
||||
v-on:keydown.enter.prevent="() => {
|
||||
sendCurrentTextMessage();
|
||||
}
|
||||
" />
|
||||
<v-btn @click="send" :disabled="!attachments || attachments.length == 0">{{ $t("menu.send") }}</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ATTACHMENT SENDING/SENT MODE -->
|
||||
<template
|
||||
v-if="attachments && attachments.length > 0 && (status == mainStatuses.SENDING || status == mainStatuses.SENT)">
|
||||
<div class="attachment-wrapper">
|
||||
<div class="file-drop-sent-stack" ref="stackContainer">
|
||||
<div v-if="status == mainStatuses.SENDING && countSent == 0" class="no-items">
|
||||
<div class="file-drop-stack-item direct" :style="stackItemTransform(null, -1)"></div>
|
||||
<div>{{ $t('file_mode.sending_progress') }}</div>
|
||||
</div>
|
||||
<div v-else v-for="(item, index) in sentItems" :key="item.id" class="file-drop-stack-item animated"
|
||||
:style="stackItemTransform(item, index)">
|
||||
<v-img v-if="item.attachment && item.attachment.image" :src="item.attachment.image" />
|
||||
</div>
|
||||
<div v-if="status == mainStatuses.SENT" class="items-sent" :style="stackItemTransform(null, -1)">
|
||||
<v-icon>$vuetify.icons.ic_check_circle</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Middle section -->
|
||||
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-container">
|
||||
<div class="file-drop-sending-item" v-for="(info, index) in sendingItems" :key="index">
|
||||
<v-img v-if="info.attachment && info.attachment.image" :src="info.attachment.image" />
|
||||
<div v-else class="filename">{{ info.attachment.name }}</div>
|
||||
<v-progress-linear :value="info.progress"></v-progress-linear>
|
||||
<div class="file-drop-cancel clickable" @click.stop="cancelSendingItem(info)">
|
||||
<v-icon size="14" color="white">close</v-icon>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sending-container">
|
||||
<div class="file-drop-files-sent">{{ $tc((this.messageInput && this.messageInput.length > 0) ?
|
||||
"file_mode.files_sent_with_note" : "file_mode.files_sent", sentItems.length) }}</div>
|
||||
<div class="file-drop-section">
|
||||
<v-textarea disabled full-width solo flat auto-grow v-model="messageInput" no-resize class="input-area-text"
|
||||
rows="1" hide-details background-color="transparent" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom section -->
|
||||
<div v-if="status == mainStatuses.SENDING" class="file-drop-sending-input-container">
|
||||
<v-textarea disabled full-width solo flat auto-grow v-model="messageInput" no-resize class="input-area-text"
|
||||
rows="1" :placeholder="$t('file_mode.add_a_message')" hide-details background-color="transparent" />
|
||||
<v-btn>{{ $t("file_mode.sending") }}<v-progress-circular indeterminate size="18" width="2"
|
||||
color="#4642F1"></v-progress-circular></v-btn>
|
||||
</div>
|
||||
<div v-else-if="status == mainStatuses.SENT" class="file-drop-sent-input-container">
|
||||
<v-btn @click.stop="reset">{{ $t("file_mode.return_to_home") }}</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import messageMixin from "../messages/messageMixin";
|
||||
import util from "../../plugins/utils";
|
||||
const prettyBytes = require("pretty-bytes");
|
||||
|
||||
export default {
|
||||
mixins: [messageMixin],
|
||||
components: {},
|
||||
props: {
|
||||
attachments: {
|
||||
type: Array,
|
||||
default: function () {
|
||||
return []
|
||||
}
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
currentItemIndex: 0,
|
||||
messageInput: "",
|
||||
mainStatuses: Object.freeze({
|
||||
SELECTING: 0,
|
||||
SENDING: 1,
|
||||
SENT: 2,
|
||||
}),
|
||||
status: 0,
|
||||
statuses: Object.freeze({
|
||||
INITIAL: 0,
|
||||
SENT: 1,
|
||||
CANCELED: 2,
|
||||
FAILED: 3,
|
||||
}),
|
||||
sendInfo: [],
|
||||
dropTarget: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
document.body.classList.add("dark");
|
||||
this.$audioPlayer.setAutoplay(false);
|
||||
},
|
||||
beforeDestroy() {
|
||||
document.body.classList.remove("dark");
|
||||
},
|
||||
computed: {
|
||||
currentItemHasImagePreview() {
|
||||
return this.currentItemIndex >= 0 && this.currentItemIndex < this.attachments.length &&
|
||||
this.attachments[this.currentItemIndex].image
|
||||
},
|
||||
countSent() {
|
||||
return this.sendInfo ? this.sendInfo.reduce((a, elem, ignoredidx, ignoredarray) => elem.status == this.statuses.SENT ? a + 1 : a, 0) : 0
|
||||
},
|
||||
sendingItems() {
|
||||
return this.sendInfo ? this.sendInfo.filter(elem => elem.status == this.statuses.INITIAL) : []
|
||||
},
|
||||
sentItems() {
|
||||
this.sortSendinfo();
|
||||
return this.sendInfo ? this.sendInfo.filter(elem => elem.status == this.statuses.SENT) : []
|
||||
},
|
||||
sentItemsReversed() {
|
||||
const array = this.sentItems;
|
||||
return array.map((ignoreditem, idx) => array[array.length - 1 - idx])
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
attachments(newValue, oldValue) {
|
||||
// Added or removed?
|
||||
if (newValue && oldValue && newValue.length > oldValue.length) {
|
||||
this.currentItemIndex = oldValue.length;
|
||||
} else if (newValue) {
|
||||
this.currentItemIndex = newValue.length - 1;
|
||||
}
|
||||
},
|
||||
messageInput() {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
filesDropped(e) {
|
||||
this.dropTarget = false;
|
||||
let droppedFiles = e.dataTransfer.files;
|
||||
if (!droppedFiles) return;
|
||||
([...droppedFiles]).forEach(f => {
|
||||
this.$emit('add-file', f);
|
||||
});
|
||||
},
|
||||
scrollToBottom() {
|
||||
const el = this.$refs.attachmentWrapper;
|
||||
if (el) {
|
||||
// Ugly - need to wait until input is auto-sized, THEN scroll to bottom.
|
||||
//
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
this.$nextTick(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
formatBytes(bytes) {
|
||||
return prettyBytes(bytes);
|
||||
},
|
||||
reset() {
|
||||
this.$emit('reset');
|
||||
this.sendInfo = [];
|
||||
this.status = this.mainStatuses.SELECTING;
|
||||
this.messageInput = "";
|
||||
this.currentItemIndex = 0;
|
||||
},
|
||||
send() {
|
||||
this.status = this.mainStatuses.SENDING;
|
||||
this.sendInfo = this.attachments.map((attachment) => {
|
||||
return {
|
||||
id: attachment.name,
|
||||
status: this.statuses.INITIAL,
|
||||
statusDate: Date.now,
|
||||
attachment: attachment,
|
||||
progress: 0,
|
||||
randomRotation: 0,
|
||||
randomTranslationX: 0,
|
||||
randomTranslationY: 0
|
||||
}
|
||||
});
|
||||
|
||||
const text = (this.messageInput && this.messageInput.length > 0) ? this.messageInput : this.$t('file_mode.files');
|
||||
util.sendTextMessage(this.$matrix.matrixClient, this.room.roomId, text)
|
||||
.then((eventId) => {
|
||||
// Use the eventId as a thread root for all the media
|
||||
let promiseChain = Promise.resolve();
|
||||
const getItemPromise = (index) => {
|
||||
if (index < this.sendInfo.length) {
|
||||
const item = this.sendInfo[index];
|
||||
if (item.status !== this.statuses.INITIAL) {
|
||||
return getItemPromise(++index);
|
||||
}
|
||||
const itemPromise = util.sendImage(this.$matrix.matrixClient, this.room.roomId, item.attachment, ({ loaded, total }) => {
|
||||
if (loaded == total) {
|
||||
item.progress = 100;
|
||||
} else if (total > 0) {
|
||||
item.progress = 100 * loaded / total;
|
||||
}
|
||||
}, eventId)
|
||||
.then(() => {
|
||||
// Look at last item rotation, flipping the sign on this, so looks more like a true stack
|
||||
let signR = 1;
|
||||
let signX = 1;
|
||||
let signY = 1;
|
||||
if (this.sentItems.length > 0) {
|
||||
if (this.sentItems[0].randomRotation >= 0) {
|
||||
signR = -1;
|
||||
}
|
||||
if (this.sentItems[0].randomTranslationX >= 0) {
|
||||
signX = -1;
|
||||
}
|
||||
if (this.sentItems[0].randomTranslationY >= 0) {
|
||||
signY = -1;
|
||||
}
|
||||
}
|
||||
item.randomRotation = signR * (2 + Math.random() * 10);
|
||||
item.randomTranslationX = signX * Math.random() * 20;
|
||||
item.randomTranslationY = signY * Math.random() * 20;
|
||||
item.status = this.statuses.SENT;
|
||||
item.statusDate = Date.now;
|
||||
}).catch(ignorederr => {
|
||||
if (item.promise.aborted) {
|
||||
item.status = this.statuses.CANCELED;
|
||||
} else {
|
||||
console.error("ERROR", ignorederr);
|
||||
item.status = this.statuses.FAILED;
|
||||
}
|
||||
});
|
||||
item.promise = itemPromise;
|
||||
return itemPromise.then(() => getItemPromise(++index));
|
||||
}
|
||||
else return Promise.resolve();
|
||||
};
|
||||
|
||||
return promiseChain.then(() => getItemPromise(0));
|
||||
})
|
||||
.then(() => {
|
||||
this.status = this.mainStatuses.SENT;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("ERROR", err);
|
||||
});
|
||||
},
|
||||
cancelSendingItem(item) {
|
||||
if (item.promise && item.status == this.statuses.INITIAL) {
|
||||
item.promise.abort();
|
||||
}
|
||||
item.status = this.statuses.CANCELED;
|
||||
},
|
||||
checkDone() {
|
||||
if (!this.sendInfo.some(a => a.status == this.statuses.INITIAL)) {
|
||||
this.status = this.mainStatuses.SENT;
|
||||
}
|
||||
},
|
||||
sortSendinfo() {
|
||||
this.sendInfo.sort((a, b) => b.statusDate - a.statusDate);
|
||||
},
|
||||
stackItemTransform(item, index) {
|
||||
const size = 0.6 * (this.$refs.stackContainer ? Math.min(this.$refs.stackContainer.clientWidth, this.$refs.stackContainer.clientHeight) : 176);
|
||||
let transform = ""
|
||||
if (item != null && index != -1) {
|
||||
transform = "transform: rotate(" + item.randomRotation + "deg) translate(" + item.randomTranslationX + "px," + item.randomTranslationY + "px); z-index:" + (index + 2) + ";";
|
||||
}
|
||||
return transform + "width:" + size + "px;height:" + size + "px;border-radius:" + (size / 8) + "px";
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "@/assets/css/chat.scss";
|
||||
</style>
|
||||
|
|
@ -1,25 +1,13 @@
|
|||
<template>
|
||||
<div class="audio-player d-flex flex-row align-center">
|
||||
<audio ref="player" :src="src" @durationchange="updateDuration">
|
||||
<slot></slot>
|
||||
</audio>
|
||||
<v-btn v-if="playing" id="btn-pause" @click.stop="pause" icon
|
||||
><v-icon size="20">pause</v-icon></v-btn
|
||||
>
|
||||
<v-btn v-else id="btn-play" @click.stop="play" icon
|
||||
><v-icon size="20">play_arrow</v-icon></v-btn
|
||||
>
|
||||
<v-progress-circular v-if="info.loading" @click.stop="pause" :value="info.loadPercent" size="24" width="2" style="margin:6px"></v-progress-circular>
|
||||
<v-btn v-else-if="info.playing" id="btn-pause" @click.stop="pause" icon><v-icon size="20">pause</v-icon></v-btn>
|
||||
<v-btn v-else id="btn-play" @click.stop="play" icon><v-icon size="20">play_arrow</v-icon></v-btn>
|
||||
<div class="play-time">
|
||||
{{ currentTime }} / {{ totalTime }}
|
||||
</div>
|
||||
<v-slider
|
||||
color="currentColor"
|
||||
track-color="#cccccc"
|
||||
class="play-progress"
|
||||
v-model="playheadPercent"
|
||||
min="0"
|
||||
max="100"
|
||||
/>
|
||||
<v-slider @change="seeked" :disabled="!info.url" color="currentColor" track-color="#cccccc" class="play-progress" :value="info.playPercent" min="0"
|
||||
max="100" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -28,8 +16,14 @@ import util from "../../plugins/utils";
|
|||
|
||||
export default {
|
||||
props: {
|
||||
src: {
|
||||
type: String,
|
||||
event: {
|
||||
type: Object,
|
||||
default: function () {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
timelineSet: {
|
||||
type: Object,
|
||||
default: function () {
|
||||
return null;
|
||||
},
|
||||
|
|
@ -37,86 +31,37 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
player: null,
|
||||
duration: 0,
|
||||
playPercent: 0,
|
||||
playTime: 0,
|
||||
playing: false,
|
||||
info: this.install(),
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on('playback-start', this.onPlaybackStart);
|
||||
this.player = this.$refs.player;
|
||||
this.player.addEventListener("timeupdate", this.updateProgressBar);
|
||||
this.player.addEventListener("play", () => {
|
||||
this.playing = true;
|
||||
});
|
||||
this.player.addEventListener("pause", () => {
|
||||
this.playing = false;
|
||||
});
|
||||
this.player.addEventListener("ended", () => {
|
||||
this.pause();
|
||||
this.playing = false;
|
||||
this.$emit("playback-ended");
|
||||
});
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$root.$off('playback-start', this.onPlaybackStart);
|
||||
this.$audioPlayer.removeListener(this._uid);
|
||||
},
|
||||
computed: {
|
||||
currentTime() {
|
||||
return util.formatDuration(this.playTime);
|
||||
return util.formatDuration(this.info.currentTime);
|
||||
},
|
||||
totalTime() {
|
||||
return util.formatDuration(this.duration);
|
||||
},
|
||||
playheadPercent: {
|
||||
get: function () {
|
||||
return this.playPercent;
|
||||
},
|
||||
set: function (percent) {
|
||||
if (this.player.src) {
|
||||
this.playPercent = percent;
|
||||
this.player.currentTime = (percent / 100) * this.player.duration;
|
||||
}
|
||||
},
|
||||
return util.formatDuration(this.info.duration);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
install() {
|
||||
return this.$audioPlayer.addListener(this._uid, this.event);
|
||||
},
|
||||
play() {
|
||||
if (this.player.src) {
|
||||
this.$root.$emit("playback-start", this);
|
||||
if (this.player.paused) {
|
||||
this.player.play();
|
||||
} else if (this.player.ended) {
|
||||
// restart
|
||||
this.player.currentTime = 0;
|
||||
this.player.play();
|
||||
}
|
||||
}
|
||||
this.$audioPlayer.play(this.event, this.timelineSet);
|
||||
},
|
||||
pause() {
|
||||
if (this.player.src) {
|
||||
this.player.pause();
|
||||
}
|
||||
},
|
||||
updateProgressBar() {
|
||||
if (this.player.duration > 0) {
|
||||
this.playPercent = Math.floor(
|
||||
(100 / this.player.duration) * this.player.currentTime
|
||||
);
|
||||
} else {
|
||||
this.playPercent = 0;
|
||||
}
|
||||
this.playTime = 1000 * this.player.currentTime;
|
||||
},
|
||||
updateDuration() {
|
||||
this.duration = 1000 * this.player.duration;
|
||||
this.$audioPlayer.pause(this.event);
|
||||
},
|
||||
onPlaybackStart(item) {
|
||||
if (item != this && this.playing) {
|
||||
if (item != this.src && this.info.playing) {
|
||||
this.pause();
|
||||
}
|
||||
},
|
||||
seeked(percent) {
|
||||
this.$audioPlayer.seek(this.event, percent);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
<div v-if="showSenderAndTime" class="senderAndTime">
|
||||
<div class="sender">{{ eventSenderDisplayName(event) }}</div>
|
||||
<div class="time">
|
||||
{{ formatTime(event.event.origin_server_ts) }}
|
||||
{{ utils.formatTime(event.event.origin_server_ts) }}
|
||||
</div>
|
||||
</div>
|
||||
<v-avatar class="avatar" ref="avatar" size="32" color="#ededed" @click.stop="otherAvatarClicked($refs.avatar.$el)">
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
<template>
|
||||
<message-incoming v-bind="{...$props, ...$attrs}" v-on="$listeners">
|
||||
<div class="bubble audio-bubble">
|
||||
<audio-player :src="src">{{ $t('fallbacks.audio_file')}}</audio-player>
|
||||
<audio-player :event="event" :timelineSet="timelineSet">{{ $t('fallbacks.audio_file')}}</audio-player>
|
||||
</div>
|
||||
</message-incoming>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import attachmentMixin from "./attachmentMixin";
|
||||
import MessageIncoming from './MessageIncoming.vue';
|
||||
import AudioPlayer from './AudioPlayer.vue';
|
||||
|
||||
export default {
|
||||
extends: MessageIncoming,
|
||||
mixins: [attachmentMixin],
|
||||
components: { MessageIncoming, AudioPlayer }
|
||||
components: { MessageIncoming, AudioPlayer },
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
114
src/components/messages/MessageIncomingThread.vue
Normal file
114
src/components/messages/MessageIncomingThread.vue
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
<template>
|
||||
<message-incoming v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
|
||||
<div class="bubble">
|
||||
<div class="message">
|
||||
<v-container fluid class="imageCollection">
|
||||
<v-row wrap>
|
||||
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
|
||||
<v-img :aspect-ratio="16 / 9" :src="item.src" cover />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
<i v-if="event.isRedacted()" class="deleted-text">
|
||||
<v-icon :color="this.senderIsAdminOrModerator(this.event) ? 'white' : ''" size="small">block</v-icon>
|
||||
{{ $t('message.incoming_message_deleted_text') }}
|
||||
</i>
|
||||
<span v-html="linkify($sanitize(messageText))" v-else />
|
||||
<span class="edit-marker" v-if="event.replacingEventId() && !event.isRedacted()">
|
||||
{{ $t('message.edited') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</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.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;
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
layoutedItems() {
|
||||
if (!this.items || this.items.length == 0) { return [] }
|
||||
let array = this.items.slice(0);
|
||||
let rows = []
|
||||
while (array.length > 0) {
|
||||
if (array.length >= 7) {
|
||||
rows.push({ size: 6, item: array[0] });
|
||||
rows.push({ size: 6, item: array[1] });
|
||||
rows.push({ size: 12, item: array[2] });
|
||||
rows.push({ size: 3, item: array[3] });
|
||||
rows.push({ size: 3, item: array[4] });
|
||||
rows.push({ size: 3, item: array[5] });
|
||||
rows.push({ size: 3, item: array[6] });
|
||||
array = array.slice(7);
|
||||
} else if (array.length >= 3) {
|
||||
rows.push({ size: 6, item: array[0] });
|
||||
rows.push({ size: 6, item: array[1] });
|
||||
rows.push({ size: 12, item: array[2] });
|
||||
array = array.slice(3);
|
||||
} else if (array.length >= 2) {
|
||||
rows.push({ size: 6, item: array[0] });
|
||||
rows.push({ size: 6, item: array[1] });
|
||||
array = array.slice(2);
|
||||
} else {
|
||||
rows.push({ size: 12, item: array[0] });
|
||||
array = array.slice(1);
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@import "@/assets/css/chat.scss";
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.bubble {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.imageCollection {
|
||||
border-radius: 15px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
.row {
|
||||
margin: -4px; // Compensate for column padding, so the border-radius above looks round!
|
||||
padding: 0;
|
||||
}
|
||||
.col {
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
<div class="messageOut">
|
||||
<div class="senderAndTime">
|
||||
<div class="time">
|
||||
{{ formatTime(event.event.origin_server_ts) }}
|
||||
{{ utils.formatTime(event.event.origin_server_ts) }}
|
||||
</div>
|
||||
<div class="status">{{ event.status }}</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,18 @@
|
|||
<template>
|
||||
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
|
||||
<div class="audio-bubble">
|
||||
<audio-player :src="src">{{ $t('fallbacks.audio_file')}}</audio-player>
|
||||
<audio-player :event="event" :timelineSet="timelineSet">{{ $t('fallbacks.audio_file')}}</audio-player>
|
||||
</div>
|
||||
</message-outgoing>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import attachmentMixin from "./attachmentMixin";
|
||||
import AudioPlayer from './AudioPlayer.vue';
|
||||
import MessageOutgoing from "./MessageOutgoing.vue";
|
||||
|
||||
export default {
|
||||
extends: MessageOutgoing,
|
||||
components: { MessageOutgoing, AudioPlayer },
|
||||
mixins: [attachmentMixin],
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,27 +1,57 @@
|
|||
<template>
|
||||
<div class="seen-by-container">
|
||||
<v-tooltip top open-delay="500" v-if="seenBy.length > 0">
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<div v-bind="attrs" v-on="on" class="clickable">
|
||||
<div class="more" v-if="seenBy.length > 0">{{ moreItems }}</div>
|
||||
<transition-group name="list" tag="div" v-if="seenBy.length > 0">
|
||||
<v-avatar v-for="(member, index) in seenBy" :key="member.userId" class="seen-by-user" size="16" color="grey"
|
||||
v-show="index < SHOW_LIMIT">
|
||||
<img v-if="memberAvatar(member)" :src="memberAvatar(member)" />
|
||||
<div>
|
||||
<div class="seen-by-container">
|
||||
<v-tooltip top open-delay="500" v-if="seenBy.length > 0">
|
||||
<template v-slot:activator="{ on, attrs }">
|
||||
<div v-bind="attrs" v-on="on" class="clickable">
|
||||
<div class="more" v-if="seenBy.length > 0">{{ moreItems }}</div>
|
||||
<transition-group name="list" tag="div" v-if="seenBy.length > 0">
|
||||
<v-avatar v-for="(member, index) in seenBy" :key="member.roomMember.userId" class="seen-by-user" size="16" color="grey"
|
||||
v-show="index < SHOW_LIMIT" @click="open">
|
||||
<img v-if="memberAvatar(member.roomMember)" :src="memberAvatar(member.roomMember)" />
|
||||
<span v-else class="white--text headline">{{
|
||||
member.roomMember.name.substring(0, 1).toUpperCase()
|
||||
}}</span>
|
||||
</v-avatar>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ $tc("message.seen_by_count", seenBy.length) }}</span>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<BottomSheet
|
||||
:halfY="0.12"
|
||||
ref="seenByListBottomSheet"
|
||||
>
|
||||
<v-list>
|
||||
<v-subheader class="text-uppercase"> {{ $tc("message.seen_by") }}</v-subheader>
|
||||
<v-list-item v-for="(member, index) in seenBy" :key="index">
|
||||
<v-list-item-icon>
|
||||
<v-avatar size="40" color="grey">
|
||||
<img v-if="memberAvatar(member.roomMember)" :src="memberAvatar(member.roomMember)" />
|
||||
<span v-else class="white--text headline">{{
|
||||
member.name.substring(0, 1).toUpperCase()
|
||||
member.roomMember.name.substring(0, 1).toUpperCase()
|
||||
}}</span>
|
||||
</v-avatar>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ $tc("message.seen_by", seenBy.length) }}</span>
|
||||
</v-tooltip>
|
||||
</v-list-item-icon>
|
||||
<v-list-item-content class="text-left">
|
||||
<v-list-item-title>{{member.roomMember.name}}</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ seenByTimeStamp(member.readTimestamp) }}</v-list-item-subtitle>
|
||||
</v-list-item-content>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</BottomSheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import BottomSheet from "../BottomSheet.vue"
|
||||
import utils from "../../plugins/utils.js";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
BottomSheet
|
||||
},
|
||||
props: {
|
||||
room: {
|
||||
type: Object,
|
||||
|
|
@ -40,6 +70,7 @@ export default {
|
|||
return {
|
||||
seenBy: [],
|
||||
SHOW_LIMIT: 5,
|
||||
utils
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -62,6 +93,17 @@ export default {
|
|||
}
|
||||
},
|
||||
methods: {
|
||||
seenByTimeStamp(timestamp) {
|
||||
let dayDiff = utils.dayDiffToday(timestamp);
|
||||
if (dayDiff < 3) {
|
||||
return this.$tc("message.time_ago", dayDiff) + ' '+utils.formatTime(timestamp);
|
||||
} else {
|
||||
return utils.formatTime(timestamp);
|
||||
}
|
||||
},
|
||||
open() {
|
||||
this.$refs.seenByListBottomSheet.open();
|
||||
},
|
||||
onReceipt(ignoredevent) {
|
||||
this.update();
|
||||
},
|
||||
|
|
@ -80,7 +122,10 @@ export default {
|
|||
update() {
|
||||
this.seenBy = ((this.room && this.event) ? this.room.getReceiptsForEvent(this.event) : [])
|
||||
.filter(receipt => receipt.type == 'm.read' && receipt.userId !== this.$matrix.currentUserId)
|
||||
.map(receipt => this.room.getMember(receipt.userId));
|
||||
.map(receipt => {
|
||||
return { readTimestamp: receipt.data.ts, roomMember: this.room.getMember(receipt.userId) }
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import QuickReactions from "./QuickReactions.vue";
|
||||
import * as linkify from 'linkifyjs';
|
||||
import linkifyHtml from 'linkify-html';
|
||||
import utils from "../../plugins/utils"
|
||||
|
||||
linkify.options.defaults.className = "link";
|
||||
linkify.options.defaults.target = { url: "_blank" };
|
||||
|
||||
|
|
@ -39,6 +41,7 @@ export default {
|
|||
event: {},
|
||||
inReplyToEvent: null,
|
||||
inReplyToSender: null,
|
||||
utils
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
|
|
@ -231,20 +234,44 @@ export default {
|
|||
return false;
|
||||
},
|
||||
|
||||
formatTime(time) {
|
||||
formatTimeAgo(time) {
|
||||
const date = new Date();
|
||||
date.setTime(time);
|
||||
|
||||
const today = new Date();
|
||||
if (
|
||||
date.getDate() == today.getDate() &&
|
||||
date.getMonth() == today.getMonth() &&
|
||||
date.getFullYear() == today.getFullYear()
|
||||
) {
|
||||
// For today, skip the date part
|
||||
return date.toLocaleTimeString();
|
||||
var ti = Math.abs(new Date().getTime() - date.getTime());
|
||||
ti = ti / 1000; // Convert to seconds
|
||||
let s = "";
|
||||
if (ti < 60) {
|
||||
s = this.$t("global.time.recently");
|
||||
} else if (ti < 3600 && Math.round(ti / 60) < 60) {
|
||||
s = this.$tc("global.time.minutes", Math.round(ti / 60));
|
||||
} else if (ti < 86400 && Math.round(ti / 60 / 60) < 24) {
|
||||
s = this.$tc("global.time.hours", Math.round(ti / 60 / 60));
|
||||
} else {
|
||||
s = this.$tc("global.time.days", Math.round(ti / 60 / 60 / 24));
|
||||
}
|
||||
return date.toLocaleString();
|
||||
return this.toLocalNumbers(s);
|
||||
},
|
||||
|
||||
/**
|
||||
* Possibly convert numerals to local representation (currently only for "bo" locale)
|
||||
* @param str String in which to convert numerals [0-9]
|
||||
* @returns converted string
|
||||
*/
|
||||
toLocalNumbers(str) {
|
||||
if (this.$i18n.locale == "bo") {
|
||||
// Translate to tibetan numerals
|
||||
let result = "";
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
let c = str.charCodeAt(i);
|
||||
if (c >= 48 && c <= 57) {
|
||||
result += String.fromCharCode(c + 0x0f20 - 48);
|
||||
} else {
|
||||
result += String.fromCharCode(c);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return str;
|
||||
},
|
||||
|
||||
linkify(text) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export default {
|
|||
if (!this.user) {
|
||||
return null;
|
||||
}
|
||||
return this.user.displayName;
|
||||
return this.$matrix.userDisplayName || this.user.displayName;
|
||||
},
|
||||
set(newValue) {
|
||||
this.user.displayName = newValue
|
||||
|
|
@ -20,17 +20,17 @@ export default {
|
|||
},
|
||||
|
||||
userAvatar() {
|
||||
if (!this.user || !this.user.avatarUrl) {
|
||||
if (!this.$matrix.userAvatar) {
|
||||
return null;
|
||||
}
|
||||
return this.$matrix.matrixClient.mxcUrlToHttp(this.user.avatarUrl, 80, 80, 'scale', true);
|
||||
return this.$matrix.matrixClient.mxcUrlToHttp(this.$matrix.userAvatar, 80, 80, 'scale', true);
|
||||
},
|
||||
|
||||
userAvatarLetter() {
|
||||
if (!this.user) {
|
||||
return null;
|
||||
}
|
||||
return (this.user.displayName || this.user.userId.substring(1)).substring(0, 1).toUpperCase();
|
||||
return (this.$matrix.userDisplayName || this.user.displayName || this.user.userId.substring(1)).substring(0, 1).toUpperCase();
|
||||
},
|
||||
|
||||
passwordsMatch() {
|
||||
|
|
@ -46,7 +46,7 @@ export default {
|
|||
})
|
||||
},
|
||||
updateDisplayName(name) {
|
||||
this.$matrix.matrixClient.setDisplayName(name || this.user.userId);
|
||||
this.$matrix.setUserDisplayName(name || this.user.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import utils from "../plugins/utils";
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
|
|
@ -57,7 +59,7 @@ export default {
|
|||
publicRoomLink() {
|
||||
if (this.room && this.roomJoinRule == "public") {
|
||||
return this.$router.getRoomLink(
|
||||
this.room.getCanonicalAlias() || this.room.roomId
|
||||
this.room.getCanonicalAlias(), this.room.roomId, this.room.name, utils.roomDisplayTypeToQueryParam(this.room)
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
24
src/components/roomTypeMixin.js
Normal file
24
src/components/roomTypeMixin.js
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ROOM_TYPE_VOICE_MODE, ROOM_TYPE_FILE_MODE, ROOM_TYPE_DEFAULT } from "../plugins/utils";
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
availableRoomTypes() {
|
||||
let types = [{ title: this.$t("room_info.room_type_default"), description: "", value: ROOM_TYPE_DEFAULT }];
|
||||
if (this.$config.experimental_voice_mode) {
|
||||
types.push({
|
||||
title: this.$t("room_info.voice_mode"),
|
||||
description: this.$t("room_info.voice_mode_info"),
|
||||
value: ROOM_TYPE_VOICE_MODE,
|
||||
});
|
||||
}
|
||||
if (this.$config.experimental_file_mode) {
|
||||
types.push({
|
||||
title: this.$t("room_info.file_mode"),
|
||||
description: this.$t("room_info.file_mode_info"),
|
||||
value: ROOM_TYPE_FILE_MODE,
|
||||
});
|
||||
}
|
||||
return types;
|
||||
},
|
||||
},
|
||||
};
|
||||
16
src/main.js
16
src/main.js
|
|
@ -7,6 +7,7 @@ import matrix from './services/matrix.service'
|
|||
import navigation from './services/navigation.service'
|
||||
import config from './services/config.service'
|
||||
import analytics from './services/analytics.service'
|
||||
import audioPlayer from './services/audio.service';
|
||||
import 'roboto-fontface/css/roboto/roboto-fontface.css'
|
||||
import 'material-design-icons-iconfont/dist/material-design-icons.css'
|
||||
import VEmojiPicker from 'v-emoji-picker';
|
||||
|
|
@ -31,10 +32,11 @@ const configLoadedPromise = new Promise((resolve, ignoredreject) => {
|
|||
// eslint-disable-next-line
|
||||
Vue.use(config, globalThis.window.location.origin, (config) => {
|
||||
resolve(config);
|
||||
}); // Use this before cleaninsights below, it depends on config!
|
||||
}); // Use this before cleaninsights below, it depends on config!
|
||||
});
|
||||
Vue.use(analytics);
|
||||
Vue.use(VueClipboard);
|
||||
Vue.use(audioPlayer);
|
||||
|
||||
const vuetify = createVuetify(config);
|
||||
|
||||
|
|
@ -176,11 +178,17 @@ const vueInstance = new Vue({
|
|||
matrix,
|
||||
config,
|
||||
analytics,
|
||||
render: h => h(App)
|
||||
audioPlayer,
|
||||
render: h => h(App),
|
||||
});
|
||||
vueInstance.$vuetify.theme.themes.light.primary = vueInstance.$config.accentColor;
|
||||
if (vueInstance.$config.accentColor) {
|
||||
vueInstance.$vuetify.theme.themes.light.primary = vueInstance.$config.accentColor;
|
||||
}
|
||||
vueInstance.$audioPlayer.$root = vueInstance; // Make sure a $root is available here
|
||||
configLoadedPromise.then((config) => {
|
||||
vueInstance.$vuetify.theme.themes.light.primary = config.accentColor;
|
||||
if (config.accentColor) {
|
||||
vueInstance.$vuetify.theme.themes.light.primary = config.accentColor;
|
||||
}
|
||||
vueInstance.$mount('#app');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import * as ContentHelpers from "matrix-js-sdk/lib/content-helpers";
|
|||
import dataUriToBuffer from "data-uri-to-buffer";
|
||||
import ImageResize from "image-resize";
|
||||
|
||||
export const ROOM_TYPE_DEFAULT = "im.keanu.room_type_default";
|
||||
export const ROOM_TYPE_VOICE_MODE = "im.keanu.room_type_voice";
|
||||
export const ROOM_TYPE_FILE_MODE = "im.keanu.room_type_file";
|
||||
|
||||
const sizeOf = require("image-size");
|
||||
|
||||
|
|
@ -28,32 +30,49 @@ var _browserCanRecordAudioF = function () {
|
|||
}
|
||||
var _browserCanRecordAudio = _browserCanRecordAudioF();
|
||||
|
||||
class UploadPromise extends Promise {
|
||||
constructor(executor) {
|
||||
const aborter = {
|
||||
aborted: false,
|
||||
abortablePromise: undefined,
|
||||
matrixClient: undefined,
|
||||
class UploadPromise {
|
||||
aborted = false;
|
||||
onAbort = undefined;
|
||||
|
||||
constructor(wrappedPromise) {
|
||||
this.wrappedPromise = wrappedPromise;
|
||||
}
|
||||
|
||||
abort() {
|
||||
this.aborted = true;
|
||||
if (this.onAbort) {
|
||||
this.onAbort();
|
||||
}
|
||||
}
|
||||
|
||||
const normalExecutor = function (resolve, reject) {
|
||||
executor(resolve, reject, aborter);
|
||||
};
|
||||
then(resolve, reject) {
|
||||
this.wrappedPromise = this.wrappedPromise.then(resolve, reject);
|
||||
return this;
|
||||
}
|
||||
|
||||
super(normalExecutor);
|
||||
this.abort = () => {
|
||||
aborter.aborted = true;
|
||||
if (aborter.abortablePromise && aborter.matrixClient) {
|
||||
aborter.matrixClient.cancelUpload(aborter.abortablePromise);
|
||||
aborter.matrixClient = undefined;
|
||||
aborter.abortablePromise = undefined;
|
||||
}
|
||||
};
|
||||
catch(handler) {
|
||||
this.wrappedPromise = this.wrappedPromise.catch(handler);
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
class Util {
|
||||
getAttachment(matrixClient, event, progressCallback, asBlob = false) {
|
||||
getAttachmentUrlAndDuration(event) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const content = event.getContent();
|
||||
if (content.url != null) {
|
||||
resolve([content.url, content.info.duration]);
|
||||
return;
|
||||
}
|
||||
if (content.file && content.file.url) {
|
||||
resolve([content.file.url, content.info.duration]);
|
||||
} else {
|
||||
reject("No url found!");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getAttachment(matrixClient, event, progressCallback, asBlob = false, abortController = undefined) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const content = event.getContent();
|
||||
if (content.url != null) {
|
||||
|
|
@ -73,6 +92,7 @@ class Util {
|
|||
}
|
||||
|
||||
axios.get(url, {
|
||||
signal: abortController ? abortController.signal : undefined,
|
||||
responseType: 'arraybuffer', onDownloadProgress: progressEvent => {
|
||||
let percentCompleted = Math.floor((progressEvent.loaded * 100) / progressEvent.total);
|
||||
if (progressCallback) {
|
||||
|
|
@ -206,13 +226,13 @@ class Util {
|
|||
return this.sendMessage(matrixClient, roomId, "m.room.message", content);
|
||||
}
|
||||
|
||||
sendQuickReaction(matrixClient, roomId, emoji, event) {
|
||||
sendQuickReaction(matrixClient, roomId, emoji, event, extraData = {}) {
|
||||
const content = {
|
||||
'm.relates_to': {
|
||||
'm.relates_to': Object.assign(extraData, {
|
||||
key: emoji,
|
||||
rel_type: 'm.annotation',
|
||||
event_id: event.getId()
|
||||
}
|
||||
})
|
||||
};
|
||||
return this.sendMessage(matrixClient, roomId, "m.reaction", content);
|
||||
}
|
||||
|
|
@ -267,7 +287,7 @@ class Util {
|
|||
matrixClient.sendEvent(roomId, eventType, content, undefined, undefined)
|
||||
.then((result) => {
|
||||
console.log("Message sent: ", result);
|
||||
resolve(true);
|
||||
resolve(result.event_id);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log("Send error: ", err);
|
||||
|
|
@ -301,12 +321,12 @@ class Util {
|
|||
// Find the exact match (= object equality)
|
||||
return e.error === err
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
matrixClient.resendEvent(event, matrixClient.getRoom(event.getRoomId()))
|
||||
.then((result) => {
|
||||
console.log("Message sent: ", result);
|
||||
resolve(true);
|
||||
resolve(result.event_id);
|
||||
})
|
||||
.catch((err) => {
|
||||
// Still error, abort
|
||||
|
|
@ -321,12 +341,12 @@ class Util {
|
|||
});
|
||||
}
|
||||
|
||||
sendImage(matrixClient, roomId, file, onUploadProgress) {
|
||||
return new UploadPromise((resolve, reject, aborter) => {
|
||||
const abortionController = aborter;
|
||||
sendImage(matrixClient, roomId, file, onUploadProgress, threadRoot) {
|
||||
const uploadPromise = new UploadPromise(undefined);
|
||||
uploadPromise.wrappedPromise = new Promise((resolve, reject) => {
|
||||
var reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
if (abortionController.aborted) {
|
||||
if (uploadPromise.aborted) {
|
||||
reject("Aborted");
|
||||
return;
|
||||
}
|
||||
|
|
@ -338,6 +358,11 @@ class Util {
|
|||
size: file.size
|
||||
};
|
||||
|
||||
// If audio, send duration in ms as well
|
||||
if (file.duration) {
|
||||
info.duration = file.duration;
|
||||
}
|
||||
|
||||
var description = file.name;
|
||||
var msgtype = 'm.image';
|
||||
if (file.type.startsWith("audio/")) {
|
||||
|
|
@ -361,6 +386,14 @@ class Util {
|
|||
msgtype: msgtype
|
||||
}
|
||||
|
||||
// If thread root (an eventId) is set, add that here
|
||||
if (threadRoot) {
|
||||
messageContent["m.relates_to"] = {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": threadRoot
|
||||
};
|
||||
}
|
||||
|
||||
// Set filename for files
|
||||
if (msgtype == 'm.file') {
|
||||
messageContent.filename = file.name;
|
||||
|
|
@ -368,9 +401,11 @@ class Util {
|
|||
|
||||
if (!matrixClient.isRoomEncrypted(roomId)) {
|
||||
// Not encrypted.
|
||||
abortionController.matrixClient = matrixClient;
|
||||
abortionController.abortablePromise = matrixClient.uploadContent(data, opts);
|
||||
abortionController.abortablePromise
|
||||
const promise = matrixClient.uploadContent(data, opts);
|
||||
uploadPromise.onAbort = () => {
|
||||
matrixClient.cancelUpload(promise);
|
||||
};
|
||||
promise
|
||||
.then((response) => {
|
||||
messageContent.url = response.content_uri;
|
||||
return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent)
|
||||
|
|
@ -417,9 +452,12 @@ class Util {
|
|||
// Encrypted data sent as octet-stream!
|
||||
opts.type = "application/octet-stream";
|
||||
|
||||
abortionController.abortablePromise = matrixClient.uploadContent(data, opts);
|
||||
abortionController.abortablePromise
|
||||
.then((response) => {
|
||||
const promise = matrixClient.uploadContent(data, opts);
|
||||
uploadPromise.onAbort = () => {
|
||||
matrixClient.cancelUpload(promise);
|
||||
};
|
||||
promise
|
||||
.then((response) => {
|
||||
if (response.error) {
|
||||
return reject(response.error);
|
||||
}
|
||||
|
|
@ -438,36 +476,69 @@ class Util {
|
|||
}
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
return uploadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return 'true' if we should use voice mode for the given room.
|
||||
* Return what "mode" to use for the given room.
|
||||
*
|
||||
* The default value is given by the room itself. If the "type" of the
|
||||
* room is set to 'im.keanu.room_type_voice' then we default to voice mode,
|
||||
* else not. The user can then override this default by flipping the "voice mode"
|
||||
* swicth on room settings (it will be persisted as a user specific tag on the room)
|
||||
* else if set to 'im.keanu.room_type_file' we default to file mode.
|
||||
* The user can then override this default by changing the "room type"
|
||||
* in room settings (it will be persisted as a user specific tag on the room)
|
||||
*/
|
||||
useVoiceMode(roomOrNull) {
|
||||
roomDisplayType(roomOrNull) {
|
||||
if (roomOrNull) {
|
||||
const room = roomOrNull;
|
||||
|
||||
// Have we changed our local view mode of this room?
|
||||
const tags = room.tags;
|
||||
if (tags && tags["ui_options"]) {
|
||||
return tags["ui_options"]["voice_mode"] === 1;
|
||||
if (tags["ui_options"]["voice_mode"] === 1) {
|
||||
return ROOM_TYPE_VOICE_MODE;
|
||||
} else if (tags["ui_options"]["file_mode"] === 1) {
|
||||
return ROOM_TYPE_FILE_MODE;
|
||||
} else if (tags["ui_options"]["file_mode"] === 0 && tags["ui_options"]["file_mode"] === 0) {
|
||||
// Explicitly set to "default"
|
||||
return ROOM_TYPE_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Was the room created with a voice mode type?
|
||||
const createEvent = room.currentState.getStateEvents(
|
||||
"m.room.create",
|
||||
""
|
||||
);
|
||||
if (createEvent) {
|
||||
return createEvent.getContent().type === ROOM_TYPE_VOICE_MODE;
|
||||
const roomType = createEvent.getContent().type;
|
||||
|
||||
// Validate value, or return default
|
||||
if ([ROOM_TYPE_FILE_MODE, ROOM_TYPE_VOICE_MODE].includes(roomType)) {
|
||||
return roomType;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return ROOM_TYPE_DEFAULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the room type for the current room
|
||||
* @param {*} roomOrNull
|
||||
*/
|
||||
roomDisplayTypeToQueryParam(roomOrNull) {
|
||||
const roomType = this.roomDisplayType(roomOrNull);
|
||||
if (roomType === ROOM_TYPE_FILE_MODE) {
|
||||
// Send "file" here, so the receiver of the invite link knows to display the "file drop" join page
|
||||
// instead of the standard one.
|
||||
return "file";
|
||||
} else if (roomType === ROOM_TYPE_VOICE_MODE) {
|
||||
// No need to return "voice" here. The invite page looks the same for default and voice mode,
|
||||
// so currently no point in cluttering the invite link with it. The corrent mode will be picked up from
|
||||
// room creation flags once the user joins.
|
||||
return undefined;
|
||||
}
|
||||
return undefined; // Default, just return undefined
|
||||
}
|
||||
|
||||
/** Generate a random user name */
|
||||
|
|
@ -571,7 +642,7 @@ class Util {
|
|||
|
||||
findOneVisibleElement(parentNode) {
|
||||
let start = 0;
|
||||
let end = parentNode.children.length - 1;
|
||||
let end = (parentNode && parentNode.children) ? parentNode.children.length - 1 : -1;
|
||||
while (start <= end) {
|
||||
let middle = Math.floor((start + end) / 2);
|
||||
let childNode = parentNode.children[middle];
|
||||
|
|
@ -758,6 +829,22 @@ class Util {
|
|||
return then.format('L');
|
||||
}
|
||||
|
||||
formatTime(time) {
|
||||
const date = new Date();
|
||||
date.setTime(time);
|
||||
|
||||
const today = new Date();
|
||||
if (
|
||||
date.getDate() == today.getDate() &&
|
||||
date.getMonth() == today.getMonth() &&
|
||||
date.getFullYear() == today.getFullYear()
|
||||
) {
|
||||
// For today, skip the date part
|
||||
return date.toLocaleTimeString();
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
formatRecordDuration(ms) {
|
||||
return dayjs.duration(ms).format("HH:mm:ss");
|
||||
}
|
||||
|
|
@ -778,6 +865,13 @@ class Util {
|
|||
return _browserCanRecordAudio;
|
||||
}
|
||||
|
||||
getRoomNameFromAlias(alias) {
|
||||
if (alias && alias.startsWith('#') && alias.indexOf(':') > 0) {
|
||||
return alias.slice(1).split(':')[0];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getUniqueAliasForRoomName(matrixClient, roomName, homeServer, iterationCount) {
|
||||
return new Promise((resolve, reject) => {
|
||||
var preferredAlias = roomName.replace(/\s/g, "").toLowerCase();
|
||||
|
|
|
|||
|
|
@ -140,8 +140,24 @@ router.beforeEach((to, from, next) => {
|
|||
}
|
||||
});
|
||||
|
||||
router.getRoomLink = function (roomId) {
|
||||
return window.location.origin + window.location.pathname + "#/room/" + encodeURIComponent(util.sanitizeRoomId(roomId));
|
||||
router.getRoomLink = function (alias, roomId, roomName, mode) {
|
||||
let params = {};
|
||||
if ((!alias || roomName.replace(/\s/g, "").toLowerCase() !== util.getRoomNameFromAlias(alias)) && roomName) {
|
||||
// There is no longer a correlation between alias and room name, probably because room name has
|
||||
// changed. Include the "?roomName" part
|
||||
params["roomName"] = roomName;
|
||||
}
|
||||
if (mode) {
|
||||
// Optional mode given, append as "m" query param
|
||||
params["m"] = mode;
|
||||
}
|
||||
if (Object.entries(params).length > 0) {
|
||||
const queryString = Object.entries(params)
|
||||
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
|
||||
.join('&')
|
||||
return window.location.origin + window.location.pathname + "?" + queryString + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId));
|
||||
}
|
||||
return window.location.origin + window.location.pathname + "#/room/" + encodeURIComponent(util.sanitizeRoomId(alias || roomId));
|
||||
}
|
||||
|
||||
export default router
|
||||
|
|
|
|||
268
src/services/audio.service.js
Normal file
268
src/services/audio.service.js
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import utils from "../plugins/utils";
|
||||
|
||||
/**
|
||||
* This plugin (available in all vue components as $audioPlayer) handles
|
||||
* access to the shared audio player, and events related to loading and
|
||||
* playback of audio attachments.
|
||||
*
|
||||
* Components use this by calling "addListener" (and corresponding removeListener) with
|
||||
* an audio matrix event and a unique component id (for example the ._uid property).
|
||||
*/
|
||||
export default {
|
||||
install(Vue) {
|
||||
class SharedAudioPlayer {
|
||||
constructor() {
|
||||
this.player = new Audio();
|
||||
this.currentEvent = null;
|
||||
this.currentClapReactions = [];
|
||||
this.infoMap = new Map();
|
||||
this.player.addEventListener("durationchange", this.onDurationChange.bind(this));
|
||||
this.player.addEventListener("timeupdate", this.onTimeUpdate.bind(this));
|
||||
this.player.addEventListener("play", this.onPlay.bind(this));
|
||||
this.player.addEventListener("pause", this.onPause.bind(this));
|
||||
this.player.addEventListener("ended", this.onEnded.bind(this));
|
||||
}
|
||||
|
||||
getPlayerElement() {
|
||||
return this.player;
|
||||
}
|
||||
|
||||
addListener(uid, event) {
|
||||
const eventId = event.getId();
|
||||
var entry = this.infoMap.get(eventId);
|
||||
if (!entry) {
|
||||
// Listeners is just a Set of component "uid" entries for now.
|
||||
entry = { url: null, listeners: new Set() };
|
||||
// Make these reactive, so AudioPlayer (and others) can listen to them
|
||||
Vue.set(entry, "loading", false);
|
||||
Vue.set(entry, "loadPercent", 0);
|
||||
Vue.set(entry, "duration", 0);
|
||||
Vue.set(entry, "currentTime", 0);
|
||||
Vue.set(entry, "playPercent", 0);
|
||||
Vue.set(entry, "playing", false);
|
||||
this.infoMap.set(eventId, entry);
|
||||
|
||||
// Get duration information
|
||||
utils
|
||||
.getAttachmentUrlAndDuration(event)
|
||||
.then(([ignoredurl, duration]) => {
|
||||
entry.duration = duration;
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch attachment duration: ", err);
|
||||
});
|
||||
}
|
||||
entry.listeners.add(uid);
|
||||
return entry;
|
||||
}
|
||||
removeListener(uid) {
|
||||
[...this.infoMap].forEach(([ignoredeventid, info]) => {
|
||||
info.listeners.delete(uid);
|
||||
if (info.listeners.size == 0 && info.url) {
|
||||
// No more listeners, release audio blob
|
||||
URL.revokeObjectURL(info.url);
|
||||
info.url = null;
|
||||
}
|
||||
});
|
||||
this.infoMap = new Map(
|
||||
[...this.infoMap].filter(([ignoredeventid, info]) => {
|
||||
return info.listeners.size > 0;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
play(event, timelineSet) {
|
||||
this.play_(event, timelineSet, false);
|
||||
}
|
||||
|
||||
load(event, timelineSet) {
|
||||
this.play_(event, timelineSet, true);
|
||||
}
|
||||
|
||||
play_(event, timelineSet, onlyLoad) {
|
||||
const eventId = event.getId();
|
||||
if (this.currentEvent != eventId) {
|
||||
// Media change, pause the one currently playing.
|
||||
this.player.pause();
|
||||
var entry = this.infoMap.get(this.currentEvent);
|
||||
if (entry) {
|
||||
entry.playing = false;
|
||||
}
|
||||
}
|
||||
this.currentEvent = eventId;
|
||||
const info = this.infoMap.get(eventId);
|
||||
if (info) {
|
||||
|
||||
// Get all clap reactions
|
||||
this.initializeClapEvents(event, timelineSet);
|
||||
|
||||
if (info.url) {
|
||||
// Restart from beginning?
|
||||
if (info.currentTime == info.duration) {
|
||||
info.currentTime = 0;
|
||||
info.playPercent = 0;
|
||||
}
|
||||
if (this.player.src != info.url) {
|
||||
this.player.src = info.url;
|
||||
this.player.currentTime = (info.currentTime || 0) / 1000;
|
||||
}
|
||||
if (onlyLoad) {
|
||||
this.player.load();
|
||||
} else {
|
||||
this.player.play();
|
||||
}
|
||||
} else {
|
||||
// Download it!
|
||||
info.loadPercent = 0;
|
||||
info.loading = true;
|
||||
info.abortController = new AbortController();
|
||||
utils
|
||||
.getAttachment(this.$root.$matrix.matrixClient, event, (progress) => {
|
||||
info.loadPercent = progress;
|
||||
}, false, info.abortController)
|
||||
.then((url) => {
|
||||
info.url = url;
|
||||
|
||||
// Still on this item? Call ourselves recursively.
|
||||
if (this.currentEvent == eventId) {
|
||||
if (onlyLoad) {
|
||||
this.load(event, timelineSet);
|
||||
} else {
|
||||
this.play(event, timelineSet);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch attachment: ", err);
|
||||
})
|
||||
.finally(() => {
|
||||
info.loading = false;
|
||||
info.abortController = undefined;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the "autoplay" property on the underlying player object.
|
||||
* @param {} autoplay
|
||||
*/
|
||||
setAutoplay(autoplay) {
|
||||
this.player.autoplay = autoplay;
|
||||
}
|
||||
|
||||
pause(event) {
|
||||
if (!event || this.currentEvent == event.getId()) {
|
||||
this.player.pause();
|
||||
}
|
||||
|
||||
if (event) {
|
||||
// If downloading, abort that!
|
||||
var entry = this.infoMap.get(event.getId());
|
||||
if (entry && entry.abortController) {
|
||||
entry.abortController.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
seek(event, percent) {
|
||||
var entry = this.infoMap.get(event.getId());
|
||||
if (entry) {
|
||||
entry.currentTime = ((percent / 100) * (entry.duration || 0));
|
||||
this.updatePlayPercent(entry);
|
||||
if (this.currentEvent == event.getId()) {
|
||||
this.player.currentTime = entry.currentTime / 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
seekRelative(event, milliseconds) {
|
||||
var entry = this.infoMap.get(event.getId());
|
||||
if (entry) {
|
||||
entry.currentTime = Math.max(0, Math.min(entry.currentTime + milliseconds, entry.duration));
|
||||
this.updatePlayPercent(entry);
|
||||
if (this.currentEvent == event.getId()) {
|
||||
this.player.currentTime = entry.currentTime / 1000;
|
||||
}
|
||||
}
|
||||
}
|
||||
onPlay() {
|
||||
var entry = this.infoMap.get(this.currentEvent);
|
||||
if (entry) {
|
||||
entry.playing = true;
|
||||
}
|
||||
this.$root.$emit("audio-playback-started", this.currentEvent);
|
||||
}
|
||||
onPause() {
|
||||
var entry = this.infoMap.get(this.currentEvent);
|
||||
if (entry) {
|
||||
entry.playing = false;
|
||||
}
|
||||
this.$root.$emit("audio-playback-paused", this.currentEvent);
|
||||
}
|
||||
onEnded() {
|
||||
var entry = this.infoMap.get(this.currentEvent);
|
||||
if (entry) {
|
||||
entry.playing = false;
|
||||
entry.currentTime = entry.duration; // Next time restart
|
||||
}
|
||||
this.$root.$emit("audio-playback-ended", this.currentEvent);
|
||||
}
|
||||
onTimeUpdate() {
|
||||
var entry = this.infoMap.get(this.currentEvent);
|
||||
if (entry) {
|
||||
const oldTime = entry.currentTime;
|
||||
entry.currentTime = 1000 * this.player.currentTime;
|
||||
this.updatePlayPercent(entry);
|
||||
this.maybePlayClapEvent(oldTime, entry.currentTime);
|
||||
}
|
||||
}
|
||||
onDurationChange() {
|
||||
const duration =
|
||||
this.player.duration && isFinite(this.player.duration) && !isNaN(this.player.duration)
|
||||
? 1000 * this.player.duration
|
||||
: 0;
|
||||
var entry = this.infoMap.get(this.currentEvent);
|
||||
if (entry) {
|
||||
entry.duration = duration;
|
||||
this.updatePlayPercent(entry);
|
||||
}
|
||||
}
|
||||
updatePlayPercent(entry) {
|
||||
if (entry.duration > 0) {
|
||||
entry.playPercent = Math.floor((100 / entry.duration) * entry.currentTime);
|
||||
} else {
|
||||
entry.playPercent = 0;
|
||||
}
|
||||
}
|
||||
|
||||
initializeClapEvents(event, timelineSet) {
|
||||
if (event) {
|
||||
const reactions = timelineSet.relations.getChildEventsForEvent(event.getId(), 'm.annotation', 'm.reaction');
|
||||
if (reactions) {
|
||||
this.currentClapReactions = reactions.getRelations()
|
||||
.filter(r => r.getRelation().key == "👏" && r.getRelation().timeOffset && parseInt(r.getRelation().timeOffset) > 0)
|
||||
.map(r => {
|
||||
return {
|
||||
sender: r.getSender(),
|
||||
emoji: r.getRelation().key,
|
||||
timeOffset: parseInt(r.getRelation().timeOffset)
|
||||
}
|
||||
})
|
||||
.sort((a,b) => a.timeOffset - b.timeOffset);
|
||||
}
|
||||
} else {
|
||||
this.currentClapReactions = [];
|
||||
}
|
||||
}
|
||||
|
||||
maybePlayClapEvent(previousTimeMs, timeNowMs) {
|
||||
(this.currentClapReactions || []).forEach(reaction => {
|
||||
if (previousTimeMs < reaction.timeOffset && timeNowMs >= reaction.timeOffset) {
|
||||
this.$root.$emit("audio-playback-reaction", reaction);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Vue.prototype.$audioPlayer = new SharedAudioPlayer();
|
||||
},
|
||||
};
|
||||
|
|
@ -369,29 +369,7 @@ export default {
|
|||
}
|
||||
break;
|
||||
|
||||
case "m.room.member":
|
||||
{
|
||||
if (this.currentRoom && event.getRoomId() == this.currentRoom.roomId) {
|
||||
// Don't use this.currentRoomId, may be an alias. We need the real id!
|
||||
if (
|
||||
(event.getContent().membership == "leave" &&
|
||||
(event.getPrevContent() || {}).membership == "join" &&
|
||||
event.getStateKey() == this.currentUserId &&
|
||||
event.getSender() != this.currentUserId) ||
|
||||
(event.getContent().membership == "ban" && event.getStateKey() == this.currentUserId)
|
||||
) {
|
||||
// We were kicked or banned
|
||||
// If this is a live event (not just backpaging) then redirect to goodbye!
|
||||
if (this.matrixClientReady) {
|
||||
const wasPurged = event.getContent().reason == "Room Deleted";
|
||||
this.$navigation.push({ name: "Goodbye", params: { roomWasPurged: wasPurged } }, -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "m.room.power_levels":
|
||||
case "m.room.power_levels":
|
||||
{
|
||||
if (this.currentRoom && event.getRoomId() == this.currentRoom.roomId) {
|
||||
this.currentRoomIsReadOnlyForUser = this.isReadOnlyRoomForUser(event.getRoomId(), this.currentUserId);
|
||||
|
|
@ -946,6 +924,14 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
setUserDisplayName(name) {
|
||||
if (this.matrixClient) {
|
||||
return this.matrixClient.setDisplayName(name || this.user.userId).then(() => this.userDisplayName = name).catch(err => console.err("Failed to set display name", err));
|
||||
} else {
|
||||
return Promise.reject("No matrix client");
|
||||
}
|
||||
},
|
||||
|
||||
setPassword(oldPassword, newPassword) {
|
||||
if (this.matrixClient && this.currentUser) {
|
||||
const authDict = {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue