طَبِّقْهاتعلّم المبدأ، وطبّقه بيدكمقرر 242 · أطر الويب
الدرس 20 · الوحدة 8: تنفيذ قنوات اتصال فورية

تطبيق دردشة وإشعارات فورية

غرف، و«يكتب الآن…»، وإشعارات تصل فورًا ⏱ نحو 50 دقيقة.

بعد هذا الدرس ستستطيع أن:
  • تصمم أحداث تطبيق فوري كما تصمم مسارات API.
  • تبني دردشة بغرف وسجل رسائل ومؤشر كتابة.
  • تتحقق من هوية الاتصال بوسيط io.use و JWT.
  • ترسل إشعارات فورية لمستخدم بعينه من أي جزء في الخادم.

١ماذا يحتاج تطبيق الدردشة؟

نجمع في هذا الدرس ما تعلمناه في تطبيق دردشة حقيقي. قبل الكود، نصمم الأحداث: هي «الواجهة البرمجية» بين الطرفين، كما كانت المسارات في REST:

الحدثالاتجاهالبياناتالرد
joinعميل ← خادماسم الغرفةack: سجل الرسائل أو خطأ
messageعميل ← خادمنص الرسالةack: نجح أو خطأ
messageخادم ← من في الغرفة{ from, text, at }—
systemخادم ← الغرفة عدا المرسِلنص مثل «سارة دخلت»—
typingفي الاتجاهين{ name, on }—

٢الدردشة كاملة

نافذتان لمستخدمين. اختر الغرفة نفسها في النافذتين، ثم اكتب: تظهر الرسالة عند الطرفين، ويظهر «يكتب…» عند الآخر أثناء الكتابة، ورسالة نظام حين يدخل أحد الغرفة. وإن اختارا غرفتين مختلفتين فلن تصل الرسائل بينهما:

import { useState, useEffect, useRef } from 'react';import { io } from 'socket.io-client'; export default function App({ user }) {  const socket = useRef(null);  const [room, setRoom] = useState('');  const [messages, setMessages] = useState([]);  const [text, setText] = useState('');  const [typing, setTyping] = useState('');   useEffect(() => {    const s = io('http://localhost:3000', { auth: { name: user } });    s.on('message', (m) => setMessages(prev => [...prev, m]));    s.on('system', (t) => setMessages(prev => [...prev, { system: true, text: t }]));    s.on('typing', ({ name, on }) => setTyping(on ? `${name} يكتب…` : ''));    socket.current = s;    return () => s.disconnect();  }, [user]);   function join(r) {    socket.current.emit('join', r, (res) => {      if (res.error) return alert(res.error);      setRoom(r);      setMessages(res.history);    });  }   function send(e) {    e.preventDefault();    socket.current.emit('message', text, (res) => {      if (res.ok) setText('');    });    socket.current.emit('typing', false);  }   function onType(v) {    setText(v);    socket.current.emit('typing', v.length > 0);  }   if (!room) return (    <div>      <p>مرحبًا {user}، اختر غرفة:</p>      <button onClick={() => join('general')}># عامة</button>      <button onClick={() => join('cs242')}># cs242</button>    </div>  );   return (    <div>      <b># {room}</b>      <ul className="msgs">        {messages.map((m, i) => m.system          ? <li key={i} className="sys"><i>{m.text}</i></li>          : <li key={i}><b>{m.from}</b>: {m.text} <small>{m.at}</small></li>)}      </ul>      <p className="typing">{typing}</p>      <form onSubmit={send}>        <input value={text} onChange={e => onType(e.target.value)} placeholder="اكتب رسالة" />        <button>إرسال</button>      </form>    </div>  );}

أهم ما في الخادم:

٣الهوية عند الاتصال بـ JWT

في التطبيق الحقيقي لا نصدّق اسمًا يرسله العميل؛ بل يرسل الرمز الذي أخذه عند تسجيل الدخول في auth، ويتحقق منه وسيط io.use:

// الواجهةconst socket = io(URL, { auth: { token } });socket.on('connect_error', (err) => setError(err.message));   // «رمز غير صالح» // الخادمio.use((socket, next) => {  try {    socket.data.user = jwt.verify(socket.handshake.auth.token, process.env.JWT_SECRET);    next();  } catch {    next(new Error('رمز غير صالح'));  }});

جرّبه: الخادم هنا يقبل الرمز التجريبي good-token فقط. النافذة الثانية ترسل رمزًا خاطئًا:

import { useState, useEffect } from 'react';import { io } from 'socket.io-client'; export default function App({ user }) {  const [status, setStatus] = useState('…');   useEffect(() => {    const token = user === 'سارة' ? 'good-token' : 'stolen-token';    const s = io('http://localhost:3000', { auth: { token } });    s.on('connect', () => setStatus('✓ متصل بهوية موثّقة'));    s.on('connect_error', (err) => setStatus('✗ رُفض الاتصال: ' + err.message));    return () => s.disconnect();  }, [user]);   return <p>{user}: {status}</p>;}

٤الإشعارات الفورية

الإشعار حدث يرسله الخادم لمستخدم بعينه مهما كانت الصفحة التي فتحها: «قُبل طلبك»، «رُصدت درجتك». الحيلة: عند الاتصال يدخل كل مستخدم غرفة باسمه user:رقمه، فيرسل الخادم إليها من أي مكان في الكود، حتى من مسار HTTP عادي:

import { useState, useEffect } from 'react';import { io } from 'socket.io-client'; export default function App({ user }) {  const [toasts, setToasts] = useState([]);   useEffect(() => {    const s = io('http://localhost:3000', { auth: { name: user } });    s.on('notify', (n) => setToasts(prev => [...prev, n]));    return () => s.disconnect();  }, [user]);   async function grade() {    // المعلم يرصد درجة عبر REST، والخادم يُشعر الطالب فورًا    await fetch('http://localhost:3000/api/grades', {      method: 'POST', headers: { 'Content-Type': 'application/json' },      body: JSON.stringify({ student: 'ريم', grade: 95 }),    });  }   return (    <div>      {user === 'المعلم' && <button onClick={grade}>ارصد درجة ريم</button>}      {toasts.map((t, i) => <p key={i} className="toast">🔔 {t}</p>)}      {user !== 'المعلم' && !toasts.length && <p>لا إشعارات بعد…</p>}    </div>  );}

توقّع وتحقّق

أين يُتحقق من هوية مستخدم Socket.io قبل قبول اتصاله؟

مثل وسيط Express.

كيف يرسل الخادم إشعارًا لمستخدم واحد بعينه؟

غرفة خاصة.

ماذا يستقبل العميل حين يرفض io.use اتصاله؟

next(new Error).

لماذا يضيف الخادم اسم المرسِل والوقت بنفسه؟

الثقة.

الخلاصة

تحدٍّ برمجي: غرفة الدعم الفني

متقدم

الواجهة جاهزة في App.jsx. أكمل الخادم في server/server.js.

المطلوب:
  • الحدث join (الغرفة، ack): أدخل العميل الغرفة ورد ack({ ok: true }).
  • الحدث say (النص، ack): إن لم يكن العميل في غرفة غير غرفته الخاصة رد ack({ error: 'ادخل غرفة أولًا' }).
  • وإن كان النص فارغًا رد ack({ error: 'رسالة فارغة' }).
  • وإلا أرسل للغرفة كلها الحدث said بالكائن { from: اسم المتصل من auth، text } ورد ack({ ok: true }).
👀 اعرض الناتج المطلوب (وجرّبه)
✓ التحقق الآلي من حلّك:
  • الكود يعمل بلا أخطاء في Console
  • الإرسال قبل الدخول يعطي «ادخل غرفة أولًا»
  • الدخول يرد بالنجاح
  • الرسالة تصل للغرفة باسم المرسِل
  • الرسالة الفارغة تُرفض ولا تُبث
  • يبث بـ io.to(room).emit

💡 تلميح 1
الغرفة الحالية: [...socket.rooms].find(r => r !== socket.id).
💡 تلميح 2
الاسم من socket.handshake.auth.name.

حاول بنفسك أولًا؛ للمسألة أكثر من حلّ صحيح.

import { useState, useEffect, useRef } from 'react';import { io } from 'socket.io-client'; // الواجهة جاهزة: تدخل الغرفة support وترسل رسالة، وتعرض ما يصل بالحدث saidexport default function App({ user = 'ضيف' }) {  const s = useRef(null);  const [log, setLog] = useState([]);  const [err, setErr] = useState('');   useEffect(() => {    s.current = io('http://localhost:3000', { auth: { name: user } });    s.current.on('said', (m) => setLog(prev => [...prev, m.from + ': ' + m.text]));    return () => s.current.disconnect();  }, [user]);   const join = () => s.current.emit('join', 'support', (r) => setErr(r.error || 'دخلت support'));  const say = (t) => s.current.emit('say', t, (r) => setErr(r.error || ''));   return (    <div>      <button onClick={join}>ادخل support</button>      <button onClick={() => say('أحتاج مساعدة')}>أرسل</button>      <button onClick={() => say('')}>أرسل فارغة</button>      <p className="st">{err}</p>      <ul className="log">{log.map((l, i) => <li key={i}>{l}</li>)}</ul>    </div>  );}
انتقل إلى تقييم الدرساختيار من متعدد، وصح وخطأ، وأكمل الفراغ، وتتبّع الناتج.ابدأ التقييم ←

📝 معلم؟ أرسل ملاحظة للمؤلف عن هذا الدرس