Kodax

مشروع: تطبيق دردشة بـ Firebase — Firebase

مشروع: تطبيق دردشة بـ Firebase

الدردشة مثال مثالي لـ Firebase: بيانات لحظية، وصول مباشر من الواجهة، وقواعد أمان هي الخادم الوحيد.

النمذجة حسب الشاشات

| الشاشة | ما تقرأه | التصميم |

|---|---|---|

| قائمة غرفي | الغرف التي أنا عضو فيها + آخر رسالة | rooms/{roomId} فيها members: [uid...] و lastMessage منسوخة — استعلام واحد where("members", "array-contains", uid) |

| غرفة | آخر 50 رسالة ثم الأقدم عند التمرير | مجموعة فرعية rooms/{roomId}/messages مرتبة بـ createdAt |

| ملف عضو | الاسم والصورة | users/{uid} |

lastMessage نسخة مقصودة: قائمة الغرف تعرضها دون قراءة رسائل كل غرفة (كل قراءة مدفوعة).

الرسائل اللحظية بترقيم

- اشترك في آخر 50 فقط: query(messages, orderBy("createdAt", "desc"), limit(50)) مع onSnapshot.

- عند التمرير للأعلى: getDocs (لا اشتراك) بـ startAfter(أقدم_رسالة).

- serverTimestamp() للوقت — لا وقت جهاز المستخدم (قد يكون خاطئاً أو مزوراً).

- ألغِ الاشتراك عند مغادرة الغرفة.

كتابة رسالة + تحديث الغرفة

writeBatch: إضافة الرسالة وتحديث lastMessage في الغرفة كعملية واحدة.

القواعد

match /rooms/{roomId} {
  allow read: if request.auth.uid in resource.data.members;
  allow create: if request.auth.uid in request.resource.data.members
                && request.resource.data.members.size() <= 50;
  allow update: if request.auth.uid in resource.data.members
                && request.resource.data.members == resource.data.members   // تغيير الأعضاء عبر دالة خادم فقط
                && request.resource.data.diff(resource.data).affectedKeys().hasOnly(['lastMessage']);

  match /messages/{messageId} {
    allow read: if request.auth.uid in get(/databases/$(database)/documents/rooms/$(roomId)).data.members;
    allow create: if request.auth.uid in get(/databases/$(database)/documents/rooms/$(roomId)).data.members
                  && request.resource.data.senderId == request.auth.uid
                  && request.resource.data.text is string
                  && request.resource.data.text.size() > 0
                  && request.resource.data.text.size() <= 2000
                  && request.resource.data.createdAt == request.time
                  && request.resource.data.keys().hasOnly(['senderId', 'text', 'createdAt', 'imagePath']);
    allow update, delete: if false;   // لا تعديل للرسائل في هذا الإصدار
  }
}

لاحظ:

- get(...) يقرأ وثيقة الغرفة داخل القاعدة لفحص العضوية (يُحتسب قراءة).

- createdAt == request.time يجبر استخدام serverTimestamp().

- senderId == request.auth.uid يمنع انتحال المرسل.

- تغيير الأعضاء من الواجهة ممنوع؛ يتم عبر Cloud Function تتحقق من الصلاحية.

الصور

Storage: chat-images/{roomId}/{uid}/{file} بحد 5MB وصور فقط، والقراءة للأعضاء (Storage Rules تستطيع قراءة Firestore بـ firestore.get).

الإشعارات

Cloud Function على onDocumentCreated("rooms/{roomId}/messages/{id}") ترسل إشعار FCM للأعضاء الآخرين — منطق خادم لا يجوز وضعه في الواجهة.

قائمة الإنجاز

- [ ] غير العضو لا يقرأ رسائل غرفة ولا يكتب فيها (اختبار بالمحاكي).

- [ ] لا يمكن إرسال رسالة باسم مستخدم آخر أو بوقت مزور.

- [ ] الاشتراك في آخر 50 فقط، والأقدم بترقيم.

- [ ] الاشتراكات تُلغى عند مغادرة الشاشة.

- [ ] الفهارس المركبة المطلوبة منشأة (firestore.indexes.json).

// chat.js — طبقة بيانات الدردشة بـ Firebase SDK v9+ (npm install firebase)
import {
  collection, doc, getDocs, limit, onSnapshot, orderBy, query,
  serverTimestamp, startAfter, where, writeBatch,
} from "firebase/firestore";
import { db, auth } from "./firebase.js";

const PAGE = 50;

// قائمة غرفي: استعلام واحد، و lastMessage منسوخة في وثيقة الغرفة
export function watchMyRooms(onChange) {
  const uid = auth.currentUser?.uid;
  if (!uid) throw new Error("سجّل الدخول أولاً");
  const q = query(collection(db, "rooms"), where("members", "array-contains", uid), orderBy("lastMessage.createdAt", "desc"), limit(30));
  return onSnapshot(q, (snap) => onChange(snap.docs.map((d) => ({ id: d.id, ...d.data() }))));
}

// آخر 50 رسالة لحظياً — يعيد دالة الإلغاء
export function watchLatestMessages(roomId, onChange, onError) {
  const q = query(collection(db, "rooms", roomId, "messages"), orderBy("createdAt", "desc"), limit(PAGE));
  return onSnapshot(
    q,
    (snap) => onChange(snap.docs.map((d) => ({ id: d.id, ...d.data() })).reverse(), snap.docs.at(-1) ?? null),
    onError   // permission-denied إن لم يكن عضواً
  );
}

// الأقدم عند التمرير: قراءة مرة واحدة بلا اشتراك
export async function loadOlder(roomId, oldestSnapshot) {
  const q = query(collection(db, "rooms", roomId, "messages"), orderBy("createdAt", "desc"), startAfter(oldestSnapshot), limit(PAGE));
  const snap = await getDocs(q);
  return { messages: snap.docs.map((d) => ({ id: d.id, ...d.data() })).reverse(), oldest: snap.docs.at(-1) ?? null };
}

// إرسال: الرسالة وتحديث lastMessage كعملية واحدة
export async function sendMessage(roomId, text) {
  const uid = auth.currentUser?.uid;
  const clean = String(text).trim();
  if (!uid) throw new Error("سجّل الدخول أولاً");
  if (!clean || clean.length > 2000) throw new Error("الرسالة بين 1 و 2000 حرف");

  const batch = writeBatch(db);
  const messageRef = doc(collection(db, "rooms", roomId, "messages"));
  batch.set(messageRef, { senderId: uid, text: clean, createdAt: serverTimestamp() });   // القواعد تفرض request.time
  batch.update(doc(db, "rooms", roomId), {
    lastMessage: { text: clean.slice(0, 100), senderId: uid, createdAt: serverTimestamp() },
  });
  await batch.commit();
  return messageRef.id;
}
اختبر فهمك