First PoC for reactions

This commit is contained in:
Half-Shot
2024-11-01 14:11:36 +00:00
parent f54e1e2046
commit 373a12a3b5
10 changed files with 339 additions and 12 deletions

View 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);
}

View File

@@ -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
View 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"],
},
];

View File

@@ -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

Binary file not shown.

BIN
src/sound/reactions/dog.ogg Normal file

Binary file not shown.

View File

@@ -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}
/>
);

View File

@@ -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

View File

@@ -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}