Initial implementation of "audio mode"

This commit is contained in:
N Pex 2023-01-30 08:36:02 +00:00
parent d5942fdb8e
commit 09173a65f1
14 changed files with 944 additions and 410 deletions

View file

@ -0,0 +1,406 @@
<template>
<div v-bind="{...$props, ...$attrs}" v-on="$listeners" class="messageIn">
<div class="load-earlier clickable" @click="loadPrevious">
<v-icon color="white" size="28">expand_less</v-icon>
</div>
<div class="sound-wave-view">
<div class="volume-container">
<div ref="volume"></div>
</div>
<v-avatar v-if="currentAudioEvent" class="avatar" ref="avatar" size="32" color="#ededed"
@click.stop="otherAvatarClicked($refs.avatar.$el)">
<img v-if="messageEventAvatar(currentAudioEvent)" :src="messageEventAvatar(currentAudioEvent)" />
<span v-else class="white--text headline">{{
eventSenderDisplayName(currentAudioEvent).substring(0, 1).toUpperCase()
}}</span>
</v-avatar>
</div>
<div v-if="currentAudioEvent" class="senderAndTime">
<div class="sender">{{ eventSenderDisplayName(currentAudioEvent) }}</div>
<div class="time">
{{ formatTime(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-icon size="28">$vuetify.icons.rewind</v-icon>
</v-btn>
<v-btn v-if="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-icon size="28">$vuetify.icons.forward</v-icon>
</v-btn>
</div>
<div class="load-later">
<v-btn v-if="canRecordAudio" class="mic-button" ref="mic_button" fab small elevation="0" v-blur
@click.stop="$emit('start-recording')">
<v-icon color="white">mic</v-icon>
</v-btn>
<v-icon class="clickable" @click="loadNext" color="white" size="28">expand_more</v-icon>
</div>
</div>
</template>
<script>
import messageMixin from "./messages/messageMixin";
import util from "../plugins/utils";
export default {
mixins: [messageMixin],
components: {},
props: {
autoplay: {
type: Boolean,
default: function () {
return true
}
},
events: {
type: Array,
default: function () {
return []
}
},
readMarker: {
type: String,
default: function () {
return null;
}
}
},
data() {
return {
src: null,
currentAudioEvent: null,
autoPlayNextEvent: false,
currentAudioSource: null,
player: null,
duration: 0,
playPercent: 0,
playTime: 0,
playing: false,
analyzer: null,
analyzerDataArray: null,
};
},
mounted() {
document.body.classList.add("dark");
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.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();
});
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);
}
},
beforeDestroy() {
document.body.classList.remove("dark");
this.currentAudioEvent = null;
this.loadAudioAttachmentSource(); // Release
this.$root.$off('playback-start', this.onPlaybackStart);
},
computed: {
canRecordAudio() {
if (this.room) {
const myUserId = this.$matrix.currentUserId;
const me = this.room.getMember(myUserId);
return me && me.powerLevelNorm > 0 && util.browserCanRecordAudio();
}
return false;
},
currentTime() {
return util.formatDuration(this.playTime);
},
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;
}
},
},
},
watch: {
autoplay: {
immediate: true,
handler(autoplay, ignoredOldValue) {
if (!autoplay) {
this.pause();
}
}
},
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);
}
}
}
},
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.autoPlayNextEvent = false;
this.loadAudioAttachmentSource();
}
},
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();
}
}
},
pause() {
this.player.autoplay = false;
if (this.player.src) {
this.player.pause();
}
},
rewind() {
if (this.player.src) {
this.player.currentTime = Math.max(0, this.player.currentTime - 15);
}
},
forward() {
if (this.player.src) {
this.player.currentTime = Math.min(this.player.duration, this.player.currentTime + 15);
}
},
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;
},
onPlaybackStart(item) {
this.player.autoplay = false;
if (item != this && this.playing) {
this.pause();
}
},
onPlaybackEnd() {
this.loadNext(true && this.autoplay);
},
loadPrevious() {
const audioMessages = this.events.filter((e) => e.getContent().msgtype === "m.audio");
for (let i = 0; i < audioMessages.length; i++) {
const e = audioMessages[i];
if (this.currentAudioEvent && e.getId() === this.currentAudioEvent.getId()) {
if (i > 0) {
this.pause();
this.currentAudioEvent = audioMessages[i - 1];
return;
}
break;
}
}
this.$emit("loadprevious");
},
loadNext(autoplay = false) {
const audioMessages = this.events.filter((e) => e.getContent().msgtype === "m.audio");
if (audioMessages.length == 0) {
// Try to load earlier
this.$emit("loadprevious");
return;
}
if (!this.currentAudioEvent) {
// Figure out which audio event to start with, i.e. our "read marker"
for (let i = 0; i < audioMessages.length; i++) {
const e = audioMessages[i];
if (e.getId() === this.readMarker) {
if (i < (audioMessages.length - 1)) {
this.pause();
this.player.autoplay = autoplay;
this.currentAudioEvent = audioMessages[i + 1];
} else {
this.autoPlayNextEvent = true;
this.player.autoplay = autoplay;
this.currentAudioEvent = e;
this.$emit("loadnext");
}
return;
}
}
// No read marker found. Just use the first event here...
if (audioMessages.length > 0) {
this.pause();
this.player.autoplay = autoplay;
this.currentAudioEvent = audioMessages[0];
}
return;
}
for (let i = 0; i < audioMessages.length; i++) {
const e = audioMessages[i];
if (e.getId() === this.currentAudioEvent.getId()) {
if (i < (audioMessages.length - 1)) {
this.pause();
this.player.autoplay = autoplay;
this.currentAudioEvent = audioMessages[i + 1];
} else {
this.autoPlayNextEvent = true;
this.player.autoplay = autoplay;
this.$emit("loadnext");
}
break;
}
}
},
updateVisualization() {
const volume = this.$refs.volume;
if (volume && this.analyser) {
const volumeContainer = volume.parentElement;
const bufferLength = this.analyser.frequencyBinCount;
this.analyser.getByteFrequencyData(this.analyzerDataArray);
var value = 0;
for (let i = 0; i < bufferLength; i++) {
value += this.analyzerDataArray[i];
}
value = value / bufferLength;
const avatarWidth = 1.1 * this.$refs.avatar ? this.$refs.avatar.clientWidth : 104;
const range = Math.max(0, (volumeContainer.clientWidth - avatarWidth));
const w = avatarWidth + (value * range) / 256;
volume.style.width = "" + w + "px";
volume.style.height = "" + w + "px";
const color = 80 + (value * (256 - 80)) / 256;
volume.style.backgroundColor = `rgb(${color},${color},${color})`;
if (this.playing) {
requestAnimationFrame(this.updateVisualization);
} else {
this.clearVisualization();
}
}
},
clearVisualization() {
const volume = this.$refs.volume;
volume.style.width = "0px";
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);
});
}
},
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -4,49 +4,39 @@
{{ $tc("room.invitations", invitationCount) }}
</div>
<ChatHeader class="chat-header flex-grow-0 flex-shrink-0" v-on:header-click="onHeaderClick" />
<div
class="chat-content flex-grow-1 flex-shrink-1"
ref="chatContainer"
v-on:scroll="onScroll"
@click="closeContextMenusIfOpen"
>
<AudioLayout ref="chatContainer" class="auto-audio-player-root" v-if="useAudioLayout" :room="room"
:events="events" :autoplay="!showRecorder"
:timelineSet="timelineSet"
:readMarker="readMarker"
v-on:start-recording="showRecorder = true"
v-on:loadnext="handleScrolledToBottom(false)"
v-on:loadprevious="handleScrolledToTop()"
v-on:mark-read="sendRR"
/>
<VoiceRecorder class="audio-layout" v-if="useAudioLayout" :micButtonRef="$refs.mic_button" :ptt="showRecorderPTT" :show="showRecorder"
v-on:close="showRecorder = false" v-on:file="onVoiceRecording" />
<div v-if="!useAudioLayout" class="chat-content flex-grow-1 flex-shrink-1" ref="chatContainer"
v-on:scroll="onScroll" @click="closeContextMenusIfOpen">
<div ref="messageOperationsStrut" class="message-operations-strut">
<message-operations
ref="messageOperations"
:style="opStyle"
:emojis="recentEmojis"
v-on:close="
showContextMenu = false;
showContextMenuAnchor = null;
"
v-if="showMessageOperations"
v-on:addreaction="addReaction"
v-on:addquickreaction="addQuickReaction"
v-on:addreply="addReply(selectedEvent)"
v-on:edit="edit(selectedEvent)"
v-on:redact="redact(selectedEvent)"
v-on:download="download(selectedEvent)"
v-on:more="
<message-operations ref="messageOperations" :style="opStyle" :emojis="recentEmojis" v-on:close="
showContextMenu = false;
showContextMenuAnchor = null;
" v-if="showMessageOperations" v-on:addreaction="addReaction" v-on:addquickreaction="addQuickReaction"
v-on:addreply="addReply(selectedEvent)" v-on:edit="edit(selectedEvent)" v-on:redact="redact(selectedEvent)"
v-on:download="download(selectedEvent)" v-on:more="
isEmojiQuickReaction= true
showMoreMessageOperations($event)
"
:event="selectedEvent"
/>
" :originalEvent="selectedEvent" />
</div>
<div ref="avatarOperationsStrut" class="avatar-operations-strut">
<avatar-operations
ref="avatarOperations"
:style="avatarOpStyle"
v-on:close="
showAvatarMenu = false;
showAvatarMenuAnchor = null;
"
v-on:start-private-chat="startPrivateChat($event)"
v-if="selectedEvent && showAvatarMenu"
:room="room"
:event="selectedEvent"
/>
<avatar-operations ref="avatarOperations" :style="avatarOpStyle" v-on:close="
showAvatarMenu = false;
showAvatarMenuAnchor = null;
" v-on:start-private-chat="startPrivateChat($event)" v-if="selectedEvent && showAvatarMenu" :room="room"
:originalEvent="selectedEvent" />
</div>
<!-- Handle resizes, e.g. when soft keyboard is shown/hidden -->
@ -59,55 +49,31 @@
<div v-if="showDayMarkerBeforeEvent(event)" class="day-marker" :title="dayForEvent(event)" />
<div v-if="!event.isRelation() && !event.isRedaction()" :ref="event.getId()">
<div
class="message-wrapper"
v-on:touchstart="
(e) => {
touchStart(e, event);
}
"
v-on:touchend="touchEnd"
v-on:touchcancel="touchCancel"
v-on:touchmove="touchMove"
>
<component
:is="componentForEvent(event)"
:room="room"
:event="event"
:nextEvent="events[index + 1]"
:timelineSet="timelineSet"
v-on:send-quick-reaction="sendQuickReaction"
v-on:context-menu="showContextMenuForEvent($event)"
v-on:own-avatar-clicked="viewProfile"
v-on:other-avatar-clicked="showAvatarMenuForEvent($event)"
v-on:download="download(event)"
v-on:poll-closed="pollWasClosed(event)"
/>
<div class="message-wrapper" v-on:touchstart="
(e) => {
touchStart(e, event);
}
" v-on:touchend="touchEnd" v-on:touchcancel="touchCancel" v-on:touchmove="touchMove">
<component :is="componentForEvent(event)" :room="room" :originalEvent="event" :nextEvent="events[index + 1]"
:timelineSet="timelineSet" v-on:send-quick-reaction="sendQuickReaction"
v-on:context-menu="showContextMenuForEvent($event)" v-on:own-avatar-clicked="viewProfile"
v-on:other-avatar-clicked="showAvatarMenuForEvent($event)" v-on:download="download(event)"
v-on:poll-closed="pollWasClosed(event)" />
<!-- <div v-if="debugging" style="user-select:text">EventID: {{ event.getId() }}</div> -->
<!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}</div> -->
<div
v-if="event.getId() == readMarker && index < events.length - 1"
class="read-marker"
:title="$t('message.unread_messages')"
/>
<div v-if="event.getId() == readMarker && index < events.length - 1" class="read-marker"
:title="$t('message.unread_messages')" />
</div>
</div>
</div>
</div>
<!-- Input area -->
<v-container v-if="room" fluid :class="['input-area-outer', replyToEvent ? 'reply-to' : '']">
<v-container v-if="!useAudioLayout && room" fluid :class="['input-area-outer', replyToEvent ? 'reply-to' : '']">
<div :class="[replyToEvent ? 'iput-area-inner-box' : '']">
<!-- "Scroll to end"-button -->
<v-btn
class="scroll-to-end"
v-show="showScrollToEnd"
fab
x-small
elevation="0"
color="black"
@click.stop="scrollToEndOfTimeline"
>
<v-btn v-if="!useAudioLayout" class="scroll-to-end" v-show="showScrollToEnd" fab x-small elevation="0" color="black"
@click.stop="scrollToEndOfTimeline">
<v-icon color="white">arrow_downward</v-icon>
</v-btn>
@ -121,10 +87,11 @@
<div v-if="replyToContentType === 'm.image'">{{ $t("message.reply_image") }}</div>
<div v-if="replyToContentType === 'm.audio'">{{ $t("message.reply_audio_message") }}</div>
<div v-if="replyToContentType === 'm.video'">{{ $t("message.reply_video") }}</div>
<div v-if="replyToContentType === 'm.poll'">{{ $t("message.reply_poll") }}</div>
<div v-if="replyToContentType === 'm.poll'">{{ $t("message.reply_poll") }}</div>
</div>
<div class="col col-auto" v-if="replyToContentType !== 'm.text'">
<img v-if="replyToContentType === 'm.image'" width="50px" height="50px" :src="replyToImg" class="rounded" />
<img v-if="replyToContentType === 'm.image'" width="50px" height="50px" :src="replyToImg"
class="rounded" />
<v-img v-if="replyToContentType === 'm.audio'" src="@/assets/icons/audio_message.svg" />
<v-img v-if="replyToContentType === 'm.video'" src="@/assets/icons/video_message.svg" />
<v-icon v-if="replyToContentType === 'm.poll'" light>$vuetify.icons.poll</v-icon>
@ -143,24 +110,13 @@
</v-row>
<v-row class="input-area-inner align-center">
<v-col class="flex-grow-1 flex-shrink-1 ma-0 pa-0">
<v-textarea
height="undefined"
ref="messageInput"
full-width
auto-grow
rows="1"
v-model="currentInput"
no-resize
class="input-area-text"
:placeholder="$t('message.your_message')"
hide-details
background-color="white"
v-on:keydown.enter.prevent="
<v-textarea height="undefined" ref="messageInput" full-width auto-grow rows="1" v-model="currentInput"
no-resize class="input-area-text" :placeholder="$t('message.your_message')" hide-details
background-color="white" v-on:keydown.enter.prevent="
() => {
sendCurrentTextMessage();
}
"
/>
" />
</v-col>
<v-col class="input-area-button text-center flex-grow-0 flex-shrink-1" v-if="editedEvent">
@ -169,150 +125,82 @@
</v-btn>
</v-col>
<v-col
v-if="(!currentInput || currentInput.length == 0) && !showRecorder && canCreatePoll"
class="input-area-button text-center flex-grow-0 flex-shrink-1"
>
<v-col v-if="(!currentInput || currentInput.length == 0) && !showRecorder && canCreatePoll"
class="input-area-button text-center flex-grow-0 flex-shrink-1">
<v-btn icon large color="black" @click="showCreatePollDialog = true">
<v-icon dark>$vuetify.icons.poll</v-icon>
</v-btn>
</v-col>
<v-col
class="input-area-button text-center flex-grow-0 flex-shrink-1"
v-if="!currentInput || currentInput.length == 0 || showRecorder"
>
<v-btn
v-if="canRecordAudio"
class="mic-button"
ref="mic_button"
fab
small
elevation="0"
v-blur
v-longTap:250="[showRecordingUI, startRecording]"
>
<v-col class="input-area-button text-center flex-grow-0 flex-shrink-1"
v-if="!currentInput || currentInput.length == 0 || showRecorder">
<v-btn v-if="canRecordAudio" class="mic-button" ref="mic_button" fab small elevation="0" v-blur
v-longTap:250="[showRecordingUI, startRecording]">
<v-icon :color="showRecorder ? 'white' : 'black'">mic</v-icon>
</v-btn>
<v-btn
v-else
class="mic-button"
ref="mic_button"
fab
small
elevation="0"
v-blur
@click.stop="showNoRecordingAvailableDialog = true"
>
<v-btn v-else class="mic-button" ref="mic_button" fab small elevation="0" v-blur
@click.stop="showNoRecordingAvailableDialog = true">
<v-icon :color="showRecorder ? 'white' : 'black'">mic</v-icon>
</v-btn>
</v-col>
<v-col class="input-area-button text-center flex-grow-0 flex-shrink-1" v-else>
<v-btn
fab
small
elevation="0"
color="black"
@click.stop="sendCurrentTextMessage"
:disabled="sendButtonDisabled"
>
<v-btn fab small elevation="0" color="black" @click.stop="sendCurrentTextMessage"
:disabled="sendButtonDisabled">
<v-icon color="white">{{ editedEvent ? "save" : "arrow_upward" }}</v-icon>
</v-btn>
</v-col>
<v-col
class="input-area-button text-center flex-grow-0 flex-shrink-1 input-more-icon"
>
<v-btn
fab
small
elevation="0"
v-blur
@click.stop="
isEmojiQuickReaction = false
showMoreMessageOperations($event)
"
>
<v-col class="input-area-button text-center flex-grow-0 flex-shrink-1 input-more-icon">
<v-btn fab small elevation="0" v-blur @click.stop="
isEmojiQuickReaction = false
showMoreMessageOperations($event)
">
<v-icon>$vuetify.icons.addReaction</v-icon>
</v-btn>
</v-col>
<v-col v-if="$config.shortCodeStickers" class="input-area-button text-center flex-grow-0 flex-shrink-1">
<v-btn
v-if="!showRecorder"
id="btn-attach"
icon
large
color="black"
@click="showStickerPicker"
:disabled="attachButtonDisabled"
>
<v-btn v-if="!showRecorder" id="btn-attach" icon large color="black" @click="showStickerPicker"
:disabled="attachButtonDisabled">
<v-icon large>face</v-icon>
</v-btn>
</v-col>
<v-col class="input-area-button text-center flex-grow-0 flex-shrink-1">
<label icon flat ref="attachmentLabel">
<v-btn
v-if="!showRecorder"
icon
large
color="black"
@click="showAttachmentPicker"
:disabled="attachButtonDisabled"
>
<v-btn v-if="!showRecorder" icon large color="black" @click="showAttachmentPicker"
:disabled="attachButtonDisabled">
<v-icon x-large>add_circle_outline</v-icon>
</v-btn>
<input
ref="attachment"
type="file"
name="attachment"
@change="handlePickedAttachment($event)"
accept="image/*|audio/*|video/*|application/pdf"
class="d-none"
/>
</label>
</v-col>
</v-row>
<VoiceRecorder
:micButtonRef="$refs.mic_button"
:ptt="showRecorderPTT"
:show="showRecorder"
v-on:close="showRecorder = false"
v-on:file="onVoiceRecording"
/>
<VoiceRecorder :micButtonRef="$refs.mic_button" :ptt="showRecorderPTT" :show="showRecorder"
v-on:close="showRecorder = false" v-on:file="onVoiceRecording" />
</div>
</v-container>
<input ref="attachment" type="file" name="attachment" @change="handlePickedAttachment($event)"
accept="image/*|audio/*|video/*|application/pdf" class="d-none" />
<div v-if="currentImageInputPath">
<v-dialog v-model="currentImageInputPath" class="ma-0 pa-0" :width="$vuetify.breakpoint.smAndUp ? '50%' : '85%'">
<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"
/>
<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
>
{{ currentImageInput.scaledDimensions.width }} x {{ currentImageInput.scaledDimensions.height }}</span>
<span v-else-if="currentImageInput && currentImageInput.dimensions">
{{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }}</span
>
{{ currentImageInput.dimensions.width }} x {{ currentImageInput.dimensions.height }}</span>
<span v-if="currentImageInput && currentImageInput.scaled && currentImageInput.useScaled">
({{ formatBytes(currentImageInput.scaledSize) }})</span
>
({{ 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"
/>
<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>
@ -321,17 +209,10 @@
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" text @click="cancelSendAttachment" id="btn-attachment-cancel">{{
$t("menu.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-btn id="btn-attachment-send" color="primary" text @click="sendAttachment"
v-if="currentSendShowSendButton" :disabled="currentSendOperation != null">{{ $t("menu.send") }}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@ -363,7 +244,7 @@
<v-card-actions>
<v-spacer></v-spacer>
<v-btn id="btn-ok" color="primary" text @click="showNoRecordingAvailableDialog = false">{{
$t("menu.ok")
$t("menu.ok")
}}</v-btn>
</v-card-actions>
</v-card>
@ -389,6 +270,7 @@ import BottomSheet from "./BottomSheet.vue";
import ImageResize from "image-resize";
import CreatePollDialog from "./CreatePollDialog.vue";
import chatMixin from "./chatMixin";
import AudioLayout from "./AudioLayout.vue";
const sizeOf = require("image-size");
const dataUriToBuffer = require("data-uri-to-buffer");
@ -405,7 +287,7 @@ function ScrollPosition(node) {
this.readyFor = "up";
}
ScrollPosition.prototype.restore = function() {
ScrollPosition.prototype.restore = function () {
if (this.readyFor === "up") {
this.node.scrollTop = this.node.scrollHeight - this.previousScrollHeightMinusTop;
} else {
@ -413,7 +295,7 @@ ScrollPosition.prototype.restore = function() {
}
};
ScrollPosition.prototype.prepareFor = function(direction) {
ScrollPosition.prototype.prepareFor = function (direction) {
this.readyFor = direction || "up";
if (this.readyFor === "up") {
this.previousScrollHeightMinusTop = this.node.scrollHeight - this.node.scrollTop;
@ -436,6 +318,7 @@ export default {
BottomSheet,
AvatarOperations,
CreatePollDialog,
AudioLayout
},
data() {
@ -516,9 +399,13 @@ export default {
},
mounted() {
const container = this.$refs.chatContainer;
this.scrollPosition = new ScrollPosition(container);
this.chatContainerSize = this.$refs.chatContainerResizer.$el.clientHeight;
const container = this.chatContainer;
if (container) {
this.scrollPosition = new ScrollPosition(container);
if (this.$refs.chatContainerResizer) {
this.chatContainerSize = this.$refs.chatContainerResizer.$el.clientHeight;
}
}
},
beforeDestroy() {
@ -531,6 +418,14 @@ export default {
},
computed: {
chatContainer() {
const container = this.$refs.chatContainer;
console.log("GOT CONTAINER", container);
if (this.useAudioLayout) {
return container.$el;
}
return container;
},
senderDisplayName() {
return this.room.getMember(this.replyToEvent.sender.userId).name;
},
@ -627,9 +522,28 @@ export default {
me && this.room.currentState && this.room.currentState.hasSufficientPowerLevelFor("redact", me.powerLevel);
return isAdmin;
},
useAudioLayout: {
get: function () {
if (this.room) {
const tags = this.room.tags;
if (tags && tags["ui_options"]) {
return tags["ui_options"]["audio_layout"] === 1;
}
}
return false;
},
}
},
watch: {
initialLoadDone: {
immediate: true,
handler(value, oldValue) {
if (value && !oldValue) {
console.log("Loading finished!");
}
}
},
roomId: {
immediate: true,
handler(value, oldValue) {
@ -729,6 +643,7 @@ export default {
const getMoreIfNeeded = function _getMoreIfNeeded() {
const container = self.$refs.chatContainer;
if (
container &&
container.scrollHeight <= (1 + 2 * WINDOW_BUFFER_SIZE) * container.clientHeight &&
self.timelineWindow &&
self.timelineWindow.canPaginate(EventTimeline.BACKWARDS)
@ -867,22 +782,22 @@ export default {
handleChatContainerResize({ ignoredWidth, height }) {
const delta = height - this.chatContainerSize;
this.chatContainerSize = height;
const container = this.$refs.chatContainer;
if (delta < 0) {
const container = this.chatContainer;
if (container && delta < 0) {
container.scrollTop -= delta;
}
},
paginateBackIfNeeded() {
this.$nextTick(() => {
const container = this.$refs.chatContainer;
if (container.scrollHeight <= container.clientHeight) {
const container = this.chatContainer;
if (container && container.scrollHeight <= container.clientHeight) {
this.handleScrolledToTop();
}
});
},
onScroll(ignoredevent) {
const container = this.$refs.chatContainer;
const container = this.chatContainer;
if (!container) {
return;
}
@ -898,7 +813,7 @@ export default {
container.scrollHeight === container.clientHeight
? false
: container.scrollHeight - container.scrollTop.toFixed(0) > container.clientHeight ||
(this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS));
(this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS));
this.restartRRTimer();
},
@ -908,19 +823,20 @@ export default {
return; // Not for this room
}
const loadingDone = this.initialLoadDone;
this.$matrix.matrixClient.decryptEventIfNeeded(event, {});
if (this.initialLoadDone) {
if (this.initialLoadDone && !this.useAudioLayout) {
this.paginateBackIfNeeded();
}
// If we are at bottom, scroll to see new events...
const container = this.$refs.chatContainer;
var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll
if (container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) {
scrollToSeeNew = true;
}
if (this.initialLoadDone && event.forwardLooking && !event.isRelation()) {
if (loadingDone && event.forwardLooking && !event.isRelation()) {
// If we are at bottom, scroll to see new events...
var scrollToSeeNew = event.getSender() == this.$matrix.currentUserId; // When we sent, scroll
const container = this.chatContainer;
if (container && container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) {
scrollToSeeNew = true;
}
this.handleScrolledToBottom(scrollToSeeNew);
}
},
@ -1140,16 +1056,18 @@ export default {
.paginate(EventTimeline.FORWARDS, 10, true)
.then((success) => {
if (success) {
this.scrollPosition.prepareFor("down");
this.events = this.timelineWindow.getEvents();
this.$nextTick(() => {
// restore scroll position!
console.log("Restore scroll!");
this.scrollPosition.restore();
if (scrollToEnd) {
this.smoothScrollToEnd();
}
});
if (!this.useAudioLayout) {
this.scrollPosition.prepareFor("down");
this.$nextTick(() => {
// restore scroll position!
console.log("Restore scroll!");
this.scrollPosition.restore();
if (scrollToEnd) {
this.smoothScrollToEnd();
}
});
}
}
})
.finally(() => {
@ -1162,7 +1080,7 @@ export default {
* Scroll so that the given event is at the middle of the chat view (if more events) or else at the bottom.
*/
scrollToEvent(eventId) {
const container = this.$refs.chatContainer;
const container = this.chatContainer;
const ref = this.$refs[eventId];
if (container && ref) {
const targetY = container.clientHeight / 2;
@ -1172,9 +1090,9 @@ export default {
},
smoothScrollToEnd() {
this.$nextTick(function() {
const container = this.$refs.chatContainer;
if (container.children.length > 0) {
this.$nextTick(function () {
const container = this.chatContainer;
if (container && container.children.length > 0) {
const lastChild = container.children[container.children.length - 1];
console.log("Scroll into view", lastChild);
window.requestAnimationFrame(() => {
@ -1251,7 +1169,7 @@ export default {
document.body.appendChild(link);
link.click();
setTimeout(function() {
setTimeout(function () {
document.body.removeChild(link);
URL.revokeObjectURL(url);
}, 200);
@ -1268,8 +1186,8 @@ export default {
},
emojiSelected(e) {
if(this.isEmojiQuickReaction) {
// When quick emoji picker is clicked
if (this.isEmojiQuickReaction) {
// When quick emoji picker is clicked
if (this.selectedEvent) {
const event = this.selectedEvent;
this.selectedEvent = null;
@ -1369,65 +1287,78 @@ export default {
* Start/restart the timer to Read Receipts.
*/
restartRRTimer() {
console.log("Restart RR timer");
this.stopRRTimer();
this.rrTimer = setTimeout(this.rrTimerElapsed, READ_RECEIPT_TIMEOUT);
let eventIdFirst = null;
let eventIdLast = null;
if (!this.useAudioLayout) {
const container = this.chatContainer;
const elFirst = util.getFirstVisibleElement(container);
const elLast = util.getLastVisibleElement(container);
if (elFirst && elLast) {
eventIdFirst = elFirst.getAttribute("eventId");
eventIdLast = elLast.getAttribute("eventId");
}
}
if (eventIdFirst && eventIdLast) {
this.rrTimer = setTimeout(() => { this.rrTimerElapsed(eventIdFirst, eventIdLast) }, READ_RECEIPT_TIMEOUT);
}
},
rrTimerElapsed() {
rrTimerElapsed(eventIdFirst, eventIdLast) {
console.log("RR timer elapsed", eventIdFirst, eventIdLast);
this.rrTimer = null;
this.sendRR(eventIdFirst, eventIdLast);
this.restartRRTimer();
},
const container = this.$refs.chatContainer;
const elFirst = util.getFirstVisibleElement(container);
const elLast = util.getLastVisibleElement(container);
if (elFirst && elLast) {
const eventIdFirst = elFirst.getAttribute("eventId");
const eventIdLast = elLast.getAttribute("eventId");
if (eventIdLast && this.room) {
var event = this.room.findEventById(eventIdLast);
const index = this.events.indexOf(event);
sendRR(eventIdFirst, eventIdLast) {
console.log("SEND RR", eventIdFirst, eventIdLast);
if (eventIdLast && this.room) {
var event = this.room.findEventById(eventIdLast);
const index = this.events.indexOf(event);
// Walk backwards through visible events to the first one that is incoming
//
var lastTimestamp = 0;
if (this.lastRR) {
lastTimestamp = this.lastRR.getTs();
// Walk backwards through visible events to the first one that is incoming
//
var lastTimestamp = 0;
if (this.lastRR) {
lastTimestamp = this.lastRR.getTs();
}
for (var i = index; i >= 0; i--) {
event = this.events[i];
if (event == this.lastRR || event.getTs() <= lastTimestamp) {
// Already sent this or too old...
break;
}
// Make sure it's not a local echo event...
if (!event.getId().startsWith("~")) {
// Send read receipt
this.$matrix.matrixClient
.sendReadReceipt(event)
.then(() => {
this.$matrix.matrixClient.setRoomReadMarkers(this.room.roomId, event.getId());
})
.then(() => {
console.log("RR sent for event: " + event.getId());
this.lastRR = event;
})
.catch((err) => {
console.log("Failed to update read marker: ", err);
})
.finally(() => {
this.restartRRTimer();
});
return; // Bail out here
}
for (var i = index; i >= 0; i--) {
event = this.events[i];
if (event == this.lastRR || event.getTs() <= lastTimestamp) {
// Already sent this or too old...
break;
}
// Make sure it's not a local echo event...
if (!event.getId().startsWith("~")) {
// Send read receipt
this.$matrix.matrixClient
.sendReadReceipt(event)
.then(() => {
this.$matrix.matrixClient.setRoomReadMarkers(this.room.roomId, event.getId());
})
.then(() => {
console.log("RR sent for event: " + event.getId());
this.lastRR = event;
})
.catch((err) => {
console.log("Failed to update read marker: ", err);
})
.finally(() => {
this.restartRRTimer();
});
return; // Bail out here
}
// Stop iterating at first visible
if (event.getId() == eventIdFirst) {
break;
}
// Stop iterating at first visible
if (event.getId() == eventIdFirst) {
break;
}
}
}
this.restartRRTimer();
},
showRecordingUI() {
@ -1499,9 +1430,9 @@ export default {
let div = document.createElement("div");
div.classList.add("toast");
div.innerText = this.$t("poll_create.results_shared");
this.$refs.chatContainer.parentElement.appendChild(div);
this.chatContainer.parentElement.appendChild(div);
setTimeout(() => {
this.$refs.chatContainer.parentElement.removeChild(div);
this.chatContainer.parentElement.removeChild(div);
}, 3000);
}
},

View file

@ -31,7 +31,7 @@
<component
:is="componentForEvent(event, true)"
:room="room"
:event="event"
:originalEvent="event"
:nextEvent="events[index + 1]"
:timelineSet="timelineSet"
ref="exportedEvent"

View file

@ -165,6 +165,16 @@
</v-card-text>
</v-card>
<v-card class="account ma-3" flat>
<v-card-title class="h2">{{ $t("room_info.ui_options") }}</v-card-title>
<v-card-text>
<v-switch
v-model="useAudioLayout"
:label="$t('room_info.audio_layout')"
></v-switch>
</v-card-text>
</v-card>
<v-card class="members ma-3" flat>
<v-card-title class="h2"
>{{ $t("room_info.members") }}<v-spacer></v-spacer>
@ -319,7 +329,7 @@ export default {
],
SHOW_MEMBER_LIMIT: 5,
exporting: false,
};
};
},
mounted() {
this.$matrix.on("Room.timeline", this.onEvent);
@ -362,6 +372,26 @@ export default {
}
return "";
},
useAudioLayout: {
get: function () {
if (this.room) {
const tags = this.room.tags;
if (tags && tags["ui_options"]) {
return tags["ui_options"]["audio_layout"] === 1;
}
}
return false;
},
set: function (audioLayout) {
if (this.room && this.room.tags) {
let options = this.room.tags["ui_options"] || {}
options["audio_layout"] = (audioLayout ? 1 : 0);
this.room.tags["ui_options"] = options;
this.$matrix.matrixClient.setRoomTag(this.room.roomId, "ui_options", options);
}
},
}
},
watch: {

View file

@ -54,9 +54,10 @@ export default {
this.player.addEventListener("pause", () => {
this.playing = false;
});
this.player.addEventListener("ended", function () {
this.player.addEventListener("ended", () => {
this.pause();
this.playing = false;
this.$emit("playback-ended");
});
},
beforeDestroy() {

View file

@ -7,25 +7,41 @@ export default {
downloadProgress: null
}
},
mounted() {
console.log("Mounted with event:", JSON.stringify(this.event.getContent()))
util
.getAttachment(this.$matrix.matrixClient, this.event, (progress) => {
this.downloadProgress = progress;
console.log("Progress: " + progress);
})
.then((url) => {
this.src = url;
})
.catch((err) => {
console.log("Failed to fetch attachment: ", err);
});
},
beforeDestroy() {
if (this.src) {
const objectUrl = this.src;
this.src = null;
URL.revokeObjectURL(objectUrl);
watch: {
event: {
immediate: false,
handler(value, ignoredOldValue) {
this.loadAttachmentSource(value);
}
}
},
mounted() {
this.loadAttachmentSource(this.event);
},
beforeDestroy() {
this.loadAttachmentSource(null); // Release
},
methods: {
loadAttachmentSource(event) {
if (this.src) {
const objectUrl = this.src;
this.src = null;
URL.revokeObjectURL(objectUrl);
}
if (event) {
util
.getAttachment(this.$matrix.matrixClient, event, (progress) => {
this.downloadProgress = progress;
console.log("Progress: " + progress);
})
.then((url) => {
this.src = url;
})
.catch((err) => {
console.log("Failed to fetch attachment: ", err);
});
}
}
}
}

View file

@ -1,51 +1,51 @@
import QuickReactions from './QuickReactions.vue';
var linkify = require('linkifyjs');
var linkifyHtml = require('linkifyjs/html');
import QuickReactions from "./QuickReactions.vue";
var linkify = require("linkifyjs");
var linkifyHtml = require("linkifyjs/html");
linkify.options.defaults.className = "link";
linkify.options.defaults.target = { url: '_blank' };
linkify.options.defaults.target = { url: "_blank" };
export default {
components: {
QuickReactions
QuickReactions,
},
props: {
room: {
type: Object,
default: function () {
return null
}
return null;
},
},
event: {
originalEvent: {
type: Object,
default: function () {
return {}
}
return {};
},
},
nextEvent: {
type: Object,
default: function () {
return null
}
return null;
},
},
timelineSet: {
type: Object,
default: function () {
return null
}
return null;
},
},
},
data() {
return {
event: {},
inReplyToEvent: null,
inReplyToSender: null
}
inReplyToSender: null,
};
},
mounted() {
const relatesTo = this.event.getWireContent()['m.relates_to'];
if (relatesTo && relatesTo['m.in_reply_to'])
{
const relatesTo = this.validEvent && this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
// Can we find the original message?
const originalEventId = relatesTo['m.in_reply_to'].event_id;
const originalEventId = relatesTo["m.in_reply_to"].event_id;
if (originalEventId && this.timelineSet) {
const originalEvent = this.timelineSet.findEventById(originalEventId);
if (originalEvent) {
@ -55,7 +55,29 @@ export default {
}
}
},
watch: {
originalEvent: {
immediate: true,
handler(originalEvent, ignoredOldValue) {
this.event = originalEvent;
// Check not null and not {}
if (originalEvent && originalEvent.isBeingDecrypted && originalEvent.isBeingDecrypted()) {
this.originalEvent.getDecryptionPromise().then(() => {
this.event = originalEvent;
});
}
},
},
},
computed: {
/**
*
* @returns true if event is non-null and contains data
*/
validEvent() {
return this.event && Object.keys(this.event).length !== 0;
},
incoming() {
return this.event && this.event.getSender() != this.$matrix.currentUserId;
},
@ -67,21 +89,20 @@ export default {
if (this.nextEvent && this.nextEvent.getSender() == this.event.getSender()) {
const ts1 = this.nextEvent.event.origin_server_ts;
const ts2 = this.event.event.origin_server_ts;
return (ts1 - ts2) < (2 * 60 * 1000); // less than 2 minutes
return ts1 - ts2 < 2 * 60 * 1000; // less than 2 minutes
}
return true;
},
inReplyToText() {
const relatesTo = this.event.getWireContent()['m.relates_to'];
if (relatesTo && relatesTo['m.in_reply_to'])
{
const relatesTo = this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
const content = this.event.getContent();
const lines = content.body.split('\n').reverse();
while (lines.length && !lines[0].startsWith('> ')) lines.shift();
const lines = content.body.split("\n").reverse();
while (lines.length && !lines[0].startsWith("> ")) lines.shift();
// Reply fallback has a blank line after it, so remove it to prevent leading newline
if (lines[0] === '') lines.shift();
const text = lines[0] && lines[0].replace(/^> (<.*> )?/g, '');
if (lines[0] === "") lines.shift();
const text = lines[0] && lines[0].replace(/^> (<.*> )?/g, "");
if (text) {
return text;
}
@ -92,23 +113,22 @@ export default {
}
// We don't have the original text (at the moment at least)
return this.$t('fallbacks.original_text');
return this.$t("fallbacks.original_text");
}
return null;
},
messageText() {
const relatesTo = this.event.getWireContent()['m.relates_to'];
if (relatesTo && relatesTo['m.in_reply_to'])
{
const relatesTo = this.event.getWireContent()["m.relates_to"];
if (relatesTo && relatesTo["m.in_reply_to"]) {
const content = this.event.getContent();
// Remove the new text and strip "> " from the old original text
const lines = content.body.split('\n');
while (lines.length && lines[0].startsWith('> ')) lines.shift();
const lines = content.body.split("\n");
while (lines.length && lines[0].startsWith("> ")) lines.shift();
// Reply fallback has a blank line after it, so remove it to prevent leading newline
if (lines[0] === '') lines.shift();
return lines.join('\n');
if (lines[0] === "") lines.shift();
return lines.join("\n");
}
return this.event.getContent().body;
},
@ -118,40 +138,36 @@ export default {
*/
messageClasses() {
return {'messageIn':true,'from-admin':this.senderIsAdminOrModerator(this.event)}
return { messageIn: true, "from-admin": this.senderIsAdminOrModerator(this.event) };
},
userAvatar() {
if (!this.$matrix.userAvatar) {
return null;
}
return this.$matrix.matrixClient.mxcUrlToHttp(
this.$matrix.userAvatar,
80,
80,
"scale",
true
);
return this.$matrix.matrixClient.mxcUrlToHttp(this.$matrix.userAvatar, 80, 80, "scale", true);
},
userAvatarLetter() {
if (!this.$matrix.currentUser) {
return null;
}
return (this.$matrix.currentUserDisplayName || this.$matrix.currentUserId.substring(1)).substring(0, 1).toUpperCase();
}
return (this.$matrix.currentUserDisplayName || this.$matrix.currentUserId.substring(1))
.substring(0, 1)
.toUpperCase();
},
},
methods: {
ownAvatarClicked() {
this.$emit("own-avatar-clicked", {event: this.event});
this.$emit("own-avatar-clicked", { event: this.event });
},
otherAvatarClicked(avatarRef) {
this.$emit("other-avatar-clicked", {event: this.event, anchor: avatarRef});
this.$emit("other-avatar-clicked", { event: this.event, anchor: avatarRef });
},
showContextMenu(buttonRef) {
this.$emit("context-menu", {event: this.event,anchor: buttonRef});
this.$emit("context-menu", { event: this.event, anchor: buttonRef });
},
/**
@ -159,7 +175,7 @@ export default {
*/
eventSenderDisplayName(event) {
if (event.getSender() == this.$matrix.currentUserId) {
return this.$t('message.you');
return this.$t("message.you");
}
if (this.room) {
const member = this.room.getMember(event.getSender());
@ -173,12 +189,12 @@ export default {
/**
* In the case where the state_key points out a userId for an operation (e.g. membership events)
* return the display name of the affected user.
* @param event
* @returns
* @param event
* @returns
*/
eventStateKeyDisplayName(event) {
if (event.getStateKey() == this.$matrix.currentUserId) {
return this.$t('message.you');
return this.$t("message.you");
}
if (this.room) {
const member = this.room.getMember(event.getStateKey());
@ -193,13 +209,7 @@ export default {
if (this.room) {
const member = this.room.getMember(event.getSender());
if (member) {
return member.getAvatarUrl(
this.$matrix.matrixClient.getHomeserverUrl(),
40,
40,
"scale",
true
);
return member.getAvatarUrl(this.$matrix.matrixClient.getHomeserverUrl(), 40, 40, "scale", true);
}
}
return null;
@ -236,6 +246,6 @@ export default {
linkify(text) {
return linkifyHtml(text);
}
},
},
}
};