Skip to content

Commit

Permalink
key reactions by firestore id + icons use friendly names
Browse files Browse the repository at this point in the history
  • Loading branch information
codyzu committed Jul 18, 2023
1 parent 8be52ba commit b6ac0f4
Show file tree
Hide file tree
Showing 15 changed files with 232 additions and 230 deletions.
4 changes: 2 additions & 2 deletions firestore.rules
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ service cloud.firestore {
}
match /sessions/{sessionId}/reactions/{reactionId} {
allow create: if
request.resource.data.keys().hasAll(['reaction', 'ttl'])
&& request.resource.data.keys().hasOnly(['reaction', 'ttl']);
request.resource.data.keys().hasAll(['reaction', 'ttl', 'created'])
&& request.resource.data.keys().hasOnly(['reaction', 'ttl', 'created']);
allow read: if true;
}
}
Expand Down
68 changes: 31 additions & 37 deletions src/components/broadcast/use-broadcast-firestore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import {useCallback, useEffect, useState} from 'react';
import {onSnapshot, collection, addDoc, setDoc, doc} from 'firebase/firestore';
import {
onSnapshot,
collection,
addDoc,
setDoc,
doc,
serverTimestamp,
} from 'firebase/firestore';
import {firestore as db} from '../../firebase';
import {type ReactionData} from '../reactions/reaction';
import {
type IncomingReactionData,
type ReactionData,
} from '../reactions/reaction';
import {type SessionData} from '../slides/sessions';
import {type Payload, type Handler} from './use-channel-handlers';

Expand All @@ -28,16 +38,12 @@ export default function useBroadcastFirebase({
const ttl = new Date(Date.now() + 60 * 60 * 1000);

if (payload.id === 'reaction') {
// Ignore the id when posting, it will get generated
const [, reaction] = payload.reaction;
return addDoc(collection(db, 'sessions', sessionId, 'reactions'), {
reaction: payload.icon,
ttl,
} satisfies ReactionData);
}

if (payload.id === 'confetti') {
return addDoc(collection(db, 'sessions', sessionId, 'reactions'), {
reaction: 'confetti',
reaction,
ttl,
created: serverTimestamp(),
} satisfies ReactionData);
}

Expand All @@ -47,10 +53,6 @@ export default function useBroadcastFirebase({
ttl,
} satisfies SessionData);
}

// Note: we don't handle the 'confetti reset' action as it always happens on the broadcast channel

console.error(new Error(`bad post payload: ${payload.id}`));
},
[sessionId],
);
Expand All @@ -64,10 +66,6 @@ export default function useBroadcastFirebase({

let mounted = true;

console.log('(re)creating channel');

let firstSnapshot = true;

const unsubscribeReactions = onSnapshot(
collection(db, 'sessions', sessionId, 'reactions'),
(next) => {
Expand All @@ -78,35 +76,31 @@ export default function useBroadcastFirebase({

setConnected(true);

if (firstSnapshot) {
// Ignore first snapshot
console.log('first snapshot, skipping');
firstSnapshot = false;
return;
}

if (!onIncoming) {
return;
}

for (const change of next.docChanges()) {
console.log('change', change);
if (change.type === 'added') {
const data = change.doc.data();
const now = Date.now();

// Special case for confetti
// Note: we don't handle the 'confetti reset' action as it always happens on the broadcast channel
for (const change of next.docChanges()) {
if (change.type !== 'added') {
continue;
}

if (data.reaction === 'confetti') {
onIncoming({id: 'confetti'});
continue;
}
const reactionData = change.doc.data({
serverTimestamps: 'estimate',
}) as IncomingReactionData;

onIncoming({id: 'reaction', icon: data.reaction as string});
console.log('data', reactionData);
// Ignore reactions older than 30 seconds
if (now - reactionData.created.toMillis() > 30_000) {
continue;
}

console.log('unexpected change', change);
onIncoming({
id: 'reaction',
reaction: [change.doc.id, reactionData.reaction],
});
}
},
(error) => {
Expand Down
29 changes: 10 additions & 19 deletions src/components/broadcast/use-channel-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {useCallback, useEffect, useState} from 'react';
import {type ReactionEntry} from '../reactions/reaction';

export type Payload =
| {
Expand All @@ -8,28 +9,16 @@ export type Payload =
}
| {
id: 'reaction';
index?: never;
icon: string;
}
| {
id: 'confetti' | 'confetti reset';
index?: never;
icon?: never;
reaction: ReactionEntry;
};
export type Handler = (payload: Payload) => void;
export type HandlerEntry = [
'slide index' | 'reaction' | 'confetti' | 'confetti reset',
Handler,
];
export type HandlerEntries = HandlerEntry[];

export function useChannelHandlers() {
const [handlers, setHandlers] = useState<Map<string, Handler>>(new Map());
const [handlers, setHandlers] = useState<Handler[]>([]);

const handleIncomingBroadcast = useCallback(
(payload: Payload) => {
const handler = handlers.get(payload.id);
if (handler) {
for (const handler of handlers) {
handler(payload);
}
},
Expand All @@ -40,16 +29,18 @@ export function useChannelHandlers() {
}

export function useCombinedHandlers(
setHandlers: (handlers: Map<string, Handler>) => void,
...handlerEntries: HandlerEntries[]
setHandlers: React.Dispatch<React.SetStateAction<Handler[]>>,
...handlers: Handler[][]
) {
useEffect(
() => {
setHandlers(new Map(handlerEntries.flat()));
setHandlers(handlers.flat());
},

// TODO: since we call flat, could we stop using memo when we define the handlers? I think yes.
// We explicitly spread the handlers entries
// Ignore the linter error
// eslint-disable-next-line react-hooks/exhaustive-deps
[...handlerEntries, setHandlers],
[...handlers.flat(), setHandlers],
);
}
34 changes: 16 additions & 18 deletions src/components/confetti/use-confetti.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import {useCallback, useMemo} from 'react';
import {
type HandlerEntries,
type Handler,
} from '../broadcast/use-channel-handlers';
import {type Handler, type Payload} from '../broadcast/use-channel-handlers';

export default function useConfetti({
postMessage,
Expand All @@ -15,30 +12,31 @@ export default function useConfetti({
}): {
postConfetti: () => void;
postConfettiReset: () => void;
handlers: HandlerEntries;
handlers: Handler[];
} {
const postConfetti = useCallback(() => {
postMessage?.({id: 'confetti'});
postMessage?.({id: 'reaction', reaction: ['', 'confetti']});
}, [postMessage]);

const postConfettiReset = useCallback(() => {
postMessage?.({id: 'confetti reset'});
postMessage?.({id: 'reaction', reaction: ['', 'confetti clear']});
}, [postMessage]);

const handlers = useMemo<HandlerEntries>(
const handlers = useMemo<Handler[]>(
() => [
[
'confetti',
() => {
(payload: Payload) => {
if (payload.id === 'reaction' && payload.reaction[1] === 'confetti') {
onConfetti?.({});
},
],
[
'confetti reset',
() => {
}
},
(payload: Payload) => {
if (
payload.id === 'reaction' &&
payload.reaction[1] === 'confetti clear'
) {
onReset?.({});
},
],
}
},
],
[onConfetti, onReset],
);
Expand Down
58 changes: 17 additions & 41 deletions src/components/reactions/ReactionControls.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import clsx from 'clsx';
import Button from '../toolbar/Button';
import {type IconReaction} from './reaction';
import reactionsIconMap from './reaction-icons-map';

export default function ReactionControls({
handleConfetti,
handleReaction,
}: {
handleConfetti: () => void;
handleReaction: (icon: string) => void;
handleReaction: (reaction: IconReaction) => void;
}) {
return (
<div className="max-w-lg mx-auto grid grid-cols-[4rem_4rem_4rem_4rem] gap-4 grid-rows-[4rem_4rem]">
Expand All @@ -19,46 +22,19 @@ export default function ReactionControls({
handleConfetti();
}}
/>
<Button
border
label="love"
title="React with love"
icon="i-fluent-emoji-flat-red-heart h-8 w-8"
className="relative"
onClick={() => {
handleReaction('i-fluent-emoji-flat-red-heart');
}}
/>
<Button
border
label="smile"
title="React with a smile"
icon="i-fluent-emoji-flat-smiling-face h-8 w-8"
className="relative"
onClick={() => {
handleReaction('i-fluent-emoji-flat-smiling-face');
}}
/>
<Button
border
label="clap"
title="React with clapping hands"
icon="i-fluent-emoji-flat-clapping-hands h-8 w-8"
className="relative"
onClick={() => {
handleReaction('i-fluent-emoji-flat-clapping-hands');
}}
/>
<Button
border
label="explode"
title="React with a exploding brain"
icon="i-fluent-emoji-flat-exploding-head h-8 w-8"
className="relative"
onClick={() => {
handleReaction('i-fluent-emoji-flat-exploding-head');
}}
/>
{Array.from(reactionsIconMap.entries()).map(([label, {icon, title}]) => (
<Button
key={label}
border
label={label}
title={title}
icon={clsx(icon, 'h-8 w-8')}
className="relative"
onClick={() => {
handleReaction(label);
}}
/>
))}
</div>
);
}
34 changes: 16 additions & 18 deletions src/components/reactions/Reactions.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,22 @@
import clsx from 'clsx';
import {useEffect, useMemo, useRef} from 'react';
import {type Reaction as ReactionType} from './reaction';
import {
type IconReaction,
type IconReactionEntry,
type IconReactionMap,
} from './reaction';
import reactionsIconMap from './reaction-icons-map';

// This file is inspired from these 2 articles:
// https://eng.butter.us/awesome-floating-emoji-reactions-using-framer-motion-styled-components-and-lottie-36b9f479a9f9
// https://www.daily.co/blog/add-flying-emoji-reactions-to-a-custom-daily-video-call/

const reactionLabels: Record<string, string> = {
'i-fluent-emoji-flat-red-heart': 'love',
'i-fluent-emoji-flat-smiling-face': 'smile',
'i-fluent-emoji-flat-clapping-hands': 'applause',
'i-fluent-emoji-flat-exploding-head': 'mind blown',
};

function Reaction({
onReactionDone,
icon = 'i-fluent-emoji-flat-red-heart',
reaction,
}: {
onReactionDone: () => void;
icon?: string;
reaction: IconReaction;
}) {
// Keep a ref to the reaction div so we can add the random style vars
const reactionRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -60,12 +58,12 @@ function Reaction({
return (
<div
ref={reactionRef}
aria-label={reactionLabels[icon] ?? 'unknown'}
aria-label={reaction}
role="figure"
style={{left: `var(--reaction-x-offset)`}}
className={clsx(
'animate-emoji absolute bottom--100px bottom--12 leading-none text-size-12 overflow-visible',
icon,
reactionsIconMap.get(reaction)?.icon,
)}
/>
);
Expand All @@ -75,18 +73,18 @@ export default function Reactions({
reactions,
removeReaction,
}: {
reactions: ReactionType[];
removeReaction: (reaction: ReactionType) => void;
reactions: IconReactionMap;
removeReaction: (reaction: IconReactionEntry) => void;
}) {
return (
<div className="fixed top-0 left-0 h-screen w-screen pointer-events-none">
<div className="relative left-[calc(3rem_+_20px)] h-full w-[calc(calc(100vw_-_6rem)_-_40px)] max-h-screen">
{reactions.map((reaction) => (
{Array.from(reactions.entries()).map(([id, reaction]) => (
<Reaction
key={reaction.id}
icon={reaction.icon}
key={id}
reaction={reaction}
onReactionDone={() => {
removeReaction(reaction);
removeReaction([id, reaction]);
}}
/>
))}
Expand Down
Loading

0 comments on commit b6ac0f4

Please sign in to comment.