diff --git a/README.md b/README.md index d99478a..e100dcd 100644 --- a/README.md +++ b/README.md @@ -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". diff --git a/package-lock.json b/package-lock.json index e04c4eb..a3fe23c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 156bd9f..ee3deaa 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/assets/config.json b/src/assets/config.json index 488b681..bfbdea1 100644 --- a/src/assets/config.json +++ b/src/assets/config.json @@ -9,6 +9,7 @@ "productLink": "letsconvene.im", "defaultServer": "https://neo.keanu.im", "identityServer_unset": "", + "registrationToken_unset": "", "rtl": false, "accentColor_unset": "", "logo_unset": "", diff --git a/src/assets/css/chat.scss b/src/assets/css/chat.scss index deb30e5..d878270 100644 --- a/src/assets/css/chat.scss +++ b/src/assets/css/chat.scss @@ -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; } diff --git a/src/assets/sounds/clapping.mp3 b/src/assets/sounds/clapping.mp3 new file mode 100644 index 0000000..bbc9886 Binary files /dev/null and b/src/assets/sounds/clapping.mp3 differ diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index f28dafe..190277d 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -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" + } } } diff --git a/src/assets/translations/ug.json b/src/assets/translations/ug.json index d76f259..af1b14a 100644 --- a/src/assets/translations/ug.json +++ b/src/assets/translations/ug.json @@ -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": { diff --git a/src/components/AudioLayout.vue b/src/components/AudioLayout.vue index 735573e..24b04d9 100644 --- a/src/components/AudioLayout.vue +++ b/src/components/AudioLayout.vue @@ -28,38 +28,53 @@ }} + + +
+ + + + {{ + reaction.member.name.substring(0, 1).toUpperCase() + }} +
{{ reaction.emoji }}
+
+
+
+
{{ eventSenderDisplayName(currentAudioEvent) }}
- {{ formatTime(currentAudioEvent.event.origin_server_ts) }} + {{ formatTimeAgo(currentAudioEvent.event.origin_server_ts) }}
{{ currentTime }} / {{ totalTime }}
-
- + $vuetify.icons.rewind - + + $vuetify.icons.pause_circle $vuetify.icons.play_circle - + $vuetify.icons.forward
- - mic - +
+ 👏 + + mic + +
expand_more
@@ -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: "👏" + }); + } } } }; diff --git a/src/components/Chat.vue b/src/components/Chat.vue index c587fd1..bfb8ade 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -10,6 +10,7 @@ v-on:loadnext="handleScrolledToBottom(false)" v-on:loadprevious="handleScrolledToTop()" v-on:mark-read="sendRR" + v-on:sendclap="sendClapReactionAtTime" /> @@ -188,35 +189,52 @@ + accept="image/*, audio/*, video/*, .pdf" class="d-none" multiple/> -
- +
+ - - -
- file: {{ currentImageInputPath.name }} - - {{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }} - - {{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }} - - ({{ formatBytes(currentImageInput.scaledSize) }}) - ({{ formatBytes(currentImageInputPath.size) }}) - -
-
{{ currentSendError }}
-
{{ currentSendProgress }}
-
+ {{ $t('message.send_attachements_dialog_title') }} + + + - - {{ - $t("menu.cancel") - }} + +
{{ currentSendError }}
+
{{ currentSendProgress }}
+
+ + {{ $t("menu.cancel") }} + {{ $t("menu.send") }}
@@ -225,7 +243,7 @@
- + @@ -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); + } + } + } + } }, }; diff --git a/src/components/ChatHeader.vue b/src/components/ChatHeader.vue index e523973..4660caa 100644 --- a/src/components/ChatHeader.vue +++ b/src/components/ChatHeader.vue @@ -19,6 +19,7 @@
$vuetify.icons.ic_dropdown
+ circle
@@ -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); } diff --git a/src/components/CreateRoom.vue b/src/components/CreateRoom.vue index efc0ec2..e48ede8 100644 --- a/src/components/CreateRoom.vue +++ b/src/components/CreateRoom.vue @@ -34,13 +34,17 @@ -
{{ $t("new_room.name_room") }}
+
{{ $t("new_room.name_room") }}
-
{{ $t("new_room.room_topic") }}
- +
{{ $t("new_room.room_topic") }}
+ @@ -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); } }, }; diff --git a/src/components/messages/MessageIncomingAudio.vue b/src/components/messages/MessageIncomingAudio.vue index 95ddab9..f1ed2c6 100644 --- a/src/components/messages/MessageIncomingAudio.vue +++ b/src/components/messages/MessageIncomingAudio.vue @@ -1,20 +1,18 @@ diff --git a/src/components/messages/MessageOutgoingAudio.vue b/src/components/messages/MessageOutgoingAudio.vue index f8cea39..b589b6d 100644 --- a/src/components/messages/MessageOutgoingAudio.vue +++ b/src/components/messages/MessageOutgoingAudio.vue @@ -1,20 +1,18 @@ diff --git a/src/components/messages/messageMixin.js b/src/components/messages/messageMixin.js index 4a822cf..7fde69e 100644 --- a/src/components/messages/messageMixin.js +++ b/src/components/messages/messageMixin.js @@ -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); }, diff --git a/src/components/profileInfoMixin.js b/src/components/profileInfoMixin.js index 7a9dd35..d0627dc 100644 --- a/src/components/profileInfoMixin.js +++ b/src/components/profileInfoMixin.js @@ -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); } } } \ No newline at end of file diff --git a/src/components/roomInfoMixin.js b/src/components/roomInfoMixin.js index cb420f5..53cb205 100644 --- a/src/components/roomInfoMixin.js +++ b/src/components/roomInfoMixin.js @@ -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; diff --git a/src/main.js b/src/main.js index 9e8ee19..dd30e8c 100644 --- a/src/main.js +++ b/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'); }); diff --git a/src/plugins/utils.js b/src/plugins/utils.js index 873b126..05dbbff 100644 --- a/src/plugins/utils.js +++ b/src/plugins/utils.js @@ -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(); diff --git a/src/router/index.js b/src/router/index.js index 1599551..7c85163 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -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 diff --git a/src/services/audio.service.js b/src/services/audio.service.js new file mode 100644 index 0000000..6037be4 --- /dev/null +++ b/src/services/audio.service.js @@ -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(); + }, +}; diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index 658f9bf..2b80c10 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -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 = {