Merge branch 'dev' into '419-push-notifications'
# Conflicts: # src/assets/translations/en.json
This commit is contained in:
commit
fa51d4a9ec
28 changed files with 1000 additions and 507 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",
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
"productLink": "letsconvene.im",
|
||||
"defaultServer": "https://neo.keanu.im",
|
||||
"identityServer_unset": "",
|
||||
"registrationToken_unset": "",
|
||||
"rtl": false,
|
||||
"accentColor_unset": "",
|
||||
"logo_unset": "",
|
||||
|
|
|
|||
|
|
@ -101,22 +101,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 +120,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 +158,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 {
|
||||
|
|
@ -791,6 +793,7 @@ body {
|
|||
|
||||
.room-name-inline {
|
||||
text-align: start;
|
||||
min-width: 75px;
|
||||
}
|
||||
|
||||
.room-name.no-upper {
|
||||
|
|
@ -1286,7 +1289,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 +1297,7 @@ body {
|
|||
line-height: 17px;
|
||||
.v-icon {
|
||||
margin-right: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1433,6 +1436,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 +1463,9 @@ body {
|
|||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
.clap-button {
|
||||
font-size: 24px;
|
||||
}
|
||||
.mic-button {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
|
|
|||
BIN
src/assets/sounds/clapping.mp3
Normal file
BIN
src/assets/sounds/clapping.mp3
Normal file
Binary file not shown.
|
|
@ -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,11 @@
|
|||
"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": "Seen by no members | Seen by 1 member | Seen by {count} members",
|
||||
"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 +174,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 +219,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",
|
||||
|
|
@ -332,5 +346,19 @@
|
|||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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: "👏"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
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" />
|
||||
|
|
@ -188,35 +189,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">
|
||||
<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="Array.isArray(currentImageInputs) && currentImageInputs.length">
|
||||
<v-card-title v-if="currentImageInputs.length > 1"> {{ $t('message.images') }} </v-card-title>
|
||||
<v-card-text :class="{'ma-0 pa-2' : true, 'd-flex flex-wrap justify-center': currentImageInputs.length > 1}">
|
||||
<div :class="{'col-4': currentImageInputs.length > 1}" v-for="(currentImageInput, id) in currentImageInputs" :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 +243,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" />
|
||||
|
|
@ -342,8 +360,8 @@ export default {
|
|||
timelineWindowPaginating: false,
|
||||
|
||||
scrollPosition: null,
|
||||
currentImageInput: null,
|
||||
currentImageInputPath: null,
|
||||
currentImageInputs: null,
|
||||
currentFileInputs: null,
|
||||
currentSendOperation: null,
|
||||
currentSendProgress: null,
|
||||
currentSendShowSendButton: true,
|
||||
|
|
@ -393,7 +411,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 +440,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 +451,8 @@ export default {
|
|||
},
|
||||
|
||||
beforeDestroy() {
|
||||
this.$root.$off('audio-playback-ended', this.audioPlaybackEnded);
|
||||
this.$audioPlayer.pause();
|
||||
this.stopRRTimer();
|
||||
},
|
||||
|
||||
|
|
@ -427,6 +462,20 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
nonImageFiles() {
|
||||
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 +549,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();
|
||||
|
|
@ -878,7 +932,20 @@ export default {
|
|||
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 +995,64 @@ export default {
|
|||
this.$refs.attachment.click();
|
||||
},
|
||||
|
||||
/**
|
||||
* Handle picked attachment
|
||||
*/
|
||||
handlePickedAttachment(event) {
|
||||
if (event.target.files && event.target.files[0]) {
|
||||
optimizeImage(e,event,file) {
|
||||
let currentImageInput = {
|
||||
image: e.target.result,
|
||||
dimensions: null,
|
||||
};
|
||||
try {
|
||||
currentImageInput.dimensions = sizeOf(dataUriToBuffer(e.target.result));
|
||||
|
||||
// Need to resize?
|
||||
const w = currentImageInput.dimensions.width;
|
||||
const h = 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(
|
||||
currentImageInput,
|
||||
"scaled",
|
||||
new File([img], file.name, {
|
||||
type: img.type,
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
);
|
||||
Vue.set(currentImageInput, "useScaled", true);
|
||||
Vue.set(currentImageInput, "scaledSize", img.size);
|
||||
Vue.set(currentImageInput, "scaledDimensions", {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Resize failed:", err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to get image dimensions: " + error);
|
||||
}
|
||||
return currentImageInput
|
||||
},
|
||||
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);
|
||||
}
|
||||
const currentImageInput = this.optimizeImage(e, event, file)
|
||||
this.currentImageInputs = Array.isArray(this.currentImageInputs) ? [...this.currentImageInputs, currentImageInput] : [currentImageInput]
|
||||
}
|
||||
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 +1061,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 +1087,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) {
|
||||
if (this.isCurrentFileInputsAnArray) {
|
||||
let inputFiles = this.currentFileInputs;
|
||||
if (Array.isArray(this.currentImageInputs) && this.currentImageInputs.scaled && this.currentImageInputs.useScaled) {
|
||||
// Send scaled version of image instead!
|
||||
inputFile = this.currentImageInput.scaled;
|
||||
inputFiles = this.currentImageInputs.map(({scaled}) => scaled)
|
||||
}
|
||||
|
||||
const promises = inputFiles.map(inputFile => util.sendImage(this.$matrix.matrixClient, this.roomId, inputFile, this.onUploadProgress));
|
||||
|
||||
Promise.all(promises).then(() => {
|
||||
this.currentSendOperation = null;
|
||||
this.currentImageInputs = 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,8 +1125,8 @@ export default {
|
|||
this.currentSendOperation.abort();
|
||||
}
|
||||
this.currentSendOperation = null;
|
||||
this.currentImageInput = null;
|
||||
this.currentImageInputPath = null;
|
||||
this.currentImageInputs = null;
|
||||
this.currentFileInputs = null;
|
||||
this.currentSendProgress = null;
|
||||
this.currentSendError = null;
|
||||
},
|
||||
|
|
@ -1248,6 +1315,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;
|
||||
|
||||
|
|
@ -1439,7 +1517,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 +1586,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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,13 +34,17 @@
|
|||
</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">
|
||||
|
|
@ -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>
|
||||
|
|
@ -295,7 +299,7 @@ export default {
|
|||
}
|
||||
if (room) {
|
||||
this.publicRoomLink = this.$router.getRoomLink(
|
||||
room.getCanonicalAlias() || roomId
|
||||
room.getCanonicalAlias(), roomId, room.name
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -424,10 +428,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 +493,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>
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
<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 +93,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"
|
||||
|
|
@ -232,42 +232,6 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
user() {
|
||||
if (!this.$matrix.matrixClient) {
|
||||
return null;
|
||||
}
|
||||
return this.$matrix.matrixClient.getUser(this.$matrix.currentUserId);
|
||||
},
|
||||
|
||||
displayName() {
|
||||
if (!this.user) {
|
||||
return null;
|
||||
}
|
||||
return this.user.displayName || this.user.userId;
|
||||
},
|
||||
|
||||
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 &&
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -247,6 +247,46 @@ export default {
|
|||
return date.toLocaleString();
|
||||
},
|
||||
|
||||
formatTimeAgo(time) {
|
||||
const date = new Date();
|
||||
date.setTime(time);
|
||||
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 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) {
|
||||
return linkifyHtml(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -57,7 +57,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
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,22 @@ class UploadPromise extends Promise {
|
|||
}
|
||||
|
||||
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 +88,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 +222,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);
|
||||
}
|
||||
|
|
@ -337,6 +353,11 @@ class Util {
|
|||
mimetype: file.type,
|
||||
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';
|
||||
|
|
@ -778,6 +799,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,13 @@ 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) {
|
||||
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
|
||||
return window.location.origin + window.location.pathname + "?roomName=" + encodeURIComponent(roomName) + "#/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