diff --git a/src/assets/css/channel.scss b/src/assets/css/channel.scss new file mode 100644 index 0000000..48644fb --- /dev/null +++ b/src/assets/css/channel.scss @@ -0,0 +1,200 @@ +.chat-root.channel { + 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; + display: flex; + flex-direction: column; + } + } + .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; + border-radius: 0 !important; + .v-image { + border-radius: 0 !important; + } + } + .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); + } + + /* 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; + } + + + .message-operations { + position: absolute; + width: fit-content; + background-color: white; + height: fit-content; + border-radius: 8px; + box-shadow: 0px 4px 4px 0px rgba(0, 0, 0, 0.15); + white-space: nowrap; + } + + /* Style file items (i.e. PDF files) */ + .thumbnail-item.file-item { + border: 1px solid black; + border-radius: 8px; + padding: 15px 40px 15px 60px; + align-items: start; + position: relative; + svg { + position: absolute; + top: 12px; + left: 12px; + width: 40px; + height: 40px; + } + &::after { + content: " "; + background: url("~@/assets/icons/ic_export.svg") no-repeat; + background-position: 0 0; + position: absolute; + right: 15px; + height: 24px; + width: 24px; + margin: auto 0; + } + } + + .swipeable-thumbnails-view { + margin-left: -15px; + margin-right: -15px; + order: 2; + .thumbnail-item.file-item { + margin: 15px; + width: auto; + } + .indicator-container { + display: flex; + align-items: center; + justify-content: center; + margin-top: 12px; + .indicator { + width: 6px; + height: 6px; + border-radius: 3px; + margin: 0 2.5px; + background: #D9D9D9; + &.current { + background: #1C1C31; + } + } + + } + } +} diff --git a/src/assets/css/chat.scss b/src/assets/css/chat.scss index 0636372..4fae2ca 100644 --- a/src/assets/css/chat.scss +++ b/src/assets/css/chat.scss @@ -2,6 +2,7 @@ @import "@/assets/css/main.scss"; @import "@/assets/css/vendors/v-emoji-picker"; @import "@/assets/css/filedrop.scss"; +@import "@/assets/css/channel.scss"; $admin-bg: black; $admin-fg: white; @@ -341,8 +342,12 @@ body { .scroll-to-end { position: absolute; - top: -64px; + bottom: 20px; right: 16px; + &.reversed { + top: 120px; + transform: rotate(180deg); + } } .op-button { diff --git a/src/assets/css/components/_poll.scss b/src/assets/css/components/_poll.scss index 8e39b64..4715902 100644 --- a/src/assets/css/components/_poll.scss +++ b/src/assets/css/components/_poll.scss @@ -64,6 +64,10 @@ position: relative; } +.poll-answer:not(:last-child) { + margin-bottom: 12px; +} + .poll-percent-indicator { position: absolute; bottom: 2px; diff --git a/src/assets/icons/ic_edit.vue b/src/assets/icons/ic_edit.vue new file mode 100644 index 0000000..07827c8 --- /dev/null +++ b/src/assets/icons/ic_edit.vue @@ -0,0 +1,9 @@ + \ No newline at end of file diff --git a/src/assets/icons/ic_export.svg b/src/assets/icons/ic_export.svg new file mode 100644 index 0000000..d6f2d8e --- /dev/null +++ b/src/assets/icons/ic_export.svg @@ -0,0 +1,4 @@ + + + diff --git a/src/assets/icons/ic_like.vue b/src/assets/icons/ic_like.vue new file mode 100644 index 0000000..33a9112 --- /dev/null +++ b/src/assets/icons/ic_like.vue @@ -0,0 +1,7 @@ + diff --git a/src/assets/icons/ic_like_filled.vue b/src/assets/icons/ic_like_filled.vue new file mode 100644 index 0000000..2900686 --- /dev/null +++ b/src/assets/icons/ic_like_filled.vue @@ -0,0 +1,7 @@ + diff --git a/src/assets/icons/ic_pin.vue b/src/assets/icons/ic_pin.vue new file mode 100644 index 0000000..85dcfdf --- /dev/null +++ b/src/assets/icons/ic_pin.vue @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/src/assets/icons/ic_pin_filled.vue b/src/assets/icons/ic_pin_filled.vue new file mode 100644 index 0000000..60eff58 --- /dev/null +++ b/src/assets/icons/ic_pin_filled.vue @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/src/assets/translations/en.json b/src/assets/translations/en.json index 1f6e8ab..792f31f 100644 --- a/src/assets/translations/en.json +++ b/src/assets/translations/en.json @@ -46,7 +46,9 @@ "user_kick_and_ban": "Kick out", "user_make_admin": "Make admin", "user_make_moderator": "Make moderator", - "user_revoke_moderator": "Revoke moderator" + "user_revoke_moderator": "Revoke moderator", + "pin": "Pin post", + "unpin": "Unpin post" }, "message": { "you": "You", diff --git a/src/components/Chat.vue b/src/components/Chat.vue index b165bcb..d25d233 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -1,5 +1,5 @@ +
{{ currentSendError }}
@@ -281,7 +301,7 @@ {{ $t("menu.cancel") }} - {{ $t("menu.send") }}
@@ -360,7 +380,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"; @@ -368,6 +388,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"); @@ -422,11 +443,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: "", @@ -442,6 +467,7 @@ export default { currentSendShowSendButton: true, currentSendError: null, currentSendErrorExceededFile: null, + attachmentCaption: undefined, showEmojiPicker: false, selectedEvent: null, editedEvent: null, @@ -512,7 +538,8 @@ export default { heartPosition: { top: 0, left: 0 - } + }, + reverseOrder: false }; }, @@ -569,6 +596,12 @@ export default { isCurrentFileInputsAnArray() { return Array.isArray(this.currentFileInputs) }, + showAttachmentCaptionInput() { + // IFF we are sending one PDF, add option to set caption. + const imageFiles = this.imageFiles || []; + const otherFiles = this.nonImageFiles || []; + return imageFiles.length == 0 && otherFiles.length == 1 && (otherFiles[0].type === "application/pdf" || (otherFiles[0].name || "").endsWith(".pdf")); + }, currentFileInputsDialog: { get() { return this.isCurrentFileInputsAnArray @@ -680,6 +713,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. @@ -692,12 +727,46 @@ export default { (!e.getPrevContent() || e.getPrevContent().membership != "join") && e.getStateKey() == this.$matrix.currentUserId) { // Our own join event. - return this.events.slice(idx + 1); + events = this.events.slice(idx + 1); } } } } - return this.events; + + // Filter out relations and redactions + events = this.events.toReversed().filter((e) => !e.isRelation() && !e.isRedaction()); + + // If Channel, remove all redacted events as well. + if (this.room && this.room.displayType == ROOM_TYPE_CHANNEL) { + events = events.filter((e) => !e.isRedacted()); + } + + // Add read marker, if it is not newer than the "latest" message we are going to display + // + let showReadMarker = false; + let lastDisplayedEvent = undefined; + events = events.flatMap((e) => { + let result = []; + Vue.set(e, "component", this.componentForEvent(e, false)); + if (e.getId() == this.readMarker && showReadMarker) { + 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; + if (e.getSender() !== this.$matrix.currentUserId) { + showReadMarker = true; + } + } + result.push(e); + return result; + }) + return (this.reverseOrder ? events : events.toReversed()) // Reverse back if needed }, roomCreatedByUsRecently() { @@ -732,6 +801,12 @@ export default { } return null; }, + messageOperationsComponent() { + if (this.room.displayType == ROOM_TYPE_CHANNEL) { + return MessageOperationsChannel; + } + return MessageOperations; + }, chatContainerStyle() { if (this.$config.chat_backgrounds && this.room && this.roomId) { const roomType = this.isDirectRoom ? "direct" : this.isPublicRoom ? "public" : "invite"; @@ -843,7 +918,7 @@ export default { this.onRoomNotJoined(); } else { if (this.room) { - this.onRoomJoined(this.readMarker); + this.onRoomJoined(this.roomDisplayType == ROOM_TYPE_CHANNEL ? null : this.readMarker); } else { this.waitingForRoomObject = true; return; // no room, wait for it (we know we are joined so need to wait for sync to complete) @@ -860,7 +935,7 @@ export default { // Were we waiting? if (this.room && this.room.roomId == this.roomId && this.waitingForRoomObject) { this.waitingForRoomObject = false; - this.onRoomJoined(this.readMarker); + this.onRoomJoined(this.roomDisplayType == ROOM_TYPE_CHANNEL ? null : this.readMarker); } }, showMessageOperations(show) { @@ -876,6 +951,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) { @@ -884,6 +965,7 @@ export default { left = 0; } } + } } this.opStyle = "top:" + top + "px;left:" + left + "px"; }); @@ -906,10 +988,32 @@ 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 + // + if (this.room) { + const pinnedEvents = this.$matrix.getPinnedEvents(this.room); + updated.forEach((e) => { + Vue.set(e, "isPinned", pinnedEvents.includes(e.threadParent ? e.threadParent.getId() : 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) { @@ -952,10 +1056,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) { @@ -969,6 +1070,9 @@ export default { this.newlyJoinedRoom = joinEvent.getLocalAge() < 1 * 60000 /* 1 minute */; } + 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); this.$matrix.on("RoomMember.typing", this.onUserTyping); @@ -1080,7 +1184,7 @@ export default { }); } else { // Can't paginate, just scroll to bottom of window! - this.smoothScrollToEnd(); + this.smoothScrollToLatest(); } }, @@ -1150,7 +1254,7 @@ export default { this.$nextTick(() => { const container = this.chatContainer; if (container && container.scrollHeight <= container.clientHeight) { - this.handleScrolledToTop(); + this.handleScrolledToOldest(); } }); }, @@ -1162,15 +1266,23 @@ export default { const bufferHeight = container.clientHeight * WINDOW_BUFFER_SIZE; if (container.scrollTop <= bufferHeight) { // Scrolled to top - this.handleScrolledToTop(); + if (this.reverseOrder) { + this.handleScrolledToLatest(false); + } else { + this.handleScrolledToOldest(); + } } else if (container.scrollHeight - container.scrollTop.toFixed(0) - container.clientHeight <= bufferHeight) { - this.handleScrolledToBottom(false); + if (this.reverseOrder) { + this.handleScrolledToOldest(); + } else { + this.handleScrolledToLatest(false); + } } this.showScrollToEnd = container.scrollHeight === container.clientHeight ? false - : container.scrollHeight - container.scrollTop.toFixed(0) > container.clientHeight || + : (this.reverseOrder ? (container.scrollTop.toFixed(0) > 0) : (container.scrollHeight - container.scrollTop.toFixed(0) > container.clientHeight)) || (this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS)); this.restartRRTimer(); @@ -1260,16 +1372,19 @@ 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; if (container) { - if (container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) { + if (this.reverseOrder && container.scrollTop.toFixed(0) == 0) { + scrollToSeeNew = true; + } + else if (!this.reverseOrder && container.scrollHeight - container.scrollTop.toFixed(0) == container.clientHeight) { scrollToSeeNew = true; } } - this.handleScrolledToBottom(scrollToSeeNew); + this.handleScrolledToLatest(scrollToSeeNew); // If kick or ban event, redirect to "goodbye"... if (event.getType() === "m.room.member" && @@ -1441,6 +1556,7 @@ export default { const promise = this.sendAttachments(text, this.currentFileInputs); promise.then(() => { this.currentFileInputs = null; + this.attachmentCaption = undefined; this.sendingStatus = this.sendStatuses.INITIAL; }) .catch((err) => { @@ -1459,6 +1575,7 @@ export default { this.$refs.attachment.value = null; this.cancelSendAttachments(); this.currentFileInputs = null; + this.attachmentCaption = undefined; this.currentSendError = null; this.currentSendErrorExceededFile = null; this.sendingStatus = this.sendStatuses.INITIAL; @@ -1494,7 +1611,7 @@ export default { }); }, - handleScrolledToTop() { + handleScrolledToOldest() { if ( this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.BACKWARDS) && @@ -1505,7 +1622,7 @@ export default { .paginate(EventTimeline.BACKWARDS, 10, true) .then((success) => { if (success && this.scrollPosition) { - this.scrollPosition.prepareFor("up"); + this.scrollPosition.prepareFor(this.reverseOrder ? "down" : "up"); this.setEvents(this.timelineWindow.getEvents()); this.$nextTick(() => { // restore scroll position! @@ -1520,7 +1637,7 @@ export default { } }, - handleScrolledToBottom(scrollToEnd) { + handleScrolledToLatest(smoothScrollToLatest) { if ( this.timelineWindow && this.timelineWindow.canPaginate(EventTimeline.FORWARDS) && @@ -1533,13 +1650,13 @@ export default { if (success) { this.setEvents(this.timelineWindow.getEvents()); if (!this.useVoiceMode && this.scrollPosition) { - this.scrollPosition.prepareFor("down"); + this.scrollPosition.prepareFor(this.reverseOrder ? "up" : "down"); this.$nextTick(() => { // restore scroll position! console.log("Restore scroll!"); this.scrollPosition.restore(); - if (scrollToEnd) { - this.smoothScrollToEnd(); + if (smoothScrollToLatest) { + this.smoothScrollToLatest(); } }); } @@ -1555,6 +1672,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) { + console.log("Scroll to event", eventId); const container = this.chatContainer; const ref = this.$refs[eventId]; if (container && ref) { @@ -1562,7 +1680,7 @@ export default { const item = ref[0].getBoundingClientRect(); let offsetY = (parent.bottom - parent.top) / 2; if (ref[0].clientHeight > offsetY) { - offsetY = Math.max(0, (parent.bottom - parent.top) - ref[0].clientHeight); + offsetY = this.reverseOrder ? 0 : Math.max(0, (parent.bottom - parent.top) - ref[0].clientHeight); } const targetY = parent.top + offsetY; const currentY = item.top; @@ -1573,10 +1691,21 @@ export default { } }, - smoothScrollToEnd() { + smoothScrollToLatest() { this.$nextTick(function () { const container = this.chatContainer; if (container && container.children.length > 0) { + if (this.reverseOrder) { + const firstChild = container.children[0]; + console.log("Scroll into view", firstChild); + window.requestAnimationFrame(() => { + firstChild.scrollIntoView({ + behavior: "smooth", + block: "end", + inline: "nearest", + }); + }); + } else { const lastChild = container.children[container.children.length - 1]; console.log("Scroll into view", lastChild); window.requestAnimationFrame(() => { @@ -1587,6 +1716,7 @@ export default { }); }); } + } }); }, @@ -1668,6 +1798,16 @@ export default { } }, + pin(event) { + const eventToPin = event.parentThread ? event.parentThread : event; + this.$matrix.setEventPinned(this.room, eventToPin, true); + }, + + unpin(event) { + const eventToUnpin = event.parentThread ? event.parentThread : event; + this.$matrix.setEventPinned(this.room, eventToUnpin, false); + }, + cancelEditReply() { this.currentInput = ""; this.editedEvent = null; @@ -1809,8 +1949,14 @@ export default { const elFirst = util.getFirstVisibleElement(container, (item) => item.hasAttribute("eventId")); const elLast = util.getLastVisibleElement(container, (item) => item.hasAttribute("eventId")); if (elFirst && elLast) { - eventIdFirst = elFirst.getAttribute("eventId"); - eventIdLast = elLast.getAttribute("eventId"); + if (this.reverseOrder) { + // For reverse order, the "first visible" is actually later in time, so swap them + eventIdFirst = elLast.getAttribute("eventId"); + eventIdLast = elFirst.getAttribute("eventId"); + } else { + eventIdFirst = elFirst.getAttribute("eventId"); + eventIdLast = elLast.getAttribute("eventId"); + } } } if (eventIdFirst && eventIdLast) { @@ -1964,9 +2110,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); diff --git a/src/components/RoomExport.vue b/src/components/RoomExport.vue index 6038c77..3750274 100644 --- a/src/components/RoomExport.vue +++ b/src/components/RoomExport.vue @@ -187,6 +187,7 @@ export default { this.cancelled = true; }, async getEvents() { + // TODO - Handle pinned messages? const eventsPerBatch = 100; let batchToken = null; var nToFetch = null; diff --git a/src/components/chatMixin.js b/src/components/chatMixin.js index 076b48c..533649b 100644 --- a/src/components/chatMixin.js +++ b/src/components/chatMixin.js @@ -1,4 +1,4 @@ -import util, { STATE_EVENT_ROOM_DELETION_NOTICE } from "../plugins/utils"; +import util, { ROOM_TYPE_CHANNEL, STATE_EVENT_ROOM_DELETION_NOTICE } from "../plugins/utils"; import MessageIncomingText from "./messages/MessageIncomingText"; import MessageIncomingFile from "./messages/MessageIncomingFile"; import MessageIncomingImage from "./messages/MessageIncomingImage.vue"; @@ -53,10 +53,14 @@ 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 ], + mixins: [ roomDisplayOptionsMixin, roomTypeMixin ], components: { ChatHeader, MessageIncomingText, @@ -100,6 +104,7 @@ export default { StickerPickerBottomSheet, BottomSheet, CreatePollDialog, + ReadMarker }, methods: { showDayMarkerBeforeEvent(event) { @@ -125,8 +130,13 @@ 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; if (event.getContent().membership == "join") { if (event.getPrevContent() && event.getPrevContent().membership == "join") { // We we already joined, so this must be a display name and/or avatar update! diff --git a/src/components/messages/MessageIncoming.vue b/src/components/messages/MessageIncoming.vue index eb40a2c..6dd4a8f 100644 --- a/src/components/messages/MessageIncoming.vue +++ b/src/components/messages/MessageIncoming.vue @@ -1,7 +1,7 @@ + + \ No newline at end of file diff --git a/src/components/messages/channel/MessageOperationsChannel.vue b/src/components/messages/channel/MessageOperationsChannel.vue new file mode 100644 index 0000000..7a726ab --- /dev/null +++ b/src/components/messages/channel/MessageOperationsChannel.vue @@ -0,0 +1,54 @@ + + + + + \ No newline at end of file diff --git a/src/components/messages/channel/QuickReactionsChannel.vue b/src/components/messages/channel/QuickReactionsChannel.vue new file mode 100644 index 0000000..446251b --- /dev/null +++ b/src/components/messages/channel/QuickReactionsChannel.vue @@ -0,0 +1,127 @@ + + + + + \ No newline at end of file diff --git a/src/components/messages/channel/SwipeableThumbnailsView.vue b/src/components/messages/channel/SwipeableThumbnailsView.vue new file mode 100644 index 0000000..fe1dcc6 --- /dev/null +++ b/src/components/messages/channel/SwipeableThumbnailsView.vue @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/src/components/messages/messageMixin.js b/src/components/messages/messageMixin.js index 274589d..3340863 100644 --- a/src/components/messages/messageMixin.js +++ b/src/components/messages/messageMixin.js @@ -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.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 @@ -180,12 +180,20 @@ export default { return this.event.getContent().body; }, + isPinned() { + return this.event.parentThread ? this.event.parentThread.isPinned : this.event.isPinned; + }, + /** - * 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.isPinned }; + } else { + return { messageOut: true, "pinned": this.isPinned }; + } }, userAvatar() { diff --git a/src/components/messages/messageOperationsMixin.js b/src/components/messages/messageOperationsMixin.js index 5143615..289c051 100644 --- a/src/components/messages/messageOperationsMixin.js +++ b/src/components/messages/messageOperationsMixin.js @@ -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}); + }, } } \ No newline at end of file diff --git a/src/plugins/utils.js b/src/plugins/utils.js index a641c47..56368b3 100644 --- a/src/plugins/utils.js +++ b/src/plugins/utils.js @@ -914,6 +914,7 @@ class Util { } download(matrixClient, event) { + console.log("DOWNLOAD"); this .getAttachment(matrixClient, event) .then((url) => { @@ -924,6 +925,7 @@ class Util { // PDFs are shown inline, not downloaded link.download = event.getContent().body || this.$t("fallbacks.download_name"); } + console.log("LINK", link); document.body.appendChild(link); link.click(); setTimeout(function () { diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index 0ac52aa..3576223 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -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 []; + } + }, }, });