You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

279 lines
6.2 KiB

<!--
- SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<template>
<TransitionWrapper class="toaster"
name="toast"
tag="ul"
group>
<li v-for="toast in toasts"
:key="toast.seed"
class="toast"
:style="styled(toast.name, toast.seed)">
<img v-if="toast.reactionURL"
class="toast__reaction-img"
:src="toast.reactionURL"
:alt="toast.reaction"
width="34"
height="34">
<span v-else class="toast__reaction">
{{ toast.reaction }}
</span>
<span class="toast__name">
{{ toast.name }}
</span>
</li>
</TransitionWrapper>
</template>
<script>
import Hex from 'crypto-js/enc-hex.js'
import SHA1 from 'crypto-js/sha1.js'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
import { t } from '@nextcloud/l10n'
import { imagePath } from '@nextcloud/router'
import usernameToColor from '@nextcloud/vue/dist/Functions/usernameToColor.js'
import TransitionWrapper from '../../UIShared/TransitionWrapper.vue'
import { useGuestNameStore } from '../../../stores/guestName.js'
const reactions = {
'❤️': 'Heart.gif',
'🎉': 'Party.gif',
'👏': 'Clap.gif',
'👋': 'Wave.gif',
'👍': 'Thumbs-up.gif',
'👎': 'Thumbs-down.gif',
'🔥': 'Fire.gif',
'😂': 'Joy.gif',
'🤩': 'Star-struck.gif',
'🤔': 'Thinking-face.gif',
'😲': 'Surprised.gif',
'😥': 'Concerned.gif',
}
export default {
name: 'ReactionToaster',
components: {
TransitionWrapper,
},
props: {
/**
* The conversation token
*/
token: {
type: String,
required: true,
},
/**
* Supported reactions
*/
supportedReactions: {
type: Array,
validator: (prop) => prop.every(e => typeof e === 'string'),
required: true,
},
callParticipantModels: {
type: Array,
required: true,
},
},
setup() {
const guestNameStore = useGuestNameStore()
return { guestNameStore }
},
data() {
return {
registeredModels: {},
reactionsQueue: [],
intervalId: null,
animationLength: 2000,
toasts: [],
}
},
computed: {
participants() {
return this.$store.getters.participantsList(this.token)
},
},
watch: {
callParticipantModels(models) {
// subscribe connected models for reaction signals
const addedModels = models.filter(model => !this.registeredModels[model.attributes.peerId])
addedModels.forEach(addedModel => {
this.registeredModels[addedModel.attributes.peerId] = addedModel
this.registeredModels[addedModel.attributes.peerId].on('reaction', this.handleReaction)
})
// unsubscribe disconnected models
const removedModelIds = Object.keys(this.registeredModels).filter(registeredModelId => !models.find(model => model.attributes.peerId === registeredModelId))
removedModelIds.forEach(removedModelId => {
this.registeredModels[removedModelId].off('reaction', this.handleReaction)
delete this.registeredModels[removedModelId]
})
},
},
mounted() {
this.intervalId = setInterval(this.processReactionsQueue, this.animationLength / 4)
subscribe('send-reaction', this.handleOwnReaction)
},
beforeDestroy() {
clearInterval(this.intervalId)
unsubscribe('send-reaction', this.handleOwnReaction)
Object.keys(this.registeredModels).forEach(modelId => {
this.registeredModels[modelId].off('reaction', this.handleReaction)
delete this.registeredModels[modelId]
})
},
methods: {
t,
handleOwnReaction({ model, reaction }) {
this.handleReaction(model, reaction, true)
},
handleReaction(model, reaction, isLocalModel = false) {
// prevent spamming to queue from a single account
if (this.reactionsQueue.some(item => item.id === model.attributes.peerId)) {
return
}
// prevent receiving anything rather than defined reactions in capabilities
if (!this.supportedReactions.includes(reaction)) {
return
}
this.reactionsQueue.push({
id: model.attributes.peerId,
reaction,
reactionURL: this.getReactionURL(reaction),
name: isLocalModel
? this.$store.getters.getDisplayName() || t('spreed', 'Guest')
: this.getParticipantName(model),
seed: Math.random(),
})
},
processReactionsQueue() {
if (this.reactionsQueue.length > 0) {
// Move reactions from queue to visible array
this.toasts.push(this.reactionsQueue.shift())
// Delete reactions from array after animation ends
setTimeout(() => {
this.toasts.shift()
}, this.animationLength)
}
},
getParticipantName(model) {
const { name, nextcloudSessionId } = model.attributes
if (name) {
return name
}
const participant = this.participants.find(participant => participant.sessionIds.includes(nextcloudSessionId))
if (participant?.displayName) {
return participant.displayName
}
return this.guestNameStore.getGuestName(this.token, Hex.stringify(SHA1(nextcloudSessionId)))
},
getReactionURL(emoji) {
return reactions[emoji]
? imagePath('spreed', 'emojis/' + reactions[emoji])
: undefined
},
styled(name, seed) {
const color = usernameToColor(name)
return {
'--background-color': `rgb(${color.r}, ${color.g}, ${color.b})`,
'--animation-length': `${this.animationLength + 300}ms`,
'--horizontal-offset': `${10 + 20 * seed}%`,
'--vertical-offset': 30 + 5 * seed,
}
},
},
}
</script>
<style lang="scss" scoped>
.toaster {
position: absolute;
bottom: 20px;
left: 0;
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
z-index: 1;
}
.toast {
position: absolute;
bottom: 0;
left: var(--horizontal-offset, 0);
display: flex;
align-items: center;
gap: 8px;
animation: toast-floating var(--animation-length) linear;
&__reaction {
font-size: 250%;
line-height: 100%;
@media only screen and (max-width: 1920px) {
& {
font-size: 150%;
}
&-img {
width: 30px;
height: 30px;
}
}
}
&__name {
padding: 8px 12px;
border-radius: 6px;
line-height: 100%;
white-space: nowrap;
color: #ffffff;
background-color: var(--background-color);
box-shadow: 1px 1px 4px var(--color-box-shadow);
}
}
@keyframes toast-floating {
0% {
transform: translateY(0);
opacity: 1;
}
50% {
transform: translateY(calc(-0.5 * var(--vertical-offset) * 1vh));
opacity: 1;
}
100% {
transform: translateY(calc(-1 * var(--vertical-offset) * 1vh));
opacity: 0;
}
}
</style>