Fenêtre modale de type Facebook pour les sujets - Est-ce mieux ?

J’ai passé du temps à bidouiller pour donner un look différent à d’autres sites, et j’ai créé une modale de sujet style Facebook. Elle semble charger plus vite pour moi et paraît plus fluide sur desktop.

Qu’en pensez-vous ?

Le lien pour tester est https://www.carptalk-online.co.uk/ (ne fonctionne que sur desktop)

bien joué ! j’apprécie beaucoup, même si je ne suis pas utilisateur de Facebook. ton site est super cool. :star_struck:

aussi, je ne me rendais pas compte que les carpes pouvaient devenir aussi énormes ! :fishing_pole:

Très cool ! L’as-tu testé dans des sujets avec plus de 20 réponses ? C’est à ce moment-là que nous chargeons davantage de « pages » de messages au défilement, et d’après mon expérience, c’est l’une des plus grandes complications avec ce type de vue alternative.

Non, je vais le faire maintenant et je te tiendrai au courant.

Fonctionne correctement - URL SUPPRIMÉE

Je vois la page se charger comme vous le dites, mais elle fonctionne comme prévu.

Le sujet n’apparaît pas dans une fenêtre modale lorsque l’on clique sur le lien direct. Par contre, cela fonctionne depuis la liste des sujets.

Oui, c’est la prochaine chose que je dois examiner, mais je me pose la question : est-ce que cela a de l’importance ? C’est exactement comme se comporte Facebook : le lien direct mène au message réel, et si on clique en naviguant, une fenêtre modale s’ouvre.

Bel concept. Êtes-vous capable de maintenir une connexion constante avec le bus de messages pour les nouveaux messages tout en chargeant les anciens ?

Oui, car la modale charge l’application Discourse normale pour les sujets à l’intérieur d’une iframe, au lieu de rendre une copie personnalisée du sujet. L’iframe établit sa propre connexion MessageBus, et Discourse continue de gérer le flux de messages normalement, y compris le chargement des anciens messages et la réception des nouveaux messages publiés.

La page parente reste également active en arrière-plan, donc techniquement, il y a deux instances Discourse et deux connexions MessageBus tant que la modale est ouverte. Cela fonctionne, bien que ce soit légèrement plus lourd qu’une implémentation entièrement native. Fermer la modale supprime l’iframe et sa connexion.

Je ne sais pas si cela a de l’importance, et je n’utilise pas Facebook :stuck_out_tongue:

J’étais simplement surpris que cliquer sur ton lien n’affichait pas le sujet dans une fenêtre modale, alors que tu l’as posté précisément pour montrer qu’il fonctionnait dans une fenêtre modale.

Mon message initial contenait un lien vers la page d’accueil. Tu as cliqué sur ma réponse à Kris qui testait les sujets, haha ? Comment vas-tu d’ailleurs ? Je ne t’ai pas vu depuis un moment.

Je viens de terminer la version mobile et je suis satisfait du fonctionnement global. La prochaine étape consiste à trouver une meilleure méthode pour optimiser le tout et réduire le temps de chargement. Avez-vous des suggestions ?

Après plusieurs tests, j’ai utilisé la version sans JavaScript que Google voit, et elle se charge instantanément dans la modale. Si je pouvais d’une manière ou d’une autre utiliser le contenu de cette version, cela accélérerait le processus.

L’affichage sur ordinateur est très réussi,

mais le chargement sur mobile est un peu lent. Après avoir cliqué, on ne voit qu’une page vide avec un bouton de fermeture en haut à droite, ce qui oblige à attendre longtemps face à un écran blanc.

Voici ce sur quoi je travaille en ce moment :slight_smile: Pour l’instant, c’est juste un concept que je souhaite concrétiser. Cependant, sur mon appareil, cela se charge en 2 secondes sur mobile avec l’icône de chargement Discourse, donc je ne suis pas sûr de ce qui se passe. Probablement parce que le serveur est à Londres, au Royaume-Uni, et qu’il n’y a pas encore de CDN.

La distance est effectivement trop grande, ce qui entraîne des délais de transmission réseau.

J’ai maintenant réussi à charger les sujets instantanément dans la modale, il me reste maintenant à trouver un moyen d’utiliser la première instance Discourse chargée pour les réponses, etc. (cela se passe sur le serveur de test)

J’ai désactivé cette fonctionnalité sur le site principal, car il commence à recevoir du trafic avec des inscriptions d’utilisateurs, donc elle a été déplacée sur un serveur de test. J’espère avoir terminé d’ici la mi-août, si quelqu’un s’intéresse à une fenêtre modale.

Bonjour :waving_hand:

Approche différente de celle de @Damian_Boon : pas d’iframe, tout fonctionne via des composants Glimmer natifs qui réutilisent le mécanisme Post/post-stream de Discourse. Comme quelques questions spécifiques ont été soulevées dans le fil de discussion, voici comment j’ai résolu chacune d’elles.


Pourquoi pas une iframe

Une iframe est de loin le moyen le plus rapide pour y parvenir : route complète du sujet, éditeur, modération, le tout gratuitement. Le coût, que la question de @nicolsdennis sur MessageBus soulève, c’est que vous vous retrouvez avec deux instances Discourse actives sur la page : deux flux de posts, deux connexions MessageBus, deux éditeurs.

J’ai choisi la voie opposée. La modale est un DModal qui rend les vrais composants Post/PostSmallAction contre un postStream construit à partir de store.createRecord("topic", …), même application, même connexion MessageBus, même éditeur. Cela signifie qu’une poignée de services singleton centraux (modal, bookmarkApi, DiscourseURL.routeTo, le controller:topic ambiant) doivent être redirigés vers le sujet de la modale tant qu’elle est ouverte :

// Les composants principaux (ex. PostBookmarkManager) lis/écrivent topic.bookmarks
// depuis controller:topic. On le pointe vers notre topicModel pendant que la modale est ouverte.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
  this.originalTopicControllerModel = this.topicController.model;
}

Chaque modification est annulée par un restoreServicePatches() idempotent lors de la fermeture, de la destruction ou de la navigation ailleurs, de sorte que rien ne fuit dans le reste de l’application une fois la modale fermée.


Liens directs et routage interne

Deux aspects. Premièrement, la modale devrait s’ouvrir là où le lecteur s’est arrêté, et non toujours au post 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)
);

Deuxièmement, les liens à l’intérieur des posts qui pointent vers le même sujet ne devraient pas échapper à la modale. DiscourseURL.routeTo est modifié pour la durée de son ouverture :

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);
};

Tout ce qui pointe ailleurs restaure d’abord les modifications et passe à une navigation normale, de sorte que quitter la modale ne laisse jamais de routage obsolète.


Le cas des réponses (20+ réponses)

Défilement infini en bas via un sentinelle + IntersectionObserver, un “charger plus ancien” symétrique en haut (une prévisualisation peut s’ouvrir au milieu du fil), les deux utilisant exactement postStream.appendMore() / prependMore() que la route réelle du sujet utilise :

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();
});

Premier rendu

Un squelette (placeholders d’avatar et de ligne, effet shimmer) remplit le premier rendu au lieu d’un indicateur de chargement, et la liste des posts elle-même se rend par étapes au lieu de tout d’un coup, juste assez de posts pour atteindre le post cible en premier, puis cinq de plus à la fois via requestIdleCallback jusqu’à ce que le reste rattrape son retard :

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;
};

Chaque conteneur de post reçoit également content-visibility: auto avec une taille intrinsèque approximative (contain-intrinsic-size), de sorte que le navigateur ignore complètement la mise en page pour les posts qui sont hors écran :

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

C’est la partie sur laquelle j’insisterais le plus. Au lieu de réutiliser une instance après le clic, je précharge avant le clic. Chaque ligne de la liste des sujets surveille sa propre visibilité, applique un délai d’attente de 200 ms pour qu’un défilement rapide ne déclenche pas une douzaine de requêtes, et délègue à une petite file d’attente partagée limitée à 2 requêtes simultanées :

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);
      }
    },
  };
}

La précharge est stockée sous une clé privée PreloadStore, délibérément pas la clé centrale topic_${id}, écrire là-dedans ferait fuiter un JSON partiel limité à last_read+1 dans une navigation réelle vers un sujet complet. Au clic effectif, elle est promue vers la clé que le chargeur de la modale attend :

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 {
    // tentative - la modale repasse à un chargement frais
  }
}

Si une ligne sort de la vue avant d’être cliquée, la requête en attente est annulée et tout résultat mis en cache est rejeté, de sorte qu’elle ne contient jamais que des données pour les sujets actuellement affichés à l’écran.


Un dernier, non soulevé dans le fil : modales imbriquées

Quelques flux de modales (ceux que le noyau ouvre lui-même plutôt que via une propriété de rappel explicite câblée à showSubModal()) appellent le service modal directement. Plutôt que de laisser ceux-ci fermer la modale de prévisualisation sous les yeux du lecteur, modal.show() est modifié pour rediriger sélectivement les modales cibles vers un petit emplacement de sous-modale locale locale tant que la prévisualisation est ouverte :

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

de sorte que ces flux cibles se superposent à la prévisualisation au lieu de la remplacer, tandis que le suivi de lecture (/topics/timings) continue de fonctionner en dessous via un vidage périodique d’un ensemble de numéros de posts visibles suivi par IntersectionObserver, de sorte que les comptes de non-lus restent corrects après la fermeture, tout comme une visite normale de sujet.


C’est un morceau de code assez conséquent réparti entre la modale, le déclencheur de la liste des sujets et le style (~1700 lignes au total), cela couvre peut-être un tiers de cela. Les parties les plus délicates de loin étaient le patching des services et le fait de faire se comporter content-visibility une fois que les hauteurs des posts changent après le chargement des images. Heureux d’entrer dans plus de détails sur l’un ou l’autre, ou de le rendre open source, s’il y a de l’intérêt.