نافذة مواضيع على غرار فيسبوك - هل هي أفضل؟

لقد كنت أجرب أمورًا مختلفة للحصول على مظهر مميز مقارنة بالمواقع الأخرى، وقمت ببناء نافذة منبثقة للمواضيع على غرار فيسبوك. يبدو أنها أسرع في التحميل بالنسبة لي، وتبدو أكثر سلاسة على أجهزة سطح المكتب.

ما رأيكم؟

رابط الاختبار هو https://www.carptalk-online.co.uk/ (يعمل فقط على أجهزة سطح المكتب)

عمل رائع! أعجبني ذلك جداً رغم أنني لست مستخدماً لفيسبوك. لديك موقع رائع. :star_struck:

أيضاً، لم أكن أدرك أن سمك القرش يمكن أن يصبح بهذا الحجم الكبير! :fishing_pole:

رائع جداً! هل جربته في مواضيع تحتوي على 20 ردًا أو أكثر؟ هذا هو الوقت الذي نحمّل فيه المزيد من “الصفحات” للمنشورات عند التمرير، ووفقاً لتجربتي، يعد ذلك أحد أكبر التعقيدات في هذا النوع من واجهات العرض البديلة

لا، سأقوم بذلك الآن وأعود بالإبلاغ

يعمل بشكل جيد - تم حذف الرابط

أستطيع رؤية تحميل الصفحة كما تقول، لكنها تعمل كما هو متوقع

لا يظهر الموضوع في نافذة منبثقة عند النقر على الرابط المباشر. أما من قائمة المواضيع، فإنه يعمل.

نعم، هذا هو الأمر التالي الذي أحتاج إلى التحقق منه، لكنني كنت أفكر في هذا الأمر، هل له أهمية؟ هذا هو نفس سلوك فيسبوك، حيث يؤدي الرابط المباشر إلى المنشور الفعلي، وإذا تم النقر عليه أثناء التصفح، تظهر نافذة منبثقة.

مفهوم رائع، هل يمكنك الحفاظ على اتصال ثابت مع حافلة الرسائل (Message Bus) للرسائل الجديدة أثناء تحميل الرسائل القديمة؟

نعم، لأن النافذة المنبثقة تقوم بتحميل تطبيق موضوعات Discourse العادي داخل إطار iframe بدلاً من عرض نسخة مخصصة من الموضوع. يقوم إطار iframe بإنشاء اتصال خاص به مع MessageBus، وتواصل Discourse التعامل مع تدوّن المنشورات بشكل طبيعي، بما في ذلك تحميل المنشورات الأقدم واستقبال المنشورات الجديدة التي يتم نشرها.

تظل الصفحة الأصلية نشطة أيضاً خلف النافذة المنبثقة، لذا فمن الناحية التقنية، يوجد نسختان من Discourse واتصالان بـ MessageBus أثناء بقاء النافذة المنبثقة مفتوحة. هذا يعمل، على الرغم من أنه أثقل قليلاً من التنفيذ الأصلي الكامل. يؤدي إغلاق النافذة المنبثقة إلى إزالة إطار iframe واتصاله.

لا أعرف إذا كان ذلك مهمًا، ولا أستخدم فيسبوك :stuck_out_tongue:

لقد كنت فقط مندهشًا لأن النقر على رابطك لم يعرض الموضوع في نافذة منبثقة، بينما كنت قد نشرته تحديدًا لإظهار أنه يعمل في نافذة منبثقة.

كان منشوري الأصلي رابطًا للصفحة الرئيسية، هل نقرت على ردّي على كريس الذي كان يختبر المواضيع؟ هahaha. كيف حالك على أي حال؟ لم أرك منذ فترة.

لقد انتهيت للتو من النسخة المحمولة وأنا سعيد بالطريقة التي تعمل بها كل شيء، الخطوة التالية هي العثور على طريقة أفضل للقيام بذلك وتقليل سرعة التحميل. هل لدى أي شخص أي اقتراحات؟

بعد إجراء المزيد من الاختبارات، استخدمتُ النسخة غير المعتمدة على JavaScript التي تراها Google، وتُحمّل فوراً داخل النافذة المنبثقة. إذا تمكّنتُ من استخدام محتوى هذه النسخة بطريقة ما، فسيجعل ذلك العملية أسرع بكثير.

يبدو رائعًا جدًا على جهاز الكمبيوتر،

سرعة التحميل على الأجهزة المحمولة بطيئة بعض الشيء، وبعد النقر يظهر فقط صفحة فارغة وزر الإغلاق في الزاوية اليمنى العليا، مما يعني أنه قد يتعين عليك الانتظار لفترة طويلة أمام صفحة بيضاء.

هذا ما أعمل عليه الآن :slight_smile: في الوقت الحالي، إنه مجرد مفهوم أرغب في تحويله إلى واقع. ومع ذلك، على جهزتي، يتم التحميل في ثانيتين على الهاتف المحمول مع أيقونة التحميل الخاصة بـ Discourse، لذا لست متأكدًا مما يحدث. ربما لأن الخادم موجود في لندن بالمملكة المتحدة ولا يوجد CDN حتى الآن.

نعم، المسافة بعيدة بالفعل، لذلك يستغرق الشبكة وقتًا لنقل البيانات

الآن تم تحميل المواضيع فوراً في النافذة المنبثقة، والآن أبحث عن طريقة لاستخدام النسخة الأولى من Discourse المحملة للردود وما إلى ذلك. (هذا على خادم الاختبار)

لقد قمت بإيقاف تشغيل هذا على الموقع الرئيسي لأنه يحصل الآن على حركة مرور مع تسجيل الناس، لذلك تم وضعه على خادم اختبار. آمل أن يكون هذا جاهزًا بحلول منتصف أغسطس إذا كان هناك أي شخص مهتم بنافذة منبثقة.

مرحباً :waving_hand:

نهج مختلف عن نهج @Damian_Boon: لا يوجد إطار (iframe)، كل شيء يعمل كمكونات Glimmer أصلية تعيد استخدام آلية Post/post-stream الخاصة بـ Discourse. نظراً لأن بعض الأسئلة المحددة ظهرت في الموضوع، إليك كيف انتهيت من حل كل منها.


لماذا لا نستخدم iframe

يعتبر iframe أسرع طريقة لتحقيق ذلك، حيث يوفر مسار الموضوع الكامل، والمحرر، والإدارة، وكل ذلك مجانياً. التكلفة، وهي ما تشير إليه سؤال @nicolsdennis حول MessageBus، هي أنك تنتهي مع نسختين حيتين من Discourse في الصفحة: نهري post-stream، واتصالان بـ MessageBus، ومحرران.

سلكت طريقاً آخر. النافذة المنبثقة هي DModal تقوم بتصيير مكونات Post/PostSmallAction الحقيقية مقابل postStream تم إنشاؤه من store.createRecord("topic", …)، نفس التطبيق، نفس اتصال MessageBus، نفس المحرر. هذا يعني أن حفنة من الخدمات المفردة الأساسية (modal، bookmarkApi، DiscourseURL.routeTo، controller:topic المحيط) تحتاج إلى التوجيه نحو موضوع النافذة المنبثقة طالما أنها مفتوحة:

// تقرأ المكونات الأساسية (مثل PostBookmarkManager) وتكتب topic.bookmarks
// من controller:topic. قم بتوجيهه نحو topicModel الخاص بنا أثناء فتح النافذة المنبثقة.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
  this.originalTopicControllerModel = this.topicController.model;
}

يتم التراجع عن كل التصحيحات بواسطة restoreServicePatches() الذي لا يتأثر بالتكرار عند الإغلاق، أو التدمير، أو التنقل بعيداً، لذا لا يتسرب أي شيء إلى بقية التطبيق بمجرد اختفاء النافذة المنبثقة.


الروابط المباشرة والتوجيه الداخلي

جزآن. أولاً، يجب أن تفتح النافذة المنبثقة حيث توقف القارئ فعلياً، وليس دائماً عند المنشور 1:

const lastRead = this.topic.last_read_post_number ?? 0;
const highestPostNumber = this.topic.highest_post_number ?? 1;
const initialPostNumber = Math.max(
  1,
  Math.min(lastRead + 1, highestPostNumber)
);

ثانياً، الروابط داخل المنشورات التي تشير مرة أخرى إلى نفس الموضوع لا ينبغي أن تخرج من النافذة المنبثقة. يتم تصحيح DiscourseURL.routeTo طوال فترة فتحها:

this.originalRouteTo = DiscourseURL.routeTo.bind(DiscourseURL);
DiscourseURL.routeTo = (path, opts) => {
  if (typeof path === "string") {
    const match = path.match(/^\/t\/(?:[^/]+\/)?(\d+)(?:\/(\d+))?/);
    if (match && parseInt(match[1], 10) === this.topicId) {
      this.jumpToPost(match[2] ? parseInt(match[2], 10) : 1);
      return Promise.resolve();
    }
  }
  this.restoreServicePatches();
  return this.originalRouteTo(path, opts);
};

أي شيء يشير إلى مكان آخر يستعيد التصحيحات أولاً وينتقل إلى تنقل عادي، لذا فإن مغادرة النافذة المنبثقة لا تترك وراءها توجيهًا قديماً.


حالة الرد (20+ رد)

تمرير لا نهائي في الأسفل عبر عنصر استشعار (sentinel) + IntersectionObserver، و"تحميل المنشورات السابقة" متماثل في الأعلى (يمكن فتح المعاينة في منتصف سلسلة المنشورات)، وكلاهما يدفع نفس postStream.appendMore() / prependMore() الذي يستخدمه مسار الموضوع الحقيقي:

sentinel = modifier((element) => {
  const obs = new IntersectionObserver(
    (entries) => {
      if (entries.some((e) => e.isIntersecting)) {
        this.loadBelow();
      }
    },
    {
      root: document.querySelector(".topic-preview-modal .d-modal__body"),
      rootMargin: "200px",
    }
  );
  obs.observe(element);
  return () => obs.disconnect();
});

الرسوم الأولي

يملأ الهيكل العظمي (صورة رمزية + عناصر نائبة للأسطر، وميض) الرسوم الأولي بدلاً من مؤشر التحميل، وقائمة المنشورات نفسها يتم تصييرها على مراحل بدلاً من دفعة واحدة، منشورات كافية للوصول إلى المنشور المستهدف أولاً، ثم خمسة إضافية في كل مرة عبر requestIdleCallback حتى يلحق الباقي:

const renderRemainingPosts = () => {
  const totalPosts = this.postStream?.posts?.length || 0;
  if (this.renderLimit < totalPosts) {
    this.renderLimit = Math.min(this.renderLimit + 5, totalPosts);
    if (this.renderLimit < totalPosts) {
      if (window.requestIdleCallback) {
        window.requestIdleCallback(renderRemainingPosts, { timeout: 300 });
      } else {
        setTimeout(renderRemainingPosts, 30);
      }
      return;
    }
  }
  this.renderLimit = Number.MAX_SAFE_INTEGER;
};

تحصل كل غلاف منشور أيضاً على content-visibility: auto مع contain-intrinsic-size تقريبي، لذا يتخطى المتصفح التخطيط بالكامل للمنشورات التي تكون خارج الشاشة:

.topic-post {
  contain: layout;
  content-visibility: auto;
  contain-intrinsic-size: 1px 180px;
}

هذه هي النقطة التي سأدفع فيها بقوة. بدلاً من إعادة استخدام نسخة بعد النقر، أقوم بالتحميل المسبق قبل النقر. كل صف في قائمة الموضوع يراقب مرئيته الخاص، يؤخر التنفيذ 200 مللي ثانية حتى لا يؤدي التمرير السريع إلى إطلاق طلبات عديدة، ويتولى طابوراً مشتركاً صغيراً محدوداً بطلبين متزامنين:

function createTopicPrefetchQueue(maxConcurrent) {
  const pending = [];
  const activeIds = new Set();
  function drain() {
    while (activeIds.size < maxConcurrent && pending.length) {
      const { id, task } = pending.shift();
      activeIds.add(id);
      Promise.resolve()
        .then(task)
        .finally(() => {
          activeIds.delete(id);
          drain();
        });
    }
  }
  return {
    schedule(id, task) {
      if (activeIds.has(id) || pending.some((item) => item.id === id)) {
        return;
      }
      pending.push({ id, task });
      drain();
    },
    cancel(id) {
      const index = pending.findIndex((item) => item.id === id);
      if (index !== -1) {
        pending.splice(index, 1);
      }
    },
  };
}

يتم تخزين التحميل المسبق تحت مفتاح خاص PreloadStore، عمداً ليس المفتاح الأساسي topic_${id}، فإن الكتابة هناك ستسرب JSON جزئي النطاق last_read+1 إلى تنقل موضوع كامل حقيقي. عند النقر الفعلي يتم ترقيته إلى المفتاح الذي يتوقعه محمل النافذة المنبثقة:

promoteTopicPrefetch(topic) {
  if (!topic?.id) {
    return;
  }
  try {
    const key = this.prefetchStoreKey(topic.id);
    const prefetched = PreloadStore.get?.(key);
    if (prefetched != null) {
      PreloadStore.remove?.(key);
      PreloadStore.store(`topic_${topic.id}`, prefetched);
    }
  } catch {
    // جهد قصوى - تعود النافذة المنبثقة إلى تحميل جديد
  }
}

إذا خرج صف من الشاشة قبل النقر عليه، يتم إلغاء الطلب المعلق وتجاهل أي نتيجة مخزنة مؤقتاً، لذا لا يحتفظ إلا بالبيانات للموضوعات الظاهرة فعلياً على الشاشة الآن.


شيء آخر، لم يتم طرحه في الموضوع بعد: النوافذ المنبثقة المتداخلة

بعض تدفقات النوافذ المنبثقة (التي يفتحها النظام الأساسي بدلاً من خلال خاصية استدعاء صريحة مربوطة بـ showSubModal()) تستدعي خدمة modal مباشرة. بدلاً من السماح لها بإغلاق نافذة المعاينة من تحت القارئ، يتم تصحيح modal.show() لإعادة توجيه النوافذ المنبثقة المستهدفة بشكل انتقائي إلى فتحة نافذة فرعية محلية صغيرة طالما أن المعاينة مفتوحة:

this.originalModalShow = this.modal.show.bind(this.modal);
this.modal.show = (component, opts = {}) => {
  return this.showSubModal(component, opts.model);
};

لذا فإن هذه التدفقات المستهدفة تتراكم فوق المعاينة بدلاً من استبدالها، بينما يستمر تتبع القراءة (/topics/timings) في العمل في الخلفية عبر تفريغ دوري لمجموعة أرقام المنشورات المرئية التي يتتبعها IntersectionObserver، لذا تكون عدادات غير المقروءة صحيحة بعد الإغلاق، تماماً مثل زيارة موضوع عادي.


إنها قطعة عادلة من الكود عبر النافذة المنبثقة، ومُفعّل قائمة الموضوع، والتنسيق (~1700 سطر معاً)، هذا يغطي ثلثها تقريباً. كانت أصعب الأجزاء من بعيد هي تصحيح الخدمات وجعل content-visibility يتصرف بشكل صحيح بمجرد تغير ارتفاعات المنشورات بعد انتهاء تحميل الصور. سعيد بالدخول في مزيد من التفاصيل حول أي منها، أو جعله مفتوح المصدر، إذا كان هناك اهتمام.