مشروع: تطبيق دردشة بـ 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;
}اختبر فهمك