Mobile: top level heart reaction on double tab

This commit is contained in:
10G Meow 2024-05-18 22:13:07 +03:00
parent ac184de2b2
commit 62cf15f2de
16 changed files with 199 additions and 39 deletions

View file

@ -19,3 +19,5 @@ $voice-recorded-color: #3ae17d;
$poll-hilite-color: #6360f0; $poll-hilite-color: #6360f0;
$poll-hilite-color-bg: #d6d5fc; $poll-hilite-color-bg: #d6d5fc;
$alert-bg-color: #FF3300; $alert-bg-color: #FF3300;
$min-touch-target: 48px;

View file

@ -286,7 +286,7 @@ body {
.input-area-button { .input-area-button {
margin: 0; margin: 0;
padding: 0; padding: 0;
min-width: 48px; min-width: $min-touch-target;
&.input-more-icon { &.input-more-icon {
svg { svg {
@ -430,6 +430,10 @@ body {
display: inline-block; display: inline-block;
position: relative; position: relative;
max-width: 70%; max-width: 70%;
@media #{map-get($display-breakpoints, 'sm-and-down')} {
min-height: $min-touch-target;
}
} }
&.from-admin .bubble { &.from-admin .bubble {
background-color: rgba($admin-bg,0.8); background-color: rgba($admin-bg,0.8);
@ -520,6 +524,10 @@ body {
display: inline-block; display: inline-block;
position: relative; position: relative;
max-width: 70%; max-width: 70%;
@media #{map-get($display-breakpoints, 'sm-and-down')} {
min-height: $min-touch-target;
}
} }
.audio-bubble { .audio-bubble {
background-color: rgba(#e5e5e5,0.8); background-color: rgba(#e5e5e5,0.8);
@ -1409,7 +1417,7 @@ body {
bottom: 68px; bottom: 68px;
left: 8px; left: 8px;
right: 8px; right: 8px;
height: 48px; height: $min-touch-target;
background: rgba(0, 0, 0, 0.69); background: rgba(0, 0, 0, 0.69);
border: 1px solid #000000; border: 1px solid #000000;
border-radius: 5px; border-radius: 5px;

View file

@ -37,7 +37,7 @@
border-radius: 4px; border-radius: 4px;
padding: 15px 14px; padding: 15px 14px;
margin: 0px; margin: 0px;
height: 48px; height: $min-touch-target;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
} }
@ -225,7 +225,7 @@
.add-answer-button { .add-answer-button {
border-radius: 4px; border-radius: 4px;
height: 48px; height: $min-touch-target;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
border: 1px solid #242424; border: 1px solid #242424;

View file

@ -1,4 +1,4 @@
$large-button-height: 48px; $large-button-height: $min-touch-target;
$small-button-height: 36px; $small-button-height: 36px;
.file-drop-root { .file-drop-root {
@ -295,11 +295,11 @@ $small-button-height: 36px;
position: relative; position: relative;
padding: 8px; padding: 8px;
.v-image { .v-image {
width: 48px; width: $min-touch-target;
height: 48px; height: $min-touch-target;
border-radius: 8px; border-radius: 8px;
object-fit: cover; object-fit: cover;
flex: 0 0 48px; flex: 0 0 $min-touch-target;
margin-right: 8px; margin-right: 8px;
} }
margin-bottom: 8px; margin-bottom: 8px;

BIN
src/assets/heart.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -82,6 +82,7 @@
showMoreMessageOperations({event: event, anchor: $event.anchor}) showMoreMessageOperations({event: event, anchor: $event.anchor})
" "
v-on:layout-change="onLayoutChange" 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">EventID: {{ event.getId() }}</div> -->
<!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}</div> --> <!-- <div v-if="debugging" style="user-select:text">Event: {{ JSON.stringify(event) }}</div> -->
@ -327,6 +328,9 @@
<!-- PURGE ROOM POPUP --> <!-- PURGE ROOM POPUP -->
<PurgeRoomDialog :show="showPurgeConfirmation" :room="room" @close="showPurgeConfirmation = false" /> <PurgeRoomDialog :show="showPurgeConfirmation" :room="room" @close="showPurgeConfirmation = false" />
<div :class="['heart-wrapper', { 'is-active': heartAnimation }]">
<div :class="['heart', { 'is-active': heartAnimation }]" />
</div>
</div> </div>
</template> </template>
@ -493,6 +497,7 @@ export default {
retentionTimer: null, retentionTimer: null,
showProfileDialog: false, showProfileDialog: false,
showPurgeConfirmation: false, showPurgeConfirmation: false,
heartAnimation: false
}; };
}, },
@ -533,6 +538,9 @@ export default {
}, },
computed: { computed: {
heartEmoji() {
return this.$refs.emojiPicker.mapEmojis["Symbols"].find(({ aliases }) => aliases.includes('heart')).data;
},
compActiveMember() { compActiveMember() {
const currentUserId= this.selectedEvent?.sender.userId || this.$matrix.currentUserId const currentUserId= this.selectedEvent?.sender.userId || this.$matrix.currentUserId
return this.joinedAndInvitedMembers.find(({userId}) => userId === currentUserId) return this.joinedAndInvitedMembers.find(({userId}) => userId === currentUserId)
@ -1569,6 +1577,10 @@ export default {
this.sendQuickReaction({ reaction: e.emoji, event: e.event }); this.sendQuickReaction({ reaction: e.emoji, event: e.event });
}, },
addQuickHeartReaction(event) {
this.sendQuickReaction({ reaction: this.heartEmoji, event }, true);
},
setReplyToImage(event) { setReplyToImage(event) {
util util
.getThumbnail(this.$matrix.matrixClient, event, this.$config) .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; let previousReaction = null;
// Figure out if we have already sent this emoji, in that case redact it again (toggle) // Figure out if we have already sent this emoji, in that case redact it again (toggle)
@ -1679,6 +1699,10 @@ export default {
.catch((err) => { .catch((err) => {
console.log("Failed to send quick reaction:", err); console.log("Failed to send quick reaction:", err);
}); });
if(heartAnimationFlag) {
this.showHeartAnimation();
}
} }
}, },
@ -1909,4 +1933,33 @@ export default {
<style lang="scss"> <style lang="scss">
@import "@/assets/css/chat.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> </style>

View file

@ -73,7 +73,7 @@ export default {
width: 100%; width: 100%;
display: flex; display: flex;
position: relative; position: relative;
min-height: 48px; min-height: $min-touch-target;
background: #f5f5f5; background: #f5f5f5;
border-radius: 4px; border-radius: 4px;

View file

@ -117,6 +117,6 @@ export default {
} }
.v-radio.flex-row-reverse { .v-radio.flex-row-reverse {
height: 48px; height: $min-touch-target;
} }
</style> </style>

View file

@ -1,16 +1,16 @@
<template> <template>
<v-responsive v-if="item.event.getContent().msgtype == 'm.video' && item.src" :class="{'thumbnail-item': true, 'preview': previewOnly}" <div ref="thumbnailRef">
@click.stop="$emit('itemclick', {item: item})"> <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"> <video :src="item.src" :controls="!previewOnly" class="w-100 h-100">
{{ $t('fallbacks.video_file') }} {{ $t('fallbacks.video_file') }}
</video> </video>
</v-responsive> </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" <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}" >
<div v-else :class="{'thumbnail-item': true, 'preview': previewOnly, 'file-item': true}" @click.stop="$emit('itemclick', {item: item})"> <v-icon>{{ fileTypeIcon }}</v-icon>
<v-icon>{{ fileTypeIcon }}</v-icon> <b>{{ $sanitize(fileName) }}</b>
<b>{{ $sanitize(fileName) }}</b> <div>{{ fileSize }}</div>
<div>{{ fileSize }}</div> </div>
</div> </div>
</template> </template>
<script> <script>
@ -54,7 +54,24 @@ export default {
fileSize() { fileSize() {
return util.getFileSizeFormatted(this.item.event); 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> </script>

View file

@ -14,7 +14,9 @@
}}</span> }}</span>
</v-avatar> </v-avatar>
<!-- SLOT FOR CONTENT --> <!-- SLOT FOR CONTENT -->
<slot></slot> <span ref="messageInOutRef">
<slot></slot>
</span>
<div class="op-button" ref="opbutton" v-if="!event.isRedacted()"> <div class="op-button" ref="opbutton" v-if="!event.isRedacted()">
<v-btn id="btn-more" icon @click.stop="showContextMenu($refs.opbutton)"> <v-btn id="btn-more" icon @click.stop="showContextMenu($refs.opbutton)">
<v-icon>more_vert</v-icon> <v-icon>more_vert</v-icon>
@ -28,10 +30,16 @@
<script> <script>
import SeenBy from "./SeenBy.vue"; import SeenBy from "./SeenBy.vue";
import messageMixin from "./messageMixin"; import messageMixin from "./messageMixin";
import util from "../../plugins/utils";
export default { export default {
mixins: [messageMixin], mixins: [messageMixin],
components: { SeenBy } components: { SeenBy },
mounted() {
if(util.isMobileOrTabletBrowser() && this.$refs.messageInOutRef) {
this.initMsgHammerJs(this.$refs.messageInOutRef);
}
}
}; };
</script> </script>

View file

@ -1,13 +1,12 @@
<template> <template>
<message-incoming v-bind="{...$props, ...$attrs}" v-on="$listeners"> <message-incoming v-bind="{...$props, ...$attrs}" v-on="$listeners">
<div class="bubble image-bubble"> <div class="bubble image-bubble" ref="imageRef">
<v-img <v-img
:aspect-ratio="16 / 9" :aspect-ratio="16 / 9"
ref="image" ref="image"
:src="src" :src="src"
:cover="cover" :cover="cover"
:contain="contain" :contain="contain"
@click.stop="dialog = true"
/> />
</div> </div>
<v-dialog <v-dialog
@ -34,6 +33,18 @@ export default {
dialog: false 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() { mounted() {
//console.log("Mounted with event:", JSON.stringify(this.event.getContent())); //console.log("Mounted with event:", JSON.stringify(this.event.getContent()));
const width = this.$refs.image.$el.clientWidth; const width = this.$refs.image.$el.clientWidth;
@ -52,6 +63,9 @@ export default {
this.contain = true; this.contain = true;
} }
this.src = url; this.src = url;
if(this.$refs.imageRef) {
this.initMessageInImageHammerJs(this.$refs.imageRef);
}
}) })
.catch((err) => { .catch((err) => {
console.log("Failed to fetch thumbnail: ", err); console.log("Failed to fetch thumbnail: ", err);

View file

@ -14,7 +14,9 @@
</v-btn> </v-btn>
</div> </div>
<!-- SLOT FOR CONTENT --> <!-- SLOT FOR CONTENT -->
<slot></slot> <span ref="messageInOutRef">
<slot></slot>
</span>
<v-avatar <v-avatar
class="avatar" class="avatar"
size="32" size="32"
@ -32,10 +34,16 @@
<script> <script>
import SeenBy from "./SeenBy.vue"; import SeenBy from "./SeenBy.vue";
import messageMixin from "./messageMixin"; import messageMixin from "./messageMixin";
import util from "../../plugins/utils";
export default { export default {
mixins: [messageMixin], mixins: [messageMixin],
components: { SeenBy } components: { SeenBy },
mounted() {
if(util.isMobileOrTabletBrowser() && this.$refs.messageInOutRef) {
this.initMsgHammerJs(this.$refs.messageInOutRef);
}
}
}; };
</script> </script>
<style lang="scss"> <style lang="scss">

View file

@ -1,13 +1,12 @@
<template> <template>
<message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> <message-outgoing v-bind="{ ...$props, ...$attrs }" v-on="$listeners">
<div class="bubble image-bubble"> <div class="bubble image-bubble" ref="imageRef">
<v-img <v-img
:aspect-ratio="16 / 9" :aspect-ratio="16 / 9"
ref="image" ref="image"
:src="src" :src="src"
:cover="cover" :cover="cover"
:contain="contain" :contain="contain"
@click.stop="dialog = true"
/> />
</div> </div>
<v-dialog <v-dialog
@ -34,6 +33,18 @@ export default {
dialog: false 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() { mounted() {
const width = this.$refs.image.$el.clientWidth; const width = this.$refs.image.$el.clientWidth;
const height = (width * 9) / 16; const height = (width * 9) / 16;
@ -51,6 +62,9 @@ export default {
this.contain = true; this.contain = true;
} }
this.src = url; this.src = url;
if(this.$refs.imageRef) {
this.initMessageOutImageHammerJs(this.$refs.imageRef);
}
}) })
.catch((err) => { .catch((err) => {
console.log("Failed to fetch thumbnail: ", err); console.log("Failed to fetch thumbnail: ", err);

View file

@ -3,6 +3,7 @@ import * as linkify from 'linkifyjs';
import linkifyHtml from 'linkify-html'; import linkifyHtml from 'linkify-html';
import utils from "../../plugins/utils" import utils from "../../plugins/utils"
import util from "../../plugins/utils"; import util from "../../plugins/utils";
import Hammer from "hammerjs";
linkify.options.defaults.className = "link"; linkify.options.defaults.className = "link";
linkify.options.defaults.target = { url: "_blank" }; linkify.options.defaults.target = { url: "_blank" };
@ -48,10 +49,10 @@ export default {
event: {}, event: {},
thread: null, thread: null,
utils, utils,
mc: null,
mcCustom: null
}; };
}, },
mounted() {
},
beforeDestroy() { beforeDestroy() {
this.thread = null; this.thread = null;
}, },
@ -326,5 +327,20 @@ export default {
* Override this to handle updates to (the) message thread. * Override this to handle updates to (the) message thread.
*/ */
processThread() {}, 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);
});
}
}, },
}; };

View file

@ -5,6 +5,7 @@ import ImageResize from "image-resize";
import { AutoDiscovery } from 'matrix-js-sdk'; import { AutoDiscovery } from 'matrix-js-sdk';
import User from '../models/user'; import User from '../models/user';
const prettyBytes = require("pretty-bytes"); 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_DELETION_NOTICE = "im.keanu.room_deletion_notice";
export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted"; export const STATE_EVENT_ROOM_DELETED = "im.keanu.room_deleted";
@ -1014,6 +1015,25 @@ class Util {
} }
return false; 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(); export default new Util();