| الملخص | نافذة معاينة الموضوعات – افتح وتفاعل مع الموضوعات دون مغادرة قائمة الموضوعات | |
| المعاينة | Theme Creator | |
| المستودع | GitHub - VaperinaDEV/discourse-topic-preview-modal: Open a topic directly from the topic list in a native Discourse modal, read and interact with the topic, and then continue browsing the list without navigating away from it. · GitHub | |
| وجدتها مفيدة؟ | > ./support --coffee | |
| دليل التثبيت | كيفية تثبيت موضوع أو مكوّن موضوع | |
| جديد على مواضيع Discourse؟ | دليل المبتدئين لاستخدام مواضيع Discourse |
ثبّت هذا المكوّن
نافذة معاينة الموضوعات – افتح وتفاعل مع الموضوعات دون مغادرة قائمة الموضوعات
لقد قمت بإنشاء مكوّن موضوع جديد لـ Discourse يُسمى Topic Preview Modal.
الفكرة بسيطة إلى حد ما:
افتح موضوعًا مباشرة من قائمة الموضوعات في نافذة حوارية أصلية لـ Discourse، واقرأ وتفاعل مع الموضوع، ثم واصل تصفح القائمة دون التنقل بعيدًا عنها.
بدأ الأمر من Facebook-style Topic Modal - Is it better? ، لكن الأمر انتهى بأن يتطلب قدرًا كبيرًا من التكامل مع أنظمة الموضوعات، وتدفق الرسائل، والمحرر، والنوافذ الحوارية، والإشارات المرجعية، والتوجيه، والحضور، وتتبع القراءة، والتحميل المسبق في Discourse.
لماذا؟
التدفق العادي في Discourse هو:
- أنت تتصفح قائمة موضوعات.
- تنقر على موضوع.
- ينتقل Discourse إلى
/t/.... - تقرأ/ترد/تتفاعل مع الموضوع.
- تعود إلى قائمة الموضوعات.
بالنسبة للعديد من سير العمل، هذا أمر مثالي تمامًا.
ومع ذلك، عند تصفح قائمة موضوعات مزدحمة، أحيانًا أريد فقط فحص موضوع بسرعة، وقراءة بعض الرسائل، والتحقق من آخر الردود، أو التفاعل مع شيء ما، أو الإجابة على سؤال سريع.
بالنسبة لحالة الاستخدام هذه، يبدو مغادرة قائمة الموضوعات مكلفًا بشكل غير ضروري.
لذلك كانت هدف هذا المكوّن هو جعل قائمة الموضوعات تتصرف بشكل أكثر تشابهًا مع صندوق الوارد:
قائمة الموضوعات → معاينة → تفاعل → إغلاق → المتابعة من حيث توقفت تمامًا.
ما الذي يفعله
المعاينة ليست مجرد مقتطف ثابت.
إنها تعرض مكوّنات رسائل Discourse الفعلية داخل نافذة DModal أصلية.
هذا يعني أن المستخدمين يمكنهم:
- قراءة الرسائل
- التمرير عبر الموضوع
- تحميل رسائل سابقة
- تحميل المزيد من الرسائل بالأسفل
- التفاعل مع الرسائل
- وضع إشارات مرجعية للرسائل
- اقتباس النص
- الرد على الموضوع
- الرد على رسائل فردية
- تعديل الرسائل عند السماح بذلك
- حذف/استعادة الرسائل عند السماح بذلك
- وضع علامات على الرسائل
- عرض سجل الرسائل
- تنفيذ إجراءات الرسائل العادية المختلفة
- رؤية حضور الموضوع
- اتباع الروابط إلى رسائل أخرى ضمن نفس الموضوع
- القفز مباشرة إلى الرسالة ذات الصلة
- فتح الموضوع الكامل عند الحاجة
النية هي أن تكون المعاينة قريبة قدر الإمكان من فتح الموضوع فعليًا.
وضعان للتشغيل
هناك طريقتان لفتح المعاينة.
1. صف قائمة الموضوعات بأكمله
هذا هو الوضع الافتراضي.
يصبح صف قائمة الموضوعات بأكمله قابلًا للنقر، بينما يتم استبعاد العناصر التفاعلية الشائعة مثل:
- بطاقات المستخدمين
- المشاركون
- روابط التصنيفات
- الوسوم
- روابط حالة الموضوع
- التحديد الجماعي
من محفز النافذة الحوارية.
يجعل هذا التجربة سريعة جدًا عند تصفح قائمة الموضوعات.
2. زر توسيع صريح
بشكل بديل، يمكن للمكوّن عرض أيقونة توسيع صغيرة عبر منفذ إضافة Discourse. يمكن لمواضيع المخصصة إنشاء <PluginOutlet /> جديد ببساطة لعرض المحفز.
في هذا الوضع، يبقى سلوك قائمة الموضوعات العادي غير منسجم تمامًا.
ينقر المستخدم على أيقونة التوسيع لفتح المعاينة، بينما يؤدي النقر على عنوان الموضوع إلى تنفيذ تنقل Discourse العادي.
هذا مفيد إذا أرادت المواقع الحفاظ على نموذج تفاعل قائمة الموضوعات القياسي.
الإعداد هو:
trigger_style:
row
أو:
trigger_style:
button
عند استخدام وضع الزر، يمكن تكوين المنفذ أيضًا.
تبدأ المعاينة من موضع غير المقروء للمستخدم
أحد التفاصيل المهمة هو أن النافذة الحوارية لا تحمّل الرسالة الأولى ببساطة.
عندما يكون الموضوع قد قُرئ جزئيًا بالفعل، تحسب المعاينة:
last_read_post_number + 1
وتفتح حول تلك الرسالة.
لذا، إذا كان للموضوع 200 رسالة وكان المستخدم قد قرأ حتى الرسالة رقم 165، فإن فتح المعاينة يبدأ حول رقم 166.
يجعل هذا المعاينة أكثر فائدة بكثير للتصفح في العالم الحقيقي.
كما يعني ذلك أن على المكوّن التعامل مع كلا الجانبين من تدفق الرسائل:
- تحميل الرسائل السابقة عند الضرورة
- تحميل الرسائل الأحدث بالأسفل
تظهر زر الرسائل السابقة عندما تكون هناك رسائل فوق النطاق المحمّل حاليًا، بينما يقوم مراقب تقاطع (IntersectionObserver) sentinel بتحميل المزيد من الرسائل تلقائيًا عندما يصل المستخدم إلى القاع.
التحميل المسبق
أحد أكبر أجزاء المكوّن هو نظام التحميل المسبق الخاص به.
المشكلة مع نافذة حوارية كهذه هي أن المستخدم يتوقع أن تبدو فورية.
إذا بدأنا تحميل الموضوع فقط بعد أن ينقر المستخدم، فقد تقضي النافذة الحوارية وقتًا ملحوظًا في الانتظار على الشبكة.
بدلًا من ذلك، يمكن للمكوّن أن يقوم بالتحميل المسبق للموضوعات بشكل استباقي أثناء تصفح المستخدم للقائمة.
عندما يقترب صف موضوع من نافذة العرض، يمكن لمراقب التقاطع (IntersectionObserver) جدولة تحميل مسبق.
هناك عدة ضوابط للتحديد لمنع هذا من أن يتحول إلى حركة خلفية غير مسيطر عليها.
إزالة الارتعاش (Debouncing)
لا يحفز الموضوع طلبًا فوريًا لمجرد أنه ظهر لفترة وجيزة في نافذة العرض.
ينتظر المكوّن فترة إزالة الارتعاش المحددة.
الافتراضي:
400 ms
هذا مفيد بشكل خاص عند التمرير السريع عبر قائمة موضوعات طويلة.
هامش الجذر (Root margin)
يمكن أن يبدأ التحميل المسبق قليلاً قبل أن يدخل الموضوع فعليًا إلى نافذة العرض.
الافتراضي:
50 px
يمنح هذا الطلب بداية صغيرة.
حد الطلبات المتزامنة
يتم تحديد عدد التحميلات المسبقة المتزامنة.
الافتراضي:
2
يسمح الإعداد بين 1 و 6 تحميلات مسبقة متزامنة.
ميزانية لكل دقيقة
هناك أيضًا آلية حماية ثانية:
max_prefetches_per_minute
الافتراضي هو:
15
لذا، حتى إذا واصل المستخدم التمرير عبر مئات الموضوعات، لن يولد المكوّن طلبات استكشافية بشكل مستمر.
0 يعطّل الحد.
يمكن تعطيل التحميل المسبق بالكامل
إذا لم ترغب المواقع في أي حركة شبكة استكشافية:
enable_prefetch = false
يستمر المكوّن في العمل بشكل طبيعي. يتم ببساطة تحميل الموضوعات عند فتح المعاينة.
يتم الاحتفاظ ببيانات التحميل المسبق منفصلة عن تنقل الموضوعات العادي
هناك تفصيل تنفيذي مهم هنا.
الاستجابة المحملة مسبقًا لا تُكتب فورًا إلى مفتاح التحميل المسبق العادي topic_<id> لـ Discourse.
بدلًا من ذلك، يستخدم المكوّن نطاقه الخاص:
topic-preview-modal:prefetch:<topicId>
فقط عندما يفتح المستخدم المعاينة فعليًا، يتم ترقية وعد التحميل المسبق إلى مفتاح التحميل المسبق الأساسي للموضوع.
هذا مقصود.
قد تكون المعاينة تقوم بتحميل موضوع يبدأ من last_read_post_number + 1، ولا أريد أن تتسرب استجابة المعاينة الخاصة هذه إلى تنقل مسار الموضوع العادي.
لذا، دورة الحياة هي في الأساس:
يدخل الموضوع نافذة العرض
↓
تحميل مسبق
↓
تخزين تحميل مسبق خاص
↓
يفتح المستخدم المعاينة
↓
ترقية التحميل المسبق
↓
Topic.find()/PostStream تستخدم نفس الوعد
هذا يعني أيضًا أن النافذة الحوارية لا تحتاج إلى انتظار اكتمال طلب التحميل المسبق قبل الفتح.
يمكن للنافذة الحوارية أن تفتح فورًا بهيكلها العظمي (skeleton) بينما يستمر نفس الوعد في الحل.
دعم الجوال
كان هذا في الواقع أحد الأسباب التي جعلتني أقضي وقتًا أطول بكثير في التنفيذ.
عملت الفكرة الأولية بشكل معقول على سطح المكتب، لكن الجوال كشف عن عدة مشاكل تتعلق بـ:
- التفاعل باللمس
- التمرير في النافذة الحوارية
- التركيز
- القوائم المتداخلة
- المحرر
- ظهور الرسائل
- تحميل الصور
- الأداء
لذلك يتجنب التنفيذ النهائي معاملة النافذة الحوارية كمنتدى مصغر منفصل تمامًا.
بدلًا من ذلك، يعيد استخدام أكبر قدر ممكن من البنية التحتية الحالية لـ Discourse.
مكوّنات رسائل Discourse الفعلية
لا تعيد النافذة الحوارية إنشاء الرسائل باستخدام قالب مخصص مبسط.
إنها تعرض مكوّنات:
Post
PostSmallAction
الفعلية من Discourse.
هذا مهم لأن المعاينة ستصبح وإلا تنفيذًا ثانيًا لواجهة الرسائل.
يمرر المكوّن الإجراءات ذات الصلة إلى مكوّنات الرسائل العادية، بما في ذلك أشياء مثل:
- الرد
- التعديل
- الحذف
- الاستعادة
- وضع علامة
- السجل
- الإشارة المرجعية
- ويكي
- القفل/فك القفل
- نوع الرسالة
- تغييرات الملكية
- الشارات
- الرسائل المخفية
- الاقتباس
- وما إلى ذلك
النتيجة هي أن المعاينة يمكن أن تتصرف بشكل أكثر تشابهًا مع موضوع عادي مقارنة بمكوّن “معاينة” تقليدي.
الردود والمحرر
المحرر هو أحد الأجزاء الأكثر تعقيدًا.
يمكن للمعاينة فتح محرر Discourse العادي لـ:
الرد على الموضوع
يُفتح محرر الموضوع مع نموذج الموضوع ومعلومات المسودة الصحيحة.
الرد على رسالة محددة
تُمرر الرسالة إلى المحرر بحيث يتصرف الرد كرد عادي على رسالة.
اقتباس النص المحدد
يتكامل المكوّن أيضًا مع PostTextSelection.
هذا يعني أن المستخدمين يمكنهم تحديد النص داخل المعاينة واستخدام تدفق الاقتباس/الرد العادي في Discourse.
النوافذ الحوارية المتداخلة
كان جزءًا آخر صعبًا هو نظام النوافذ الحوارية في Discourse.
يمكن للرسائل فتح نوافذ حوارية ومربعات حوار أخرى:
- وضع علامة
- السجل
- مربعات الحوار المتعلقة بالشارات
- تغييرات الملكية
- تأكيدات الحذف
- وما إلى ذلك
إذا سُمح لها بالتفاعل مع خدمة النوافذ الحوارية العالمية بشكل عادي، فقد يؤدي فتح أحدها إلى إغلاق معاينة الموضوع بأكملها.
لتجنب ذلك، ينشئ المكوّن آلية نافذة حوارية فرعية محلية.
مفاهيميًا:
نافذة معاينة الموضوع
│
├── نافذة وضع علامة
├── نافذة السجل
├── تأكيد الحذف
├── نافذة الشارة
└── نافذة حوار أخرى متعلقة بالرسالة
تبقى المعاينة مثبتة في الأسفل.
يقوم المكوّن بتدبيج طرق خدمة النوافذ الحوارية ذات الصلة مؤقتًا أثناء نشاطه ويعيدها عند تدميره.
التوجيه داخل النافذة الحوارية
تفصيل مهم آخر هو الروابط إلى الرسائل داخل نفس الموضوع.
على سبيل المثال، إذا احتوت رسالة على رابط إلى:
/t/my-topic/123
فلا تحتاج المعاينة إلى الإغلاق والتنقل بعيدًا.
بدلًا من ذلك، يعترض المكوّن تنقل نفس الموضوع ويقفز إلى الرسالة المطلوبة داخل النافذة الحوارية.
ينطبق الشيء نفسه على الروابط المستهدفة للموضوع دون رقم رسالة محدد.
هذا يبقي المستخدم داخل المعاينة.
إذا أشار الرابط إلى موضوع مختلف تمامًا، يقوم المكوّن أولًا باستعادة تدبيجات الخدمة المؤقتة الخاصة به وإغلاق نفسه قبل السماح بانتقال مسار Discourse العادي.
هذا التنظيف مهم لأن النوافذ الحوارية قد تبقى حية وإلا الاشتراكات ومؤقت التوقيت الخاص بالمعاينة بينما يتم تهيئة مسار الموضوع الفعلي.
تتبع القراءة وتتبع الوقت
أردت أيضًا أن تتصرف المعاينة بشكل صحيح من منظور Discourse.
لا ينبغي أن يعني فتح معاينة أن تتبع القراءة يتم تجاوزه بالكامل.
لذلك يتعامل المكوّن مع:
- تتبع زيارات الموضوع
- تتبع الرسائل المرئية
- توقيت الموضوع
- تحديثات آخر رسالة مقروءة
يستخدم مؤقت التوقيت مراقب تقاطع (IntersectionObserver) لتحديد الرسائل المرئية فعليًا.
كل 5 ثوانٍ، يتم تفريغ توقيت الرسائل المرئية إلى:
/topics/timings
عند إغلاق النافذة الحوارية، يتم تنفيذ تفريغ نهائي واحد حتى لا تضيع الثواني الأخيرة.
يقيد التنفيذ أيضًا فترة توقيت واحدة بحد أقصى 60 ثانية.
إبقاء حالة غير المقروء في قائمة الموضوعات متزامنة
كانت هناك مشكلة خفية أخرى هنا.
تحديث حالة تتبع الموضوعات في Discourse وحدها غير كافية لتحديث شارة غير المقروء المعروضة مباشرة على صف قائمة الموضوعات.
لذلك يقوم المكوّن بتحديث كائن الموضوع الفعلي المرتبط بالصف بعد تفريغ معلومات التوقيت.
يقوم بتحديث قيم مثل:
last_read_post_number
unread_posts
unread
new_posts
عند الاقتضاء.
هذا يعني أنه بعد قراءة موضوع داخل النافذة الحوارية، يمكن لقائمة الموضوعات أن تعكس حالة القراءة الجديدة فورًا بدلاً من الحاجة إلى تحديث كامل للصفحة.
ظهور الرسائل
تستخدم المعاينة مراقب تقاطع (IntersectionObserver) مشتركًا لتحديد متى تصبح الرسائل الفردية مرئية.
هناك أيضًا فحص مزامنة للظهور عند إرفاق المراقب.
يتعامل هذا مع حالة حافة حيث تكون الرسالة مرئية بالفعل عند تثبيتها، لكن أول استدعاء غير متزامن لمراقب التقاطع لم يتم تشغيله بعد.
هذا ذو صلة خاصة بالمواضيع القصيرة جدًا حيث قد يكون الموضوع بأكمله مرئيًا بالفعل عند فتح النافذة الحوارية.
اعتبارات الأداء
كان الهدف الرئيسي هو تجنب تحويل النافذة الحوارية إلى صفحة موضوع مصغرة ثقيلة الأداء.
يتم تنفيذ عدة أشياء خصيصًا لذلك.
العرض التدريجي
لا يعرض التحميل الأولي كل رسالة فورًا.
يعرض المكوّن أولًا ما يكفي من الرسائل للوصول إلى الموضع المستهدف.
ثم يتم عرض الرسائل المتبقية تدريجيًا باستخدام:
requestIdleCallback
عند التوفر، مع بديل إلى setTimeout.
هذا مفيد بشكل خاص عند فتح موضوع طويل حول رسالة بعيدة في أسفل التدفق.
احتواء CSS
تستخدم الرسائل:
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
يسمح هذا للمتصفح بتجنب القيام بعمل عرض غير ضروري للرسائل غير المرئية حاليًا.
الصور الكسولة
تُعطى الصور التي لم تحدد وضع تحميل بعد تلقائيًا:
loading="lazy"
decoding="async"
يمنع هذا موضوعًا طويلًا يحتوي على العديد من الصور من تحميل كل شيء فورًا.
حالة التحميل
لا تعرض النافذة الحوارية مجرد منطقة بيضاء/فارغة بينما يتم تنفيذ الطلب.
لديها واجهة هيكلية (skeleton UI) مع:
- عناصر نائبة للصور الرمزية
- عناصر نائبة لأسماء المستخدمين/الأسماء
- عناصر نائبة لجسم الرسالة
- حركة لمعان (shimmer animation)
تحترم حركة اللمعان:
prefers-reduced-motion
لذلك يتم تعطيل الحركة للمستخدمين الذين طلبوا تقليل الحركة.
إبقاء موضع التمرير مستقرًا
هناك عدة أماكن يحتاج فيها المكوّن إلى التلاعب بموضع التمرير يدويًا.
على سبيل المثال، عند تحميل رسائل سابقة، يزيد المحتوى المُدرج الجديد من ارتفاع التمرير.
سيؤدي مجرد إدراج الرسائل في المقدمة إلى جعل الموضع الحالي للمستخدم يقفز.
لذلك يسجل المكوّن ارتفاع التمرير السابق ويعوض الفرق بعد إدراج الرسائل.
هذا يبقي المحتوى المرئي حاليًا في نفس المكان تقريبًا.
ينطبق الشيء نفسه عند القفز إلى رسالة معينة.
يؤدي المكوّن خطوة تموضع بعد العرض ويتحقق من الموضع مرة أخرى في الإطارات اللاحقة لمراعاة المحتوى الذي قد يستقر بعد.
حضور الموضوع
عندما تكون بيانات الموضوع ذات الصلة متاحة، يمكن للمعاينة أيضًا عرض معلومات حضور الموضوع في Discourse في أسفل النافذة الحوارية.
لذا يمكن للمستخدمين رؤية من هو آخر يشاهد الموضوع حاليًا دون الحاجة إلى مغادرة المعاينة.
التفاعل مع قوائم الجوال والتركيز
أدخل الجوال فئة أخرى من المشاكل.
تستخدم بعض عناصر واجهة Discourse خدمات النوافذ/القوائم المشتركة، وهذه الخدمات لا تعرف بالضرورة أن معاينة الموضوع تعمل حاليًا كسياق تصفح متداخل.
لذلك يحتوي المكوّن على معالجة إضافية حول:
modal.close()- قوائم Float Kit
- استعادة التركيز
- المحرر
- عناصر التحكم بلوحة المفاتيح في عارض الصور (lightbox)
- أقفال تمرير الجسم
على سبيل المثال، إذا حاولت قائمة داخليًا استدعاء طريقة إغلاق النافذة الحوارية العالمية، فذلك لا ينبغي أن يغلق معاينة الموضوع بأكملها عن طريق الخطأ.
بالمثل، عندما يكون المحرر مفتوحًا، يجب أن يبقى التركيز داخل المحرر بدلاً من سحبه مرة أخرى إلى سياق تركيز المعاينة.
التكوين
يعرض المكوّن حاليًا الإعدادات التالية:
| الإعداد | الافتراضي | الوصف |
|---|---|---|
trigger_style |
row |
اجعل الصف بأكمله قابلًا للنقر أو استخدم زرًا صريحًا |
plugin_outlet |
topic-list-after-title |
المنفذ المستخدم بواسطة محفز الزر |
enable_prefetch |
true |
تفعيل/تعطيل التحميل المسبق الخلفي للموضوعات |
max_concurrent_prefetches |
2 |
الحد الأقصى لطلبات التحميل المسبق المتزامنة |
prefetch_debounce_ms |
400 |
التأخير قبل بدء التحميل المسبق |
prefetch_root_margin_px |
50 |
ابدأ التحميل المسبق بهذا العدد من البكسلات قبل دخول الصف إلى نافذة العرض |
max_prefetches_per_minute |
15 |
الحد الأقصى لطلبات الاستكشاف لكل دقيقة |
تم تصميم ضوابط التحميل المسبق لتكون قابلة للتكوين عمدًا لأن المجتمعات المختلفة يمكن أن يكون لديها أنماط حركة وخصائص استضافة/شبكة مختلفة جدًا.
أحد الأهداف التصميمية الرئيسية: لا تكسر Discourse العادي
حاولت إبقاء المكوّن قريبًا قدر الإمكان من البنية الحالية لـ Discourse.
لا ينفذ مكوّن رسائل خاصًا به، أو محررًا خاصًا به، أو نموذج موضوع خاصًا به، أو تدفق رسائل منفصلًا تمامًا.
بدلًا من ذلك، يبني سياق تصفح مؤقتًا حول مكوّنات وخدمات Discourse الحالية.
هذا هو السبب أيضًا في أن بعض أجزاء التنفيذ أكثر تعقيدًا مما قد يبدو في البداية.
كان التحدي الأكثر إثارة للاهتمام هو:
هل يمكن لموضوع أن يتصرف تقريبًا كموضوع عادي في Discourse بينما يتم عرضه فعليًا داخل سياق واجهة أخرى؟
تطلب ذلك التعامل مع الحدود بين الخدمات العالمية لـ Discourse والمعاينة المحلية.





