paint-brush
ChatPlus の構築: モバイル アプリのようなオープンソース PWA@aladinyo
896 測定値
896 測定値

ChatPlus の構築: モバイル アプリのようなオープンソース PWA

Alaa Eddine Boubekeur29m2024/07/10
Read on Terminal Reader

長すぎる; 読むには

ChatPlus は私が作成した最も優れたアプリケーションの 1 つで、鮮やかなユーザー インターフェイスと、すべて Web 上に実装された複数のメッセージング機能および通話機能で注目を集めています。フロントエンドは、どこにでもインストールでき、スタンドアロン アプリのように動作する PWA アプリケーションとして設計されているため、クロス プラットフォーム エクスペリエンスを提供します。プッシュ通知などの機能により、ChatPlus は Web テクノロジーでモバイル アプリの完全な機能を提供できる軽量アプリの定義です。
featured image - ChatPlus の構築: モバイル アプリのようなオープンソース PWA
Alaa Eddine Boubekeur HackerNoon profile picture
0-item
1-item

ChatPlus はチャットに最適な PWA です 💬✨🤩

ChatPlus は、React、NodeJS、Firebase などのサービスを使用して開発されたプログレッシブ ウェブ アプリです。

友達全員とリアルタイムで会話できます🗣️✨🧑‍🤝‍🧑❤️

友達に電話してビデオ通話や音声通話ができます🎥🔉🤩

友達に画像や音声メッセージを送信すると、フランス語、英語、スペイン語のいずれを話す場合でも、AI があなたのスピーチをテキストに変換します 🤖✨

ウェブアプリはどのデバイスにもインストールでき、通知を受け取ることができます⬇️🔔🎉

皆さんのサポートを心から感謝します。Github リポジトリにスターを付けて、友達と共有してください ⭐✨

完全なインストールとデプロイメントのドキュメントについては、この Github リポジトリをご覧ください: https://github.com/aladinyo/ChatPlus

シンプルでクリーンなUIとUX

ChatPlus モバイルビュー


ChatPlusデスクトップビュー


ではChatPlusとは何でしょうか?

ChatPlus は私が作成した最も優れたアプリケーションの 1 つで、鮮やかなユーザー インターフェイスと、すべて Web 上に実装された複数のメッセージング機能および通話機能で注目を集めています。フロントエンドは、どこにでもインストールでき、スタンドアロン アプリのように動作する PWA アプリケーションとして設計されているため、クロス プラットフォーム エクスペリエンスを提供します。プッシュ通知などの機能により、ChatPlus は Web テクノロジーでモバイル アプリの完全な機能を提供できる軽量アプリの定義です。

ソフトウェアアーキテクチャ

当社のアプリは MVC ソフトウェア アーキテクチャに準拠しています。MVC (モデル ビュー コントローラ) は、ユーザー インターフェイス、データ、および制御ロジックを実装するために一般的に使用されるソフトウェア設計のパターンです。ソフトウェアのビジネス ロジックと表示の分離を重視しています。この「関心の分離」により、作業の分担が改善され、メンテナンスが改善されます。

ユーザーインターフェース層(ビュー)

これは複数の React インターフェース コンポーネントで構成されています。

  • MaterialUI: Material UI は、Google のマテリアル デザインを実装するオープンソースの React コンポーネント ライブラリです。包括的で、すぐに本番環境で使用できます。アイコン、ボタン、オーディオ スライダーなどのインターフェースの一部の要素に使用します。

  • LoginView:ユーザーがユーザー名とパスワードを入力したり、Google でログインしたりできるシンプルなログイン ビューです。

  • SidebarViews : 「sidebar__header」、「sidebar__search」、「sidebar menu」、「SidebarChats」などの複数のビュー コンポーネント。ユーザー情報のヘッダー、ユーザーを検索するための検索バー、チャット、グループ、ユーザー間を移動するためのサイドバー メニュー、最近メッセージを送信したすべてのチャットを表示する SidebarChats コンポーネントで構成され、ユーザー名と写真、最後のメッセージが表示されます。

  • ChatViews:次のような多くのコンポーネントで構成されています。

    1.'chat__header ' には、会話しているユーザーの情報、オンライン ステータス、プロフィール写真が含まれており、音声通話とビデオ通話のボタンが表示され、ユーザーが入力中かどうかも表示されます。

    2.'chat__body --container' には、他のユーザーとのメッセージの情報が含まれています。このコンテナには、テキスト メッセージ、メッセージを含む画像、音声時間や音声が再生されたかどうかなどの情報を含む音声メッセージを表示するメッセージ コンポーネントがあり、このコンポーネントの最後には、メッセージが表示されたかどうかを表示する 'seen' 要素があります。

    3.「 AudioPlayer」: スライダーでオーディオを表示し、オーディオの全時間と現在の時間を表示できる React コンポーネントです。このビュー コンポーネントは、「chat__body-- container」内に読み込まれます。

    4. 「ChatFooter」: メッセージを入力するための入力、入力時にメッセージを送信するためのボタン、それ以外の場合は音声を録音するためのボタン、画像やファイルをインポートするためのボタンが含まれています。

    5. 「MediaPreview」: チャットで送信するために選択した画像やファイルをプレビューできるReactコンポーネント。カルーセルに表示され、ユーザーは画像やファイルをスライドして、それぞれに特定のメッセージを入力できます。

    6.「 ImagePreview」: チャットで画像が送信されると、このコンポーネントはスムーズなアニメーションで画像を全画面に表示します。コンポーネントは画像をクリックするとマウントされます。

  • scalePage:フル HD 画面や 4K 画面などの大画面に表示されるときに Web アプリのサイズを拡大する表示関数。

  • CallViews:すべての呼び出しビュー要素を含む一連の React コンポーネント。画面全体にドラッグすることができ、次のものから構成されます。

    1 つの「ボタン」: 赤色バージョンの通話ボタンと緑色のビデオ通話ボタン。

    2 「AudioCallView」: 着信音声通話に応答し、タイマー付きで通話を表示し、通話をキャンセルできるビュー コンポーネント。

    3 「StartVideoCallView」: ローカル MediaAPI に接続して自分のビデオを表示し、他のユーザーが通話を受け入れるのを待つか、着信ビデオ通話に応答するためのボタンを表示するビュー コンポーネントです。

    4 「VideoCallView」: 自分と他のユーザーのビデオを表示するビュー コンポーネント。カメラを切り替えたり、カメラとオーディオを無効にしたり、全画面表示にしたりできます。

  • RouteViews:ローカル コンポーネント ナビゲーションを作成するためのすべてのビューを含む React コンポーネント。「VideoCallRoute」、「SideBarMenuRoute」、「ChatsRoute」があります。

クライアント側モデル (モデル)

クライアント側モデルは、フロントエンドがデータベースや複数のローカルおよびサーバー側 API と対話できるようにするロジックであり、次の要素で構成されます。

  • Firebase SDK: Web アプリのデータベースを構築するために使用される SDK です。
  • AppModel:認証後にユーザーを生成するモデルであり、Web アセットの最新バージョンがあることも確認します。
  • ChatModels:データベースにメッセージを送信し、新しいメッセージを聞くリスナーを確立し、他のユーザーがオンラインかどうか、入力中かどうかを聞くモデル ロジックで構成されます。また、画像や音声などのメディアをデータベース ストレージに送信します。SidebarChatsModel :ユーザーの最新のメッセージを聞き、ユーザーからのすべての新しいメッセージの配列を提供するロジックです。また、未読メッセージの数とユーザーのオンライン ステータスを提供し、最後のメッセージの時間に基づいてユーザーを整理します。
  • UsersSearchModel:データベース上のユーザーを検索するロジック。サーバー上のデータベースにリンクしてユーザーのリストを持つアルゴリズム検索を使用します。
  • CallModel: Daily SDK を使用して Web アプリで呼び出しを作成し、データをサーバーに送信して DailyAPI と対話するロジック。

クライアント側コントローラー (コントローラー)

これは、ビューを特定のモデルにリンクする React コンポーネントで構成されています。

  • App Controller:認証されたユーザーをすべてのコンポーネントにリンクし、scalePage 関数を実行してアプリのサイズを調整します。また、Firebase をロードしてすべてのコンポーネントをアタッチします。これは、コンポーネントへのラッパーと考えることができます。
  • SideBarController:ユーザー データと最新のチャットのリストをリンクし、メニューをモデル ロジックにリンクし、検索バーを algolia 検索 API にリンクします。
  • ChatController:これは、ほとんどのメッセージング機能とチャット機能をリンクする非常に大きなコントローラーです。
  • CallController:通話モデルとそのビューをリンクします。

サーバーサイドモデル

使用した SDK にはサーバー側の機能もいくつか必要であり、次のような機能が含まれているため、すべての機能がフロントエンドで実行されるわけではありません。

  • CallServerModel: Daily API と対話し、Firestore データベースを更新することで通話用の部屋を作成できるロジック。
  • TranscriptModel:音声ファイルを受信し、Google Cloud 音声テキスト変換 API と対話して音声メッセージのトランスクリプトを作成するサーバー上のロジック。
  • オンライン ステータス ハンドラー:ユーザーのオンライン ステータスを監視し、それに応じてデータベースを更新するリスナー。通知モデル: 他のユーザーに通知を送信するサービス。
  • AlgoliaSaver:データベース上の新しいユーザーをリッスンし、それに応じて Algolia を更新して、フロントエンドの検索機能に使用できるようにするリスナー。
  • サーバー側コントローラー: CallServer: callModel を含む API エンドポイント。Worker: すべての Firebase 処理サービスを実行するワーカー サービス。

チャットフローチャート

チャットフローチャート


サイドバーフローチャート

サイドバーフローチャート

モデルコールフローチャート:

モデルコールフローチャート

通話フロー図を見る

通話フロー図を見る

バックエンドワーカーのフローチャート

バックエンドワーカーのフローチャート

データベース設計

私たちの Web アプリは、Firebase NoSql データベースであるデータベースを保存するために Firestore を使用します。ユーザー情報、すべてのメッセージのリスト、チャットのリスト、ルームのチャットを保存しています。これらはデータベースに保存されるデータです:

  • 認証後のユーザーデータ。
  • メッセージの詳細がすべて含まれる部屋。
  • 各ユーザーの最新のチャットのリスト。
  • 送信される通知のリスト。
  • 書き起こす音声のリスト。

データベースUML

魔法のコードの説明🔮

次の章では、ChatPlus の特定の機能について簡単に説明し、チュートリアルを紹介します。また、JS コードを示し、その背後にあるアルゴリズムを説明し、コードをデータベースにリンクするための適切な統合ツールも提供します。

オンラインステータスの処理

ユーザーのオンライン ステータスは、フロントエンドの「.info/connected」に接続し、それに応じて Firestore とデータベースの両方を更新することで、Firebase データベース接続機能を使用して実装されました。

 var disconnectRef; function setOnlineStatus(uid) { try { console.log("setting up online status"); const isOfflineForDatabase = { state: 'offline', last_changed: createTimestamp2, id: uid, }; const isOnlineForDatabase = { state: 'online', last_changed: createTimestamp2, id: uid }; const userStatusFirestoreRef = db.collection("users").doc(uid); const userStatusDatabaseRef = db2.ref('/status/' + uid); // Firestore uses a different server timestamp value, so we'll // create two more constants for Firestore state. const isOfflineForFirestore = { state: 'offline', last_changed: createTimestamp(), }; const isOnlineForFirestore = { state: 'online', last_changed: createTimestamp(), }; disconnectRef = db2.ref('.info/connected').on('value', function (snapshot) { console.log("listening to database connected info") if (snapshot.val() === false) { // Instead of simply returning, we'll also set Firestore's state // to 'offline'. This ensures that our Firestore cache is aware // of the switch to 'offline.' userStatusFirestoreRef.set(isOfflineForFirestore, { merge: true }); return; }; userStatusDatabaseRef.onDisconnect().set(isOfflineForDatabase).then(function () { userStatusDatabaseRef.set(isOnlineForDatabase); // We'll also add Firestore set here for when we come online. userStatusFirestoreRef.set(isOnlineForFirestore, { merge: true }); }); }); } catch (error) { console.log("error setting onlins status: ", error); } };

また、バックエンドでは、データベースの変更を監視し、それに応じて Firestore を更新するリスナーも設定します。この関数は、ユーザーのオンライン ステータスをリアルタイムで提供することもできるため、その内部で他の関数も実行できます。

 async function handleOnlineStatus(data, event) { try { console.log("setting online status with event: ", event); // Get the data written to Realtime Database const eventStatus = data.val(); // Then use other event data to create a reference to the // corresponding Firestore document. const userStatusFirestoreRef = db.doc(`users/${eventStatus.id}`); // It is likely that the Realtime Database change that triggered // this event has already been overwritten by a fast change in // online / offline status, so we'll re-read the current data // and compare the timestamps. const statusSnapshot = await data.ref.once('value'); const status = statusSnapshot.val(); // If the current timestamp for this data is newer than // the data that triggered this event, we exit this function. if (eventStatus.state === "online") { console.log("event status: ", eventStatus) console.log("status: ", status) } if (status.last_changed <= eventStatus.last_changed) { // Otherwise, we convert the last_changed field to a Date eventStatus.last_changed = new Date(eventStatus.last_changed); //handle the call delete handleCallDelete(eventStatus); // ... and write it to Firestore. await userStatusFirestoreRef.set(eventStatus, { merge: true }); console.log("user: " + eventStatus.id + " online status was succesfully updated with data: " + eventStatus.state); } else { console.log("next status timestamp is newer for user: ", eventStatus.id); } } catch (error) { console.log("handle online status crashed with error :", error) } }

通知

通知は優れた機能であり、Firebase メッセージングを使用して実装されています。フロントエンドでは、ユーザーのブラウザが通知をサポートしている場合は通知を設定し、ユーザーの Firebase メッセージング トークンを取得します。

 const configureNotif = (docID) => { messaging.getToken().then((token) => { console.log(token); db.collection("users").doc(docID).set({ token: token }, { merge: true }) }).catch(e => { console.log(e.message); db.collection("users").doc(docID).set({ token: "" }, { merge: true }); }); }

ユーザーがメッセージを送信するたびに、データベースに通知が追加されます。

 db.collection("notifications").add({ userID: user.uid, title: user.displayName, body: inputText, photoURL: user.photoURL, token: token, });


バックエンドでは通知コレクションをリッスンし、Firebaseメッセージングを使用してユーザーに送信します

let listening = false; db.collection("notifications").onSnapshot(snap => { if (!listening) { console.log("listening for notifications..."); listening = true; } const docs = snap.docChanges(); if (docs.length > 0) { docs.forEach(async change => { if (change.type === "added") { const data = change.doc.data(); if (data) { const message = { data: data, token: data.token }; await db.collection("notifications").doc(change.doc.id).delete(); try { const response = await messaging.send(message); console.log("notification successfully sent :", data); } catch (error) { console.log("error sending notification ", error); }; }; }; }); }; });

AI音声文字起こし

当社の Web アプリケーションでは、ユーザーが互いに音声メッセージを送信できます。その機能の 1 つは、英語、フランス語、スペイン語で録音された音声をテキストに変換する機能です。この機能は、Google Cloud Speech to Text 機能を使用して実装されました。当社のバックエンドは、Firestore に追加された新しいトランスクリプトをリッスンし、トランスクリプトを作成してデータベースに書き込みます。

 db.collection("transcripts").onSnapshot(snap => { const docs = snap.docChanges(); if (docs.length > 0) { docs.forEach(async change => { if (change.type === "added") { const data = change.doc.data(); if (data) { db.collection("transcripts").doc(change.doc.id).delete(); try { const text = await textToAudio(data.audioName, data.short, data.supportWebM); const roomRef = db.collection("rooms").doc(data.roomID).collection("messages").doc(data.messageID); db.runTransaction(async transaction => { const roomDoc = await transaction.get(roomRef); if (roomDoc.exists && !roomDoc.data()?.delete) { transaction.update(roomRef, { transcript: text }); console.log("transcript added with text: ", text); return; } else { console.log("room is deleted"); return; } }) db.collection("rooms").doc(data.roomID).collection("messages").doc(data.messageID).update({ transcript: text }); } catch (error) { console.log("error transcripting audio: ", error); }; }; }; }); }; });

明らかに、あなたの目は textToAudio 関数を見ていて、どうやって作ったのか疑問に思っているでしょう。心配しないでください。私がお手伝いします。

 // Imports the Google Cloud client library const speech = require('@google-cloud/speech').v1p1beta1; const { gcsUriLink } = require("./configKeys") // Creates a client const client = new speech.SpeechClient({ keyFilename: "./audio_transcript.json" }); async function textToAudio(audioName, isShort) { // The path to the remote LINEAR16 file const gcsUri = gcsUriLink + "/audios/" + audioName; // The audio file's encoding, sample rate in hertz, and BCP-47 language code const audio = { uri: gcsUri, }; const config = { encoding: "MP3", sampleRateHertz: 48000, languageCode: 'en-US', alternativeLanguageCodes: ['es-ES', 'fr-FR'] }; console.log("audio config: ", config); const request = { audio: audio, config: config, }; // Detects speech in the audio file if (isShort) { const [response] = await client.recognize(request); return response.results.map(result => result.alternatives[0].transcript).join('\n'); } const [operation] = await client.longRunningRecognize(request); const [response] = await operation.promise().catch(e => console.log("response promise error: ", e)); return response.results.map(result => result.alternatives[0].transcript).join('\n'); }; module.exports = textToAudio;

ビデオ通話機能

私たちのウェブ アプリは、Daily API を使用してリアルタイムの Web RTC 接続を実装し、ユーザー同士のビデオ通話を可能にします。そのため、まず、Daily でルームを作成および削除するための多くの API エントリ ポイントを持つバックエンド コール サーバーをセットアップします。

 const app = express(); app.use(cors()); app.use(express.json()); app.delete("/delete-call", async (req, res) => { console.log("delete call data: ", req.body); deleteCallFromUser(req.body.id1); deleteCallFromUser(req.body.id2); try { fetch("https://api.daily.co/v1/rooms/" + req.body.roomName, { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "DELETE" }); } catch(e) { console.log("error deleting room for call delete!!"); console.log(e); } res.status(200).send("delete-call success !!"); }); app.post("/create-room/:roomName", async (req, res) => { var room = await fetch("https://api.daily.co/v1/rooms/", { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "POST", body: JSON.stringify({ name: req.params.roomName }) }); room = await room.json(); console.log(room); res.json(room); }); app.delete("/delete-room/:roomName", async (req, res) => { var deleteResponse = await fetch("https://api.daily.co/v1/rooms/" + req.params.roomName, { headers: { Authorization: `Bearer ${dailyApiKey}`, "Content-Type": "application/json" }, method: "DELETE" }); deleteResponse = await deleteResponse.json(); console.log(deleteResponse); res.json(deleteResponse); }) app.listen(process.env.PORT || 7000, () => { console.log("call server is running"); }); const deleteCallFromUser = userID => db.collection("users").doc(userID).collection("call").doc("call").delete();

フロントエンドでは、この API を呼び出す複数の関数が用意されています。

 import { callAPI as api } from "../../configKeys"; //const api = "http://localhost:7000" export async function createRoom(roomName) { var room = await fetch(`${api}/create-room/${roomName}`, { method: "POST", }); room = await room.json(); console.log(room); return room; } export async function deleteRoom(roomName) { var deletedRoom = await fetch(`${api}/delete-room/${roomName}`, { method: "DELETE", }); deletedRoom = await deletedRoom.json(); console.log(deletedRoom); console.log("deleted"); }; export function deleteCall() { window.callDelete && fetch(`${api}/delete-call`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify(window.callDelete) }); };

さて、次は通話ルームを作成し、Daily JS SDK を使用してこれらのルームに接続し、そこからデータを送受信するだけです。

 export default async function startVideoCall(dispatch, receiverQuery, userQuery, id, otherID, userName, otherUserName, sendNotif, userPhoto, otherPhoto, audio) { var room = null; const call = new DailyIframe.createCallObject(); const roomName = nanoid(); window.callDelete = { id1: id, id2: otherID, roomName } dispatch({ type: "set_other_user_name", otherUserName }); console.log("audio: ", audio); if (audio) { dispatch({ type: "set_other_user_photo", photo: otherPhoto }); dispatch({ type: "set_call_type", callType: "audio" }); } else { dispatch({ type: "set_other_user_photo", photo: null }); dispatch({ type: "set_call_type", callType: "video" }); } dispatch({ type: "set_caller", caller: true }); dispatch({ type: "set_call", call }); dispatch({ type: "set_call_state", callState: "state_creating" }); try { room = await createRoom(roomName); console.log("created room: ", room); dispatch({ type: "set_call_room", callRoom: room }); } catch (error) { room = null; console.log('Error creating room', error); await call.destroy(); dispatch({ type: "set_call_room", callRoom: null }); dispatch({ type: "set_call", call: null }); dispatch({ type: "set_call_state", callState: "state_idle" }); window.callDelete = null; //destroy the call object; }; if (room) { dispatch({ type: "set_call_state", callState: "state_joining" }); dispatch({ type: "set_call_queries", callQueries: { userQuery, receiverQuery } }); try { await db.runTransaction(async transaction => { console.log("runing transaction"); var userData = (await transaction.get(receiverQuery)).data(); //console.log("user data: ", userData); if (!userData || !userData?.callerID || userData?.otherUserLeft) { console.log("runing set"); transaction.set(receiverQuery, { room, callType: audio ? "audio" : "video", isCaller: false, otherUserLeft: false, callerID: id, otherID, otherUserName: userName, otherUserRatio: window.screen.width / window.screen.height, photo: audio ? userPhoto : "" }); transaction.set(userQuery, { room, callType: audio ? "audio" : "video", isCaller: true, otherUserLeft: false, otherUserJoined: false, callerID: id, otherID }); } else { console.log('transaction failed'); throw userData; } }); if (sendNotif) { sendNotif(); const notifTimeout = setInterval(() => { sendNotif(); }, 1500); dispatch({ type: "set_notif_tiemout", notifTimeout }); } call.join({ url: room.url, videoSource: !audio }); } catch (userData) { //delete the room we made deleteRoom(roomName); await call.destroy(); if (userData.otherID === id) { console.log("you and the other user are calling each other at the same time"); joinCall(dispatch, receiverQuery, userQuery, userData.room, userName, audio ? userPhoto : "", userData.callType); } else { console.log("other user already in a call"); dispatch({ type: "set_call_room", callRoom: null }); dispatch({ type: "set_call", call: null }); dispatch({ type: "set_call_state", callState: "state_otherUser_calling" }); } }; }; };

OtherUserQuery と UserQuery は単なる Firebase Firestore ドキュメント パスです。これで、アプリの残りの部分には、上記の関数によってトリガーされる状態の変更に反応するビュー コンポーネントがあり、それに応じて呼び出し UI 要素が表示されます。

通話要素を移動する:

次の機能は、Call 要素をページ全体にドラッグできるようにするマジックです。

 export function dragElement(elmnt, page) { var pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0, top, left, prevTop = 0, prevLeft = 0, x, y, maxTop, maxLeft; const widthRatio = page.width / window.innerWidth; const heightRatio = page.height / window.innerHeight; //clear element's mouse listeners closeDragElement(); // setthe listener elmnt.addEventListener("mousedown", dragMouseDown); elmnt.addEventListener("touchstart", dragMouseDown, { passive: false }); function dragMouseDown(e) { e = e || window.event; // get the mouse cursor position at startup: if (e.type === "touchstart") { if (typeof(e.target.className) === "string") { if (!e.target.className.includes("btn")) { e.preventDefault(); } } else if (!typeof(e.target.className) === "function") { e.stopPropagation(); } pos3 = e.touches[0].clientX * widthRatio; pos4 = e.touches[0].clientY * heightRatio; } else { e.preventDefault(); pos3 = e.clientX * widthRatio; pos4 = e.clientY * heightRatio; }; maxTop = elmnt.offsetParent.offsetHeight - elmnt.offsetHeight; maxLeft = elmnt.offsetParent.offsetWidth - elmnt.offsetWidth; document.addEventListener("mouseup", closeDragElement); document.addEventListener("touchend", closeDragElement, { passive: false }); // call a function whenever the cursor moves: document.addEventListener("mousemove", elementDrag); document.addEventListener("touchmove", elementDrag, { passive: false }); } function elementDrag(e) { e = e || window.event; e.preventDefault(); // calculate the new cursor position: if (e.type === "touchmove") { x = e.touches[0].clientX * widthRatio; y = e.touches[0].clientY * heightRatio; } else { e.preventDefault(); x = e.clientX * widthRatio; y = e.clientY * heightRatio; }; pos1 = pos3 - x; pos2 = pos4 - y; pos3 = x pos4 = y; // set the element's new position: top = elmnt.offsetTop - pos2; left = elmnt.offsetLeft - pos1; //prevent the element from overflowing the viewport if (top >= 0 && top <= maxTop) { elmnt.style.top = top + "px"; } else if ((top > maxTop && pos4 < prevTop) || (top < 0 && pos4 > prevTop)) { elmnt.style.top = top + "px"; }; if (left >= 0 && left <= maxLeft) { elmnt.style.left = left + "px"; } else if ((left > maxLeft && pos3 < prevLeft) || (left < 0 && pos3 > prevLeft)) { elmnt.style.left = left + "px"; }; prevTop = y; prevLeft = x; } function closeDragElement() { // stop moving when mouse button is released: document.removeEventListener("mouseup", closeDragElement); document.removeEventListener("touchend", closeDragElement); document.removeEventListener("mousemove", elementDrag); document.removeEventListener("touchmove", elementDrag); }; return function() { elmnt.removeEventListener("mousedown", dragMouseDown); elmnt.removeEventListener("touchstart", dragMouseDown); closeDragElement(); }; };

画像をドラッグアンドドロップ

チャットに画像をドラッグ アンド ドロップして他のユーザーに送信できます。この機能は、次のコマンドを実行することで可能になります。

 useEffect(() => { const dropArea = document.querySelector(".chat"); ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, e => { e.preventDefault(); e.stopPropagation(); }, false); }); ['dragenter', 'dragover'].forEach(eventName => { dropArea.addEventListener(eventName, () => setShowDrag(true), false) }); ['dragleave', 'drop'].forEach(eventName => { dropArea.addEventListener(eventName, () => setShowDrag(false), false) }); dropArea.addEventListener('drop', e => { if (window.navigator.onLine) { if (e.dataTransfer?.files) { const dropedFile = e.dataTransfer.files; console.log("dropped file: ", dropedFile); const { imageFiles, imagesSrc } = mediaIndexer(dropedFile); setSRC(prevImages => [...prevImages, ...imagesSrc]); setImage(prevFiles => [...prevFiles, ...imageFiles]); setIsMedia("images_dropped"); }; }; }, false); }, []);

mediaIndexer は、提供される画像の BLOB にインデックスを付ける単純な関数です。

 function mediaIndexer(files) { const imagesSrc = []; const filesArray = Array.from(files); filesArray.forEach((file, index) => { imagesSrc[index] = URL.createObjectURL(file); }); return { imagesSrc, imageFiles: filesArray }; }