Skip to content

Commit

Permalink
Quick reactions (#7243)
Browse files Browse the repository at this point in the history
Co-authored-by: Julian Jelfs <[email protected]>
  • Loading branch information
ivan-jukic and julianjelfs authored Jan 20, 2025
1 parent 756f66a commit 6dd0063
Show file tree
Hide file tree
Showing 5 changed files with 205 additions and 35 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ summary.md
temp.did
website_release.md
frontend/app/src/stores/timeline.ts
/Makefile
83 changes: 62 additions & 21 deletions frontend/app/src/components/home/ChatMessage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
import Badges from "./profile/Badges.svelte";
import BotMessageContext from "../bots/BotMessageContext.svelte";
import BotProfile, { type BotProfileProps } from "../bots/BotProfile.svelte";
import { quickReactions } from "../../stores/quickReactions";
const client = getContext<OpenChat>("client");
const dispatch = createEventDispatcher();
Expand Down Expand Up @@ -255,10 +256,14 @@
}
function selectReaction(ev: CustomEvent<string>) {
toggleReaction(ev.detail);
toggleReaction(false, ev.detail);
}
function toggleReaction(reaction: string) {
function selectQuickReaction(unicode: string) {
toggleReaction(true, unicode);
}
function toggleReaction(isQuickReaction: boolean, reaction: string) {
if (canReact) {
const kind = client.containsReaction(user.userId, reaction, msg.reactions)
? "remove"
Expand All @@ -278,6 +283,15 @@
.then((success) => {
if (success && kind === "add") {
client.trackEvent("reacted_to_message");
if (isQuickReaction) {
// Note: Manually selected reactions do not increment
// their fav counter by default, so we do it manually.
// Also refresh loaded reactions.
quickReactions.incrementFavourite(reaction);
}
quickReactions.reload();
}
});
}
Expand Down Expand Up @@ -425,7 +439,10 @@
</HoverIcon>
</span>
</div>
<EmojiPicker on:emojiSelected={selectReaction} mode={"reaction"} />
<EmojiPicker
on:emojiSelected={selectReaction}
on:skintoneChanged={(ev) => quickReactions.reload(ev.detail)}
mode={"reaction"} />
</span>
<span slot="footer" />
</ModalContent>
Expand Down Expand Up @@ -620,6 +637,12 @@
<pre>expiresAt: {expiresAt}</pre>
<pre>thread: {JSON.stringify(msg.thread, null, 4)}</pre>
<pre>botContext: {JSON.stringify(botContext, null, 4)}</pre>
<pre>inert: {inert}</pre>
<pre>canRevealDeleted: {canRevealDeleted}</pre>
<pre>canlRevealBlocked: {canRevealBlocked}</pre>
<pre>readonly: {readonly}</pre>
<pre>showChatMenu: {showChatMenu}</pre>
<pre>intersecting: {intersecting}</pre>
{/if}

{#if showChatMenu && intersecting}
Expand Down Expand Up @@ -649,9 +672,9 @@
{canUndelete}
{canRevealDeleted}
{canRevealBlocked}
{crypto}
translatable={canTranslate}
{translated}
{selectQuickReaction}
on:collapseMessage
on:forward
on:reply={reply}
Expand Down Expand Up @@ -697,7 +720,7 @@
<div class="message-reactions" class:me class:indent={showAvatar}>
{#each msg.reactions as { reaction, userIds } (reaction)}
<MessageReaction
on:click={() => toggleReaction(reaction)}
on:click={() => toggleReaction(false, reaction)}
{reaction}
{userIds}
myUserId={user?.userId} />
Expand All @@ -722,29 +745,45 @@
$avatar-width: toRem(56);
$avatar-width-mob: toRem(43);
@media (hover: hover) {
:global(.message-bubble:hover .menu-icon) {
opacity: 1;
@keyframes show-bubble-menu {
0% {
z-index: -1;
opacity: 0;
}
:global(.message-bubble:hover .menu-icon .wrapper) {
background-color: var(--icon-msg-hv);
1% {
z-index: 1;
opacity: 0;
}
100% {
z-index: 1;
opacity: 1;
}
}
:global(.message-bubble.me:hover .menu-icon .wrapper) {
background-color: var(--icon-inverted-hv);
@include mobile() {
:global(.message-bubble .menu) {
display: none;
}
}
:global(.message-bubble.crypto:hover .menu-icon .wrapper) {
background-color: rgba(255, 255, 255, 0.3);
@include not-mobile() {
:global(.message-bubble .menu) {
display: flex;
z-index: -1;
opacity: 0;
}
:global(.me .menu-icon:hover .wrapper) {
background-color: var(--icon-inverted-hv);
// Keeps hover menu showing if context menu is clicked!
:global(.message-bubble .menu:has(.menu-icon.open)) {
border-color: var(--primary);
z-index: 1;
opacity: 1;
}
:global(.message-bubble.fill.me:hover .menu-icon .wrapper) {
background-color: var(--icon-hv);
@media (hover: hover) {
:global(.message-bubble:hover .menu:not(:has(.menu-icon.open))) {
animation: show-bubble-menu 200ms ease-in-out forwards;
}
}
}
Expand Down Expand Up @@ -774,6 +813,10 @@
color: inherit;
}
:global(.message-bubble.first .menu) {
top: -24px;
}
:global(.actions .reaction .wrapper) {
padding: 6px;
}
Expand Down Expand Up @@ -886,8 +929,6 @@
border-radius: $radius;
max-width: var(--max-width);
min-width: 90px;
overflow: hidden;
overflow-wrap: break-word;
border: var(--currentChat-msg-bd);
box-shadow: var(--currentChat-msg-sh);
Expand Down
75 changes: 62 additions & 13 deletions frontend/app/src/components/home/ChatMessageMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
import Menu from "../Menu.svelte";
import MenuItem from "../MenuItem.svelte";
import MenuIcon from "../MenuIcon.svelte";
import ChevronDown from "svelte-material-icons/ChevronDown.svelte";
import PencilOutline from "svelte-material-icons/PencilOutline.svelte";
import ContentCopy from "svelte-material-icons/ContentCopy.svelte";
import Reply from "svelte-material-icons/Reply.svelte";
Expand All @@ -22,6 +21,7 @@
import CollapseIcon from "svelte-material-icons/ArrowCollapseUp.svelte";
import EyeArrowRightIcon from "svelte-material-icons/EyeArrowRight.svelte";
import EyeOffIcon from "svelte-material-icons/EyeOff.svelte";
import DotsVertical from "svelte-material-icons/DotsVertical.svelte";
import HoverIcon from "../HoverIcon.svelte";
import Bitcoin from "../icons/Bitcoin.svelte";
import { _, locale } from "svelte-i18n";
Expand All @@ -47,6 +47,7 @@
import { copyToClipboard } from "../../utils/urls";
import { isTouchDevice } from "../../utils/devices";
import Translatable from "../Translatable.svelte";
import { quickReactions } from "../../stores/quickReactions";
const dispatch = createEventDispatcher();
const client = getContext<OpenChat>("client");
Expand Down Expand Up @@ -74,10 +75,10 @@
export let canRevealBlocked: boolean;
export let translatable: boolean;
export let translated: boolean;
export let crypto: boolean;
export let msg: Message;
export let threadRootMessage: Message | undefined;
export let canTip: boolean;
export let selectQuickReaction: (unicode: string) => void;
let menuIcon: MenuIcon;
Expand Down Expand Up @@ -262,11 +263,20 @@
}
</script>

<div class="menu" class:rtl={$rtlStore}>
<div class="menu" class:inert class:rtl={$rtlStore}>
{#if !inert}
{#each $quickReactions as reaction}
<HoverIcon compact={true} onclick={() => selectQuickReaction(reaction)}>
<div class="quick-reaction">
{reaction}
</div>
</HoverIcon>
{/each}
{/if}
<MenuIcon bind:this={menuIcon} centered position={"right"} align={"end"}>
<div class="menu-icon" slot="icon">
<HoverIcon compact>
<ChevronDown size="1.6em" color={me ? "#fff" : "var(--icon-txt)"} />
<DotsVertical size="1.4em" color="var(--menu-txt)" />
</HoverIcon>
</div>
<div slot="menu">
Expand Down Expand Up @@ -512,24 +522,63 @@
</div>

<style lang="scss">
// This will align the menu relative to the selected side of the chat
// bubble with 0.75rem overflow, or align it to the opposite edge of the
// chat bubble if the menu width is larger than the chat bubble's.
@mixin calcMenuOffset($property, $menu-width) {
#{$property}: calc(100% - min(100%, calc($menu-width - 0.75rem)));
}
.menu {
$offset: -2px;
// Menu width for 3 reactions and a menu button.
&:not(.inert) {
$menu-width: 7.75rem;
}
&.inert {
$menu-width: 2rem;
}
position: absolute;
top: -4px;
right: $offset;
width: fit-content;
background-color: var(--menu-bg);
border: var(--bw) solid var(--menu-bd);
&.rtl {
left: $offset;
right: unset;
top: -1.5rem;
padding: 0.125rem;
border-radius: 0.375rem;
&:not(.inert) {
$menu-width: 7.75rem;
&:not(.rtl) {
@include calcMenuOffset(left, $menu-width);
}
&.rtl {
@include calcMenuOffset(right, $menu-width);
}
}
&.inert {
// For inert messages we don't display reactions
$menu-width: 2rem;
&:not(.rtl) {
@include calcMenuOffset(left, $menu-width);
}
&.rtl {
@include calcMenuOffset(right, $menu-width);
}
}
}
.emojicon {
margin-left: $sp1;
}
.menu-icon {
transition: opacity ease-in-out 200ms;
opacity: 0;
.quick-reaction {
width: 1.4rem;
height: 1.4rem;
display: flex;
align-items: center;
justify-content: center;
}
</style>
8 changes: 7 additions & 1 deletion frontend/app/src/components/home/EmojiPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@
const dispatch = createEventDispatcher();
onMount(() => {
document.querySelector("emoji-picker")?.addEventListener("emoji-click", (event) => {
const emojiPicker = document.querySelector("emoji-picker");
emojiPicker?.addEventListener("emoji-click", (event) => {
dispatch("emojiSelected", event.detail.unicode);
});
emojiPicker?.addEventListener("skin-tone-change", (event) => {
dispatch("skintoneChanged", event.detail.skinTone);
});
});
</script>

Expand Down
73 changes: 73 additions & 0 deletions frontend/app/src/stores/quickReactions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { writable } from "svelte/store";
import { Database as EmojiDatabase } from "emoji-picker-element";
import type { Emoji } from "emoji-picker-element/shared";

let emojiDb = new EmojiDatabase();
let showQuickReactionCount = 3;
let defaultReactions = ["yes", "tears_of_joy", "pray"];

function initQuickReactions() {

// Filter the reactions by taking into account the appropriate skin tone.
function getUnicodeBySkintone(skintone: number, reactions: Emoji[]): string[] {
return reactions.map((emoji) => {
if ("unicode" in emoji) {
return undefined === emoji.skins || 0 === skintone
? emoji.unicode
: emoji.skins.find((val) => val.tone === skintone ? val.unicode : null)?.unicode
}
})
.filter((u) => u !== undefined) as string[];
}

function loadQuickReactions(skintone: number) {
return emojiDb
.getTopFavoriteEmoji(showQuickReactionCount)
.then((fav) => {
const favUnicode = getUnicodeBySkintone(skintone, fav);

// If we have less emoji than we want to show, expand with
// a default selection of emoji.
if (fav.length < showQuickReactionCount) {
return Promise.all(
defaultReactions.map((em) => emojiDb.getEmojiByShortcode(em)),
)
.then((def) => getUnicodeBySkintone(skintone, def.filter((v) => v != null) as Emoji[]))
.then((defUnicode) => [...new Set(favUnicode.concat(defUnicode))].slice(0, showQuickReactionCount))
}

return favUnicode;
}).catch((e) => {
console.log(e);
return ([] as string[]);
});
}

function loadSkintoneAndQuickReactions() {
return emojiDb
.getPreferredSkinTone()
.then(loadQuickReactions);
}

const { subscribe, set } = writable<string[]>([]);
loadSkintoneAndQuickReactions().then(set);

return {
subscribe,

// Increment favourites
incrementFavourite: (unicode: string): void => {
emojiDb.incrementFavoriteEmojiCount(unicode);
},

// Reload reactions
reload: (skintone?: number): void => {
(skintone
? loadQuickReactions(skintone)
: loadSkintoneAndQuickReactions()
).then(set);
},
};
}

export const quickReactions = initQuickReactions();

0 comments on commit 6dd0063

Please sign in to comment.