mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-31 07:00:26 +00:00
First PoC for reactions
This commit is contained in:
40
src/button/RaisedHandToggleButton.module.css
Normal file
40
src/button/RaisedHandToggleButton.module.css
Normal file
@@ -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);
|
||||
}
|
||||
@@ -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<InnerButtonProps> = ({ raised, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip label={t("common.raise_hand")}>
|
||||
<Tooltip label={t("action.send_reaction")}>
|
||||
<CpdButton
|
||||
kind={raised ? "primary" : "secondary"}
|
||||
{...props}
|
||||
@@ -53,6 +67,79 @@ const InnerButton: FC<InnerButtonProps> = ({ 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<ChangeEventHandler<HTMLInputElement>>((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 (
|
||||
<div className={styles.reactionPopupMenu}>
|
||||
<section className={styles.handRaiseSection}>
|
||||
<Tooltip label={t("common.raise_hand")}>
|
||||
<CpdButton
|
||||
kind={isHandRaised ? "primary" : "secondary"}
|
||||
className={styles.reactionButton}
|
||||
key={"raise-hand"}
|
||||
onClick={() => toggleRaisedHand()}
|
||||
>
|
||||
🖐️
|
||||
</CpdButton>
|
||||
</Tooltip>
|
||||
</section>
|
||||
<div className={styles.verticalSeperator}></div>
|
||||
<section>
|
||||
<Form.Root onSubmit={(e) => e.preventDefault()}>
|
||||
<Search
|
||||
value={searchText}
|
||||
name="reactionSearch"
|
||||
onChange={onSearch}
|
||||
/>
|
||||
</Form.Root>
|
||||
<Separator />
|
||||
<menu>
|
||||
{filteredReactionSet.map((reaction) => (
|
||||
<li className={styles.reactionPopupMenuItem}>
|
||||
<Tooltip label={reaction.name}>
|
||||
<CpdButton
|
||||
kind="secondary"
|
||||
className={styles.reactionButton}
|
||||
key={reaction.name}
|
||||
disabled={!canReact}
|
||||
onClick={() => sendRelation(reaction)}
|
||||
>
|
||||
{reaction.emoji}
|
||||
</CpdButton>
|
||||
</Tooltip>
|
||||
</li>
|
||||
))}
|
||||
</menu>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<void> => {
|
||||
@@ -124,10 +249,20 @@ export function RaiseHandToggleButton({
|
||||
]);
|
||||
|
||||
return (
|
||||
<InnerButton
|
||||
disabled={busy}
|
||||
onClick={toggleRaisedHand}
|
||||
raised={isHandRaised}
|
||||
/>
|
||||
<>
|
||||
<InnerButton
|
||||
disabled={busy}
|
||||
onClick={() => setShowReactionsMenu((show) => !show)}
|
||||
raised={isHandRaised}
|
||||
/>
|
||||
{showReactionsMenu && (
|
||||
<ReactionPopupMenu
|
||||
isHandRaised={isHandRaised}
|
||||
canReact={canReact}
|
||||
sendRelation={sendRelation}
|
||||
toggleRaisedHand={toggleRaisedHand}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
74
src/reactions/index.ts
Normal file
74
src/reactions/index.ts
Normal file
@@ -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"],
|
||||
},
|
||||
];
|
||||
@@ -175,13 +175,23 @@ export const InCallView: FC<InCallViewProps> = ({
|
||||
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<InCallViewProps> = ({
|
||||
<source src={handSoundOgg} type="audio/ogg; codecs=vorbis" />
|
||||
<source src={handSoundMp3} type="audio/mpeg" />
|
||||
</audio>
|
||||
{reactionsSet.map(
|
||||
(r) =>
|
||||
r.sound && (
|
||||
<audio key={r.name} autoPlay hidden>
|
||||
<source src={r.sound.ogg} type="audio/ogg; codecs=vorbis" />
|
||||
{r.sound.mp3 ? (
|
||||
<source src={r.sound.mp3} type="audio/mpeg" />
|
||||
) : null}
|
||||
</audio>
|
||||
),
|
||||
)}
|
||||
{footer}
|
||||
{!noControls && <RageshakeRequestModal {...rageshakeRequestModalProps} />}
|
||||
<SettingsModal
|
||||
|
||||
BIN
src/sound/reactions/dog.mp3
Normal file
BIN
src/sound/reactions/dog.mp3
Normal file
Binary file not shown.
BIN
src/sound/reactions/dog.ogg
Normal file
BIN
src/sound/reactions/dog.ogg
Normal file
Binary file not shown.
@@ -45,6 +45,7 @@ import { Slider } from "../Slider";
|
||||
import { MediaView } from "./MediaView";
|
||||
import { useLatest } from "../useLatest";
|
||||
import { useReactions } from "../useReactions";
|
||||
import { ReactionOption } from "../reactions";
|
||||
|
||||
interface TileProps {
|
||||
className?: string;
|
||||
@@ -91,7 +92,7 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
},
|
||||
[vm],
|
||||
);
|
||||
const { raisedHands } = useReactions();
|
||||
const { raisedHands, reactions } = useReactions();
|
||||
|
||||
const MicIcon = audioEnabled ? MicOnSolidIcon : MicOffSolidIcon;
|
||||
|
||||
@@ -110,6 +111,8 @@ const UserMediaTile = forwardRef<HTMLDivElement, UserMediaTileProps>(
|
||||
);
|
||||
|
||||
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<HTMLDivElement, UserMediaTileProps>(
|
||||
</Menu>
|
||||
}
|
||||
raisedHandTime={handRaised}
|
||||
currentReaction={currentReaction}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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<typeof animated.div> {
|
||||
className?: string;
|
||||
@@ -35,6 +36,7 @@ interface Props extends ComponentProps<typeof animated.div> {
|
||||
displayName: string;
|
||||
primaryButton?: ReactNode;
|
||||
raisedHandTime?: Date;
|
||||
currentReaction?: ReactionOption;
|
||||
}
|
||||
|
||||
export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
@@ -54,6 +56,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
displayName,
|
||||
primaryButton,
|
||||
raisedHandTime,
|
||||
currentReaction,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
@@ -101,7 +104,7 @@ export const MediaView = forwardRef<HTMLDivElement, Props>(
|
||||
<div className={styles.nameTag}>
|
||||
{nameTagLeadingIcon}
|
||||
<Text as="span" size="sm" weight="medium" className={styles.name}>
|
||||
{displayName}
|
||||
{displayName} {currentReaction?.emoji ?? ""}
|
||||
</Text>
|
||||
{unencryptedWarning && (
|
||||
<Tooltip
|
||||
|
||||
@@ -26,11 +26,18 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { useMatrixRTCSessionMemberships } from "./useMatrixRTCSessionMemberships";
|
||||
import { useClientState } from "./ClientContext";
|
||||
import {
|
||||
ECallReactionEventContent,
|
||||
GenericReaction,
|
||||
ReactionOption,
|
||||
ReactionSet,
|
||||
} from "./reactions";
|
||||
|
||||
interface ReactionsContextType {
|
||||
raisedHands: Record<string, Date>;
|
||||
supportsReactions: boolean;
|
||||
myReactionId: string | null;
|
||||
reactions: Record<string, ReactionOption>;
|
||||
}
|
||||
|
||||
const ReactionsContext = createContext<ReactionsContextType | undefined>(
|
||||
@@ -80,6 +87,10 @@ export const ReactionsProvider = ({
|
||||
const room = rtcSession.room;
|
||||
const myUserId = room.client.getUserId();
|
||||
|
||||
const [reactions, setReactions] = useState<Record<string, ReactionOption>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// 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}
|
||||
|
||||
Reference in New Issue
Block a user