diff --git a/public/locales/en-GB/app.json b/public/locales/en-GB/app.json index ea849bf0..54525f1a 100644 --- a/public/locales/en-GB/app.json +++ b/public/locales/en-GB/app.json @@ -11,6 +11,7 @@ "no": "No", "register": "Register", "remove": "Remove", + "send_reaction": "Send reaction", "sign_in": "Sign in", "sign_out": "Sign out", "submit": "Submit", diff --git a/src/button/RaisedHandToggleButton.module.css b/src/button/RaisedHandToggleButton.module.css new file mode 100644 index 00000000..1dd2766c --- /dev/null +++ b/src/button/RaisedHandToggleButton.module.css @@ -0,0 +1,40 @@ +.reactionPopupMenu { + padding: 1em; + position: absolute; + z-index: 99; + background: var(--cpd-color-bg-canvas-default); + top: -8em; + border-radius: var(--cpd-space-4x); + display: flex; +} + +.reactionPopupMenu menu { + margin: 0; + padding: 0; + display: flex; +} + +.reactionPopupMenu section { + height: fit-content; + margin-top: auto; + margin-bottom: auto; +} + +.reactionPopupMenuItem { + list-style: none; + gap: 1em; +} + +.reactionButton { + width: 2em; + height: 2em; + border-radius: 2em; +} + +.verticalSeperator { + background-color: var(--cpd-color-gray-400); + width: 1px; + height: auto; + margin-left: var(--cpd-separator-spacing); + margin-right: var(--cpd-separator-spacing); +} diff --git a/src/button/RaisedHandToggleButton.tsx b/src/button/RaisedHandToggleButton.tsx index 277817de..cae7aa51 100644 --- a/src/button/RaisedHandToggleButton.tsx +++ b/src/button/RaisedHandToggleButton.tsx @@ -5,12 +5,20 @@ 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 { + Button as CpdButton, + Tooltip, + Separator, + Search, + Form, +} from "@vector-im/compound-web"; +import { + ChangeEventHandler, ComponentPropsWithoutRef, FC, ReactNode, useCallback, + useMemo, useState, } from "react"; import { useTranslation } from "react-i18next"; @@ -21,6 +29,12 @@ import { MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc/MatrixRTCSession"; import { useReactions } from "../useReactions"; import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships"; +import styles from "./RaisedHandToggleButton.module.css"; +import { + ECallReactionEventContent, + ReactionOption, + ReactionSet, +} from "../reactions"; interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> { raised: boolean; @@ -30,7 +44,7 @@ const InnerButton: FC = ({ raised, ...props }) => { const { t } = useTranslation(); return ( - + = ({ raised, ...props }) => { ); }; +export function ReactionPopupMenu({ + sendRelation, + toggleRaisedHand, + isHandRaised, + canReact, +}: { + sendRelation: (reaction: ReactionOption) => void; + toggleRaisedHand: () => void; + isHandRaised: boolean; + canReact: boolean; +}): ReactNode { + const { t } = useTranslation(); + const [searchText, setSearchText] = useState(""); + const onSearch = useCallback>((ev) => { + ev.preventDefault(); + setSearchText(ev.target.value.trim().toLocaleLowerCase()); + }, []); + + const filteredReactionSet = useMemo( + () => + ReactionSet.filter( + (reaction) => + reaction.name.startsWith(searchText) || + reaction.alias?.some((a) => a.startsWith(searchText)), + ).slice(0, 6), + [searchText], + ); + return ( +
+
+ + toggleRaisedHand()} + > + 🖐️ + + +
+
+
+ e.preventDefault()}> + + + + + {filteredReactionSet.map((reaction) => ( +
  • + + sendRelation(reaction)} + > + {reaction.emoji} + + +
  • + ))} +
    +
    +
    + ); +} + interface RaisedHandToggleButtonProps { rtcSession: MatrixRTCSession; client: MatrixClient; @@ -62,11 +149,49 @@ export function RaiseHandToggleButton({ client, rtcSession, }: RaisedHandToggleButtonProps): ReactNode { - const { raisedHands, myReactionId } = useReactions(); + const { raisedHands, myReactionId, reactions } = useReactions(); const [busy, setBusy] = useState(false); const userId = client.getUserId()!; const isHandRaised = !!raisedHands[userId]; const memberships = useMatrixRTCSessionMemberships(rtcSession); + const [showReactionsMenu, setShowReactionsMenu] = useState(true); + + const canReact = !reactions[userId]; + + const sendRelation = useCallback( + async (reaction: ReactionOption) => { + 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); + // XXX: Trying to send a unspec'd event seems to miss the 3rd overload, need to come back to this. + // @ts-expect-error + await client.sendEvent( + rtcSession.room.roomId, + null, + "io.element.call.reaction", + { + "m.relates_to": { + rel_type: RelationType.Reference, + event_id: parentEventId, + }, + emoji: reaction.emoji, + name: reaction.name, + } as ECallReactionEventContent, + ); + setShowReactionsMenu(false); + } catch (ex) { + logger.error("Failed to send reaction", ex); + } finally { + setBusy(false); + } + }, + [memberships, client], + ); const toggleRaisedHand = useCallback(() => { const raiseHand = async (): Promise => { @@ -124,10 +249,20 @@ export function RaiseHandToggleButton({ ]); return ( - + <> + setShowReactionsMenu((show) => !show)} + raised={isHandRaised} + /> + {showReactionsMenu && ( + + )} + ); } diff --git a/src/reactions/index.ts b/src/reactions/index.ts new file mode 100644 index 00000000..9138f10d --- /dev/null +++ b/src/reactions/index.ts @@ -0,0 +1,74 @@ +import dogSoundOgg from "../sound/reactions/dog.ogg?url"; +import dogSoundMp3 from "../sound/reactions/dog.mp3?url"; +import { RelationType } from "matrix-js-sdk/src/types"; + +export interface ReactionOption { + emoji: string; + name: string; + alias?: string[]; + 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 +}; + +export const ReactionSet: ReactionOption[] = [ + { + emoji: "🐶", + name: "dog", + alias: ["doggo", "pupper", "woofer"], + sound: { + ogg: dogSoundOgg, + mp3: dogSoundMp3, + }, + }, + { + emoji: "👍", + name: "thumbsup", + alias: ["+1", "yes", "thumbs up"], + }, + { + emoji: "👎", + name: "thumbsdown", + alias: ["-1", "no", "thumbs no"], + }, + { + emoji: "🎉", + name: "party", + alias: ["hurray", "success"], + }, + { + emoji: "🦗", + name: "crickets", + alias: ["awkward", "silence"], + }, + { + emoji: "🐱", + name: "cat", + alias: ["meow", "kitty"], + }, + { + emoji: "😵‍💫", + name: "dizzy", + alias: ["dazed", "confused"], + }, + { + emoji: "👌", + name: "ok", + alias: ["okay", "cool"], + }, +]; diff --git a/src/room/InCallView.tsx b/src/room/InCallView.tsx index 63e16d12..1982fa26 100644 --- a/src/room/InCallView.tsx +++ b/src/room/InCallView.tsx @@ -175,13 +175,23 @@ export const InCallView: FC = ({ connState, onShareClick, }) => { - const { supportsReactions, raisedHands } = useReactions(); + const { supportsReactions, raisedHands, reactions } = useReactions(); const raisedHandCount = useMemo( () => Object.keys(raisedHands).length, [raisedHands], ); const previousRaisedHandCount = useDeferredValue(raisedHandCount); + // TODO: This may need to ensure we don't change the value if a duplicate reaction comes down. + const reactionsSet = useMemo( + () => [...new Set([...Object.values(reactions)])], + [reactions], + ); + + useEffect(() => { + console.log("Got reaction change", reactionsSet); + }, [reactionsSet]); + useWakeLock(); useEffect(() => { @@ -634,6 +644,17 @@ export const InCallView: FC = ({ + {reactionsSet.map( + (r) => + r.sound && ( + + ), + )} {footer} {!noControls && } ( }, [vm], ); - const { raisedHands } = useReactions(); + const { raisedHands, reactions } = useReactions(); const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon; @@ -110,6 +111,8 @@ const UserMediaTile = forwardRef( ); const handRaised: Date | undefined = raisedHands[vm.member?.userId ?? ""]; + const currentReaction: ReactionOption | undefined = + reactions[vm.member?.userId ?? ""]; const showSpeaking = showSpeakingIndicators && speaking; @@ -152,6 +155,7 @@ const UserMediaTile = forwardRef( } raisedHandTime={handRaised} + currentReaction={currentReaction} {...props} /> ); diff --git a/src/tile/MediaView.tsx b/src/tile/MediaView.tsx index d8b03dc9..7ba04a7c 100644 --- a/src/tile/MediaView.tsx +++ b/src/tile/MediaView.tsx @@ -19,6 +19,7 @@ import styles from "./MediaView.module.css"; import { Avatar } from "../Avatar"; import { RaisedHandIndicator } from "../reactions/RaisedHandIndicator"; import { showHandRaisedTimer, useSetting } from "../settings/settings"; +import { ReactionOption } from "../reactions"; interface Props extends ComponentProps { className?: string; @@ -35,6 +36,7 @@ interface Props extends ComponentProps { displayName: string; primaryButton?: ReactNode; raisedHandTime?: Date; + currentReaction?: ReactionOption; } export const MediaView = forwardRef( @@ -54,6 +56,7 @@ export const MediaView = forwardRef( displayName, primaryButton, raisedHandTime, + currentReaction, ...props }, ref, @@ -101,7 +104,7 @@ export const MediaView = forwardRef(
    {nameTagLeadingIcon} - {displayName} + {displayName} {currentReaction?.emoji ?? ""} {unencryptedWarning && ( ; supportsReactions: boolean; myReactionId: string | null; + reactions: Record; } const ReactionsContext = createContext( @@ -80,6 +87,10 @@ export const ReactionsProvider = ({ const room = rtcSession.room; const myUserId = room.client.getUserId(); + const [reactions, setReactions] = useState>( + {}, + ); + // Calculate our own reaction event. const myReactionId = useMemo( (): string | null => @@ -184,7 +195,44 @@ export const ReactionsProvider = ({ return; } - if (event.getType() === EventType.Reaction) { + if (event.getType() === "io.element.call.reaction") { + // TODO: Validate content. + const content: ECallReactionEventContent = event.getContent(); + + 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( + (e) => e.eventId === membershipEventId && e.sender === sender, + ) + ) { + logger.warn( + `Reaction target was not a membership event for ${sender}, ignoring`, + ); + return; + } + + // One of our custom reactions + const reaction = ReactionSet.find((r) => r.name === content.name) ?? { + ...GenericReaction, + emoji: content.emoji, + }; + setReactions((reactions) => { + if (reactions[sender]) { + // We've still got a reaction from this user, ignore it to prevent spamming + return reactions; + } + setTimeout(() => { + // Clear the reaction after some time. + setReactions(({ [sender]: _unused, ...remaining }) => remaining); + }, 3000); + return { + ...reactions, + [sender]: reaction, + }; + }); + } else if (event.getType() === EventType.Reaction) { const content = event.getContent() as ReactionEventContent; const membershipEventId = content["m.relates_to"].event_id; @@ -241,6 +289,7 @@ export const ReactionsProvider = ({ raisedHands: resultRaisedHands, supportsReactions, myReactionId, + reactions, }} > {children}