Merge branch 'dev' into '419-push-notifications'

# Conflicts:
#   src/assets/translations/en.json
This commit is contained in:
N Pex 2023-06-14 12:31:42 +00:00
commit fa51d4a9ec
28 changed files with 1000 additions and 507 deletions

View file

@ -9,6 +9,7 @@
"productLink": "letsconvene.im",
"defaultServer": "https://neo.keanu.im",
"identityServer_unset": "",
"registrationToken_unset": "",
"rtl": false,
"accentColor_unset": "",
"logo_unset": "",

View file

@ -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;
}

Binary file not shown.

View file

@ -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"
}
}
}

View file

@ -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": {

View file

@ -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: "👏"
});
}
}
}
};

View file

@ -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>

View file

@ -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);
}

View file

@ -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,

View file

@ -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) => {

View file

@ -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)
)

View file

@ -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>

View file

@ -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 &&

View file

@ -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();
}

View file

@ -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);
}
},
};

View file

@ -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>

View file

@ -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>

View file

@ -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);
},

View file

@ -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);
}
}
}

View file

@ -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;

View file

@ -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');
});

View file

@ -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();

View file

@ -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

View 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();
},
};

View file

@ -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 = {