Mobile: top level heart reaction on double tab
This commit is contained in:
parent
ac184de2b2
commit
62cf15f2de
16 changed files with 199 additions and 39 deletions
|
|
@ -18,4 +18,6 @@ $voice-recording-color: red;
|
|||
$voice-recorded-color: #3ae17d;
|
||||
$poll-hilite-color: #6360f0;
|
||||
$poll-hilite-color-bg: #d6d5fc;
|
||||
$alert-bg-color: #FF3300;
|
||||
$alert-bg-color: #FF3300;
|
||||
|
||||
$min-touch-target: 48px;
|
||||
|
|
@ -286,7 +286,7 @@ body {
|
|||
.input-area-button {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 48px;
|
||||
min-width: $min-touch-target;
|
||||
|
||||
&.input-more-icon {
|
||||
svg {
|
||||
|
|
@ -430,6 +430,10 @@ body {
|
|||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 70%;
|
||||
|
||||
@media #{map-get($display-breakpoints, 'sm-and-down')} {
|
||||
min-height: $min-touch-target;
|
||||
}
|
||||
}
|
||||
&.from-admin .bubble {
|
||||
background-color: rgba($admin-bg,0.8);
|
||||
|
|
@ -520,6 +524,10 @@ body {
|
|||
display: inline-block;
|
||||
position: relative;
|
||||
max-width: 70%;
|
||||
|
||||
@media #{map-get($display-breakpoints, 'sm-and-down')} {
|
||||
min-height: $min-touch-target;
|
||||
}
|
||||
}
|
||||
.audio-bubble {
|
||||
background-color: rgba(#e5e5e5,0.8);
|
||||
|
|
@ -1409,7 +1417,7 @@ body {
|
|||
bottom: 68px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
height: 48px;
|
||||
height: $min-touch-target;
|
||||
background: rgba(0, 0, 0, 0.69);
|
||||
border: 1px solid #000000;
|
||||
border-radius: 5px;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
border-radius: 4px;
|
||||
padding: 15px 14px;
|
||||
margin: 0px;
|
||||
height: 48px;
|
||||
height: $min-touch-target;
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -225,7 +225,7 @@
|
|||
|
||||
.add-answer-button {
|
||||
border-radius: 4px;
|
||||
height: 48px;
|
||||
height: $min-touch-target;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid #242424;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
$large-button-height: 48px;
|
||||
$large-button-height: $min-touch-target;
|
||||
$small-button-height: 36px;
|
||||
|
||||
.file-drop-root {
|
||||
|
|
@ -295,11 +295,11 @@ $small-button-height: 36px;
|
|||
position: relative;
|
||||
padding: 8px;
|
||||
.v-image {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
width: $min-touch-target;
|
||||
height: $min-touch-target;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
flex: 0 0 48px;
|
||||
flex: 0 0 $min-touch-target;
|
||||
margin-right: 8px;
|
||||
}
|
||||
margin-bottom: 8px;
|
||||
|
|
|
|||
BIN
src/assets/heart.png
Normal file
BIN
src/assets/heart.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -82,6 +82,7 @@
|
|||
showMoreMessageOperations({event: event, anchor: $event.anchor})
|
||||
"
|
||||
v-on:layout-change="onLayoutChange"
|
||||
v-on:addQuickHeartReaction="addQuickHeartReaction(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> -->
|
||||
|
|
@ -327,6 +328,9 @@
|
|||
|
||||
<!-- PURGE ROOM POPUP -->
|
||||
<PurgeRoomDialog :show="showPurgeConfirmation" :room="room" @close="showPurgeConfirmation = false" />
|
||||
<div :class="['heart-wrapper', { 'is-active': heartAnimation }]">
|
||||
<div :class="['heart', { 'is-active': heartAnimation }]" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -493,6 +497,7 @@ export default {
|
|||
retentionTimer: null,
|
||||
showProfileDialog: false,
|
||||
showPurgeConfirmation: false,
|
||||
heartAnimation: false
|
||||
};
|
||||
},
|
||||
|
||||
|
|
@ -533,6 +538,9 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
heartEmoji() {
|
||||
return this.$refs.emojiPicker.mapEmojis["Symbols"].find(({ aliases }) => aliases.includes('heart')).data;
|
||||
},
|
||||
compActiveMember() {
|
||||
const currentUserId= this.selectedEvent?.sender.userId || this.$matrix.currentUserId
|
||||
return this.joinedAndInvitedMembers.find(({userId}) => userId === currentUserId)
|
||||
|
|
@ -1569,6 +1577,10 @@ export default {
|
|||
this.sendQuickReaction({ reaction: e.emoji, event: e.event });
|
||||
},
|
||||
|
||||
addQuickHeartReaction(event) {
|
||||
this.sendQuickReaction({ reaction: this.heartEmoji, event }, true);
|
||||
},
|
||||
|
||||
setReplyToImage(event) {
|
||||
util
|
||||
.getThumbnail(this.$matrix.matrixClient, event, this.$config)
|
||||
|
|
@ -1652,7 +1664,15 @@ export default {
|
|||
});
|
||||
},
|
||||
|
||||
sendQuickReaction(e) {
|
||||
showHeartAnimation() {
|
||||
const self = this;
|
||||
this.heartAnimation = true;
|
||||
setTimeout(() => {
|
||||
self.heartAnimation = false;
|
||||
}, 1000)
|
||||
},
|
||||
|
||||
sendQuickReaction(e, heartAnimationFlag = false) {
|
||||
let previousReaction = null;
|
||||
|
||||
// Figure out if we have already sent this emoji, in that case redact it again (toggle)
|
||||
|
|
@ -1679,6 +1699,10 @@ export default {
|
|||
.catch((err) => {
|
||||
console.log("Failed to send quick reaction:", err);
|
||||
});
|
||||
|
||||
if(heartAnimationFlag) {
|
||||
this.showHeartAnimation();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -1909,4 +1933,33 @@ export default {
|
|||
|
||||
<style lang="scss">
|
||||
@import "@/assets/css/chat.scss";
|
||||
.heart-wrapper {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: -1;
|
||||
|
||||
.heart {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: url("../assets/heart.png") no-repeat;
|
||||
background-position: 0 0;
|
||||
cursor: pointer;
|
||||
transition: background-position 1s steps(28);
|
||||
transition-duration: 0s;
|
||||
visibility: hidden;
|
||||
|
||||
|
||||
&.is-active {
|
||||
transition-duration: 1s;
|
||||
background-position: -2800px 0;
|
||||
visibility: visible;
|
||||
z-index: 10000;
|
||||
}
|
||||
}
|
||||
&.is-active {
|
||||
z-index: 1000;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ export default {
|
|||
width: 100%;
|
||||
display: flex;
|
||||
position: relative;
|
||||
min-height: 48px;
|
||||
min-height: $min-touch-target;
|
||||
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
|
|
|
|||
|
|
@ -117,6 +117,6 @@ export default {
|
|||
}
|
||||
|
||||
.v-radio.flex-row-reverse {
|
||||
height: 48px;
|
||||
height: $min-touch-target;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
<template>
|
||||
<v-responsive v-if="item.event.getContent().msgtype == 'm.video' && item.src" :class="{'thumbnail-item': true, 'preview': previewOnly}"
|
||||
@click.stop="$emit('itemclick', {item: item})">
|
||||
<video :src="item.src" :controls="!previewOnly" class="w-100 h-100">
|
||||
{{ $t('fallbacks.video_file') }}
|
||||
</video>
|
||||
</v-responsive>
|
||||
<v-img v-else-if="item.event.getContent().msgtype == 'm.image' && item.src" :aspect-ratio="previewOnly ? (16 / 9) : undefined" :class="{'thumbnail-item': true, 'preview': previewOnly}" :src="item.src" :contain="!previewOnly" :cover="previewOnly"
|
||||
@click.stop="$emit('itemclick', {item: item})" />
|
||||
<div v-else :class="{'thumbnail-item': true, 'preview': previewOnly, 'file-item': true}" @click.stop="$emit('itemclick', {item: item})">
|
||||
<v-icon>{{ fileTypeIcon }}</v-icon>
|
||||
<b>{{ $sanitize(fileName) }}</b>
|
||||
<div>{{ fileSize }}</div>
|
||||
<div ref="thumbnailRef">
|
||||
<v-responsive v-if="item.event.getContent().msgtype == 'm.video' && item.src" :class="{'thumbnail-item': true, 'preview': previewOnly}">
|
||||
<video :src="item.src" :controls="!previewOnly" class="w-100 h-100">
|
||||
{{ $t('fallbacks.video_file') }}
|
||||
</video>
|
||||
</v-responsive>
|
||||
<v-img v-else-if="item.event.getContent().msgtype == 'm.image' && item.src" :aspect-ratio="previewOnly ? (16 / 9) : undefined" :class="{'thumbnail-item': true, 'preview': previewOnly}" :src="item.src" :contain="!previewOnly" :cover="previewOnly" />
|
||||
<div v-else :class="{'thumbnail-item': true, 'preview': previewOnly, 'file-item': true}" >
|
||||
<v-icon>{{ fileTypeIcon }}</v-icon>
|
||||
<b>{{ $sanitize(fileName) }}</b>
|
||||
<div>{{ fileSize }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
|
|
@ -20,7 +20,7 @@ import util from "../../plugins/utils";
|
|||
export default {
|
||||
props: {
|
||||
/**
|
||||
* Item is an object of { event: MXEvent, src: URL }
|
||||
* Item is an object of { event: MXEvent, src: URL }
|
||||
*/
|
||||
item: {
|
||||
type: Object,
|
||||
|
|
@ -54,7 +54,24 @@ export default {
|
|||
fileSize() {
|
||||
return util.getFileSizeFormatted(this.item.event);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// listen for custom hammerJs singletab click to differentiate it from double click(heart animation).
|
||||
initThumbnailHammerJs(element) {
|
||||
const hammerInstance = util.singleOrDoubleTabRecognizer(element)
|
||||
|
||||
hammerInstance.on("singletap doubletap", (ev) => {
|
||||
if(ev.type === 'singletap') {
|
||||
this.$emit('itemclick', { item: this.item })
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if(this.$refs.thumbnailRef) {
|
||||
this.initThumbnailHammerJs(this.$refs.thumbnailRef);
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
}}</span>
|
||||
</v-avatar>
|
||||
<!-- SLOT FOR CONTENT -->
|
||||
<slot></slot>
|
||||
<span ref="messageInOutRef">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<div class="op-button" ref="opbutton" v-if="!event.isRedacted()">
|
||||
<v-btn id="btn-more" icon @click.stop="showContextMenu($refs.opbutton)">
|
||||
<v-icon>more_vert</v-icon>
|
||||
|
|
@ -28,10 +30,16 @@
|
|||
<script>
|
||||
import SeenBy from "./SeenBy.vue";
|
||||
import messageMixin from "./messageMixin";
|
||||
import util from "../../plugins/utils";
|
||||
|
||||
export default {
|
||||
mixins: [messageMixin],
|
||||
components: { SeenBy }
|
||||
components: { SeenBy },
|
||||
mounted() {
|
||||
if(util.isMobileOrTabletBrowser() && this.$refs.messageInOutRef) {
|
||||
this.initMsgHammerJs(this.$refs.messageInOutRef);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<template>
|
||||
<message-incoming v-bind="{...$props, ...$attrs}" v-on="$listeners">
|
||||
<div class="bubble image-bubble">
|
||||
<div class="bubble image-bubble" ref="imageRef">
|
||||
<v-img
|
||||
:aspect-ratio="16 / 9"
|
||||
ref="image"
|
||||
:src="src"
|
||||
:cover="cover"
|
||||
:contain="contain"
|
||||
@click.stop="dialog = true"
|
||||
/>
|
||||
</div>
|
||||
<v-dialog
|
||||
|
|
@ -34,6 +33,18 @@ export default {
|
|||
dialog: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// listen for custom hammerJs singletab click to differentiate it from double click(heart animation).
|
||||
initMessageInImageHammerJs(element) {
|
||||
const hammerInstance = util.singleOrDoubleTabRecognizer(element);
|
||||
|
||||
hammerInstance.on("singletap doubletap", (ev) => {
|
||||
if(ev.type === 'singletap') {
|
||||
this.dialog = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
//console.log("Mounted with event:", JSON.stringify(this.event.getContent()));
|
||||
const width = this.$refs.image.$el.clientWidth;
|
||||
|
|
@ -52,6 +63,9 @@ export default {
|
|||
this.contain = true;
|
||||
}
|
||||
this.src = url;
|
||||
if(this.$refs.imageRef) {
|
||||
this.initMessageInImageHammerJs(this.$refs.imageRef);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
</v-btn>
|
||||
</div>
|
||||
<!-- SLOT FOR CONTENT -->
|
||||
<slot></slot>
|
||||
<span ref="messageInOutRef">
|
||||
<slot></slot>
|
||||
</span>
|
||||
<v-avatar
|
||||
class="avatar"
|
||||
size="32"
|
||||
|
|
@ -32,10 +34,16 @@
|
|||
<script>
|
||||
import SeenBy from "./SeenBy.vue";
|
||||
import messageMixin from "./messageMixin";
|
||||
import util from "../../plugins/utils";
|
||||
|
||||
export default {
|
||||
mixins: [messageMixin],
|
||||
components: { SeenBy }
|
||||
components: { SeenBy },
|
||||
mounted() {
|
||||
if(util.isMobileOrTabletBrowser() && this.$refs.messageInOutRef) {
|
||||
this.initMsgHammerJs(this.$refs.messageInOutRef);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<style lang="scss">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<template>
|
||||
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
|
||||
<div class="bubble image-bubble">
|
||||
<div class="bubble image-bubble" ref="imageRef">
|
||||
<v-img
|
||||
:aspect-ratio="16 / 9"
|
||||
ref="image"
|
||||
:src="src"
|
||||
:cover="cover"
|
||||
:contain="contain"
|
||||
@click.stop="dialog = true"
|
||||
/>
|
||||
</div>
|
||||
<v-dialog
|
||||
|
|
@ -34,6 +33,18 @@ export default {
|
|||
dialog: false
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
// listen for custom hammerJs singletab click to differentiate it from double click(heart animation).
|
||||
initMessageOutImageHammerJs(element) {
|
||||
const hammerInstance = util.singleOrDoubleTabRecognizer(element);
|
||||
|
||||
hammerInstance.on("singletap doubletap", (ev) => {
|
||||
if(ev.type === 'singletap') {
|
||||
this.dialog = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const width = this.$refs.image.$el.clientWidth;
|
||||
const height = (width * 9) / 16;
|
||||
|
|
@ -51,6 +62,9 @@ export default {
|
|||
this.contain = true;
|
||||
}
|
||||
this.src = url;
|
||||
if(this.$refs.imageRef) {
|
||||
this.initMessageOutImageHammerJs(this.$refs.imageRef);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log("Failed to fetch thumbnail: ", err);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
<v-container fluid class="imageCollection">
|
||||
<v-row wrap>
|
||||
<v-col v-for="({ size, item }) in layoutedItems()" :key="item.event.getId()" :cols="size">
|
||||
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
|
||||
<ThumbnailView :item="item" :previewOnly="true" v-on:itemclick="onItemClick($event)" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
</div>
|
||||
<GalleryItemsView :originalEvent="originalEvent" :items="items" :initialItem="showItem" v-if="!!showItem" v-on:close="showItem = null" />
|
||||
</message-outgoing>
|
||||
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)"
|
||||
<component v-else-if="items.length == 1" :is="componentFn(items[0].event)"
|
||||
:originalEvent="items[0].event"
|
||||
v-bind="{...$props, ...$attrs}" v-on="$listeners"
|
||||
/>
|
||||
|
|
@ -147,7 +147,7 @@ export default {
|
|||
.col {
|
||||
padding: 2px;
|
||||
}
|
||||
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import * as linkify from 'linkifyjs';
|
|||
import linkifyHtml from 'linkify-html';
|
||||
import utils from "../../plugins/utils"
|
||||
import util from "../../plugins/utils";
|
||||
import Hammer from "hammerjs";
|
||||
|
||||
linkify.options.defaults.className = "link";
|
||||
linkify.options.defaults.target = { url: "_blank" };
|
||||
|
|
@ -48,10 +49,10 @@ export default {
|
|||
event: {},
|
||||
thread: null,
|
||||
utils,
|
||||
mc: null,
|
||||
mcCustom: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.thread = null;
|
||||
},
|
||||
|
|
@ -326,5 +327,20 @@ export default {
|
|||
* Override this to handle updates to (the) message thread.
|
||||
*/
|
||||
processThread() {},
|
||||
initMsgHammerJs(element) {
|
||||
this.mc = new Hammer(element);
|
||||
this.mcCustom = new Hammer.Manager(element);
|
||||
|
||||
this.mcCustom.add(new Hammer.Tap({ event: 'doubletap', taps: 2 }));
|
||||
|
||||
this.mcCustom.on("doubletap", () => {
|
||||
this.$emit("addQuickHeartReaction");
|
||||
});
|
||||
|
||||
this.mc.on("press", () => {
|
||||
this.showContextMenu(this.$refs.opbutton);
|
||||
});
|
||||
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import ImageResize from "image-resize";
|
|||
import { AutoDiscovery } from 'matrix-js-sdk';
|
||||
import User from '../models/user';
|
||||
const prettyBytes = require("pretty-bytes");
|
||||
import Hammer from "hammerjs";
|
||||
|
||||
export const STATE_EVENT_ROOM_DELETION_NOTICE = "im.keanu.room_deletion_notice";
|
||||
export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted";
|
||||
|
|
@ -1014,6 +1015,25 @@ class Util {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isMobileOrTabletBrowser() {
|
||||
// Regular expression to match common mobile and tablet browser user agent strings
|
||||
const mobileTabletPattern = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Tablet|Mobile|CriOS/i;
|
||||
const userAgent = navigator.userAgent;
|
||||
return mobileTabletPattern.test(userAgent);
|
||||
}
|
||||
|
||||
singleOrDoubleTabRecognizer(element) {
|
||||
// reference: https://codepen.io/jtangelder/pen/xxYyJQ
|
||||
const hm = new Hammer.Manager(element);
|
||||
|
||||
hm.add(new Hammer.Tap({ event: 'doubletap', taps: 2 }));
|
||||
hm.add(new Hammer.Tap({ event: 'singletap' }) );
|
||||
|
||||
hm.get('doubletap').recognizeWith('singletap');
|
||||
hm.get('singletap').requireFailure('doubletap');
|
||||
return hm
|
||||
}
|
||||
}
|
||||
export default new Util();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue