Lots of channel related fixes and updates

This commit is contained in:
N-Pex 2024-10-11 17:04:32 +02:00
parent e3bfede77e
commit ca777a83be
17 changed files with 508 additions and 59 deletions

View file

@ -1,24 +1,130 @@
.chat-root.channel {
background-color: #f2f2f2;
.chat-content {
width: 100%;
max-width: 700px;
align-self: center;
background-color: white;
background-color: #f2f2f2;
.chat-content {
width: 100%;
max-width: 700px;
align-self: center;
background-color: white;
padding: 0 0;
}
.messageOut,
.messageIn,
.messageOut.from-admin,
.messageIn.from-admin {
display: flex;
flex-wrap: wrap;
flex-direction: row;
margin: 0 0 8px 0;
padding: 16px 0 0 0;
text-align: start;
.senderAndTime {
order: 2;
flex: 1 1 auto;
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 2px 12px 0 12px;
.sender {
font-size: 12 * $chat-text-size;
font-weight: 700;
margin: 0;
color: #1c1c31;
flex: 0 0 100%;
}
.time {
font-size: 12 * $chat-text-size;
margin: 0;
color: #5f5f5f;
}
.status {
font-size: 12 * $chat-text-size;
margin: 0 0 0 10px;
color: #5f5f5f;
}
}
.avatar {
order: 1;
width: 40px !important;
height: 40px !important;
min-width: 40px !important;
min-height: 40px !important;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
margin-left: 15px;
}
.pin-icon {
order: 3;
margin-right: 15px;
}
.op-button {
order: 4;
margin-right: 15px;
}
.content {
order: 5;
flex: 0 0 100%;
margin-top: 24px;
.message {
color: black !important;
}
}
.bubble {
width: 100%;
max-width: 100%;
color: black !important;
background: none !important;
border: none !important;
border-radius: 0 !important;
padding: 0 15px 0 15px;
}
.bubble.image-bubble {
/* full bleed */
padding: 0 0 0 0;
}
.quick-reaction-container {
order: 6;
flex: 0 0 100%;
margin: 24px 7px 0 7px;
padding: 0 8px 16px 8px;
.emoji {
font-size: 12 * $chat-text-size;
font-weight: 500;
color: #1c1c31;
.v-icon {
width: 21px;
height: 21px;
}
}
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
}
.messageOut, .messageIn {
display: flex;
flex-wrap: wrap;
.senderAndTime {
flex: 0 0 100%;
}
.content {
flex: 1 1 auto;
}
.bubble {
width: 100%;
max-width: 100%;
}
/* Make all images 'cover' */
.v-image__image {
background-size: cover;
}
}
}
.audio-player {
color: #1c1c31 !important;
.currentColor {
background-color: #000000 !important;
}
.v-icon {
color: black !important;
}
}
.poll-answer {
border-radius: 10px;
}
.messageOut.pinned,
.messageIn.pinned,
.messageOut.from-admin.pinned,
.messageIn.from-admin.pinned {
background-color: #f8f8f8;
}
}

View file

@ -64,6 +64,10 @@
position: relative;
}
.poll-answer:not(:last-child) {
margin-bottom: 12px;
}
.poll-percent-indicator {
position: absolute;
bottom: 2px;

View file

@ -0,0 +1,7 @@
<template>
<svg width="23" height="22" viewBox="0 0 23 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0.937496 8.11875C0.937496 12.4078 4.59281 16.6369 10.3003 20.2444C10.6172 20.4347 10.9941 20.625 11.25 20.625C11.5153 20.625 11.8931 20.4347 12.1997 20.2444C17.9062 16.6369 21.5625 12.4069 21.5625 8.11875C21.5625 4.44 18.9797 1.875 15.63 1.875C13.6903 1.875 12.1687 2.75625 11.25 4.08C10.3519 2.7675 8.82 1.875 6.87 1.875C3.53062 1.875 0.937496 4.44 0.937496 8.11875Z"
stroke="#1C1C31" stroke-width="1.875" />
</svg>
</template>

View file

@ -0,0 +1,7 @@
<template>
<svg width="23" height="22" viewBox="0 0 23 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M0.937496 8.11875C0.937496 12.4078 4.59281 16.6369 10.3003 20.2444C10.6172 20.4347 10.9941 20.625 11.25 20.625C11.5153 20.625 11.8931 20.4347 12.1997 20.2444C17.9062 16.6369 21.5625 12.4069 21.5625 8.11875C21.5625 4.44 18.9797 1.875 15.63 1.875C13.6903 1.875 12.1687 2.75625 11.25 4.08C10.3519 2.7675 8.82 1.875 6.87 1.875C3.53062 1.875 0.937496 4.44 0.937496 8.11875Z"
stroke="#1C1C31" stroke-width="1.875" fill="#1C1C31" />
</svg>
</template>

View file

@ -0,0 +1,8 @@
<template>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M15.7073 13.6872L17.6164 10.9004C17.7545 10.6988 17.9912 10.588 18.2345 10.6111C18.4308 10.6297 18.6254 10.5611 18.7668 10.4236L19.0964 10.1028C19.5543 9.6572 19.7832 9.43441 19.855 9.17105C19.9019 8.9989 19.9019 8.81733 19.855 8.64518C19.7832 8.38182 19.5543 8.15903 19.0964 7.71343L16.4271 5.11574C16.0064 4.7064 15.7961 4.50174 15.5519 4.43288C15.3745 4.38285 15.1866 4.38285 15.0092 4.43288C14.765 4.50174 14.5547 4.7064 14.134 5.11574L13.7465 5.4929C13.5929 5.64236 13.5245 5.85893 13.5645 6.06947C13.613 6.32422 13.5024 6.58294 13.2849 6.72408L10.4028 8.59391L10.4027 8.59393C9.91388 8.91108 9.66945 9.06966 9.41623 9.20907C8.85769 9.51658 8.2644 9.75622 7.64897 9.92288C7.36996 9.99843 7.08396 10.0541 6.51198 10.1654L6.51195 10.1654L5.66826 10.3296C5.08079 10.444 4.80785 11.1271 5.15478 11.6148C7.16198 14.4362 9.66135 16.8728 12.533 18.8075L12.6081 18.858C13.1023 19.191 13.7762 18.9158 13.8962 18.3321L14.0479 17.5941C14.1585 17.0557 14.2138 16.7865 14.2867 16.524C14.4737 15.8504 14.7481 15.2043 15.103 14.6021C15.2414 14.3674 15.3967 14.1406 15.7073 13.6872Z"
stroke="#222222" stroke-width="1.2" />
<path d="M8.30331 15.668L3.00001 20.9713" stroke="black" stroke-width="1.2" stroke-linecap="round" />
</svg>
</template>

View file

@ -0,0 +1,8 @@
<template>
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16.2072 13.6872L18.1163 10.9004C18.2545 10.6988 18.4911 10.588 18.7344 10.6111C18.9307 10.6297 19.1254 10.5611 19.2667 10.4236L19.5963 10.1028C20.0542 9.6572 20.2832 9.43441 20.3549 9.17105C20.4018 8.9989 20.4018 8.81733 20.3549 8.64518C20.2832 8.38182 20.0542 8.15903 19.5963 7.71343L16.927 5.11574L16.927 5.11573C16.5063 4.7064 16.296 4.50174 16.0518 4.43288C15.8744 4.38285 15.6865 4.38285 15.5091 4.43288C15.2649 4.50174 15.0546 4.7064 14.6339 5.11574L14.2464 5.4929C14.0928 5.64236 14.0245 5.85893 14.0645 6.06947C14.1129 6.32422 14.0023 6.58294 13.7848 6.72408L10.9027 8.59391C10.4138 8.91107 10.1694 9.06965 9.91614 9.20907C9.3576 9.51658 8.7643 9.75622 8.14887 9.92288C7.86986 9.99843 7.58386 10.0541 7.01186 10.1654L6.16816 10.3296C5.5807 10.444 5.30775 11.1271 5.65469 11.6148C7.66189 14.4362 10.1613 16.8728 13.0329 18.8075L13.108 18.858C13.6022 19.191 14.2761 18.9158 14.3961 18.3321L14.5478 17.5941L14.5478 17.5941C14.6584 17.0557 14.7137 16.7865 14.7866 16.524C14.9736 15.8504 15.248 15.2043 15.603 14.6021C15.7413 14.3674 15.8966 14.1406 16.2072 13.6872L16.2072 13.6872Z"
fill="black" stroke="#222222" stroke-width="1.2" />
<path d="M8.80322 15.668L3.49992 20.9713" stroke="black" stroke-width="1.2" stroke-linecap="round" />
</svg>
</template>

View file

@ -1,5 +1,5 @@
<template>
<div :class="{'chat-root': true, 'fill-height': true, 'd-flex': true, 'flex-column': true, 'channel': roomDisplayType == 'im.keanu.room_type_channel'}" :style="chatContainerStyle">
<div :class="{'chat-root': true, 'fill-height': true, 'd-flex': true, 'flex-column': true, 'channel': roomDisplayType == ROOM_TYPE_CHANNEL}" :style="chatContainerStyle">
<ChatHeaderPrivate class="chat-header flex-grow-0 flex-shrink-0"
v-on:header-click="onHeaderClick"
v-on:view-room-details="viewRoomDetails"
@ -35,9 +35,14 @@
<div v-if="!useVoiceMode && !useFileModeNonAdmin" :class="{'chat-content': true, 'flex-grow-1': true, 'flex-shrink-1': true, 'invisible': !initialLoadDone}" ref="chatContainer"
v-on:scroll="onScroll" @click="closeContextMenusIfOpen">
<div ref="messageOperationsStrut" class="message-operations-strut">
<message-operations-channel ref="messageOperations" :style="opStyle" v-on:close="showContextMenu = false;" v-if="showMessageOperations && room.displayType == ROOM_TYPE_CHANNEL" :userCanPin="true"
:originalEvent="selectedEvent" :timelineSet="timelineSet"
v-on:pin="pin(selectedEvent)"
v-on:unpin="unpin(selectedEvent)"
/>
<message-operations ref="messageOperations" :style="opStyle" :emojis="recentEmojis" v-on:close="
showContextMenu = false;
" v-if="showMessageOperations" v-on:addreaction="addReaction" v-on:addquickreaction="addQuickReaction"
" v-else-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
@ -56,11 +61,12 @@
<!-- If we have a retention timer, it means we have active message retention. Show header. -->
<WelcomeHeaderChannelUser v-if="retentionTimer && !roomWelcomeHeader && newlyJoinedRoom" />
<div v-for="(event, index) in filteredEvents" :key="event.getId()" :eventId="event.getId()">
<div v-for="(event) in filteredEvents" :key="event.getId()" :eventId="event !== ROOM_READ_MARKER_EVENT_PLACEHOLDER ? event.getId() : undefined">
<!-- DAY Marker, shown for every new day in the timeline -->
<div v-if="!reverseOrder && showDayMarkerBeforeEvent(event) && !!componentForEvent(event, isForExport = false)" class="day-marker"><div class="line"></div><div class="text">{{ dayForEvent(event) }}</div><div class="line"></div></div>
<div v-if="!reverseOrder && showDayMarkerBeforeEvent(event) && !!event.component && event !== ROOM_READ_MARKER_EVENT_PLACEHOLDER" class="day-marker"><div class="line"></div><div class="text">{{ dayForEvent(event) }}</div><div class="line"></div></div>
<div v-if="!event.isRelation() && !event.isRedaction()" :ref="event.getId()">
<div :ref="event.getId()">
<MessageErrorHandler>
<div class="message-wrapper" v-on:touchstart="
(e) => {
@ -68,14 +74,12 @@
}
" v-on:touchend="touchEnd" v-on:touchcancel="touchCancel" v-on:touchmove="touchMove">
<div v-if="reverseOrder && event.getId() == readMarker && index > 0" class="read-marker"><div class="line"></div><div class="text">{{ $t('message.unread_messages') }}</div><div class="line"></div></div>
<!-- Note: For threaded media messages, IF there is only one item we show that media item as a single component.
We might therefore get calls to v-on:context-menu that has the event set to that single media item, not the top level thread event
that is really displayed in the flow. Therefore, we rewrite these events with "{event: event, anchor: $event.anchor}",
see below. Otherwise things like context menus won't work as designed.
-->
<component :is="componentForEvent(event)" :room="room" :originalEvent="event" :nextEvent="filteredEvents[index + 1]"
<component :is="event.component" :room="room" :originalEvent="event" :nextEvent="event.nextDisplayedEvent"
:timelineSet="timelineSet" v-on:send-quick-reaction.stop="sendQuickReaction"
:componentFn="componentForEvent"
v-on:context-menu="showContextMenuForEvent({event: event, anchor: $event.anchor})"
@ -92,13 +96,12 @@
/>
<!-- <div v-if="debugging" style="user-select:text">EventID: {{ event.getId() }}</div> -->
<!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}<br /><br /></div> -->
<div v-if="!reverseOrder && event.getId() == readMarker && index < filteredEvents.length - 1" class="read-marker"><div class="line"></div><div class="text">{{ $t('message.unread_messages') }}</div><div class="line"></div></div>
</div>
</MessageErrorHandler>
</div>
<!-- Day marker when reverseOrder is set -->
<div v-if="reverseOrder && showDayMarkerBeforeEvent(event) && !!componentForEvent(event, isForExport = false)" class="day-marker"><div class="line"></div><div class="text">{{ dayForEvent(event) }}</div><div class="line"></div></div>
<div v-if="reverseOrder && showDayMarkerBeforeEvent(event) && !!event.component && event !== ROOM_READ_MARKER_EVENT_PLACEHOLDER" class="day-marker"><div class="line"></div><div class="text">{{ dayForEvent(event) }}</div><div class="line"></div></div>
</div>
@ -369,7 +372,7 @@ import UserProfileDialog from "./UserProfileDialog.vue"
import BottomSheet from "./BottomSheet.vue";
import ImageResize from "image-resize";
import CreatePollDialog from "./CreatePollDialog.vue";
import chatMixin from "./chatMixin";
import chatMixin, { ROOM_READ_MARKER_EVENT_PLACEHOLDER } from "./chatMixin";
import sendAttachmentsMixin from "./sendAttachmentsMixin";
import AudioLayout from "./AudioLayout.vue";
import FileDropLayout from "./file_mode/FileDropLayout";
@ -377,6 +380,7 @@ import roomTypeMixin from "./roomTypeMixin";
import roomMembersMixin from "./roomMembersMixin";
import PurgeRoomDialog from "../components/PurgeRoomDialog";
import MessageErrorHandler from "./MessageErrorHandler";
import MessageOperationsChannel from './messages/channel/MessageOperationsChannel.vue';
const sizeOf = require("image-size");
const dataUriToBuffer = require("data-uri-to-buffer");
@ -431,11 +435,15 @@ export default {
UserProfileDialog,
PurgeRoomDialog,
WelcomeHeaderChannelUser,
MessageErrorHandler
MessageErrorHandler,
MessageOperationsChannel
},
data() {
return {
ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL,
ROOM_READ_MARKER_EVENT_PLACEHOLDER: ROOM_READ_MARKER_EVENT_PLACEHOLDER,
waitingForRoomObject: false,
events: [],
currentInput: "",
@ -690,6 +698,8 @@ export default {
},
filteredEvents() {
let events = this.events;
if (this.room && this.$matrix.matrixClient.isRoomEncrypted(this.room.roomId)) {
if (this.room.getHistoryVisibility() == "joined") {
// For encrypted rooms where history is set to "joined" we can't read old events.
@ -702,12 +712,37 @@ export default {
(!e.getPrevContent() || e.getPrevContent().membership != "join") &&
e.getStateKey() == this.$matrix.currentUserId) {
// Our own join event.
return this.reverseOrder ? this.events.slice(idx + 1).toReversed() : this.events.slice(idx + 1);
events = this.events.slice(idx + 1);
}
}
}
}
return this.reverseOrder ? this.events.toReversed() : this.events;
// Filter out relations and redactions
events = this.events.toReversed().filter((e) => !e.isRelation() && !e.isRedaction());
// Add read marker, if it is not newer than the "latest" message we are going to display
//
let lastDisplayedEvent = undefined;
events = events.flatMap((e) => {
let result = [];
Vue.set(e, "component", this.componentForEvent(e, false));
if (e.getId() == this.readMarker && lastDisplayedEvent !== undefined) {
const readMarkerEvent = ROOM_READ_MARKER_EVENT_PLACEHOLDER;
Vue.set(readMarkerEvent, "component", this.componentForEvent(readMarkerEvent, false));
if (readMarkerEvent.component) {
Vue.set(e, "nextDisplayedEvent", lastDisplayedEvent);
}
result.push(readMarkerEvent);
}
if (e.component) {
Vue.set(e, "nextDisplayedEvent", lastDisplayedEvent);
lastDisplayedEvent = e;
}
result.push(e);
return result;
})
return (this.reverseOrder ? events : events.toReversed()) // Reverse back if needed
},
roomCreatedByUsRecently() {
@ -886,6 +921,12 @@ export default {
var rectAnchor = this.showContextMenuAnchor.getBoundingClientRect();
var rectChat = this.$refs.messageOperationsStrut.getBoundingClientRect();
var rectOps = this.$refs.messageOperations.$el.getBoundingClientRect();
if (this.room.displayType == ROOM_TYPE_CHANNEL) {
top = rectAnchor.top - rectChat.top;
let right = rectChat.right - rectAnchor.right;
this.opStyle = "top:" + top + "px;right:" + right + "px";
return;
} else {
top = rectAnchor.top - rectChat.top - 50;
left = rectAnchor.left - rectChat.left - 75;
if (left + rectOps.width + 10 >= rectChat.right) {
@ -894,6 +935,7 @@ export default {
left = 0;
}
}
}
}
this.opStyle = "top:" + top + "px;left:" + left + "px";
});
@ -916,10 +958,30 @@ export default {
},
/**
* Set events to display. At the same time, filter out messages that are past rentention period etc.
* Set events to display. At the same time, filter out messages that are past rentention period etc. Also, filter pinned events "at the top"
*/
setEvents(events) {
this.events = this.filterOutOldAndInvisible(events);
setEvents(events, onlyIfLengthChanges = false) {
let updated = this.filterOutOldAndInvisible(events);
// Handle pinning
//
const pinnedEvents = this.$matrix.getPinnedEvents(this.room);
console.log("Pinned events in room", JSON.stringify(pinnedEvents));
events.forEach((e) => {
Vue.set(e, "isPinned", pinnedEvents.includes(e.getId()));
});
updated = updated.sort((e1, e2) => {
if (!e1.isPinned && !e2.isPinned) return 0;
else if (e1.isPinned && !e2.isPinned) return this.reverseOrder ? 1 : -1;
else if (e2.isPinned && !e1.isPinned) return this.reverseOrder ? -1 : 1;
else {
// Look at order in "pinned" value in the m.room.pinned_events event!
return pinnedEvents.indexOf(e1.getId()) < pinnedEvents.indexOf(e2.getId()) ? (this.reverseOrder ? 1 : -1) : (this.reverseOrder ? -1 : 1)
}
});
if (!onlyIfLengthChanges || updated.length != this.events.length) {
this.events = updated; // Changed
}
},
filterOutOldAndInvisible(events) {
@ -962,10 +1024,7 @@ export default {
},
onRetentionTimer() {
const events = this.filterOutOldAndInvisible(this.events);
if (events.length != this.events.length) {
this.events = events; // Changed
}
this.setEvents(this.events, true);
},
onRoomJoined(initialEventId) {
@ -980,6 +1039,7 @@ export default {
}
this.reverseOrder = (this.room && this.roomDisplayType == ROOM_TYPE_CHANNEL);
Vue.set(this.room, "displayType", this.roomDisplayType);
// Listen to events
this.$matrix.on("Room.timeline", this.onEvent);
@ -1280,7 +1340,7 @@ export default {
this.paginateBackIfNeeded();
}
if (loadingDone && event.forwardLooking && (!event.isRelation() || event.isMxThread || event.threadRootId || event.parentThread)) {
if (loadingDone && event.forwardLooking && (!(event.isRelation() || event.isRedaction()) || event.isMxThread || event.threadRootId || event.parentThread)) {
// 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;
@ -1704,6 +1764,14 @@ export default {
}
},
pin(event) {
this.$matrix.setEventPinned(this.room, event, true);
},
unpin(event) {
this.$matrix.setEventPinned(this.room, event, false);
},
cancelEditReply() {
this.currentInput = "";
this.editedEvent = null;
@ -2006,9 +2074,9 @@ export default {
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];
const event = filteredEvents.find(e => e.getId() === matrixEventId);
if (event && event.nextDisplayedEvent) {
const nextEvent = event.nextDisplayedEvent;
if (nextEvent.getContent().msgtype === "m.audio") {
// Yes, audio event!
this.$audioPlayer.play(nextEvent, this.timelineSet);

View file

@ -187,6 +187,7 @@ export default {
this.cancelled = true;
},
async getEvents() {
// TODO - Handle pinned messages?
const eventsPerBatch = 100;
let batchToken = null;
var nToFetch = null;

View file

@ -53,9 +53,12 @@ import RoomGuestAccessChanged from "./messages/RoomGuestAccessChanged.vue";
import RoomEncrypted from "./messages/RoomEncrypted.vue";
import RoomDeletionNotice from "./messages/RoomDeletionNotice.vue";
import DebugEvent from "./messages/DebugEvent.vue";
import ReadMarker from "./messages/ReadMarker.vue";
import roomDisplayOptionsMixin from "./roomDisplayOptionsMixin";
import roomTypeMixin from "./roomTypeMixin";
export const ROOM_READ_MARKER_EVENT_PLACEHOLDER = { getId: () => "ROOM_READ_MARKER" };
export default {
mixins: [ roomDisplayOptionsMixin, roomTypeMixin ],
components: {
@ -101,6 +104,7 @@ export default {
StickerPickerBottomSheet,
BottomSheet,
CreatePollDialog,
ReadMarker
},
methods: {
showDayMarkerBeforeEvent(event) {
@ -127,6 +131,9 @@ export default {
componentForEvent(event, isForExport = false) {
const isChannel = this.roomDisplayType === ROOM_TYPE_CHANNEL;
if (event === ROOM_READ_MARKER_EVENT_PLACEHOLDER) {
return ReadMarker;
}
switch (event.getType()) {
case "m.room.member":
if (isChannel) break;

View file

@ -1,7 +1,7 @@
<template>
<!-- BASE CLASS FOR INCOMING MESSAGE -->
<div :class="messageClasses">
<div v-if="showSenderAndTime" class="senderAndTime">
<div v-if="showSenderAndTime || room.displayType == ROOM_TYPE_CHANNEL" class="senderAndTime">
<div class="sender">{{ eventSenderDisplayName(event) }}</div>
<div class="time">
{{ utils.formatTime(event.event.origin_server_ts) }}
@ -17,24 +17,31 @@
<span ref="messageInOutRef" class="content">
<slot></slot>
</span>
<div class="op-button" ref="opbutton" v-if="!event.isRedacted()">
<div class="pin-icon" v-if="event.isPinned"><v-icon>$vuetify.icons.ic_pin_filled</v-icon></div>
<div class="op-button" ref="opbutton" v-if="!event.isRedacted() && room.displayType != ROOM_TYPE_CHANNEL">
<v-btn id="btn-more" icon @click.stop="showContextMenu($refs.opbutton)">
<v-icon>more_vert</v-icon>
</v-btn>
</div>
<QuickReactions :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<SeenBy :room="room" :event="event"/>
<QuickReactionsChannel v-if="room.displayType == ROOM_TYPE_CHANNEL" :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<QuickReactions v-else :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<SeenBy v-if="room.displayType != ROOM_TYPE_CHANNEL" :room="room" :event="event"/>
</div>
</template>
<script>
import SeenBy from "./SeenBy.vue";
import messageMixin from "./messageMixin";
import util from "../../plugins/utils";
import util, { ROOM_TYPE_CHANNEL } from "../../plugins/utils";
import QuickReactions from "./QuickReactions.vue";
import QuickReactionsChannel from "./channel/QuickReactionsChannel.vue";
export default {
mixins: [messageMixin],
components: { SeenBy },
components: { QuickReactions, QuickReactionsChannel, SeenBy },
data() {
return { ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL }
},
mounted() {
if(util.isMobileOrTabletBrowser() && this.$refs.messageInOutRef) {
this.initMsgHammerJs(this.$refs.messageInOutRef);

View file

@ -1,7 +1,8 @@
<template>
<!-- BASE CLASS FOR OUTGOING MESSAGE -->
<div class="messageOut">
<div :class="messageClasses">
<div class="senderAndTime">
<div class="sender" v-if="room.displayType == ROOM_TYPE_CHANNEL">{{ eventSenderDisplayName(event) }}</div>
<div class="time">
{{ utils.formatTime(event.event.origin_server_ts) }}
</div>
@ -13,6 +14,8 @@
<v-icon>more_vert</v-icon>
</v-btn>
</div>
<div class="pin-icon" v-if="event.isPinned"><v-icon>$vuetify.icons.ic_pin_filled</v-icon></div>
<!-- SLOT FOR CONTENT -->
<span ref="messageInOutRef" class="content">
<slot></slot>
@ -26,19 +29,25 @@
<img v-if="userAvatar" :src="userAvatar" />
<span v-else class="white--text headline">{{ userAvatarLetter }}</span>
</v-avatar>
<QuickReactions :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<SeenBy :room="room" :event="event"/>
<QuickReactionsChannel v-if="room.displayType == ROOM_TYPE_CHANNEL" :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<QuickReactions v-else :event="eventForReactions" :timelineSet="timelineSet" v-on="$listeners"/>
<SeenBy v-if="room.displayType != ROOM_TYPE_CHANNEL" :room="room" :event="event"/>
</div>
</template>
<script>
import SeenBy from "./SeenBy.vue";
import messageMixin from "./messageMixin";
import util from "../../plugins/utils";
import util, { ROOM_TYPE_CHANNEL } from "../../plugins/utils";
import QuickReactions from "./QuickReactions.vue";
import QuickReactionsChannel from "./channel/QuickReactionsChannel.vue";
export default {
mixins: [messageMixin],
components: { SeenBy },
components: { QuickReactions, QuickReactionsChannel, SeenBy },
data() {
return { ROOM_TYPE_CHANNEL: ROOM_TYPE_CHANNEL }
},
mounted() {
if(util.isMobileOrTabletBrowser() && this.$refs.messageInOutRef) {
this.initMsgHammerJs(this.$refs.messageInOutRef);

View file

@ -0,0 +1,17 @@
<template>
<div class="read-marker">
<div class="line"></div>
<div class="text">{{ $t('message.unread_messages') }}</div>
<div class="line"></div>
</div>
</template>
<script>
export default {
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -0,0 +1,34 @@
<template>
<div :class="{'message-operations':true,'incoming':incoming,'outgoing':!incoming}">
<v-btn id="btn-pin" icon @click.stop="pin" class="ma-0 pa-0" v-if="userCanPin && !event.isPinned">
<v-icon small>$vuetify.icons.ic_pin_filled</v-icon>
</v-btn>
<v-btn id="btn-unpin" icon @click.stop="unpin" class="ma-0 pa-0" v-if="userCanPin && event.isPinned">
<v-icon small>$vuetify.icons.ic_pin</v-icon>
</v-btn>
</div>
</template>
<script>
import messageMixin from "../messageMixin";
import messageOperationsMixin from "../messageOperationsMixin";
export default {
mixins: [messageMixin, messageOperationsMixin],
data() {
return {}
},
props: {
userCanPin: {
type: Boolean,
default: function () {
return false;
}
},
},
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -0,0 +1,127 @@
<template>
<div class="quick-reaction-container">
<div
class="emoji"
v-for="(value, name) in reactionMap"
:key="name"
v-show="name == '❤️'"
>
<v-tooltip top v-if="value.includes($matrix.currentUserId)">
<template v-slot:activator="{ on, attrs }">
<v-icon class="ma-1 ml-0 clickable" v-bind="attrs" v-on="on" @click="onClickEmoji(name)">$vuetify.icons.ic_like_filled</v-icon>
</template>
<span>{{ $t("global.click_to_remove") }}</span>
</v-tooltip>
<v-icon v-else class="ma-1 ml-0 clickable" @click="onClickEmoji(name)">$vuetify.icons.ic_like</v-icon> {{ value.length }}
</div>
</div>
</template>
<script>
import messageOperationsMixin from "../messageOperationsMixin";
export default {
mixins: [messageOperationsMixin],
props: {
event: {
type: Object,
default: function () {
return {}
}
},
timelineSet: {
type: Object,
default: function () {
return null
}
},
},
data() {
return {
reactionMap: {"❤️": []},
reactions: null,
REACTION_LIMIT: 5,
showAllReaction: false
}
},
mounted() {
this.reactions = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), 'm.annotation', 'm.reaction');
this.event.on("Event.relationsCreated", this.onRelationsCreated);
},
beforeDestroy() {
this.event.off("Event.relationsCreated", this.onRelationsCreated);
if (this.reactions) {
this.reactions.off('Relations.add', this.onAddRelation);
}
},
computed: {
totalReaction() {
return Object.keys(this.reactionMap).length
},
otherReactionText() {
return this.showAllReaction ? this.$t("global.show_less") : this.$t("message.reaction_count_more", { reactionCount: this.totalReaction - this.REACTION_LIMIT })
}
},
methods: {
onRelationsCreated() {
this.reactions = this.timelineSet.relations.getChildEventsForEvent(this.event.getId(), 'm.annotation', 'm.reaction');
},
onClickEmoji(emoji) {
this.$bubble('send-quick-reaction', {reaction:emoji, event:this.event});
},
onAddRelation(ignoredevent) {
this.processReactions();
},
onRemoveRelation(ignoredevent) {
this.processReactions();
},
onRedactRelation(ignoredevent) {
this.processReactions();
},
processReactions() {
var reactionMap = {"❤️": []};
if (this.reactions && this.reactions._eventsCount > 0) {
const relations = this.reactions.getRelations();
for (const r of relations) {
const emoji = r.getRelation().key;
const sender = r.getSender();
if (reactionMap[emoji]) {
const array = reactionMap[emoji];
if (r.isRedacted()) {
delete array[sender];
}
if (!array.includes(sender)) {
array.push(sender)
}
} else if (!r.isRedacted()) {
reactionMap[emoji] = [sender];
}
}
}
this.reactionMap = reactionMap;
}
},
watch: {
reactions: {
handler(newValue, oldValue) {
if (oldValue) {
oldValue.off('Relations.add', this.onAddRelation);
oldValue.off('Relations.remove', this.onRemoveRelation);
oldValue.off('Relations.redaction', this.onRedactRelation);
}
if (newValue) {
newValue.on('Relations.add', this.onAddRelation);
newValue.on('Relations.remove', this.onRemoveRelation);
newValue.on('Relations.redaction', this.onRedactRelation);
}
this.processReactions();
},
immediate: true
}
}
};
</script>
<style lang="scss">
@import "@/assets/css/chat.scss";
</style>

View file

@ -109,7 +109,7 @@ export default {
* Don't show sender and time if the next event is within 2 minutes and also from us (= back to back messages)
*/
showSenderAndTime() {
if (this.nextEvent && this.nextEvent.getSender() == this.event.getSender()) {
if (!this.event.isPinned && 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
@ -181,11 +181,15 @@ export default {
},
/**
* Classes to set for the message. Currently only for "messageIn", TODO: - detect messageIn or messageOut.
* Classes to set for the message. Currently only for "messageIn"
*/
messageClasses() {
return { messageIn: true, "from-admin": this.senderIsAdminOrModerator(this.event) };
if (this.incoming) {
return { messageIn: true, "from-admin": this.senderIsAdminOrModerator(this.event), "pinned": this.event.isPinned };
} else {
return { messageOut: true, "pinned": this.event.isPinned };
}
},
userAvatar() {

View file

@ -50,5 +50,13 @@ export default {
this.$emit("close");
this.$emit("more", {event:this.event});
},
pin() {
this.$emit("close");
this.$emit("pin", {event:this.event});
},
unpin() {
this.$emit("close");
this.$emit("unpin", {event:this.event});
},
}
}

View file

@ -1292,6 +1292,33 @@ export default {
});
this.notificationCount = count;
},
setEventPinned(room, event, pinned) {
if (room && room.currentState && event) {
const pinnedMessagesEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
const content = pinnedMessagesEvent ? pinnedMessagesEvent.getContent() : {}
let pinnedEvents = content["pinned"] || [];
if (pinned && !pinnedEvents.includes(event.getId())) {
pinnedEvents.push(event.getId());
} else if (!pinned && pinnedEvents.includes(event.getId())) {
pinnedEvents = pinnedEvents.filter((e) => e != event.getId());
} else {
return; // no change
}
content.pinned = pinnedEvents;
this.matrixClient.sendStateEvent(room.roomId, "m.room.pinned_events", content);
}
},
getPinnedEvents(room) {
if (room && room.currentState) {
const pinnedMessagesEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
const content = pinnedMessagesEvent ? pinnedMessagesEvent.getContent() : {}
return content["pinned"] || [];
} else {
return [];
}
},
},
});