mirror of
https://github.com/vector-im/element-call.git
synced 2026-03-31 07:00:26 +00:00
323 lines
9.1 KiB
TypeScript
323 lines
9.1 KiB
TypeScript
/*
|
||
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,
|
||
Separator,
|
||
Search,
|
||
Form,
|
||
} from "@vector-im/compound-web";
|
||
import {
|
||
SearchIcon,
|
||
CloseIcon,
|
||
RaisedHandSolidIcon,
|
||
} from "@vector-im/compound-design-tokens/assets/web/icons";
|
||
import {
|
||
ChangeEventHandler,
|
||
ComponentPropsWithoutRef,
|
||
FC,
|
||
FormEventHandler,
|
||
KeyboardEvent,
|
||
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";
|
||
|
||
interface InnerButtonProps extends ComponentPropsWithoutRef<"button"> {
|
||
raised: boolean;
|
||
}
|
||
|
||
const InnerButton: FC<InnerButtonProps> = ({ raised, ...props }) => {
|
||
const { t } = useTranslation();
|
||
|
||
return (
|
||
<Tooltip label={t("action.raise_hand_or_send_reaction")}>
|
||
<CpdButton
|
||
className={classNames(raised && styles.raisedButton)}
|
||
aria-expanded={raised}
|
||
kind={raised ? "primary" : "secondary"}
|
||
iconOnly
|
||
Icon={RaisedHandSolidIcon}
|
||
{...props}
|
||
/>
|
||
</Tooltip>
|
||
);
|
||
};
|
||
|
||
export function ReactionPopupMenu({
|
||
sendReaction,
|
||
toggleRaisedHand,
|
||
isHandRaised,
|
||
canReact,
|
||
}: {
|
||
sendReaction: (reaction: ReactionOption) => void;
|
||
toggleRaisedHand: () => void;
|
||
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],
|
||
);
|
||
|
||
return (
|
||
<div className={styles.reactionPopupMenu}>
|
||
<section className={styles.handRaiseSection}>
|
||
<Tooltip label={t("common.raise_hand")}>
|
||
<CpdButton
|
||
kind={isHandRaised ? "primary" : "secondary"}
|
||
aria-pressed={isHandRaised}
|
||
aria-label="Toggle hand raised"
|
||
className={styles.reactionButton}
|
||
key="raise-hand"
|
||
onClick={() => toggleRaisedHand()}
|
||
>
|
||
🖐️
|
||
</CpdButton>
|
||
</Tooltip>
|
||
</section>
|
||
<div className={styles.verticalSeperator} />
|
||
<section>
|
||
{isSearching ? (
|
||
<>
|
||
<Form.Root className={styles.searchForm}>
|
||
<Search
|
||
required
|
||
value={searchText}
|
||
name="reactionSearch"
|
||
placeholder="Search reactions…"
|
||
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="close search"
|
||
size="sm"
|
||
kind="destructive"
|
||
onClick={() => setIsSearching(false)}
|
||
/>
|
||
</Form.Root>
|
||
<Separator />
|
||
</>
|
||
) : null}
|
||
<menu>
|
||
{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>
|
||
))}
|
||
{!isSearching ? (
|
||
<li key="search" className={styles.reactionPopupMenuItem}>
|
||
<Tooltip label="Search">
|
||
<CpdButton
|
||
iconOnly
|
||
aria-label="Open reactions search"
|
||
Icon={SearchIcon}
|
||
kind="tertiary"
|
||
onClick={() => setIsSearching(true)}
|
||
/>
|
||
</Tooltip>
|
||
</li>
|
||
) : null}
|
||
</menu>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface ReactionToggleButtonProps {
|
||
rtcSession: MatrixRTCSession;
|
||
client: MatrixClient;
|
||
}
|
||
|
||
export function ReactionToggleButton({
|
||
client,
|
||
rtcSession,
|
||
}: ReactionToggleButtonProps): ReactNode {
|
||
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(false);
|
||
|
||
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);
|
||
await client.sendEvent(
|
||
rtcSession.room.roomId,
|
||
ElementCallReactionEventType,
|
||
{
|
||
"m.relates_to": {
|
||
rel_type: RelationType.Reference,
|
||
event_id: parentEventId,
|
||
},
|
||
emoji: reaction.emoji,
|
||
name: reaction.name,
|
||
},
|
||
);
|
||
// Do NOT close the menu after this.
|
||
} catch (ex) {
|
||
logger.error("Failed to send reaction", ex);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
},
|
||
[memberships, client, userId, rtcSession],
|
||
);
|
||
|
||
const toggleRaisedHand = useCallback(() => {
|
||
const raiseHand = async (): Promise<void> => {
|
||
if (isHandRaised) {
|
||
if (!myReactionId) {
|
||
logger.warn(`Hand raised but no reaction event to redact!`);
|
||
return;
|
||
}
|
||
try {
|
||
setBusy(true);
|
||
await client.redactEvent(rtcSession.room.roomId, myReactionId);
|
||
logger.debug("Redacted raise hand event");
|
||
} catch (ex) {
|
||
logger.error("Failed to redact reaction event", myReactionId, ex);
|
||
} 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);
|
||
setShowReactionsMenu(false);
|
||
}
|
||
}
|
||
};
|
||
|
||
void raiseHand();
|
||
}, [
|
||
client,
|
||
isHandRaised,
|
||
memberships,
|
||
myReactionId,
|
||
rtcSession.room.roomId,
|
||
userId,
|
||
]);
|
||
|
||
return (
|
||
<>
|
||
<InnerButton
|
||
disabled={busy}
|
||
onClick={() => setShowReactionsMenu((show) => !show)}
|
||
raised={isHandRaised || showReactionsMenu}
|
||
/>
|
||
{showReactionsMenu && (
|
||
<ReactionPopupMenu
|
||
isHandRaised={isHandRaised}
|
||
canReact={canReact}
|
||
sendReaction={(reaction) => void sendRelation(reaction)}
|
||
toggleRaisedHand={toggleRaisedHand}
|
||
/>
|
||
)}
|
||
</>
|
||
);
|
||
}
|