diff --git a/package-lock.json b/package-lock.json index a0e75f3..8674e6c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "raw-loader": "^4.0.2", "roboto-fontface": "*", + "v-emoji-picker": "^2.3.1", "vue": "^2.6.11", "vue-router": "^3.2.0", "vuetify": "^2.2.11", @@ -13304,6 +13305,18 @@ "uuid": "bin/uuid" } }, + "node_modules/v-emoji-picker": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/v-emoji-picker/-/v-emoji-picker-2.3.1.tgz", + "integrity": "sha512-KpFum53KelwRunQhB8c5Jj83YCGWDnBBrFaIHg0NJN0WCGTxSiVjPh+GyrHxssvLB9wvo+1YLKzal+iuJ+08eA==", + "dependencies": { + "vue-class-component": "^7.2.6", + "vue-property-decorator": "^9.0.2" + }, + "peerDependencies": { + "vue": "^2.6.11" + } + }, "node_modules/v8-compile-cache": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz", @@ -13362,6 +13375,14 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.12.tgz", "integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg==" }, + "node_modules/vue-class-component": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.6.tgz", + "integrity": "sha512-+eaQXVrAm/LldalI272PpDe3+i4mPis0ORiMYxF6Ae4hyuCh15W8Idet7wPUEs4N4YptgFHGys4UrgNQOMyO6w==", + "peerDependencies": { + "vue": "^2.0.0" + } + }, "node_modules/vue-cli-plugin-vuetify": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/vue-cli-plugin-vuetify/-/vue-cli-plugin-vuetify-2.0.7.tgz", @@ -13557,6 +13578,15 @@ "integrity": "sha1-M7QHd3VMZDJXPBIMw4CLvRDUfwQ=", "dev": true }, + "node_modules/vue-property-decorator": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/vue-property-decorator/-/vue-property-decorator-9.1.2.tgz", + "integrity": "sha512-xYA8MkZynPBGd/w5QFJ2d/NM0z/YeegMqYTphy7NJQXbZcuU6FC6AOdUAcy4SXP+YnkerC6AfH+ldg7PDk9ESQ==", + "peerDependencies": { + "vue": "*", + "vue-class-component": "*" + } + }, "node_modules/vue-router": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.9.tgz", @@ -25496,6 +25526,15 @@ "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==" }, + "v-emoji-picker": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/v-emoji-picker/-/v-emoji-picker-2.3.1.tgz", + "integrity": "sha512-KpFum53KelwRunQhB8c5Jj83YCGWDnBBrFaIHg0NJN0WCGTxSiVjPh+GyrHxssvLB9wvo+1YLKzal+iuJ+08eA==", + "requires": { + "vue-class-component": "^7.2.6", + "vue-property-decorator": "^9.0.2" + } + }, "v8-compile-cache": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz", @@ -25544,6 +25583,12 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.12.tgz", "integrity": "sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg==" }, + "vue-class-component": { + "version": "7.2.6", + "resolved": "https://registry.npmjs.org/vue-class-component/-/vue-class-component-7.2.6.tgz", + "integrity": "sha512-+eaQXVrAm/LldalI272PpDe3+i4mPis0ORiMYxF6Ae4hyuCh15W8Idet7wPUEs4N4YptgFHGys4UrgNQOMyO6w==", + "requires": {} + }, "vue-cli-plugin-vuetify": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/vue-cli-plugin-vuetify/-/vue-cli-plugin-vuetify-2.0.7.tgz", @@ -25685,6 +25730,12 @@ } } }, + "vue-property-decorator": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/vue-property-decorator/-/vue-property-decorator-9.1.2.tgz", + "integrity": "sha512-xYA8MkZynPBGd/w5QFJ2d/NM0z/YeegMqYTphy7NJQXbZcuU6FC6AOdUAcy4SXP+YnkerC6AfH+ldg7PDk9ESQ==", + "requires": {} + }, "vue-router": { "version": "3.4.9", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-3.4.9.tgz", diff --git a/package.json b/package.json index 0c68e04..1a23876 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "raw-loader": "^4.0.2", "roboto-fontface": "*", + "v-emoji-picker": "^2.3.1", "vue": "^2.6.11", "vue-router": "^3.2.0", "vuetify": "^2.2.11", diff --git a/src/assets/css/chat.scss b/src/assets/css/chat.scss index 96de7ca..414964f 100644 --- a/src/assets/css/chat.scss +++ b/src/assets/css/chat.scss @@ -185,4 +185,35 @@ $chat-text-size: 0.7pt; color: #1c242a; text-align: center; margin: 20px; +} + +.messageOperations { + position: absolute; + bottom: 10px; + &.incoming { + left: -20px; + } + &.outgoing { + right: 20px; + } +} + +.quick-reaction-container { + .quick-reaction { + border: 1px solid #e2e2e2; + border-radius: 9px; + margin: 0px 2px; + padding: 2px; + &:hover { + border: 1px solid #888888; + background-color: #e2e2e2; + } + .quick-reaction-count { + color: #888888; + font-size: 0.7rem; + } + } + .sent { + background-color: palegreen; + } } \ No newline at end of file diff --git a/src/components/Chat.vue b/src/components/Chat.vue index 4b94fb0..71e9c5c 100644 --- a/src/components/Chat.vue +++ b/src/components/Chat.vue @@ -7,7 +7,29 @@ v-on:scroll="onScroll" >
- + +
+ + +
+
@@ -68,11 +90,23 @@ Cancel - Send + Send + +
+ + + +
@@ -90,6 +124,7 @@ import DebugEvent from "./messages/DebugEvent.vue"; import MessageOutgoingImage from "./messages/MessageOutgoingImage.vue"; import MessageIncomingImage from "./messages/MessageIncomingImage.vue"; import util from "../plugins/utils"; +import MessageOperations from "./messages/MessageOperations.vue"; // from https://kirbysayshi.com/2013/08/19/maintaining-scroll-position-knockoutjs-list.html function ScrollPosition(node) { @@ -133,6 +168,7 @@ export default { DebugEvent, MessageOutgoingImage, MessageIncomingImage, + MessageOperations, }, data() { @@ -148,37 +184,24 @@ export default { currentSendOperation: null, currentSendProgress: null, currentSendError: null, - joinRoom: null, + showEmojiPicker: false, + selectedEvent: null, }; }, mounted() { const container = this.$refs.chatContainer; this.scrollPosition = new ScrollPosition(container); - this.$matrix.on("Room.timeline", this.onEvent); this.$matrix.on("RoomMember.typing", this.onUserTyping); - this.$matrix.on("Matrix.initialized", this.onInitialized); - - if (this.$route.params && this.$route.params.joinRoom) { - this.joinRoom = this.$route.params.joinRoom; - } - - if (this.$matrix.matrixClientReady) { - this.onInitialized(this.$matrix.matrixClient); - } }, destroyed() { this.$matrix.off("Room.timeline", this.onEvent); this.$matrix.off("RoomMember.typing", this.onUserTyping); - this.$matrix.off("Matrix.initialized", this.onInitialized); }, computed: { - myUserId() { - return this.$store.state.auth.user.user_id; - }, roomId() { return this.$matrix.currentRoomId; }, @@ -188,69 +211,55 @@ export default { }, watch: { - roomId() { - console.log("Chat: Current room changed"); + roomId: { + handler(ignoredNewVal, ignoredOldVal) { + console.log("Chat: Current room changed"); - // Clear old events - this.events = []; - this.timelineWindow = null; - this.contactIsTyping = false; + // Clear old events + this.events = []; + this.timelineWindow = null; + this.contactIsTyping = false; - if (!this.roomId) { - return; // no room - } + if (!this.roomId) { + return; // no room + } - this.room = this.$matrix.getRoom(this.roomId); - if (!this.room) { - return; // Not found - } + this.room = this.$matrix.getRoom(this.roomId); + if (!this.room) { + return; // Not found + } - this.timelineWindow = new TimelineWindow( - this.$matrix.matrixClient, - this.room.getUnfilteredTimelineSet(), - {} - ); - this.timelineWindow.load(null, 20).then(() => { - this.events = this.timelineWindow.getEvents(); - this.$nextTick(() => { - this.paginateBackIfNeeded(); + this.timelineWindow = new TimelineWindow( + this.$matrix.matrixClient, + this.room.getUnfilteredTimelineSet(), + {} + ); + this.timelineWindow.load(null, 20).then(() => { + this.events = this.timelineWindow.getEvents(); + this.$nextTick(() => { + this.paginateBackIfNeeded(); + }); }); - }); + }, + immediate: true, }, }, methods: { - onInitialized(matrixClient) { - if (this.joinRoom) { - const roomId = this.joinRoom; - this.joinRoom = null; - matrixClient - .joinRoom(roomId) - .then((room) => { - this.$matrix.setCurrentRoomId(room.roomId); - }) - .catch((err) => { - // TODO - handle error - console.log("Failed to join room", err); - }); - } - }, componentForEvent(event) { switch (event.getType()) { case "m.room.member": - if (event.event.state_key != this.myUserId) { - if (event.getContent().membership == "join") { - return ContactJoin; - } else if (event.getContent().membership == "leave") { - return ContactLeave; - } else if (event.getContent().membership == "invite") { - return ContactInvited; - } + if (event.getContent().membership == "join") { + return ContactJoin; + } else if (event.getContent().membership == "leave") { + return ContactLeave; + } else if (event.getContent().membership == "invite") { + return ContactInvited; } - break; + break; case "m.room.message": - if (event.getSender() != this.myUserId) { + if (event.getSender() != this.$matrix.currentUserId) { if (event.getContent().msgtype == "m.image") { return MessageIncomingImage; } @@ -319,13 +328,18 @@ export default { sendMessage() { if (this.currentInput.length > 0) { - util.sendTextMessage(this.$matrix.matrixClient, this.roomId, this.currentInput) - .then(() => { - console.log("Sent message"); - }) - .catch(err => { - console.log("Failed to send:", err); - }) + util + .sendTextMessage( + this.$matrix.matrixClient, + this.roomId, + this.currentInput + ) + .then(() => { + console.log("Sent message"); + }) + .catch((err) => { + console.log("Failed to send:", err); + }); this.currentInput = ""; } }, @@ -356,18 +370,23 @@ export default { sendAttachment() { if (this.currentImageInputPath) { this.currentSendProgress = 0; - this.currentSendOperation = util.sendEncyptedImage(this.$matrix.matrixClient, this.roomId, this.currentImageInputPath, this.onUploadProgress); + this.currentSendOperation = util.sendImage( + this.$matrix.matrixClient, + this.roomId, + this.currentImageInputPath, + this.onUploadProgress + ); this.currentSendOperation - .then(() => { - this.currentSendOperation = null; - this.currentImageInput = null; - this.currentSendProgress = 0; - }) - .catch(err => { - this.currentSendError = err.toLocaleString(); - this.currentSendOperation = null; - this.currentSendProgress = 0; - }); + .then(() => { + this.currentSendOperation = null; + this.currentImageInput = null; + this.currentSendProgress = 0; + }) + .catch((err) => { + this.currentSendError = err.toLocaleString(); + this.currentSendOperation = null; + this.currentSendProgress = 0; + }); } }, @@ -441,6 +460,39 @@ export default { } }); }, + + addReaction(e) { + const event = e.event; + // Store the event we are reacting to, so that we know where to + // send when the picker closes. + this.selectedEvent = event; + this.showEmojiPicker = true; + }, + + emojiSelected(e) { + this.showEmojiPicker = false; + if (this.selectedEvent) { + const event = this.selectedEvent; + this.selectedEvent = null; + this.sendQuickReaction({reaction:e.data, event: event}); + } + }, + + sendQuickReaction(e) { + util + .sendQuickReaction( + this.$matrix.matrixClient, + this.roomId, + e.reaction, + e.event + ) + .then(() => { + console.log("Quick reaction message"); + }) + .catch((err) => { + console.log("Failed to send quick reaction:", err); + }); + } }, }; diff --git a/src/components/Join.vue b/src/components/Join.vue index c47419f..a0026f3 100644 --- a/src/components/Join.vue +++ b/src/components/Join.vue @@ -38,38 +38,29 @@ export default { handleJoin() { this.loading = true; this.loadingMessage = "Logging in..."; + var clientPromise; if (this.currentUser) { - this.$matrix - .getMatrixClient(this.currentUser) - .then((ignoreduser) => { - this.loadingMessage = "Joining room..."; - return this.$matrix.matrixClient.joinRoom(this.roomId); - }) - .then((room) => { - this.$matrix.setCurrentRoomId(room.roomId); - this.loading = false; - this.loadingMessage = null; - this.$router.replace({ name: "Chat" }); - }) - .catch((err) => { - // TODO - handle error - console.log("Failed to join room", err); - this.loading = false; - this.loadingMessage = err.toString(); - }); + clientPromise = this.$matrix.getMatrixClient(this.currentUser); } else { - this.$store.dispatch("auth/login", this.guestUser).then( - () => { - this.loading = false; - this.loadingMessage = null; - this.$router.replace({ name: "Chat" }); - }, - (error) => { - this.loading = false; - this.loadingMessage = error.toString(); - } - ); + clientPromise = this.$store.dispatch("auth/login", this.guestUser); } + return clientPromise + .then((ignoreduser) => { + this.loadingMessage = "Joining room..."; + return this.$matrix.matrixClient.joinRoom(this.roomId); + }) + .then((room) => { + this.$matrix.setCurrentRoomId(room.roomId); + this.loading = false; + this.loadingMessage = null; + this.$router.replace({ name: "Chat" }); + }) + .catch((err) => { + // TODO - handle error + console.log("Failed to join room", err); + this.loading = false; + this.loadingMessage = err.toString(); + }); }, }, }; diff --git a/src/components/messages/MessageIncomingImage.vue b/src/components/messages/MessageIncomingImage.vue index d259ce3..4bb2f89 100644 --- a/src/components/messages/MessageIncomingImage.vue +++ b/src/components/messages/MessageIncomingImage.vue @@ -4,6 +4,7 @@
{{ messageEventDisplayName(event) }}
+
{{ event.getContent().body }}
+
diff --git a/src/components/messages/MessageOperations.vue b/src/components/messages/MessageOperations.vue new file mode 100644 index 0000000..b4436d9 --- /dev/null +++ b/src/components/messages/MessageOperations.vue @@ -0,0 +1,40 @@ + + + + + \ No newline at end of file diff --git a/src/components/messages/MessageOutgoingImage.vue b/src/components/messages/MessageOutgoingImage.vue index 03ddb27..00e4dc4 100644 --- a/src/components/messages/MessageOutgoingImage.vue +++ b/src/components/messages/MessageOutgoingImage.vue @@ -4,6 +4,7 @@
{{ "You" }}
+
{{ event.status }}
diff --git a/src/components/messages/MessageOutgoingText.vue b/src/components/messages/MessageOutgoingText.vue index 113fb85..6932509 100644 --- a/src/components/messages/MessageOutgoingText.vue +++ b/src/components/messages/MessageOutgoingText.vue @@ -4,6 +4,7 @@
{{ "You" }}
{{ event.getContent().body }}
+
{{ event.status }}
@@ -20,7 +21,6 @@ export default { mixins: [messageMixin], }; - \ No newline at end of file diff --git a/src/components/messages/QuickReactions.vue b/src/components/messages/QuickReactions.vue new file mode 100644 index 0000000..4c59909 --- /dev/null +++ b/src/components/messages/QuickReactions.vue @@ -0,0 +1,81 @@ + + + + + \ No newline at end of file diff --git a/src/components/messages/messageMixin.js b/src/components/messages/messageMixin.js index 2d049d2..82e3101 100644 --- a/src/components/messages/messageMixin.js +++ b/src/components/messages/messageMixin.js @@ -1,4 +1,9 @@ +import QuickReactions from './QuickReactions.vue'; + export default { + components: { + QuickReactions + }, props: { room: { type: Object, @@ -12,6 +17,12 @@ export default { return {} } }, + reactions: { + type: Object, + default: function () { + return null + } + } }, computed: { }, @@ -20,6 +31,9 @@ export default { * Get a display name given an event. */ stateEventDisplayName(event) { + if (event.getSender() == this.$matrix.currentUserId) { + return "You"; + } if (this.room) { const member = this.room.getMember(event.getSender()); if (member) { @@ -64,5 +78,5 @@ export default { } return date.toLocaleString(); }, - } + }, } \ No newline at end of file diff --git a/src/main.js b/src/main.js index 9b7bf94..ac23b9f 100644 --- a/src/main.js +++ b/src/main.js @@ -6,11 +6,26 @@ import store from './store' import matrix from './services/matrix.service' import 'roboto-fontface/css/roboto/roboto-fontface.css' import 'material-design-icons-iconfont/dist/material-design-icons.css' +import VEmojiPicker from 'v-emoji-picker'; Vue.config.productionTip = false +Vue.use(VEmojiPicker); Vue.use(matrix, {store: store}); +// Add bubble functionality to custom events. +// From here: https://stackoverflow.com/questions/41993508/vuejs-bubbling-custom-events +Vue.use((Vue) => { + Vue.prototype.$bubble = function $bubble(eventName, ...args) { + // Emit the event on all parent components + let component = this; + do { + component.$emit(eventName, ...args); + component = component.$parent; + } while (component); + }; +}); + new Vue({ vuetify, router, diff --git a/src/plugins/utils.js b/src/plugins/utils.js index 96681af..d06d0e3 100644 --- a/src/plugins/utils.js +++ b/src/plugins/utils.js @@ -76,12 +76,23 @@ class Util { } sendTextMessage(matrixClient, roomId, text) { - return this.sendMessage(matrixClient, roomId, ContentHelpers.makeTextMessage(text)); + return this.sendMessage(matrixClient, roomId, "m.room.message", ContentHelpers.makeTextMessage(text)); } - sendMessage(matrixClient, roomId, content) { + sendQuickReaction(matrixClient, roomId, emoji, event) { + const content = { + 'm.relates_to': { + key: emoji, + rel_type: 'm.annotation', + event_id: event.getId() + } + }; + return this.sendMessage(matrixClient, roomId, "m.reaction", content); + } + + sendMessage(matrixClient, roomId, eventType, content) { return new Promise((resolve, reject) => { - matrixClient.sendMessage(roomId, content, undefined, undefined) + matrixClient.sendEvent(roomId, eventType, content, undefined, undefined) .then((result) => { console.log("Message sent: ", result); resolve(true); @@ -127,22 +138,57 @@ class Util { }); } - sendEncyptedImage(matrixClient, roomId, file, onUploadProgress) { + sendImage(matrixClient, roomId, file, onUploadProgress) { return new Promise((resolve, reject) => { var reader = new FileReader(); reader.onload = (e) => { const fileContents = e.target.result; + var data = new Uint8Array(fileContents); + + const info = { + mimetype: file.type, + size: file.size + }; + + const opts = { + type: file.type, + name: 'Image', + progressHandler: onUploadProgress, + onlyContentUri: false + }; + + if (!matrixClient.isRoomEncrypted(roomId)) { + // Not encrypted. + matrixClient.uploadContent(data, opts) + .then((response) => { + const messageContent = { + body: 'Image', + url: response.content_uri, + info: info, + msgtype: 'm.image' + } + return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent) + }) + .then(result => { + resolve(result); + }) + .catch(err => { + reject(err); + }); + return; // Don't fall through + } + const crypto = require('crypto'); let key = crypto.randomBytes(256 / 8); let iv = Buffer.concat([crypto.randomBytes(8),Buffer.alloc(8)]); // Initialization vector. // Encrypt var aesCtr = new aesjs.ModeOfOperation.ctr(key, new aesjs.Counter(iv)); - const data = new Uint8Array(fileContents); var encryptedBytes = aesCtr.encrypt(data); + data = encryptedBytes; // Calculate sha256 - var hash = new Uint8Array(sha256.create().update(encryptedBytes).arrayBuffer()); + var hash = new Uint8Array(sha256.create().update(data).arrayBuffer()); const jwk = { kty: 'oct', @@ -159,20 +205,7 @@ class Util { hashes: { sha256: Buffer.from(hash).toString('base64').replace( /=/g, '' )}, v: 'v2' }; - console.log("Encrypted file:", encryptedFile); - - const info = { - mimetype: file.type, - size: file.size - }; - - const opts = { - type: file.type, - name: 'Image', - progressHandler: onUploadProgress, - }; - const messageContent = { body: 'Image', file: encryptedFile, @@ -180,10 +213,10 @@ class Util { msgtype: 'm.image' } - matrixClient.uploadContent(encryptedBytes, opts) - .then((uri) => { - encryptedFile.url = uri; - return this.sendMessage(matrixClient, roomId, messageContent) + matrixClient.uploadContent(data, opts) + .then((response) => { + encryptedFile.url = response.content_uri; + return this.sendMessage(matrixClient, roomId, "m.room.message", messageContent) }) .then(result => { resolve(result); diff --git a/src/services/matrix.service.js b/src/services/matrix.service.js index 37d7318..2f1a963 100644 --- a/src/services/matrix.service.js +++ b/src/services/matrix.service.js @@ -38,6 +38,11 @@ export default { return this.$store.state.auth.user; }, + currentUserId() { + const user = this.currentUser || {} + return user.user_id; + }, + currentRoomId() { return this.$store.state.currentRoomId; },