コメントのための絵文字反応—反応におけるリアルタイムコメントシステムの構築[第3部/3]


first part このシリーズの中で、我々はsecond one 最後にネストしたコメントを追加しました.この3番目と最後の記事では絵文字の反応を追加します.人々がコメントを書く必要なしであなたの内容と対話することができるので、これは役に立ちそうです.代替案はRedditのような投票システムですが、私はEmojisが我々のコメントに少しの色を加えると思うので、私は彼らのために行くことに決めました.

発表:プロジェクトのこの部分をスタンドアローンライブラリにしました.あなたは今、簡単に、パフォーマンスに影響を与えることなく絵文字の反応を追加することができます!ここでチェックしてください.lepre on npm .

機能


我々は笑顔の顔の束だけですべてを遅くしたくないようにEmojisはリアルタイムを更新し、軽量にする必要があります.私はいろいろなライブラリを試みました、しかし、彼ら全員は重すぎました(我々はメガバイトを話しています)、あるいは、遅くなります.私たちは、それぞれのコメントのための反応を必要とし、ライブラリが速くない場合、我々は非常に簡単にサイトを破ることができます.そのため、もちろんいくつかの制限を受けて自分の絵文字ピッカーを作ることにしました.
  • Emojisの限られた選択(これは偉大なものTBH、私はすぐに説明するつもりです).
  • いいえ肌の色の選択肢は、誰もがシンプソン(再び、偉大な)です
  • それぞれの絵文字がそれ自身のカウンタでレンダリングされて、コメントの近くに表示されるので、これらの制限は実際に役に立ちます、そして、現在3304 Emojisで、それは彼ら全員を描くために不可能です.また、我々はちょうどコンテキストに応じてテーマEmojisを使用するように選択することができます.あなたはあなたのブログ料理を使用したいですか?ちょうどあなたのブログをもっと楽しくするためにいくつかの料理関連Emojisを追加します.

    データスキーマ


    私たちはすでに最初の記事でデータスキーマを作成したので、すぐに構造を説明するつもりです.commentId IDかキー(通常、異なるパラメータです、しかし、我々のケースでは、彼らは同じです)、たとえそれが親か子供であるならば、コメント.reactions は、そのコメントと関連するすべての反応を含む配列です.反応は以下の通りである.
  • emoji , 絵文字そのもの
  • counter たびに絵文字をクリック/選択された
  • label , アクセシビリティのために

  • コンポーネント


    基本的なものから始めて、それぞれの段階で何かを加えるようにしましょう.新しいフォルダを作成するcomponents ものをきちんとしておくもの.私は、単に私のものを呼びましたEmoji .

    絵文字コンポーネント


    アクセシビリティのために正しい属性で絵文字をレンダリングする基本的なコンポーネントrole="img" and aria-label .
    // components/Emoji/Emoji.js
    
    export default function Emoji({ emoji, label, className, onClickCallback }) {
        return (
            <span
                className={
                    className ? className + " emoji" : "emoji"
                }
                role="img"
                aria-label={label ? label : ""}
                aria-hidden={label ? "false" : "true"}
                onClick={onClickCallback}
            >
                {emoji}
            </span>
        );
    }
    
    このコンポーネントは単に絵文字をレンダリングします.小道具emoji and label 我々は、正気から得るでしょう.className オプションの追加クラスです.onClickCallbackonClick イベント.その後、いくつかの基本的なスタイルを行いますので、今回はクラスも定義します.

    カウンター付き絵文字


    それを選択した回数を示すカウンタと絵文字.
    // components/Emoji/EmojiWithCounter.js
    import Emoji from "./Emoji";
    
    export default function EmojiWithCounter({emoji, emojiLabel, initialCounter, onIncrease}) {
        return (
            <span
                className="emoji-container"
                id={emojiLabel}
                onClick={() => onIncrease(emoji)}
            >
                <Emoji emoji={emoji} label={emojiLabel} />
                <div className="emoji-counter-div">
                    <span className="emoji-counter">{initialCounter}</span>
                </div>
            </span>
        );
    }
    
    かなり自己説明、これはその上にカウンタを絵文字をレンダリングします.onIncreaseonClick イベント.
    続ける前に、私はこれらの2つの構成要素の違いを説明する必要があると感じます、なぜならば、私が通わなければならなくて、2つの異なるコールバックを呼ぶ必要があった理由に若干の混乱があるかもしれないのでonClick イベント.
    違いは全く簡単です.あなたが記事の冒頭のスクリーンショットで見たように、そこに“選択されていない”emojisとボックスを選択されたEmojisの行をカウンターにthe demo を返します.だから、我々はEmoji 選択されていないEmojisのコンポーネント.コールバックはデータベース内の新しいオブジェクトを作成し、カウンタを1から開始する.また、それは未選択のボックスから絵文字を削除し、選択したものの行に移動します.EmojiWithCounter 選択したEmojisをレンダリングするコンポーネントです.

    加算器加算器


    このコンポーネントは、選択されていないEmojisの開閉を扱います.我々は、どこでも選択されたものが表示される必要がありますので、すべてのEmojisとのコメントを乱雑にしたくない.また、選択しないEmojisメニューをレンダリングします.
    // components/Emoji/EmojiAdder.js
    
    import Emoji from "./Emoji";
    import { Fragment, useState } from "react";
    import { nanoid } from 'nanoid'
    
    export default function EmojiAdder({selectedEmojis, updateEmojiCount, EMOJI_OPTIONS}) {
        const [isMenuOpen, setIsMenuOpen] = useState(false);
        const toggleMenu = () => setIsMenuOpen(!isMenuOpen);
    
        // We have an array of already selected emojis
        const alreadySelectedEmojis = selectedEmojis.map(e => e.emoji);
    
        // We create an array of Emoji components that are not already selected
        const emojiOptions = EMOJI_OPTIONS.filter(
            e => !alreadySelectedEmojis.includes(e.emoji)
        ).map(singleEmoji => (
            <Emoji
                key={nanoid()}
                emoji={singleEmoji.emoji}
                label={singleEmoji.label}
                onClickCallback={() => {
                    updateEmojiCount(singleEmoji.emoji); // We pass a callback which will add the emoji to the selected ones on click
                    toggleMenu();
                }}
            />
        ));
    
        return (
            <Fragment>
                {emojiOptions.length > 0 && (
                    <span className="reaction-adder-emoji">
                        <Emoji
                            onClickCallback={toggleMenu}
                            emoji={"+"}
                            label="emoji-adder"
                        />
                        <EmojiMenu />
                    </span>
                )}
            </Fragment>
        );
    
        function EmojiMenu() {
            return (
                <div
                    className={
                        isMenuOpen
                            ? "emoji-adder-menu-open"
                            : "emoji-adder-menu-closed"
                    }
                >
                    {emojiOptions}
                </div>
            );
        }
    }
    
    我々は今、一緒にこれらのコンポーネントのすべてをステッチする必要がありますが、我々はそれを行う前に何か他の必要があります.

    絵文字文脈


    useContext グローバルな状態のような何かを提供することができる反応フックです.それを説明することは、この記事の範囲外です、あなたがより多くを知っているならば、反応ドキュメンテーションは始まる良い場所です.
    我々はすべての反応をすべてのコメントに追加保持するコンテキストを作成するつもりです.私は、健全性バックエンドへの呼び出しを減らすためにこれをすることに決めました.
    それでは、オープンしましょうcomponents/Comments/AllComments.js ファイル.
    import { useState, useEffect, createContext } from "react";
    [...]
    
    const ReactionsContext = createContext(undefined);
    
    export default function AllComments() {
        const [reactions, setReactions] = useState();
        [...]
    
        useEffect(async () => {
            [...]
    
            client
                .fetch(`*[_type == "commentReactions"]`)
                .then(r => setReactions(r));
        }
    
        [...]
    
        return (
            <ReactionsContext.Provider value={reactions}>
                <ul>{commentList}</ul>
            </ReactionsContext.Provider>
        );
    }
    
    これらの追加で我々は現在アクセスすることができますReactionsContextreactions 我々のアプリケーションの至る所から.
    このファイルの完全なコードはthe repo .

    絵文字選択


    この記事の冒頭で述べたように、利用可能なEmojisを定義する必要があります.
    必要に応じて、あなたの反応で使用するEmojisの配列を保持するファイルを作成します.
    私はlib フォルダと内部emojiConfig.js ファイル.
    const DEFAULT_EMOJI_OPTIONS = [
        {
            emoji: "😄",
            label: "happy",
        },
        {
            emoji: "📚",
            label: "books",
        },
        {
            emoji: "😟",
            label: "suprised",
        },
        {
            emoji: "🐱",
            label: "cat",
        },
        {
            emoji: "🐼",
            label: "panda",
        },
    ];
    
    export { DEFAULT_EMOJI_OPTIONS };
    
    今、我々は戻って、我々の反応ブロックを終えることができます.

    完全反応ブロック


    時間はすべてをアセンブルする!
    まず、必要なすべてをインポートし、後で必要なグローバル変数を作成します.
    import EmojiWithCounter from "./EmojiWithCounter";
    import EmojiAdder from "./EmojiAdder";
    import { ReactionsContext } from "../Comments/AllComments";
    import { DEFAULT_EMOJI_OPTIONS } from "../../lib/emojiConfig";
    import {nanoid} from "nanoid";
    import { useState, useEffect, useContext } from "react";
    import { client } from "../../lib/sanityClient";
    
    let dbDebouncerTimer;
    let querySub;
    
    今状態を準備します.
    export default function ReactionBlock({ commentId }) {
        // We get the initial reactions we previously fetched from the Context
        // and filter them so we only have the ones for this comment.
        // Also, I wanted to sort them by their amount.
        const contextReactions = useContext(ReactionsContext)
            ?.filter(r => r.commentId === commentId)
            .map(r => r.reactions)
            ?.sort((a, b) => (a.counter < b.counter ? 1 : -1))[0];
        const [reactions, setReactions] = useState([]);
        const [shouldUpdateDb, setShouldUpdateDb] = useState(false);
    
    今、我々はuseEffect フックは、クエリを購読し、リアルタイムの更新を取得します.
    useEffect(() => {
        // If there are reactions in the context, set them
        if (contextReactions) setReactions(contextReactions);
    
        // Subscribe to the query Observable and update the state on each update
        const query = `*[_type == "commentReactions" && commentId=="${commentId}"]`;
        querySub = client.listen(query).subscribe(update => {
            if (update) {
                setReactions([
                    ...update.result.reactions.sort((a, b) =>
                        a.counter < b.counter ? 1 : -1
                    ),
                ]);
            }
        });
    
        // Unsubscribe on Component unmount
        return () => {
            querySub.unsubscribe();
        };
    }, []);
    
    今、我々は絵文字をクリックするたびにデータベースを更新する機能が必要です.
    const updateEmojiCount = emoji => {
        setShouldUpdateDb(false);
        let emojiFromState = reactions.filter(em => em.emoji === emoji)[0];
        // If the selected emoji wasn't in the state, it's a new one
        if (!emojiFromState) {
            emojiFromState = DEFAULT_EMOJI_OPTIONS.filter(
                em => em.emoji === emoji
            )[0];
            emojiFromState.counter = 1;
            setReactions(reactions =>
                [...reactions, emojiFromState].sort((a, b) =>
                    a.counter < b.counter ? 1 : -1
                )
            );
        } else {
            emojiFromState.counter++;
            setReactions(reactions =>
                [
                    ...reactions.filter(
                        rea => rea.emoji !== emojiFromState.emoji
                    ),
                    emojiFromState,
                ].sort((a, b) => (a.counter < b.counter ? 1 : -1))
            );
        }
        setShouldUpdateDb(true);
    };
    
    この関数はshouldUpdateDb 状態と我々は別の関数を呼び出すためにその変更を聞くことができます.
    useEffect(() => {
        if (shouldUpdateDb) updateReactionsOnDatabase();
        setShouldUpdateDb(false);
    }, [shouldUpdateDb]);
    
    function updateReactionsOnDatabase() {
        clearTimeout(dbDebouncerTimer);
        dbDebouncerTimer = setTimeout(() => {
            fetch("/api/addReaction", {
                method: "POST",
                body: JSON.stringify({
                    commentId: commentId,
                    reactions: reactions,
                }),
            });
            dbDebouncerTimer = null;
        }, 1000 * 1);
    }
    
    すべては、データベースの更新を議論するために必要です.私たちの反応ブロックは、データベースの更新を実行しない10のクリックを意味する最後のクリック後にデータベースを1秒後に更新されます.
    最後に、我々は反応をマップし、すべてをレンダリングします.
    const mappedReactions = reactions.map(reaction => (
        <EmojiWithCounter
            key={nanoid()}
            emoji={reaction.emoji}
            emojiLabel={reaction}
            initialCounter={reaction.counter}
            onIncrease={updateEmojiCount}
        />
    ));
    
    return (
        <div className="reaction-block">
            {mappedReactions}
            <EmojiAdder
                selectedEmojis={reactions}
                updateEmojiCount={updateEmojiCount}
                EMOJI_OPTIONS={DEFAULT_EMOJI_OPTIONS}
            />
        </div>
    );
    
    完全なコード(同じ順序ではない)は以下の通りです.
    import EmojiWithCounter from "./EmojiWithCounter";
    import {nanoid} from "nanoid";
    import EmojiAdder from "./EmojiAdder";
    import { useState, useEffect, useContext } from "react";
    import { ReactionsContext } from "../Comments/AllComments";
    import { client } from "../../lib/sanityClient";
    import { DEFAULT_EMOJI_OPTIONS } from "../../lib/emojiConfig";
    
    let dbDebouncerTimer;
    export default function ReactionBlock({ commentId }) {
        // We get the initial reactions we previously fetched from the Context
        const contextReactions = useContext(ReactionsContext)
            ?.filter(r => r.commentId === commentId)
            .map(r => r.reactions)
            ?.sort((a, b) => (a.counter < b.counter ? 1 : -1))[0];
        const [reactions, setReactions] = useState([]);
        const [shouldUpdateDb, setShouldUpdateDb] = useState(false);
    
        let querySub = undefined;
    
        useEffect(() => {
            // If there are reactions in the context, set them
            if (contextReactions) setReactions(contextReactions);
    
            // Subscribe to the query Observable and update the state on each update
            const query = `*[_type == "commentReactions" && commentId=="${commentId}"]`;
            querySub = client.listen(query).subscribe(update => {
                if (update) {
                    setReactions([
                        ...update.result.reactions.sort((a, b) =>
                            a.counter < b.counter ? 1 : -1
                        ),
                    ]);
                }
            });
    
            // Unsubscribe on Component unmount
            return () => {
                querySub.unsubscribe();
            };
        }, []);
    
        useEffect(() => {
            if (shouldUpdateDb) updateReactionsOnDatabase();
            setShouldUpdateDb(false);
        }, [shouldUpdateDb]);
    
        // Onclick, update the emoji counter and start a timer to update the database
        const updateEmojiCount = emoji => {
            setShouldUpdateDb(false);
            let emojiFromState = reactions.filter(em => em.emoji === emoji)[0];
            if (!emojiFromState) {
                emojiFromState = DEFAULT_EMOJI_OPTIONS.filter(
                    em => em.emoji === emoji
                )[0];
                emojiFromState.counter = 1;
                setReactions(reactions =>
                    [...reactions, emojiFromState].sort((a, b) =>
                        a.counter < b.counter ? 1 : -1
                    )
                );
            } else {
                emojiFromState.counter++;
                setReactions(reactions =>
                    [
                        ...reactions.filter(
                            rea => rea.emoji !== emojiFromState.emoji
                        ),
                        emojiFromState,
                    ].sort((a, b) => (a.counter < b.counter ? 1 : -1))
                );
            }
            setShouldUpdateDb(true);
        };
    
        // Debouncer to avoid updating the database on every click
        function updateReactionsOnDatabase() {
            clearTimeout(dbDebouncerTimer);
            dbDebouncerTimer = setTimeout(() => {
                fetch("/api/addReaction", {
                    method: "POST",
                    body: JSON.stringify({
                        commentId: commentId,
                        reactions: reactions,
                    }),
                });
                dbDebouncerTimer = null;
            }, 1000 * 1);
        }
    
        const mappedReactions = reactions.map(reaction => (
            <EmojiWithCounter
                key={nanoid()}
                emoji={reaction.emoji}
                emojiLabel={reaction}
                initialCounter={reaction.counter}
                onIncrease={updateEmojiCount}
            />
        ));
    
        return (
            <div className="reaction-block">
                {mappedReactions}
                <EmojiAdder
                    selectedEmojis={reactions}
                    updateEmojiCount={updateEmojiCount}
                    EMOJI_OPTIONS={DEFAULT_EMOJI_OPTIONS}
                />
            </div>
        );
    }
    

    バックエンド


    最後になりますが、少なくとも、データベースを更新するためのServerless関数が必要です.これはコメント作成機能より簡単です.
    // pages/api/addReaction.js
    
    import { writeClient } from "../../lib/sanityClient";
    
    export default (req, res) => {
        return new Promise(resolve => {
            const body = JSON.parse(req.body);
            const _id = body.commentId;
            const reactions = body.reactions;
            reactions.forEach(r => (r._key = r.label));
    
            const query = `*[_type == "commentReactions" && commentId == "${_id}"]{_id}[0]`;
            writeClient.fetch(query).then(comment => {
                if (comment) {
                    writeClient
                        .patch(comment._id)
                        .set({ reactions: reactions })
                        .commit()
                        .then(() => {
                            resolve(res.status(200).end());
                        });
                } else {
                    writeClient
                        .create({
                            _type: "commentReactions",
                            commentId: _id,
                            reactions: reactions,
                        })
                        .then(() => {
                            resolve(res.status(200).end());
                        });
                }
            });
        });
    };
    

    スタイリング


    約束通り、いくつかの基本的なスタイルがあります.
    .emoji {
        margin: 10px;
        font-size: 25px;
        display: flex;
        align-items: center;
        cursor: pointer;
        vertical-align: middle;
        transform: translateZ(0);
        box-shadow: 0 0 1px rgba(0, 0, 0, 0);
        backface-visibility: hidden;
        -moz-osx-font-smoothing: grayscale;
        transition-duration: 0.1s;
        transition-property: transform;
    }
    
    .reaction-div {
        margin-top: 5px;
        display: inline-flex;
        flex-flow: wrap;
    }
    
    .emoji-container {
        position: relative;
        user-select: none;
        display: flex;
    }
    
    .emoji-counter-div {
        position: absolute;
        top: -2px;
        right: 3px;
        z-index: -5;
    }
    
    .emoji-counter {
        font-weight: bold;
        padding: 2px 5px;
        border-radius: 30%;
        background-color: #f55742;
        color: #fefefe;
    }
    .emoji:hover,
    emoji:focus,
    emoji:active {
        transform: scale(1.1);
    }
    
    .comment-info {
        margin: auto 0px;
    }
    
    .comment-info-container {
        height: 40px;
        display: flex;
    }
    
    .reaction-block {
        display: inline-flex;
        flex-flow: wrap;
    }
    
    .reaction-adder-emoji {
        user-select: none;
        position: relative;
        display: inline-block;
    }
    
    .emoji-adder-menu-open {
        position: absolute;
        display: flex;
        top: 0px;
        left: 35px;
        border-radius: 10px;
        box-shadow: rgba(0, 0, 0, 0.24) 0px 3px 8px;
        background-color: #fefefe;
        flex-wrap: wrap;
        z-index: 10;
        width: 400%;
    }
    
    .emoji-adder-menu-closed {
        display: none;
    }
    

    結論


    このシリーズは現在完了です.私はそれが誰かにとって有用であることを望みます、そして、すべてはそうでした-大部分-明確な.
    あなたが疑問を持っているならば、あなたはここでコメントすることができるか、私のソーシャルメディアで私に手紙を書くことができます.
    フルレポGitHub .
    デモhere .
    フルシリーズ:
  • 1/3Building a Real-Time Commenting System in React
  • 2/3Making Nested Comments
  • 3/3Emoji Reactions for Comments