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

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -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>

View file

@ -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;

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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);

View file

@ -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">

View file

@ -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);

View file

@ -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;

View file

@ -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);
});
}
},
};

View file

@ -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();