Compare commits

...
Author SHA1 Message Date
Half-Shot c2ab6f45bc Fix, currently the GridTile is broken. 2024-11-08 17:34:08 +00:00
Half-Shot dbad2b3902 Begin moving over to membershipEvents 2024-11-08 17:04:10 +00:00
Half-Shot 1a95c576e0 Merge remote-tracking branch 'origin/livekit' into hs/emoji-reactions 2024-11-08 16:20:42 +00:00
Half-Shot 31bf036aa1 fix 2024-11-08 16:19:03 +00:00
Half-Shot 9a9a479610 fix button 2024-11-08 16:18:28 +00:00
Half-Shot a6d1edbc7d cleanup 2024-11-08 16:10:17 +00:00
Half-Shot 689f94b7e5 Another attempt to fix the sizing of the reactions 2024-11-08 14:42:10 +00:00
Half-Shot 1062b98289 Replace deer sound 2024-11-08 14:08:34 +00:00
Half-Shot 251d1c86f7 lint 2024-11-08 13:39:43 +00:00
Half-Shot 0d94d9abc9 Move polyfill. 2024-11-08 13:37:34 +00:00
Half-Shot a50198dcbc Add comments on reaction interface 2024-11-08 13:37:31 +00:00
Half-Shot fbb3882b81 More translations 2024-11-08 13:37:23 +00:00
Half-Shot d6d6efdff2 Relabel lower hand 2024-11-08 13:30:35 +00:00
Half-Shot caf2d6bb5e Fix tests 2024-11-08 13:26:15 +00:00
Half-Shot 90fa6d02d7 Clear reactions if we're clearing timeouts. 2024-11-08 13:14:45 +00:00
Half-Shot e97c779ee4 Fix generic sound not playing. 2024-11-08 13:14:38 +00:00
Half-Shot f628809809 Remove unrequired media query 2024-11-08 13:03:02 +00:00
Half-Shot f6e32742f2 Spacing for emoji buttons search 2024-11-08 13:01:40 +00:00
Half-Shot 0a8e53e393 i18n 2024-11-08 12:53:10 +00:00
Half-Shot e98f84eb8f Add error for failured to send reaction. 2024-11-08 12:52:05 +00:00
Half-Shot afe64cfe20 Decomplicate className system for Modal 2024-11-08 12:16:44 +00:00
Half-Shot 3b625226de Add lower motion mode. 2024-11-08 12:13:48 +00:00
Half-Shot c2162a361e Add lower hand text 2024-11-08 12:13:44 +00:00
Half-Shot 688e7a12b4 Add toggle for raise hand. 2024-11-08 12:06:29 +00:00
Half-Shot 230c85e513 Merge remote-tracking branch 'origin/livekit' into hs/emoji-reactions 2024-11-08 09:57:41 +00:00
Half-Shot 63137a53d9 Use prefers-reduced-motion 2024-11-08 09:57:28 +00:00
Half-Shot 19626324c7 Remove useless useCallback 2024-11-08 09:49:09 +00:00
Half-Shot 63f1b7346f Clear timeouts on component close. 2024-11-08 09:47:05 +00:00
Half-Shot 17f2647d89 Use Intl.Segmenter 2024-11-08 09:46:52 +00:00
Half-Shot 54f7afa0ef Fix mobile view for emoji picker. 2024-11-07 16:53:00 +00:00
Half-Shot 234bdaa842 Swap icon, make mobile view better. 2024-11-07 16:00:32 +00:00
Half-Shot 5a9eddfd70 Remove closed menu test since we're using Modal. 2024-11-07 14:25:35 +00:00
Half-Shot 54b495c79d Modal refactor attempts. 2024-11-07 12:08:13 +00:00
Half-Shot 60300c2f84 modal impl 2024-11-07 11:33:10 +00:00
Half-Shot 29d562aea1 Tweak line height 2024-11-07 10:25:05 +00:00
Half-Shot 83eb5b4977 simplify 2024-11-07 10:14:10 +00:00
Half-Shot fabbdac1ca lint 2024-11-07 10:12:31 +00:00
Half-Shot f4f0821987 Fix test 2024-11-07 09:53:47 +00:00
Half-Shot e8e905c710 add onclick back 2024-11-07 09:26:50 +00:00
Half-Shot a262c4b59f Merge remote-tracking branch 'origin/livekit' into hs/emoji-reactions 2024-11-07 09:10:42 +00:00
Half-Shot b1d90f8262 Update rock sounds 2024-11-07 09:02:13 +00:00
Half-Shot 6294fbb8e1 Preload reaction sounds to prevent delays. 2024-11-07 09:01:58 +00:00
Half-Shot b4a126d466 final lint? 2024-11-05 19:10:12 +00:00
Half-Shot d3870500d4 i18n sort 2024-11-05 19:06:40 +00:00
Half-Shot 9c6ab58bc4 prettier 2024-11-05 19:03:24 +00:00
Half-Shot 69b18a95a0 lint 2024-11-05 19:02:22 +00:00
Half-Shot d3cecccdab Add tests for ReactionAudioRenderer 2024-11-05 19:01:57 +00:00
Half-Shot b1246f90bf lint 2024-11-05 18:34:08 +00:00
Half-Shot c9b6c7fc2f type tidyups 2024-11-05 18:34:04 +00:00
Half-Shot 507663df87 Add keyboard shortcuts for reaction sending. 2024-11-05 18:33:56 +00:00
Half-Shot 9ff8197987 Add tests for ReactionToggleButton 2024-11-05 17:00:58 +00:00
Half-Shot 2eb62d8e6f Factor out reactions test structure to utils file. 2024-11-05 15:53:22 +00:00
Half-Shot 0e2296430c better reaction description 2024-11-05 15:29:56 +00:00
Half-Shot 05b56c7057 Add type def for custom reaction type. 2024-11-05 15:24:16 +00:00
Half-Shot d5122f670a Add licence file. 2024-11-05 15:20:47 +00:00
Half-Shot 8863b7cb6a Add rock emoji 2024-11-05 15:20:43 +00:00
Half-Shot f556e613b3 Document time in css. 2024-11-05 14:41:44 +00:00
Half-Shot 4db7b4fbf5 Const the active time. 2024-11-05 14:40:29 +00:00
Half-Shot 56c40720e8 Fix case where you could send larger strings as emoji 2024-11-05 14:34:51 +00:00
Half-Shot 714ed35e8d Add deer. 2024-11-05 11:32:19 +00:00
Half-Shot c9e375ff6c Update compound hand raised icon. 2024-11-05 11:32:13 +00:00
Will Hunt abfcba91a3 Update text 2024-11-04 15:41:48 +00:00
Will Hunt 8b4bd16647 Add new icon 2024-11-04 15:41:44 +00:00
Will Hunt 4e6ffd64c2 Remove old raised hand component 2024-11-04 15:41:40 +00:00
Will Hunt ece04fdd68 Fix some tests. 2024-11-04 12:48:35 +00:00
Will Hunt c64c4e61d7 lint 2024-11-04 12:43:59 +00:00
Will Hunt d251af4e6d Make event listener more resillient. 2024-11-04 12:12:37 +00:00
Will Hunt 844379aa7d Make vertical divider more visually distinct. 2024-11-04 12:10:04 +00:00
Will Hunt 41c5a8053e Add settings for disabling animations / sounds. 2024-11-04 11:37:45 +00:00
Will Hunt c9fe0bd932 lint 2024-11-04 11:15:50 +00:00
Will Hunt 3e69e2529c Merge remote-tracking branch 'origin/livekit' into hs/emoji-reactions 2024-11-04 11:14:36 +00:00
Half-Shot 27e3cec575 Overflow buttons when screen is too narrow 2024-11-01 17:35:47 +00:00
Half-Shot 0f4fec46b6 Center emoji picker 2024-11-01 17:34:16 +00:00
Half-Shot 28bef94227 fix import 2024-11-01 17:25:34 +00:00
Half-Shot a60d48965b Subscribe to the ecall reaction event type. 2024-11-01 17:23:29 +00:00
Half-Shot e3c23fa2cf Linting and general stability improvements. 2024-11-01 17:22:45 +00:00
Half-Shot dfe9569720 Tidy up + add support for floating emoji. 2024-11-01 15:27:20 +00:00
Half-Shot fd46f8ad46 Add sounds. 2024-11-01 14:49:00 +00:00
Half-Shot edc4142fb1 Add reaction indicator. 2024-11-01 14:34:33 +00:00
Half-Shot 80b40b6b00 Add lightbulb. 2024-11-01 14:34:09 +00:00
Half-Shot a207536907 hide menu by default 2024-11-01 14:17:22 +00:00
Half-Shot 373a12a3b5 First PoC for reactions 2024-11-01 14:11:36 +00:00
Half-Shot f54e1e2046 Transpose border to GridTile. 2024-11-01 08:57:35 +00:00
Half-Shot 748cc58c3f Be even less brittle. 2024-11-01 08:41:43 +00:00
Half-Shot 167caa32a3 Merge remote-tracking branch 'origin/livekit' into raise-hand-button 2024-10-31 17:09:01 +00:00
Half-Shot a9e6aa3d54 Add tests for cases where we got a reaction from someone else. 2024-10-31 16:39:59 +00:00
Half-Shot 21380c7791 linting 2024-10-31 16:25:31 +00:00
Half-Shot ec9dec89bb Use deferred value 2024-10-31 16:20:17 +00:00
Half-Shot e49eb55a3d More tidying 2024-10-31 16:17:13 +00:00
Half-Shot a354a4013d Fix redactions not working because they pick up events in transit. 2024-10-31 16:04:37 +00:00
Half-Shot 7229f4bf1b Remove rerender 2024-10-31 15:24:44 +00:00
Half-Shot 2d95d4ff36 Use proper duration formatter 2024-10-31 15:17:43 +00:00
Half-Shot 32294984fe Even more comments. 2024-10-29 17:05:54 +00:00
Half-Shot a45b01dc00 Add some explanation comments. 2024-10-29 16:57:06 +00:00
Half-Shot ff7da135ca Tidy up and finish test rewrites 2024-10-29 16:49:37 +00:00
Half-Shot 5a5c1bedfe Major refactor to support various state problems. 2024-10-29 16:19:01 +00:00
Half-Shot cd73ad8794 Prevent infinite loop 2024-10-29 15:19:16 +00:00
Half-Shot 528e692d6b Handle cases when a new member event happens. 2024-10-29 15:08:59 +00:00
Half-Shot 0b6cf18d22 Drop border from raised hand icon 2024-10-29 13:17:33 +00:00
Half-Shot e1a4310e29 Add preferences tab for choosing to enable timer. 2024-10-29 13:13:01 +00:00
Half-Shot dbabf45ca7 Allow timer to be configurable. 2024-10-29 13:12:20 +00:00
Half-Shot f13bd7923b Fix and update tests 2024-10-29 09:45:30 +00:00
Half-Shot 23d849bd9a lint 2024-10-29 09:35:10 +00:00
Half-Shot b7e82362b8 Fix call border resizing video 2024-10-29 09:28:42 +00:00
Half-Shot 07d345191b Fix timestamp calculation on relaod. 2024-10-28 22:24:20 +00:00
Half-Shot 198859db08 Add a small mode for spotlight 2024-10-28 22:24:11 +00:00
Half-Shot 33724efee3 Add better sound. (woosh) 2024-10-28 18:00:39 +00:00
Half-Shot 38878d3ee8 Remove extra layer 2024-10-28 17:45:12 +00:00
Half-Shot 9d01e8c129 Lint tests 2024-10-28 17:44:17 +00:00
Half-Shot ba921f8c67 Tidy up useEffect 2024-10-28 16:23:17 +00:00
Half-Shot 4a712dcaa2 Redact the right thing. 2024-10-28 16:11:28 +00:00
Half-Shot 4501e670b2 Refactor into own files. 2024-10-28 15:57:55 +00:00
Half-Shot 43b4fc0a0c lint 2024-10-28 12:46:26 +00:00
Half-Shot a23d256fb6 Refactor raised hand indicator and add tests. 2024-10-28 12:32:33 +00:00
Half-Shot 7f268a3e10 Add a sound for when a hand is raised. 2024-10-25 17:10:14 +01:00
Half-Shot 1c8e547b9a Add support for displaying the duration of a raised hand. 2024-10-25 17:09:45 +01:00
Half-Shot 16afb568fd Center align hand raise. 2024-10-25 11:37:40 +01:00
Milton Moura 42a7b1ec2a Fix failing GridTile test
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:59:06 +01:00
Milton Moura 69a50fb2a8 Check for reaction & redaction capabilities in widget mode
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:40:46 +01:00
Milton Moura 7ac5642245 Removing RaiseHand.svg 2024-09-19 11:39:52 +01:00
Milton Moura ab5654cf32 Links to sha commit of matrix-js-sdk that exposes the call membership event id and refactors some async code
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:39:52 +01:00
Milton Moura 0730ba5c70 Use relations to load existing reactions when joining the call
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:36:11 +01:00
fkwp bcad5003e7 Update src/room/useRaisedHands.tsx
Element Call recently changed to AGPL-3.0
2024-09-19 11:36:11 +01:00
Milton Moura ac7321d1e6 SpotlightTile should not duplicate the raised hand
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:36:11 +01:00
Milton Moura f6ae6a0765 Replacing button svg with raised hand emoji
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:35:05 +01:00
Milton Moura 2d1917c22a Refactored to use reaction and redaction events
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:35:05 +01:00
Milton Moura 48cf487e0a Initial support for Hand Raise feature
Signed-off-by: Milton Moura <miltonmoura@gmail.com>
2024-09-19 11:35:05 +01:00
48 changed files with 2090 additions and 424 deletions
+2 -1
View File
@@ -25,6 +25,7 @@
"@babel/preset-react": "^7.22.15",
"@babel/preset-typescript": "^7.23.0",
"@formatjs/intl-durationformat": "^0.6.1",
"@formatjs/intl-segmenter": "^11.7.3",
"@livekit/components-core": "^0.11.0",
"@livekit/components-react": "^2.0.0",
"@opentelemetry/api": "^1.4.0",
@@ -57,7 +58,7 @@
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"@use-gesture/react": "^10.2.11",
"@vector-im/compound-design-tokens": "^1.0.0",
"@vector-im/compound-design-tokens": "^1.9.1",
"@vector-im/compound-web": "^6.0.0",
"@vitejs/plugin-basic-ssl": "^1.0.1",
"@vitejs/plugin-react": "^4.0.1",
+17 -1
View File
@@ -4,11 +4,17 @@
},
"action": {
"close": "Close",
"close_search": "Close search",
"copy_link": "Copy link",
"edit": "Edit",
"go": "Go",
"invite": "Invite",
"lower_hand": "Lower hand",
"no": "No",
"open_search": "Open search",
"pick_reaction": "Pick reaction",
"raise_hand": "Raise hand",
"raise_hand_or_send_reaction": "Raise hand or send reaction",
"register": "Register",
"remove": "Remove",
"sign_in": "Sign in",
@@ -55,8 +61,10 @@
"password": "Password",
"preferences": "Preferences",
"profile": "Profile",
"raise_hand": "Raise hand",
"reaction": "Reaction",
"search": "Search",
"settings": "Settings",
"something_went_wrong": "Something went wrong",
"unencrypted": "Not encrypted",
"username": "Username",
"video": "Video"
@@ -120,6 +128,7 @@
"rageshake_sending": "Sending…",
"rageshake_sending_logs": "Sending debug logs…",
"rageshake_sent": "Thanks!",
"reaction_search": "Search reactions…",
"recaptcha_caption": "This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>End User Licensing Agreement (EULA)</12>",
"recaptcha_dismissed": "Recaptcha dismissed",
"recaptcha_not_loaded": "Recaptcha not loaded",
@@ -147,6 +156,13 @@
"feedback_tab_title": "Feedback",
"more_tab_title": "More",
"opt_in_description": "<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.",
"preferences_tab": {
"reactions_play_sound_description": "Play a sound effect when anyone sends a reaction into a call.",
"reactions_play_sound_label": "Play reaction sounds",
"reactions_show_description": "Show reactions",
"reactions_show_label": "Show an animation when anyone sends a reaction.",
"reactions_title": "Reactions"
},
"preferences_tab_body": "Here you can configure extra options for an improved experience",
"preferences_tab_h4": "Preferences",
"preferences_tab_show_hand_raised_timer_description": "Show a timer when a participant raises their hand",
+18
View File
@@ -0,0 +1,18 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import {
ElementCallReactionEventType,
ECallReactionEventContent,
} from "../reactions";
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
declare module "matrix-js-sdk/src/types" {
export interface TimelineEvents {
[ElementCallReactionEventType]: ECallReactionEventContent;
}
}
+44 -16
View File
@@ -27,8 +27,21 @@ import { useMediaQuery } from "./useMediaQuery";
export interface Props {
title: string;
/**
* Hide the modal header. Used for smaller popups where the context is readily apparent.
* A title should still be specified for users using assistive technology.
*/
hideHeader?: boolean;
children: ReactNode;
className?: string;
/**
* Class name to be used when in drawer mode (touchscreen).
*/
classNameDrawer?: string;
/**
* Class name to be used when in modal mode (desktop).
*/
classNameModal?: string;
/**
* The controlled open state of the modal.
*/
@@ -54,8 +67,11 @@ export interface Props {
*/
export const Modal: FC<Props> = ({
title,
hideHeader,
children,
className,
classNameDrawer,
classNameModal,
open,
onDismiss,
tabbed,
@@ -84,6 +100,7 @@ export const Modal: FC<Props> = ({
<Drawer.Content
className={classNames(
className,
classNameDrawer,
overlayStyles.overlay,
styles.modal,
styles.drawer,
@@ -109,6 +126,28 @@ export const Modal: FC<Props> = ({
</Drawer.Root>
);
} else {
const titleNode = (
<DialogTitle asChild>
<Heading as="h2" weight="semibold" size="md">
{title}
</Heading>
</DialogTitle>
);
const header = (
<div className={styles.header}>
{titleNode}
{onDismiss !== undefined && (
<DialogClose
className={styles.close}
data-testid="modal_close"
aria-label={t("action.close")}
>
<CloseIcon width={20} height={20} />
</DialogClose>
)}
</div>
);
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal>
@@ -126,6 +165,7 @@ export const Modal: FC<Props> = ({
<Glass
className={classNames(
className,
classNameModal,
overlayStyles.overlay,
overlayStyles.animate,
styles.modal,
@@ -134,22 +174,10 @@ export const Modal: FC<Props> = ({
)}
>
<div className={styles.content}>
<div className={styles.header}>
<DialogTitle asChild>
<Heading as="h2" weight="semibold" size="md">
{title}
</Heading>
</DialogTitle>
{onDismiss !== undefined && (
<DialogClose
className={styles.close}
data-testid="modal_close"
aria-label={t("action.close")}
>
<CloseIcon width={20} height={20} />
</DialogClose>
)}
</div>
{!hideHeader ? header : null}
{hideHeader ? (
<VisuallyHidden asChild>{titleNode}</VisuallyHidden>
) : null}
<div className={styles.body}>{children}</div>
</div>
</Glass>
-126
View File
@@ -1,126 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { Button as CpdButton, Tooltip } from "@vector-im/compound-web";
import {
ComponentPropsWithoutRef,
FC,
ReactNode,
useCallback,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import { useReactions } from "../useReactions";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
}
const InnerButton: FC<InnerButtonProps> = ({ raised, ...props }) => {
const { t } = useTranslation();
return (
<Tooltip label={t("common.raise_hand")}>
<CpdButton
kind={raised ? "primary" : "secondary"}
{...props}
style={{ paddingLeft: 8, paddingRight: 8 }}
>
<p
role="img"
aria-hidden
style={{
width: "30px",
height: "0px",
display: "inline-block",
fontSize: "22px",
}}
>
✋
</p>
</CpdButton>
</Tooltip>
);
};
interface RaisedHandToggleButtonProps {
rtcSession: MatrixRTCSession;
client: MatrixClient;
}
export function RaiseHandToggleButton({
client,
rtcSession,
}: RaisedHandToggleButtonProps): ReactNode {
const { raisedHands, lowerHand } = useReactions();
const [busy, setBusy] = useState(false);
const userId = client.getUserId()!;
const isHandRaised = !!raisedHands[userId];
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const toggleRaisedHand = useCallback(() => {
const raiseHand = async (): Promise<void> => {
if (isHandRaised) {
try {
setBusy(true);
await lowerHand();
} finally {
setBusy(false);
}
} else {
const myMembership = memberships.find((m) => m.sender === userId);
if (!myMembership?.eventId) {
logger.error("Cannot find own membership event");
return;
}
const parentEventId = myMembership.eventId;
try {
setBusy(true);
const reaction = await client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: parentEventId,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
} catch (ex) {
logger.error("Failed to send reaction event", ex);
} finally {
setBusy(false);
}
}
};
void raiseHand();
}, [
client,
isHandRaised,
memberships,
rtcSession.room.roomId,
userId,
lowerHand,
]);
return (
<InnerButton
disabled={busy}
onClick={toggleRaisedHand}
raised={isHandRaised}
/>
);
}
@@ -0,0 +1,82 @@
.raisedButton > svg {
color: var(--cpd-color-icon-on-solid-primary);
}
.reactionPopupMenu {
display: flex;
}
.reactionPopupMenuModal {
width: fit-content !important;
top: 82vh !important;
}
.reactionPopupMenuModal > div > div {
padding-inline: var(--cpd-space-6x) !important;
padding-block: var(--cpd-space-6x) var(--cpd-space-8x) !important;
}
.reactionPopupMenu menu {
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: var(--cpd-separator-spacing);
}
.reactionPopupMenu section {
height: fit-content;
margin-top: auto;
margin-bottom: auto;
}
.reactionPopupMenuItem {
list-style: none;
}
.reactionsMenu {
min-height: 3em;
}
.reactionButton {
padding: 1em;
font-size: 1.6em;
width: 1.4em;
height: 1.4em;
border-radius: var(--cpd-radius-pill-effect);
}
.verticalSeperator {
background-color: var(--cpd-color-gray-800);
width: 1px;
height: auto;
margin-left: var(--cpd-separator-spacing);
margin-right: var(--cpd-separator-spacing);
}
.searchForm {
display: flex;
flex-direction: row;
gap: var(--cpd-separator-spacing);
margin-bottom: var(--cpd-space-3x);
}
.searchForm > label {
flex: auto;
}
.alert {
margin-bottom: var(--cpd-space-3x);
animation: grow-in 200ms;
height: 2.5em;
}
@keyframes grow-in {
from {
height: 0;
}
to {
height: 2.5em;
}
}
+214
View File
@@ -0,0 +1,214 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { fireEvent, render } from "@testing-library/react";
import { act } from "react";
import { expect, test } from "vitest";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
import { TooltipProvider } from "@vector-im/compound-web";
import { userEvent } from "@testing-library/user-event";
import { ReactNode } from "react";
import {
MockRoom,
MockRTCSession,
TestReactionsWrapper,
} from "../utils/testReactions";
import { ReactionToggleButton } from "./ReactionToggleButton";
import { ElementCallReactionEventType } from "../reactions";
const memberUserIdAlice = "@alice:example.org";
const memberEventAlice = "$membership-alice:example.org";
const membership: Record<string, string> = {
[memberEventAlice]: memberUserIdAlice,
};
function TestComponent({
rtcSession,
room,
}: {
rtcSession: MockRTCSession;
room: MockRoom;
}): ReactNode {
return (
<TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}>
<ReactionToggleButton
rtcSession={rtcSession as unknown as MatrixRTCSession}
client={room.client}
/>
</TestReactionsWrapper>
</TooltipProvider>
);
}
test("Can open menu", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
expect(container).toMatchSnapshot();
});
test("Can raise hand", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.raise_hand"));
expect(room.testSentEvents).toEqual([
[
undefined,
"m.reaction",
{
"m.relates_to": {
event_id: memberEventAlice,
key: "🖐️",
rel_type: "m.annotation",
},
},
],
]);
expect(container).toMatchSnapshot();
});
test.only("Can lower hand", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
const reactionEvent = room.testSendHandRaise(memberEventAlice, membership);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.lower_hand"));
expect(room.testRedactedEvents).toEqual([[undefined, reactionEvent]]);
expect(container).toMatchSnapshot();
});
test("Can react with emoji", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, getByText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByText("🐶"));
expect(room.testSentEvents).toEqual([
[
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "dog",
emoji: "🐶",
},
],
]);
});
test("Can search for and send emoji", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByText, container, getByLabelText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
// Search should autofocus.
await user.keyboard("crickets");
expect(container).toMatchSnapshot();
await user.click(getByText("🦗"));
expect(room.testSentEvents).toEqual([
[
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "crickets",
emoji: "🦗",
},
],
]);
});
test("Can search for and send emoji with the keyboard", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, getByPlaceholderText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
const searchField = getByPlaceholderText("reaction_search");
// Search should autofocus.
await user.keyboard("crickets");
expect(container).toMatchSnapshot();
act(() => {
fireEvent.keyDown(searchField, { key: "Enter" });
});
expect(room.testSentEvents).toEqual([
[
undefined,
ElementCallReactionEventType,
{
"m.relates_to": {
event_id: memberEventAlice,
rel_type: "m.reference",
},
name: "crickets",
emoji: "🦗",
},
],
]);
});
test("Can close search", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
await user.click(getByLabelText("action.close_search"));
expect(container).toMatchSnapshot();
});
test("Can close search with the escape key", async () => {
const user = userEvent.setup();
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { getByLabelText, container, getByPlaceholderText } = render(
<TestComponent rtcSession={rtcSession} room={room} />,
);
await user.click(getByLabelText("action.raise_hand_or_send_reaction"));
await user.click(getByLabelText("action.open_search"));
const searchField = getByPlaceholderText("reaction_search");
act(() => {
fireEvent.keyDown(searchField, { key: "Escape" });
});
expect(container).toMatchSnapshot();
});
+354
View File
@@ -0,0 +1,354 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import {
Button as CpdButton,
Tooltip,
Search,
Form,
Alert,
} from "@vector-im/compound-web";
import {
SearchIcon,
CloseIcon,
RaisedHandSolidIcon,
ReactionIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import {
ChangeEventHandler,
ComponentPropsWithoutRef,
FC,
KeyboardEventHandler,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/src/logger";
import { EventType, RelationType } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import classNames from "classnames";
import { useReactions } from "../useReactions";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import styles from "./ReactionToggleButton.module.css";
import {
ReactionOption,
ReactionSet,
ElementCallReactionEventType,
} from "../reactions";
import { Modal } from "../Modal";
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
raised: boolean;
open: boolean;
}
const InnerButton: FC<InnerButtonProps> = ({ raised, open, ...props }) => {
const { t } = useTranslation();
return (
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
<CpdButton
className={classNames(raised && styles.raisedButton)}
aria-expanded={open}
aria-haspopup
aria-label={t("action.raise_hand_or_send_reaction")}
kind={raised || open ? "primary" : "secondary"}
iconOnly
Icon={raised ? RaisedHandSolidIcon : ReactionIcon}
{...props}
/>
</Tooltip>
);
};
export function ReactionPopupMenu({
sendReaction,
toggleRaisedHand,
isHandRaised,
canReact,
errorText,
}: {
sendReaction: (reaction: ReactionOption) => void;
toggleRaisedHand: () => void;
errorText?: string;
isHandRaised: boolean;
canReact: boolean;
}): ReactNode {
const { t } = useTranslation();
const [searchText, setSearchText] = useState("");
const [isSearching, setIsSearching] = useState(false);
const onSearch = useCallback<ChangeEventHandler<HTMLInputElement>>((ev) => {
ev.preventDefault();
setSearchText(ev.target.value.trim().toLocaleLowerCase());
}, []);
const filteredReactionSet = useMemo(
() =>
ReactionSet.filter(
(reaction) =>
!isSearching ||
(!!searchText &&
(reaction.name.startsWith(searchText) ||
reaction.alias?.some((a) => a.startsWith(searchText)))),
).slice(0, 6),
[searchText, isSearching],
);
const onSearchKeyDown = useCallback<KeyboardEventHandler<never>>(
(ev) => {
if (ev.key === "Enter") {
ev.preventDefault();
if (!canReact) {
return;
}
if (filteredReactionSet.length !== 1) {
return;
}
sendReaction(filteredReactionSet[0]);
setIsSearching(false);
} else if (ev.key === "Escape") {
ev.preventDefault();
setIsSearching(false);
}
},
[sendReaction, filteredReactionSet, canReact, setIsSearching],
);
const label = isHandRaised ? t("action.lower_hand") : t("action.raise_hand");
return (
<>
{errorText && (
<Alert
className={styles.alert}
type="critical"
title={t("common.something_went_wrong")}
>
{errorText}
</Alert>
)}
<div className={styles.reactionPopupMenu}>
<section className={styles.handRaiseSection}>
<Tooltip label={label}>
<CpdButton
kind={isHandRaised ? "primary" : "secondary"}
aria-pressed={isHandRaised}
aria-label={label}
onClick={() => toggleRaisedHand()}
iconOnly
Icon={RaisedHandSolidIcon}
/>
</Tooltip>
</section>
<div className={styles.verticalSeperator} />
<section>
{isSearching ? (
<>
<Form.Root className={styles.searchForm}>
<Search
required
value={searchText}
name="reactionSearch"
placeholder={t("reaction_search")}
onChange={onSearch}
onKeyDown={onSearchKeyDown}
// This is a reasonable use of autofocus, we are focusing when
// the search button is clicked (which matches the Element Web reaction picker)
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<CpdButton
Icon={CloseIcon}
aria-label={t("action.close_search")}
size="sm"
kind="destructive"
onClick={() => setIsSearching(false)}
/>
</Form.Root>
</>
) : null}
<menu className={styles.reactionsMenu}>
{filteredReactionSet.map((reaction) => (
<li className={styles.reactionPopupMenuItem} key={reaction.name}>
<Tooltip label={reaction.name}>
<CpdButton
kind="secondary"
className={styles.reactionButton}
disabled={!canReact}
onClick={() => sendReaction(reaction)}
>
{reaction.emoji}
</CpdButton>
</Tooltip>
</li>
))}
</menu>
</section>
{!isSearching ? (
<section style={{ marginLeft: "var(--cpd-separator-spacing)" }}>
<li key="search" className={styles.reactionPopupMenuItem}>
<Tooltip label={t("common.search")}>
<CpdButton
iconOnly
aria-label={t("action.open_search")}
Icon={SearchIcon}
kind="tertiary"
onClick={() => setIsSearching(true)}
/>
</Tooltip>
</li>
</section>
) : null}
</div>
</>
);
}
interface ReactionToggleButtonProps {
rtcSession: MatrixRTCSession;
client: MatrixClient;
}
export function ReactionToggleButton({
client,
rtcSession,
}: ReactionToggleButtonProps): ReactNode {
const { t } = useTranslation();
const { raisedHands, lowerHand, reactions } = useReactions();
const [busy, setBusy] = useState(false);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const [showReactionsMenu, setShowReactionsMenu] = useState(false);
const [errorText, setErrorText] = useState<string>();
const myMembershipEventId = useMemo(
() =>
memberships.find(
(m) =>
m.sender === client.getUserId() &&
m.deviceId === client.getDeviceId(),
),
[memberships],
)?.eventId;
console.log(memberships);
useEffect(() => {
// Clear whenever the reactions menu state changes.
setErrorText(undefined);
}, [showReactionsMenu]);
const canReact = !reactions[myMembershipEventId ?? ""];
const isHandRaised = !!raisedHands[myMembershipEventId ?? ""];
const sendRelation = useCallback(
async (reaction: ReactionOption) => {
try {
if (!myMembershipEventId) {
throw Error("Could not find own membership event");
}
setBusy(true);
await client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: myMembershipEventId,
},
emoji: reaction.emoji,
name: reaction.name,
},
);
setErrorText(undefined);
setShowReactionsMenu(false);
} catch (ex) {
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
logger.error("Failed to send reaction", ex);
} finally {
setBusy(false);
}
},
[myMembershipEventId, client, rtcSession],
);
const toggleRaisedHand = useCallback(() => {
const raiseHand = async (): Promise<void> => {
if (isHandRaised) {
try {
setBusy(true);
await lowerHand();
setShowReactionsMenu(false);
} finally {
setBusy(false);
}
} else {
try {
if (!myMembershipEventId) {
throw Error("Could not find own membership event");
}
setBusy(true);
const reaction = await client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembershipEventId,
key: "🖐️",
},
},
);
logger.debug("Sent raise hand event", reaction.event_id);
setErrorText(undefined);
setShowReactionsMenu(false);
} catch (ex) {
setErrorText(ex instanceof Error ? ex.message : "Unknown error");
logger.error("Failed to raise hand", ex);
} finally {
setBusy(false);
}
}
};
void raiseHand();
}, [
client,
isHandRaised,
myMembershipEventId,
lowerHand,
rtcSession.room.roomId,
]);
return (
<>
<InnerButton
disabled={busy}
onClick={() => setShowReactionsMenu((show) => !show)}
raised={isHandRaised}
open={showReactionsMenu}
/>
<Modal
open={showReactionsMenu}
title={t("action.pick_reaction")}
hideHeader
classNameModal={styles.reactionPopupMenuModal}
onDismiss={() => setShowReactionsMenu(false)}
>
<ReactionPopupMenu
errorText={errorText}
isHandRaised={isHandRaised}
canReact={canReact}
sendReaction={(reaction) => void sendRelation(reaction)}
toggleRaisedHand={toggleRaisedHand}
/>
</Modal>
</>
);
}
@@ -0,0 +1,241 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Can close search 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":rec:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can close search with the escape key 1`] = `
<div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":rhh:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can lower hand 1`] = `
<div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r3i:"
class="_button_i91xf_17 raisedButton _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 3a1 1 0 1 1 2 0v8.5a.5.5 0 0 0 1 0V4a1 1 0 1 1 2 0v10.2l3.284-2.597a1.081 1.081 0 0 1 1.47 1.577c-.613.673-1.214 1.367-1.818 2.064-1.267 1.463-2.541 2.934-3.944 4.235A6 6 0 0 1 5 15V7a1 1 0 0 1 2 0v5.5a.5.5 0 0 0 1 0V4a1 1 0 0 1 2 0v7.5a.5.5 0 0 0 1 0V3Z"
/>
</svg>
</button>
</div>
`;
exports[`Can open menu 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r0:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can raise hand 1`] = `
<div>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r1p:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can search for and send emoji 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":r74:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
exports[`Can search for and send emoji with the keyboard 1`] = `
<div
aria-hidden="true"
data-aria-hidden="true"
>
<button
aria-disabled="false"
aria-expanded="true"
aria-haspopup="true"
aria-label="action.raise_hand_or_send_reaction"
aria-labelledby=":ra3:"
class="_button_i91xf_17 _has-icon_i91xf_66 _icon-only_i91xf_59"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15.536 15.536a1 1 0 0 0-1.415-1.415 2.987 2.987 0 0 1-2.12.879 2.988 2.988 0 0 1-2.122-.879 1 1 0 1 0-1.414 1.415A4.987 4.987 0 0 0 12 17c1.38 0 2.632-.56 3.536-1.464ZM10 10.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm5.5 1.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"
/>
<path
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10Zm-2 0a8 8 0 1 0-16 0 8 8 0 0 0 16 0Z"
/>
</svg>
</button>
</div>
`;
+1 -1
View File
@@ -7,4 +7,4 @@ Please see LICENSE in the repository root for full details.
export * from "./Button";
export * from "./LinkButton";
export * from "./RaisedHandToggleButton";
export * from "./ReactionToggleButton";
+2
View File
@@ -20,6 +20,8 @@ import {
setLogExtension as setLKLogExtension,
setLogLevel as setLKLogLevel,
} from "livekit-client";
import "@formatjs/intl-segmenter/polyfill";
import "@formatjs/intl-durationformat/polyfill";
import { App } from "./App";
import { init as initRageshake } from "./settings/rageshake";
+10 -20
View File
@@ -12,11 +12,10 @@ import {
useEffect,
useState,
} from "react";
import classNames from "classnames";
import "@formatjs/intl-durationformat/polyfill";
import { DurationFormat } from "@formatjs/intl-durationformat";
import { useTranslation } from "react-i18next";
import styles from "./RaisedHandIndicator.module.css";
import { ReactionIndicator } from "./ReactionIndicator";
const durationFormatter = new DurationFormat(undefined, {
minutesDisplay: "always",
@@ -36,6 +35,7 @@ export function RaisedHandIndicator({
showTimer?: boolean;
onClick?: () => void;
}): ReactNode {
const { t } = useTranslation();
const [raisedHandDuration, setRaisedHandDuration] = useState("");
const clickCallback = useCallback<MouseEventHandler<HTMLButtonElement>>(
@@ -76,29 +76,19 @@ export function RaisedHandIndicator({
}
const content = (
<div
className={classNames(styles.raisedHandWidget, {
[styles.raisedHandWidgetLarge]: !miniature,
})}
>
<div
className={classNames(styles.raisedHand, {
[styles.raisedHandLarge]: !miniature,
})}
>
<span role="img" aria-label="raised hand">
✋
</span>
</div>
<ReactionIndicator emoji="✋" miniature={miniature}>
{showTimer && <p>{raisedHandDuration}</p>}
</div>
</ReactionIndicator>
);
if (onClick) {
return (
<button
aria-label="lower raised hand"
className={styles.button}
aria-label={t("action.lower_hand")}
style={{
display: "contents",
background: "none",
}}
onClick={clickCallback}
>
{content}
@@ -1,33 +1,28 @@
.raisedHandWidget {
.reactionIndicatorWidget {
display: flex;
background-color: var(--cpd-color-bg-subtle-primary);
border-radius: var(--cpd-radius-pill-effect);
color: var(--cpd-color-icon-secondary);
}
.button {
display: contents;
background: none;
}
.raisedHandWidget > p {
.reactionIndicatorWidget > p {
padding: none;
margin-top: auto;
margin-bottom: auto;
width: 4em;
}
.raisedHandWidgetLarge > p {
.reactionIndicatorWidgetLarge > p {
padding: var(--cpd-space-2x);
}
.raisedHandLarge {
.reactionLarge {
margin: var(--cpd-space-2x);
padding: var(--cpd-space-2x);
padding-block: var(--cpd-space-2x);
padding: var(--cpd-space-4x);
padding-block: var(--cpd-space-4x);
}
.raisedHand {
.reaction {
margin: var(--cpd-space-1x);
color: var(--cpd-color-icon-secondary);
background-color: var(--cpd-color-icon-secondary);
@@ -40,18 +35,22 @@
box-sizing: border-box;
max-inline-size: 100%;
max-width: fit-content;
padding: var(--cpd-space-1x);
padding-block: var(--cpd-space-1x);
}
.raisedHand > span {
.reaction > span {
width: var(--cpd-space-6x);
height: var(--cpd-space-6x);
display: inline-block;
text-align: center;
font-size: 1.3em;
font-size: 1.2em;
/* Centralise */
line-height: 1.2;
}
.raisedHandLarge > span {
width: var(--cpd-space-8x);
height: var(--cpd-space-8x);
font-size: 1.9em;
.reactionLarge > span {
width: 30px;
height: 30px;
font-size: 25px;
}
+41
View File
@@ -0,0 +1,41 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { PropsWithChildren, ReactNode } from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import styles from "./ReactionIndicator.module.css";
export function ReactionIndicator({
emoji,
miniature,
children,
}: PropsWithChildren<{
miniature?: boolean;
emoji: string;
}>): ReactNode {
const { t } = useTranslation();
return (
<div
className={classNames(styles.reactionIndicatorWidget, {
[styles.reactionIndicatorWidgetLarge]: !miniature,
})}
>
<div
className={classNames(styles.reaction, {
[styles.reactionLarge]: !miniature,
})}
>
<span role="img" aria-label={t("common.reaction")}>
{emoji}
</span>
</div>
{children}
</div>
);
}
@@ -2,13 +2,13 @@
exports[`RaisedHandIndicator > renders a smaller indicator when miniature is specified 1`] = `
<div
class="raisedHandWidget"
class="reactionIndicatorWidget"
>
<div
class="raisedHand"
class="reaction"
>
<span
aria-label="raised hand"
aria-label="common.reaction"
role="img"
>
✋
@@ -22,13 +22,13 @@ exports[`RaisedHandIndicator > renders a smaller indicator when miniature is spe
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised 1`] = `
<div
class="raisedHandWidget raisedHandWidgetLarge"
class="reactionIndicatorWidget reactionIndicatorWidgetLarge"
>
<div
class="raisedHand raisedHandLarge"
class="reaction reactionLarge"
>
<span
aria-label="raised hand"
aria-label="common.reaction"
role="img"
>
✋
@@ -42,13 +42,13 @@ exports[`RaisedHandIndicator > renders an indicator when a hand has been raised
exports[`RaisedHandIndicator > renders an indicator when a hand has been raised with the expected time 1`] = `
<div
class="raisedHandWidget raisedHandWidgetLarge"
class="reactionIndicatorWidget reactionIndicatorWidgetLarge"
>
<div
class="raisedHand raisedHandLarge"
class="reaction reactionLarge"
>
<span
aria-label="raised hand"
aria-label="common.reaction"
role="img"
>
✋
+181
View File
@@ -0,0 +1,181 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { RelationType } from "matrix-js-sdk/src/types";
import catSoundOgg from "../sound/reactions/cat.ogg?url";
import catSoundMp3 from "../sound/reactions/cat.mp3?url";
import clapSoundOgg from "../sound/reactions/clap.ogg?url";
import clapSoundMp3 from "../sound/reactions/clap.mp3?url";
import cricketsSoundOgg from "../sound/reactions/crickets.ogg?url";
import cricketsSoundMp3 from "../sound/reactions/crickets.mp3?url";
import dogSoundOgg from "../sound/reactions/dog.ogg?url";
import dogSoundMp3 from "../sound/reactions/dog.mp3?url";
import genericSoundOgg from "../sound/reactions/generic.ogg?url";
import genericSoundMp3 from "../sound/reactions/generic.mp3?url";
import lightbulbSoundOgg from "../sound/reactions/lightbulb.ogg?url";
import lightbulbSoundMp3 from "../sound/reactions/lightbulb.mp3?url";
import partySoundOgg from "../sound/reactions/party.ogg?url";
import partySoundMp3 from "../sound/reactions/party.mp3?url";
import deerSoundOgg from "../sound/reactions/deer.ogg?url";
import deerSoundMp3 from "../sound/reactions/deer.mp3?url";
import rockSoundOgg from "../sound/reactions/rock.ogg?url";
import rockSoundMp3 from "../sound/reactions/rock.mp3?url";
export const ElementCallReactionEventType = "io.element.call.reaction";
export interface ReactionOption {
/**
* The emoji to display. This is always displayed even if no emoji is matched
* from `ReactionSet`.
*
* @note Any excess characters are trimmed from this string.
*/
emoji: string;
/**
* The name of the emoji. This is the unique key used when looking for a local
* effect in our `ReactionSet` array.
*/
name: string;
/**
* Optional aliases to look for when searching for an emoji in the interface.
*/
alias?: string[];
/**
* Optional sound to play. An ogg sound must always be provided.
* If this sound isn't given, `GenericReaction` is used.
*/
sound?: {
mp3?: string;
ogg: string;
};
}
export interface ECallReactionEventContent {
"m.relates_to": {
rel_type: RelationType.Reference;
event_id: string;
};
emoji: string;
name: string;
}
export const GenericReaction: ReactionOption = {
name: "generic",
emoji: "", // Filled in by user
sound: {
mp3: genericSoundMp3,
ogg: genericSoundOgg,
},
};
// The first 6 reactions are always visible.
export const ReactionSet: ReactionOption[] = [
{
emoji: "👍",
name: "thumbsup",
// TODO: These need to be translated.
alias: ["+1", "yes", "thumbs up"],
},
{
emoji: "🎉",
name: "party",
alias: ["hurray", "success"],
sound: {
ogg: partySoundOgg,
mp3: partySoundMp3,
},
},
{
emoji: "👏",
name: "clapping",
alias: ["celebrate", "success"],
sound: {
ogg: clapSoundOgg,
mp3: clapSoundMp3,
},
},
{
emoji: "🐶",
name: "dog",
alias: ["doggo", "pupper", "woofer", "bark"],
sound: {
ogg: dogSoundOgg,
mp3: dogSoundMp3,
},
},
{
emoji: "🐱",
name: "cat",
alias: ["meow", "kitty"],
sound: {
ogg: catSoundOgg,
mp3: catSoundMp3,
},
},
{
emoji: "💡",
name: "lightbulb",
alias: ["bulb", "light", "idea", "ping"],
sound: {
ogg: lightbulbSoundOgg,
mp3: lightbulbSoundMp3,
},
},
{
emoji: "🦗",
name: "crickets",
alias: ["awkward", "silence"],
sound: {
ogg: cricketsSoundOgg,
mp3: cricketsSoundMp3,
},
},
{
emoji: "👎",
name: "thumbsdown",
alias: ["-1", "no", "thumbs no"],
},
{
emoji: "😵‍💫",
name: "dizzy",
alias: ["dazed", "confused"],
},
{
emoji: "👌",
name: "ok",
alias: ["okay", "cool"],
},
{
emoji: "🥰",
name: "heart",
alias: ["heart", "love", "smiling"],
},
{
emoji: "😄",
name: "laugh",
alias: ["giggle", "joy", "smiling"],
},
{
emoji: "🦌",
name: "deer",
alias: ["stag", "doe", "bleat"],
sound: {
ogg: deerSoundOgg,
mp3: deerSoundMp3,
},
},
{
emoji: "🤘",
name: "rock",
alias: ["cool", "horns", "guitar"],
sound: {
ogg: rockSoundOgg,
mp3: rockSoundMp3,
},
},
];
+45
View File
@@ -144,3 +144,48 @@ Please see LICENSE in the repository root for full details.
position: relative;
flex-grow: 1;
}
.floatingReaction {
position: relative;
display: inline;
z-index: 2;
font-size: 32pt;
/* Reactions are "active" for 3 seconds (as per REACTION_ACTIVE_TIME_MS), give a bit more time for it to fade out. */
animation-duration: 4s;
animation-name: reaction-up;
width: fit-content;
pointer-events: none;
}
@keyframes reaction-up {
from {
opacity: 1;
translate: 100vw 0;
scale: 200%;
}
to {
opacity: 0;
translate: 100vw -100vh;
scale: 100%;
}
}
@media (prefers-reduced-motion) {
@keyframes reaction-up-reduced {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.floatingReaction {
font-size: 48pt;
animation-name: reaction-up-reduced;
top: calc(-50vh + (48pt / 2));
left: calc(50vw - (48pt / 2)) !important;
}
}
+29 -4
View File
@@ -41,7 +41,7 @@ import {
VideoButton,
ShareScreenButton,
SettingsButton,
RaiseHandToggleButton,
ReactionToggleButton,
SwitchCameraButton,
} from "../button";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
@@ -83,7 +83,9 @@ import { GridTileViewModel, TileViewModel } from "../state/TileViewModel";
import { ReactionsProvider, useReactions } from "../useReactions";
import handSoundOgg from "../sound/raise_hand.ogg?url";
import handSoundMp3 from "../sound/raise_hand.mp3?url";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { useSwitchCamera } from "./useSwitchCamera";
import { showReactions, useSetting } from "../settings/settings";
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
@@ -179,13 +181,26 @@ export const InCallView: FC<InCallViewProps> = ({
connState,
onShareClick,
}) => {
const { supportsReactions, raisedHands } = useReactions();
const [shouldShowReactions] = useSetting(showReactions);
const { supportsReactions, raisedHands, reactions } = useReactions();
const raisedHandCount = useMemo(
() => Object.keys(raisedHands).length,
[raisedHands],
);
const previousRaisedHandCount = useDeferredValue(raisedHandCount);
const reactionsIcons = useMemo(
() =>
shouldShowReactions
? Object.entries(reactions).map(([sender, { emoji }]) => ({
sender,
emoji,
startX: -Math.ceil(Math.random() * 50) - 25,
}))
: [],
[shouldShowReactions, reactions],
);
useWakeLock();
useEffect(() => {
@@ -551,7 +566,7 @@ export const InCallView: FC<InCallViewProps> = ({
}
if (supportsReactions) {
buttons.push(
<RaiseHandToggleButton
<ReactionToggleButton
client={client}
rtcSession={rtcSession}
key="4"
@@ -639,10 +654,20 @@ export const InCallView: FC<InCallViewProps> = ({
))}
<RoomAudioRenderer />
{renderContent()}
<audio ref={handRaisePlayer} hidden>
<audio ref={handRaisePlayer} preload="auto" hidden>
<source src={handSoundOgg} type="audio/ogg; codecs=vorbis" />
<source src={handSoundMp3} type="audio/mpeg" />
</audio>
<ReactionsAudioRenderer />
{reactionsIcons.map(({ sender, emoji, startX }) => (
<span
style={{ left: `${startX}vw` }}
className={styles.floatingReaction}
key={sender}
>
{emoji}
</span>
))}
{footer}
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
<SettingsModal
+155
View File
@@ -0,0 +1,155 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { render } from "@testing-library/react";
import { afterAll, expect, test } from "vitest";
import { TooltipProvider } from "@vector-im/compound-web";
import { act, ReactNode } from "react";
import {
MockRoom,
MockRTCSession,
TestReactionsWrapper,
} from "../utils/testReactions";
import { ReactionsAudioRenderer } from "./ReactionAudioRenderer";
import { GenericReaction, ReactionSet } from "../reactions";
import { playReactionsSound } from "../settings/settings";
const memberUserIdAlice = "@alice:example.org";
const memberUserIdBob = "@bob:example.org";
const memberUserIdCharlie = "@charlie:example.org";
const memberEventAlice = "$membership-alice:example.org";
const memberEventBob = "$membership-bob:example.org";
const memberEventCharlie = "$membership-charlie:example.org";
const membership: Record<string, string> = {
[memberEventAlice]: memberUserIdAlice,
[memberEventBob]: memberUserIdBob,
[memberEventCharlie]: memberUserIdCharlie,
};
function TestComponent({
rtcSession,
}: {
rtcSession: MockRTCSession;
}): ReactNode {
return (
<TooltipProvider>
<TestReactionsWrapper rtcSession={rtcSession}>
<ReactionsAudioRenderer />
</TestReactionsWrapper>
</TooltipProvider>
);
}
const originalPlayFn = window.HTMLMediaElement.prototype.play;
afterAll(() => {
playReactionsSound.setValue(playReactionsSound.defaultValue);
window.HTMLMediaElement.prototype.play = originalPlayFn;
});
test("preloads all audio elements", () => {
playReactionsSound.setValue(true);
const rtcSession = new MockRTCSession(
new MockRoom(memberUserIdAlice),
membership,
);
const { container } = render(<TestComponent rtcSession={rtcSession} />);
expect(container.getElementsByTagName("audio")).toHaveLength(
// All reactions plus the generic sound
ReactionSet.filter((r) => r.sound).length + 1,
);
});
test("loads no audio elements when disabled in settings", () => {
playReactionsSound.setValue(false);
const rtcSession = new MockRTCSession(
new MockRoom(memberUserIdAlice),
membership,
);
const { container } = render(<TestComponent rtcSession={rtcSession} />);
expect(container.getElementsByTagName("audio")).toHaveLength(0);
});
test("will play an audio sound when there is a reaction", () => {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
playReactionsSound.setValue(true);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
render(<TestComponent rtcSession={rtcSession} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !!r.sound);
if (!chosenReaction) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
);
}
act(() => {
room.testSendReaction(memberEventAlice, chosenReaction, membership);
});
expect(audioIsPlaying).toHaveLength(1);
expect(audioIsPlaying[0]).toContain(chosenReaction.sound?.ogg);
});
test("will play the generic audio sound when there is soundless reaction", () => {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
playReactionsSound.setValue(true);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
render(<TestComponent rtcSession={rtcSession} />);
// Find the first reaction with a sound effect
const chosenReaction = ReactionSet.find((r) => !r.sound);
if (!chosenReaction) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
);
}
act(() => {
room.testSendReaction(memberEventAlice, chosenReaction, membership);
});
expect(audioIsPlaying).toHaveLength(1);
expect(audioIsPlaying[0]).toContain(GenericReaction.sound?.ogg);
});
test("will play multiple audio sounds when there are multiple different reactions", () => {
const audioIsPlaying: string[] = [];
window.HTMLMediaElement.prototype.play = async function (): Promise<void> {
audioIsPlaying.push((this.children[0] as HTMLSourceElement).src);
return Promise.resolve();
};
playReactionsSound.setValue(true);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
render(<TestComponent rtcSession={rtcSession} />);
// Find the first reaction with a sound effect
const [reaction1, reaction2] = ReactionSet.filter((r) => !!r.sound);
if (!reaction1 || !reaction2) {
throw Error(
"No reactions have sounds configured, this test cannot succeed",
);
}
act(() => {
room.testSendReaction(memberEventAlice, reaction1, membership);
room.testSendReaction(memberEventBob, reaction2, membership);
room.testSendReaction(memberEventCharlie, reaction1, membership);
});
expect(audioIsPlaying).toHaveLength(2);
expect(audioIsPlaying[0]).toContain(reaction1.sound?.ogg);
expect(audioIsPlaying[1]).toContain(reaction2.sound?.ogg);
});
+68
View File
@@ -0,0 +1,68 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ReactNode, useEffect, useRef } from "react";
import { useReactions } from "../useReactions";
import { playReactionsSound, useSetting } from "../settings/settings";
import { GenericReaction, ReactionSet } from "../reactions";
export function ReactionsAudioRenderer(): ReactNode {
const { reactions } = useReactions();
const [shouldPlay] = useSetting(playReactionsSound);
const audioElements = useRef<Record<string, HTMLAudioElement | null>>({});
useEffect(() => {
if (!audioElements.current) {
return;
}
if (!shouldPlay) {
return;
}
for (const reactionName of new Set(
Object.values(reactions).map((r) => r.name),
)) {
const audioElement =
audioElements.current[reactionName] ?? audioElements.current.generic;
if (audioElement?.paused) {
void audioElement.play();
}
}
}, [audioElements, shouldPlay, reactions]);
// Do not render any audio elements if playback is disabled. Will save
// audio file fetches.
if (!shouldPlay) {
return null;
}
// NOTE: We load all audio elements ahead of time to allow the cache
// to be populated, rather than risk a cache miss and have the audio
// be delayed.
return (
<>
{[GenericReaction, ...ReactionSet].map(
(r) =>
r.sound && (
<audio
ref={(el) => (audioElements.current[r.name] = el)}
data-testid={r.name}
key={r.name}
preload="auto"
hidden
>
<source src={r.sound.ogg} type="audio/ogg; codecs=vorbis" />
{r.sound.mp3 ? (
<source src={r.sound.mp3} type="audio/mpeg" />
) : null}
</audio>
),
)}
</>
);
}
+39 -8
View File
@@ -1,17 +1,19 @@
/*
Copyright 2022-2024 New Vector Ltd.
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { ChangeEvent, FC, useCallback } from "react";
import { ChangeEvent, FC } from "react";
import { useTranslation } from "react-i18next";
import { Text } from "@vector-im/compound-web";
import { FieldRow, InputField } from "../input/Input";
import {
showHandRaisedTimer as showHandRaisedTimerSetting,
showReactions as showReactionsSetting,
playReactionsSound as playReactionsSoundSetting,
useSetting,
} from "./settings";
@@ -21,13 +23,19 @@ export const PreferencesSettingsTab: FC = () => {
showHandRaisedTimerSetting,
);
const onChangeSetting = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setShowHandRaisedTimer(e.target.checked);
},
[setShowHandRaisedTimer],
const [showReactions, setShowReactions] = useSetting(showReactionsSetting);
const [playReactionsSound, setPlayReactionSound] = useSetting(
playReactionsSoundSetting,
);
const onChangeSetting = (
e: ChangeEvent<HTMLInputElement>,
fn: (value: boolean) => void,
): void => {
fn(e.target.checked);
};
return (
<div>
<h4>{t("settings.preferences_tab_h4")}</h4>
@@ -41,7 +49,30 @@ export const PreferencesSettingsTab: FC = () => {
)}
type="checkbox"
checked={showHandRaisedTimer}
onChange={onChangeSetting}
onChange={(e) => onChangeSetting(e, setShowHandRaisedTimer)}
/>
</FieldRow>
<h5>{t("settings.preferences_tab.reactions_title")}</h5>
<FieldRow>
<InputField
id="showReactions"
label={t("settings.preferences_tab.reactions_show_label")}
description={t("settings.preferences_tab.reactions_show_description")}
type="checkbox"
checked={showReactions}
onChange={(e) => onChangeSetting(e, setShowReactions)}
/>
</FieldRow>
<FieldRow>
<InputField
id="playReactionSound"
label={t("settings.preferences_tab.reactions_play_sound_label")}
description={t(
"settings.preferences_tab.reactions_play_sound_description",
)}
type="checkbox"
checked={playReactionsSound}
onChange={(e) => onChangeSetting(e, setPlayReactionSound)}
/>
</FieldRow>
</div>
+11 -1
View File
@@ -12,7 +12,10 @@ import { useObservableEagerState } from "observable-hooks";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
export class Setting<T> {
public constructor(key: string, defaultValue: T) {
public constructor(
key: string,
public readonly defaultValue: T,
) {
this.key = `matrix-setting-${key}`;
const storedValue = localStorage.getItem(this.key);
@@ -90,4 +93,11 @@ export const showHandRaisedTimer = new Setting<boolean>(
false,
);
export const showReactions = new Setting<boolean>("reactions-show", true);
export const playReactionsSound = new Setting<boolean>(
"reactions-play-sound",
true,
);
export const alwaysShowSelf = new Setting<boolean>("always-show-self", true);
+22
View File
@@ -0,0 +1,22 @@
# Sound effect licences
The following sound effects have been licensed from Pixabay, under https://pixabay.com/service/license-summary/.
- `raise_hand`
- `reactions/cat`
- `reactions/clap`
- `reactions/crickets`
- `reactions/dog`
- `reactions/generic`
- `reactions/lightbulb`
- `reactions/party`
### Other
The following sound effects have been originally created by Element.
- `blocked`
- `end_talk`
- `start_talk_local`
- `start_talk_remote`
- `reactions/rock`
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+11 -2
View File
@@ -48,6 +48,7 @@ import { useLatest } from "../useLatest";
import { GridTileViewModel } from "../state/TileViewModel";
import { useMergedRefs } from "../useMergedRefs";
import { useReactions } from "../useReactions";
import { ReactionOption } from "../reactions";
interface TileProps {
className?: string;
@@ -93,7 +94,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
},
[vm],
);
const { raisedHands, lowerHand } = useReactions();
const { raisedHands, lowerHand, reactions } = useReactions();
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
@@ -111,7 +112,14 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
</>
);
const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""];
console.log(vm.member);
console.log(vm.member?.events.member?.getId(), raisedHands, reactions);
const handRaised: Date | undefined =
raisedHands[vm.member?.events.member?.getId() ?? ""];
const currentReaction: ReactionOption | undefined =
reactions[vm.member?.events.member?.getId() ?? ""];
const raisedHandOnClick =
vm.local && handRaised ? (): void => void lowerHand() : undefined;
@@ -157,6 +165,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
</Menu>
}
raisedHandTime={handRaised}
currentReaction={currentReaction}
raisedHandOnClick={raisedHandOnClick}
{...props}
/>
+18 -6
View File
@@ -20,6 +20,8 @@ import { Avatar } from "../Avatar";
import { EncryptionStatus } from "../state/MediaViewModel";
import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator";
import { showHandRaisedTimer, useSetting } from "../settings/settings";
import { ReactionOption } from "../reactions";
import { ReactionIndicator } from "../reactions/ReactionIndicator";
interface Props extends ComponentProps<typeof animated.div> {
className?: string;
@@ -37,6 +39,7 @@ interface Props extends ComponentProps<typeof animated.div> {
displayName: string;
primaryButton?: ReactNode;
raisedHandTime?: Date;
currentReaction?: ReactionOption;
raisedHandOnClick?: () => void;
}
@@ -58,6 +61,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
primaryButton,
encryptionStatus,
raisedHandTime,
currentReaction,
raisedHandOnClick,
...props
},
@@ -98,6 +102,20 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
)}
</div>
<div className={styles.fg}>
<div style={{ display: "flex" }}>
<RaisedHandIndicator
raisedHandTime={raisedHandTime}
miniature={avatarSize < 96}
showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
/>
{currentReaction && (
<ReactionIndicator
miniature={avatarSize < 96}
emoji={currentReaction.emoji}
/>
)}
</div>
{/* TODO: Bring this back once encryption status is less broken */}
{/*encryptionStatus !== EncryptionStatus.Okay && (
<div className={styles.status}>
@@ -113,12 +131,6 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
</Text>
</div>
)*/}
<RaisedHandIndicator
raisedHandTime={raisedHandTime}
miniature={avatarSize < 96}
showTimer={handRaiseTimerVisible}
onClick={raisedHandOnClick}
/>
<div className={styles.nameTag}>
{nameTagLeadingIcon}
<Text as="span" size="sm" weight="medium" className={styles.name}>
+94 -163
View File
@@ -5,26 +5,33 @@ SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { act, render } from "@testing-library/react";
import { FC, ReactNode } from "react";
import { render } from "@testing-library/react";
import { act, FC } from "react";
import { describe, expect, test } from "vitest";
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession";
import {
EventTimeline,
EventTimelineSet,
EventType,
MatrixClient,
MatrixEvent,
Room,
RoomEvent,
} from "matrix-js-sdk/src/matrix";
import EventEmitter from "events";
import { randomUUID } from "crypto";
import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { ReactionsProvider, useReactions } from "./useReactions";
import { useReactions } from "./useReactions";
import {
createHandRaisedReaction,
createRedaction,
MockRoom,
MockRTCSession,
TestReactionsWrapper,
} from "./utils/testReactions";
const memberUserIdAlice = "@alice:example.org";
const memberEventAlice = "$membership-alice:example.org";
const memberEventAlice2 = "$membership-alice2:example.org";
const memberUserIdBob = "@bob:example.org";
const memberEventBob = "$membership-bob:example.org";
const membership: Record<string, string> = {
[memberEventAlice]: memberUserIdAlice,
[memberEventBob]: memberUserIdBob,
"$membership-charlie:example.org": "@charlie:example.org",
// Second device
[memberEventAlice2]: memberEventAlice,
};
/**
* Test explanation.
@@ -33,25 +40,14 @@ import { ReactionsProvider, useReactions } from "./useReactions";
* a large amount of test structure used to construct a mock environment.
*/
const memberUserIdAlice = "@alice:example.org";
const memberEventAlice = "$membership-alice:example.org";
const memberUserIdBob = "@bob:example.org";
const memberEventBob = "$membership-bob:example.org";
const membership: Record<string, string> = {
[memberEventAlice]: memberUserIdAlice,
[memberEventBob]: memberUserIdBob,
"$membership-charlie:example.org": "@charlie:example.org",
};
const TestComponent: FC = () => {
const { raisedHands } = useReactions();
return (
<div>
<ul>
{Object.entries(raisedHands).map(([userId, date]) => (
<li key={userId}>
<span>{userId}</span>
{Object.entries(raisedHands).map(([membershipEventId, date]) => (
<li key={membershipEventId}>
<span>{membershipEventId}</span>
<time>{date.getTime()}</time>
</li>
))}
@@ -60,136 +56,42 @@ const TestComponent: FC = () => {
);
};
const TestComponentWrapper = ({
rtcSession,
}: {
rtcSession: MockRTCSession;
}): ReactNode => {
return (
<ReactionsProvider rtcSession={rtcSession as unknown as MatrixRTCSession}>
<TestComponent />
</ReactionsProvider>
);
};
export class MockRTCSession extends EventEmitter {
public memberships = Object.entries(membership).map(([eventId, sender]) => ({
sender,
eventId,
createdTs: (): Date => new Date(),
}));
public constructor(public readonly room: MockRoom) {
super();
}
public testRemoveMember(userId: string): void {
this.memberships = this.memberships.filter((u) => u.sender !== userId);
this.emit(MatrixRTCSessionEvent.MembershipsChanged);
}
public testAddMember(sender: string): void {
this.memberships.push({
sender,
eventId: `!fake-${randomUUID()}:event`,
createdTs: (): Date => new Date(),
});
this.emit(MatrixRTCSessionEvent.MembershipsChanged);
}
}
function createReaction(
parentMemberEvent: string,
overridenSender?: string,
): MatrixEvent {
return new MatrixEvent({
sender: overridenSender ?? membership[parentMemberEvent],
type: EventType.Reaction,
origin_server_ts: new Date().getTime(),
content: {
"m.relates_to": {
key: "🖐️",
event_id: parentMemberEvent,
},
},
event_id: randomUUID(),
});
}
function createRedaction(sender: string, reactionEventId: string): MatrixEvent {
return new MatrixEvent({
sender,
type: EventType.RoomRedaction,
origin_server_ts: new Date().getTime(),
redacts: reactionEventId,
content: {},
event_id: randomUUID(),
});
}
export class MockRoom extends EventEmitter {
public constructor(private readonly existingRelations: MatrixEvent[] = []) {
super();
}
public get client(): MatrixClient {
return {
getUserId: (): string => memberUserIdAlice,
} as unknown as MatrixClient;
}
public get relations(): Room["relations"] {
return {
getChildEventsForEvent: (membershipEventId: string) => ({
getRelations: (): MatrixEvent[] => {
return this.existingRelations.filter(
(r) =>
r.getContent()["m.relates_to"]?.event_id === membershipEventId,
);
},
}),
} as unknown as Room["relations"];
}
public testSendReaction(
parentMemberEvent: string,
overridenSender?: string,
): string {
const evt = createReaction(parentMemberEvent, overridenSender);
this.emit(RoomEvent.Timeline, evt, this, undefined, false, {
timeline: new EventTimeline(new EventTimelineSet(undefined)),
});
return evt.getId()!;
}
}
describe("useReactions", () => {
test("starts with an empty list", () => {
const rtcSession = new MockRTCSession(new MockRoom());
const rtcSession = new MockRTCSession(
new MockRoom(memberUserIdAlice),
membership,
);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("handles incoming raised hand", async () => {
const room = new MockRoom();
const rtcSession = new MockRTCSession(room);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
await act(() => room.testSendReaction(memberEventAlice));
await act(() => room.testSendHandRaise(memberEventAlice, membership));
expect(queryByRole("list")?.children).to.have.lengthOf(1);
await act(() => room.testSendReaction(memberEventBob));
await act(() => room.testSendHandRaise(memberEventBob, membership));
expect(queryByRole("list")?.children).to.have.lengthOf(2);
});
test("handles incoming unraised hand", async () => {
const room = new MockRoom();
const rtcSession = new MockRTCSession(room);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
const reactionEventId = await act(() =>
room.testSendReaction(memberEventAlice),
room.testSendHandRaise(memberEventAlice, membership),
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
await act(() =>
@@ -203,30 +105,42 @@ describe("useReactions", () => {
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("handles loading prior raised hand events", () => {
const room = new MockRoom([createReaction(memberEventAlice)]);
const rtcSession = new MockRTCSession(room);
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
});
// If the membership event changes for a user, we want to remove
// the raised hand event.
test("will remove reaction when a member leaves the call", () => {
const room = new MockRoom([createReaction(memberEventAlice)]);
const rtcSession = new MockRTCSession(room);
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
act(() => rtcSession.testRemoveMember(memberUserIdAlice));
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("will remove reaction when a member joins via a new event", () => {
const room = new MockRoom([createReaction(memberEventAlice)]);
const rtcSession = new MockRTCSession(room);
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership),
]);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(1);
// Simulate leaving and rejoining
@@ -237,22 +151,39 @@ describe("useReactions", () => {
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("ignores invalid sender for historic event", () => {
const room = new MockRoom([
createReaction(memberEventAlice, memberUserIdBob),
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, memberUserIdBob),
]);
const rtcSession = new MockRTCSession(room);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("ignores invalid sender for new event", async () => {
const room = new MockRoom([]);
const rtcSession = new MockRTCSession(room);
const room = new MockRoom(memberUserIdAlice);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
<TestComponentWrapper rtcSession={rtcSession} />,
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
await act(() => room.testSendReaction(memberEventAlice, memberUserIdBob));
await act(() => room.testSendHandRaise(memberEventAlice, memberUserIdBob));
expect(queryByRole("list")?.children).to.have.lengthOf(0);
});
test("handles multiple membership event reactions for the same sender", () => {
const room = new MockRoom(memberUserIdAlice, [
createHandRaisedReaction(memberEventAlice, membership),
createHandRaisedReaction(memberEventAlice2, membership),
]);
const rtcSession = new MockRTCSession(room, membership);
const { queryByRole } = render(
<TestReactionsWrapper rtcSession={rtcSession}>
<TestComponent />
</TestReactionsWrapper>,
);
expect(queryByRole("list")?.children).to.have.lengthOf(2);
});
});
+143 -44
View File
@@ -26,10 +26,19 @@ import { logger } from "matrix-js-sdk/src/logger";
import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships";
import { useClientState } from "./ClientContext";
import {
ECallReactionEventContent,
ElementCallReactionEventType,
GenericReaction,
ReactionOption,
ReactionSet,
} from "./reactions";
import { useLatest } from "./useLatest";
interface ReactionsContextType {
raisedHands: Record<string, Date>;
supportsReactions: boolean;
reactions: Record<string, ReactionOption>;
lowerHand: () => Promise<void>;
}
@@ -39,9 +48,9 @@ const ReactionsContext = createContext<ReactionsContextType | undefined>(
interface RaisedHandInfo {
/**
* Call membership event that was reacted to.
* The sender who sent the raised hand.
*/
membershipEventId: string;
sender: string;
/**
* Event ID of the reaction itself.
*/
@@ -52,6 +61,8 @@ interface RaisedHandInfo {
time: Date;
}
const REACTION_ACTIVE_TIME_MS = 3000;
export const useReactions = (): ReactionsContextType => {
const context = useContext(ReactionsContext);
if (!context) {
@@ -60,6 +71,8 @@ export const useReactions = (): ReactionsContextType => {
return context;
};
type MembershipEventId = string;
/**
* Provider that handles raised hand reactions for a given `rtcSession`.
*/
@@ -71,7 +84,7 @@ export const ReactionsProvider = ({
rtcSession: MatrixRTCSession;
}): JSX.Element => {
const [raisedHands, setRaisedHands] = useState<
Record<string, RaisedHandInfo>
Record<MembershipEventId, RaisedHandInfo>
>({});
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const clientState = useClientState();
@@ -80,25 +93,36 @@ export const ReactionsProvider = ({
const room = rtcSession.room;
const myUserId = room.client.getUserId();
const [reactions, setReactions] = useState<
Record<MembershipEventId, ReactionOption>
>({});
// Reduce the data down for the consumers.
const resultRaisedHands = useMemo(
() =>
Object.fromEntries(
Object.entries(raisedHands).map(([uid, data]) => [uid, data.time]),
Object.entries(raisedHands).map(([membershipEventId, data]) => [
membershipEventId,
data.time,
]),
),
[raisedHands],
);
const addRaisedHand = useCallback((userId: string, info: RaisedHandInfo) => {
setRaisedHands((prevRaisedHands) => ({
...prevRaisedHands,
[userId]: info,
}));
}, []);
const addRaisedHand = useCallback(
(membershipEventId: string, info: RaisedHandInfo) => {
setRaisedHands((prevRaisedHands) => ({
...prevRaisedHands,
[membershipEventId]: info,
}));
},
[],
);
const removeRaisedHand = useCallback((userId: string) => {
const removeRaisedHand = useCallback((membershipEventId: string) => {
setRaisedHands(
({ [userId]: _removed, ...remainingRaisedHands }) => remainingRaisedHands,
({ [membershipEventId]: _removed, ...remainingRaisedHands }) =>
remainingRaisedHands,
);
}, []);
@@ -123,11 +147,10 @@ export const ReactionsProvider = ({
);
};
// Remove any raised hands for users no longer joined to the call.
for (const userId of Object.keys(raisedHands).filter(
(rhId) => !memberships.find((u) => u.sender == rhId),
for (const previousMemberEventId of Object.keys(raisedHands).filter(
(eventId) => !memberships.some((m) => m.eventId === eventId),
)) {
removeRaisedHand(userId);
removeRaisedHand(previousMemberEventId);
}
// For each member in the call, check to see if a reaction has
@@ -136,22 +159,14 @@ export const ReactionsProvider = ({
if (!m.sender || !m.eventId) {
continue;
}
if (
raisedHands[m.sender] &&
raisedHands[m.sender].membershipEventId !== m.eventId
) {
// Membership event for sender has changed since the hand
// was raised, reset.
removeRaisedHand(m.sender);
}
const reaction = getLastReactionEvent(m.eventId, m.sender);
if (reaction) {
const eventId = reaction?.getId();
if (!eventId) {
continue;
}
addRaisedHand(m.sender, {
membershipEventId: m.eventId,
addRaisedHand(m.eventId, {
sender: m.sender,
reactionEventId: eventId,
time: new Date(reaction.localTimestamp),
});
@@ -162,8 +177,12 @@ export const ReactionsProvider = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, memberships, myUserId, addRaisedHand, removeRaisedHand]);
const latestMemberships = useLatest(memberships);
const latestRaisedHands = useLatest(raisedHands);
// This effect handles any *live* reaction/redactions in the room.
useEffect(() => {
const reactionTimeouts = new Set<NodeJS.Timeout>();
const handleReactionEvent = (event: MatrixEvent): void => {
if (event.isSending()) {
// Skip any events that are still sending.
@@ -177,14 +196,78 @@ export const ReactionsProvider = ({
return;
}
if (event.getType() === EventType.Reaction) {
if (event.getType() === ElementCallReactionEventType) {
const content: ECallReactionEventContent = event.getContent();
console.log(latestMemberships, content);
const membershipEventId = content?.["m.relates_to"]?.event_id;
// Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership)
if (
!latestMemberships.current.some(
(e) => e.eventId === membershipEventId && e.sender === sender,
)
) {
logger.warn(
`Reaction target was not a membership event for ${sender}, ignoring`,
);
return;
}
if (!content.emoji) {
logger.warn(`Reaction had no emoji from ${reactionEventId}`);
return;
}
const segment = new Intl.Segmenter(undefined, {
granularity: "grapheme",
})
.segment(content.emoji)
[Symbol.iterator]();
const emoji = segment.next().value?.segment;
if (!emoji) {
logger.warn(
`Reaction had no emoji from ${reactionEventId} after splitting`,
);
return;
}
// One of our custom reactions
const reaction = {
...GenericReaction,
emoji,
// If we don't find a reaction, we can fallback to the generic sound.
...ReactionSet.find((r) => r.name === content.name),
};
setReactions((reactions) => {
if (reactions[membershipEventId]) {
// We've still got a reaction from this user, ignore it to prevent spamming
return reactions;
}
const timeout = setTimeout(() => {
// Clear the reaction after some time.
setReactions(
({ [membershipEventId]: _unused, ...remaining }) => remaining,
);
reactionTimeouts.delete(timeout);
}, REACTION_ACTIVE_TIME_MS);
reactionTimeouts.add(timeout);
return {
...reactions,
[membershipEventId]: reaction,
};
});
} else if (event.getType() === EventType.Reaction) {
const content = event.getContent() as ReactionEventContent;
const membershipEventId = content["m.relates_to"].event_id;
// Check to see if this reaction was made to a membership event (and the
// sender of the reaction matches the membership)
if (
!memberships.some(
!latestMemberships.current.some(
(e) => e.eventId === membershipEventId && e.sender === sender,
)
) {
@@ -195,15 +278,15 @@ export const ReactionsProvider = ({
}
if (content?.["m.relates_to"].key === "🖐️") {
addRaisedHand(sender, {
addRaisedHand(membershipEventId, {
reactionEventId,
membershipEventId,
sender,
time: new Date(event.localTimestamp),
});
}
} else if (event.getType() === EventType.RoomRedaction) {
const targetEvent = event.event.redacts;
const targetUser = Object.entries(raisedHands).find(
const targetUser = Object.entries(latestRaisedHands.current).find(
([_u, r]) => r.reactionEventId === targetEvent,
)?.[0];
if (!targetUser) {
@@ -225,39 +308,55 @@ export const ReactionsProvider = ({
room.off(MatrixRoomEvent.Timeline, handleReactionEvent);
room.off(MatrixRoomEvent.Redaction, handleReactionEvent);
room.off(MatrixRoomEvent.LocalEchoUpdated, handleReactionEvent);
reactionTimeouts.forEach((t) => clearTimeout(t));
// If we're clearing timeouts, we also clear all reactions.
setReactions({});
};
}, [room, addRaisedHand, removeRaisedHand, memberships, raisedHands]);
}, [
room,
addRaisedHand,
removeRaisedHand,
latestMemberships,
latestRaisedHands,
]);
const myMembershipEventId = useMemo(() => {
console.log(
room.client.getUserId(),
room.client.getDeviceId(),
memberships,
);
return memberships.find(
(m) =>
m.sender === room.client.getUserId() &&
m.deviceId === room.client.getDeviceId(),
);
}, [memberships, room])?.eventId;
const lowerHand = useCallback(async () => {
if (
!myUserId ||
clientState?.state !== "valid" ||
!clientState.authenticated ||
!raisedHands[myUserId]
) {
if (!myMembershipEventId || !raisedHands[myMembershipEventId]) {
logger.warn(`No membership event for us!`, myMembershipEventId);
return;
}
const myReactionId = raisedHands[myUserId].reactionEventId;
const myReactionId = raisedHands[myMembershipEventId].reactionEventId;
if (!myReactionId) {
logger.warn(`Hand raised but no reaction event to redact!`);
return;
}
try {
await clientState.authenticated.client.redactEvent(
rtcSession.room.roomId,
myReactionId,
);
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
}
}, [myUserId, raisedHands, clientState, rtcSession]);
}, [myMembershipEventId, raisedHands, rtcSession, room]);
return (
<ReactionsContext.Provider
value={{
raisedHands: resultRaisedHands,
supportsReactions,
reactions,
lowerHand,
}}
>
+207
View File
@@ -0,0 +1,207 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
*/
import { PropsWithChildren, ReactNode } from "react";
import { randomUUID } from "crypto";
import EventEmitter from "events";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { EventType, RoomEvent, RelationType } from "matrix-js-sdk/src/matrix";
import {
MatrixEvent,
EventTimeline,
EventTimelineSet,
Room,
} from "matrix-js-sdk/src/matrix";
import {
MatrixRTCSession,
MatrixRTCSessionEvent,
} from "matrix-js-sdk/src/matrixrtc";
import { ReactionsProvider } from "../useReactions";
import {
ECallReactionEventContent,
ElementCallReactionEventType,
ReactionOption,
} from "../reactions";
export const TestReactionsWrapper = ({
rtcSession,
children,
}: PropsWithChildren<{
rtcSession: MockRTCSession;
}>): ReactNode => {
return (
<ReactionsProvider rtcSession={rtcSession as unknown as MatrixRTCSession}>
{children}
</ReactionsProvider>
);
};
const OWN_DEVICE_ID = "OWN_DEVICE";
export class MockRTCSession extends EventEmitter {
public memberships: {
sender: string;
eventId: string;
deviceId: string;
createdTs: () => Date;
}[];
public constructor(
public readonly room: MockRoom,
membership: Record<string, string>,
) {
super();
this.memberships = Object.entries(membership).map(
([eventId, sender], index) => ({
sender,
eventId,
deviceId: index === 0 ? OWN_DEVICE_ID : randomUUID(),
createdTs: (): Date => new Date(),
}),
);
}
public testRemoveMember(userId: string): void {
this.memberships = this.memberships.filter((u) => u.sender !== userId);
this.emit(MatrixRTCSessionEvent.MembershipsChanged);
}
public testAddMember(sender: string): void {
this.memberships.push({
sender,
eventId: `!fake-${randomUUID()}:event`,
deviceId: randomUUID(),
createdTs: (): Date => new Date(),
});
this.emit(MatrixRTCSessionEvent.MembershipsChanged);
}
}
export function createHandRaisedReaction(
parentMemberEvent: string,
membershipOrOverridenSender: Record<string, string> | string,
): MatrixEvent {
return new MatrixEvent({
sender:
typeof membershipOrOverridenSender === "string"
? membershipOrOverridenSender
: membershipOrOverridenSender[parentMemberEvent],
type: EventType.Reaction,
origin_server_ts: new Date().getTime(),
content: {
"m.relates_to": {
key: "🖐️",
event_id: parentMemberEvent,
},
},
event_id: randomUUID(),
});
}
export function createRedaction(
sender: string,
reactionEventId: string,
): MatrixEvent {
return new MatrixEvent({
sender,
type: EventType.RoomRedaction,
origin_server_ts: new Date().getTime(),
redacts: reactionEventId,
content: {},
event_id: randomUUID(),
});
}
export class MockRoom extends EventEmitter {
public readonly testSentEvents: Parameters<MatrixClient["sendEvent"]>[] = [];
public readonly testRedactedEvents: Parameters<
MatrixClient["redactEvent"]
>[] = [];
public constructor(
private readonly ownUserId: string,
private readonly existingRelations: MatrixEvent[] = [],
) {
super();
}
public get client(): MatrixClient {
return {
getUserId: (): string => this.ownUserId,
getDeviceId: (): string => OWN_DEVICE_ID,
sendEvent: async (
...props: Parameters<MatrixClient["sendEvent"]>
): ReturnType<MatrixClient["sendEvent"]> => {
this.testSentEvents.push(props);
return Promise.resolve({ event_id: randomUUID() });
},
redactEvent: async (
...props: Parameters<MatrixClient["redactEvent"]>
): ReturnType<MatrixClient["redactEvent"]> => {
this.testRedactedEvents.push(props);
return Promise.resolve({ event_id: randomUUID() });
},
} as unknown as MatrixClient;
}
public get relations(): Room["relations"] {
return {
getChildEventsForEvent: (membershipEventId: string) => ({
getRelations: (): MatrixEvent[] => {
return this.existingRelations.filter(
(r) =>
r.getContent()["m.relates_to"]?.event_id === membershipEventId,
);
},
}),
} as unknown as Room["relations"];
}
public testSendHandRaise(
parentMemberEvent: string,
membershipOrOverridenSender: Record<string, string> | string,
): string {
const evt = createHandRaisedReaction(
parentMemberEvent,
membershipOrOverridenSender,
);
this.emit(RoomEvent.Timeline, evt, this, undefined, false, {
timeline: new EventTimeline(new EventTimelineSet(undefined)),
});
return evt.getId()!;
}
public testSendReaction(
parentMemberEvent: string,
reaction: ReactionOption,
membershipOrOverridenSender: Record<string, string> | string,
): string {
const evt = new MatrixEvent({
sender:
typeof membershipOrOverridenSender === "string"
? membershipOrOverridenSender
: membershipOrOverridenSender[parentMemberEvent],
type: ElementCallReactionEventType,
origin_server_ts: new Date().getTime(),
content: {
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: parentMemberEvent,
},
emoji: reaction.emoji,
name: reaction.name,
} satisfies ECallReactionEventContent,
event_id: randomUUID(),
});
this.emit(RoomEvent.Timeline, evt, this, undefined, false, {
timeline: new EventTimeline(new EventTimelineSet(undefined)),
});
return evt.getId()!;
}
}
+2
View File
@@ -15,6 +15,7 @@ import type { IWidgetApiRequest } from "matrix-widget-api";
import { LazyEventEmitter } from "./LazyEventEmitter";
import { getUrlParams } from "./UrlParams";
import { Config } from "./config/Config";
import { ElementCallReactionEventType } from "./reactions";
// Subset of the actions in matrix-react-sdk
export enum ElementWidgetActions {
@@ -105,6 +106,7 @@ export const widget = ((): WidgetHelpers | null => {
EventType.CallEncryptionKeysPrefix,
EventType.Reaction,
EventType.RoomRedaction,
ElementCallReactionEventType,
];
const sendState = [
+13 -4
View File
@@ -1608,6 +1608,15 @@
dependencies:
tslib "2"
"@formatjs/intl-segmenter@^11.7.3":
version "11.7.3"
resolved "https://registry.yarnpkg.com/@formatjs/intl-segmenter/-/intl-segmenter-11.7.3.tgz#aeb49c33c81fec68419922c64c72188b659eaa5a"
integrity sha512-IvEDQRe0t0ouqaqZK2KobGt/+BhwDHdtbS8GWhdl+fjmWbhXMz2mHihu5fAYkYChum5eNfGhEF5P+bLCeYq67w==
dependencies:
"@formatjs/ecma402-abstract" "2.2.3"
"@formatjs/intl-localematcher" "0.5.7"
tslib "2"
"@gulpjs/to-absolute-glob@^4.0.0":
version "4.0.0"
resolved "https://registry.yarnpkg.com/@gulpjs/to-absolute-glob/-/to-absolute-glob-4.0.0.tgz#1fc2460d3953e1d9b9f2dfdb4bcc99da4710c021"
@@ -3100,10 +3109,10 @@
dependencies:
"@use-gesture/core" "10.3.1"
"@vector-im/compound-design-tokens@^1.0.0":
version "1.9.0"
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.9.0.tgz#a3788845110fdcafb1720f633cb060b86f9a1592"
integrity sha512-09eIRJSiWtAqK605eIu+PfT1ugu7u13gkvfxvfN7kjJMHQOzHSvDxmwADmfIzlV7oBQ8M+5D4KSKHNskvMxWsA==
"@vector-im/compound-design-tokens@^1.9.1":
version "1.9.1"
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-1.9.1.tgz#644dc7ca5ca251fd476af2a7c075e9d740c08871"
integrity sha512-zjI+PhoNLNrJrLU8whEGjzCuxdqIz6tM0ARYBMS8AG1vC+NlGak6Y21TWnzHT3VINNhnF+PiQ9lFWsU65GydOg==
"@vector-im/compound-web@^6.0.0":
version "6.3.2"