| Résumé | Modale d’aperçu des sujets – ouvrir et interagir avec les sujets sans quitter la liste des sujets | |
| Aperçu | Theme Creator | |
| Dépôt | 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 | |
| Vous l’avez trouvé utile ? | > ./support --coffee | |
| Guide d’installation | Comment installer un thème ou un composant de thème | |
| Nouveau aux thèmes Discourse ? | Guide du débutant pour l’utilisation des thèmes Discourse |
Installer ce composant de thème
Modale d’aperçu des sujets – ouvrir et interagir avec les sujets sans quitter la liste des sujets
J’ai créé un nouveau composant de thème Discourse appelé Topic Preview Modal.
L’idée est assez simple :
Ouvrir un sujet directement depuis la liste des sujets dans une modale native de Discourse, lire et interagir avec le sujet, puis continuer à parcourir la liste sans en partir.
Cela a commencé avec Facebook-style Topic Modal - Is it better? , mais cela a fini par nécessiter une intégration assez importante avec les systèmes de sujets, de flux de messages, de composeur, de modales, de signets, de routage, de présence, de suivi de lecture et de préchargement de Discourse.
Pourquoi ?
Le flux normal de Discourse est le suivant :
- Vous parcourez une liste de sujets.
- Vous cliquez sur un sujet.
- Discourse navigue vers
/t/.... - Vous lisez/répondez/interagissez avec le sujet.
- Vous retournez à la liste des sujets.
Pour de nombreux flux de travail, c’est parfaitement acceptable.
Cependant, lorsqu’on parcourt une liste de sujets animée, parfois je veux juste inspecter rapidement un sujet, lire quelques messages, vérifier les dernières réponses, réagir à quelque chose ou répondre à une question rapide.
Pour ce cas d’utilisation, quitter la liste des sujets semble inutilement coûteux.
L’objectif de ce composant était donc de faire en sorte que la liste des sujets se comporte davantage comme une boîte de réception :
liste des sujets → aperçu → interaction → fermeture → reprendre exactement là où vous étiez.
Ce qu’il fait
L’aperçu n’est pas juste un extrait statique.
Il affiche les composants de message réels de Discourse dans une DModal native.
Cela signifie que les utilisateurs peuvent :
- lire des messages
- faire défiler le sujet
- charger des messages antérieurs
- charger plus de messages en dessous
- réagir aux messages
- mettre en signet des messages
- citer du texte
- répondre au sujet
- répondre à des messages individuels
- modifier des messages si autorisé
- supprimer/récupérer des messages si autorisé
- signaler des messages
- consulter l’historique des messages
- effectuer diverses actions normales sur les messages
- voir la présence du sujet
- suivre les liens vers d’autres messages au sein du même sujet
- sauter directement au message pertinent
- ouvrir le sujet complet si nécessaire
L’intention est que l’aperçu se sente aussi proche que possible de l’ouverture réelle du sujet.
Deux modes de déclenchement
Il y a deux façons d’ouvrir l’aperçu.
1. Ligne entière de la liste des sujets
C’est le mode par défaut.
Toute la ligne de la liste des sujets devient cliquable, tandis que les éléments interactifs courants tels que :
- les cartes utilisateur
- les participants
- les liens de catégorie
- les balises
- les liens de statut du sujet
- la sélection groupée
sont exclus du déclenchement de la modale.
Cela rend l’expérience très rapide lors du parcours d’une liste de sujets.
2. Bouton d’expansion explicite
Alternativement, le composant peut afficher une petite icône d’expansion via une prise en charge de plugin Discourse. Les thèmes personnalisés peuvent simplement créer une nouvelle <PluginOutlet /> pour afficher le déclencheur.
Dans ce mode, le comportement normal de la liste des sujets reste totalement intact.
L’utilisateur clique sur l’icône d’expansion pour ouvrir l’aperçu, tandis que le clic sur le titre du sujet effectue toujours la navigation Discourse normale.
Cela est utile si un site souhaite préserver le modèle d’interaction standard de la liste des sujets.
Le paramètre est :
trigger_style:
row
ou :
trigger_style:
button
Lors de l’utilisation du mode bouton, la prise en charge (outlet) est également configurable.
L’aperçu démarre à la position non lue de l’utilisateur
L’un des détails importants est que la modale ne charge pas simplement le premier message.
Lorsqu’un sujet a déjà été partiellement lu, l’aperçu calcule :
last_read_post_number + 1
et s’ouvre autour de ce message.
Donc, si un sujet a 200 messages et que l’utilisateur a lu jusqu’au message n°165, l’ouverture de l’aperçu commence autour du n°166.
Cela rend l’aperçu beaucoup plus utile pour la navigation réelle.
Cela signifie également que le composant doit gérer les deux côtés du flux de messages :
- charger des messages antérieurs si nécessaire
- charger des messages plus récents en dessous
Le bouton Messages antérieurs est affiché lorsqu’il y a des messages au-dessus de la plage actuellement chargée, tandis qu’un sentinelle IntersectionObserver charge automatiquement plus de messages lorsque l’utilisateur atteint le bas.
Préchargement (Prefetching)
L’une des plus grandes parties du composant est son système de préchargement.
Le problème avec une modale comme celle-ci est que l’utilisateur s’attend à ce qu’elle se sente instantanée.
Si nous ne commençons à charger le sujet qu’après le clic de l’utilisateur, la modale peut encore passer un temps notable à attendre le réseau.
Au lieu de cela, le composant peut précharger proactivement les sujets pendant que l’utilisateur parcourt la liste.
Lorsqu’une ligne de sujet approche du viewport, un IntersectionObserver peut planifier un préchargement.
Il y a plusieurs garde-fous pour empêcher cela de se transformer en trafic de fond incontrôlé.
Anti-rebond (Debouncing)
Un sujet ne déclenche pas immédiatement une demande juste parce qu’il est brièvement apparu dans le viewport.
Le composant attend la période d’anti-rebond configurée.
Par défaut :
400 ms
Cela est particulièrement utile lors du défilement rapide d’une longue liste de sujets.
Marge racine (Root margin)
Le préchargement peut commencer légèrement avant que le sujet n’entre réellement dans le viewport.
Par défaut :
50 px
Cela donne à la demande une petite avance.
Limite de demandes simultanées
Le nombre de préchargements simultanés est limité.
Par défaut :
2
Le paramètre permet entre 1 et 6 préchargements simultanés.
Budget par minute
Il y a aussi un second mécanisme de protection :
max_prefetches_per_minute
La valeur par défaut est :
15
Ainsi, même si l’utilisateur continue à faire défiler des centaines de sujets, le composant ne générera pas continuellement des demandes spéculatives.
0 désactive la limite.
Le préchargement peut être complètement désactivé
Si un site ne veut aucun trafic réseau spéculatif :
enable_prefetch = false
Le composant continue de fonctionner normalement. Les sujets sont simplement chargés lorsque l’aperçu est ouvert.
Les données de préchargement sont maintenues séparées de la navigation normale des sujets
Il y a un détail d’implémentation important ici.
La réponse préchargée n’est pas immédiatement écrite dans la clé de préchargement normale topic_<id> de Discourse.
Au lieu de cela, le composant utilise son propre espace de noms :
topic-preview-modal:prefetch:<topicId>
Seulement lorsque l’utilisateur ouvre réellement l’aperçu, la promesse préchargée est promue vers la clé de préchargement du sujet principal.
C’est intentionnel.
L’aperçu peut charger un sujet en commençant par last_read_post_number + 1, et je ne veux pas que cette réponse spécifique à l’aperçu se retrouve dans une navigation de route de sujet normale.
Donc, le cycle de vie est essentiellement :
le sujet entre dans le viewport
↓
préchargement
↓
stockage de préchargement privé
↓
l'utilisateur ouvre l'aperçu
↓
promotion du préchargement
↓
Topic.find()/PostStream utilise la même promesse
Cela signifie également que la modale n’a pas à attendre la fin de la demande de préchargement avant de s’ouvrir.
La modale peut s’ouvrir immédiatement avec son squelette (skeleton) pendant que la même promesse continue de se résoudre.
Support mobile
C’était en fait l’une des raisons pour lesquelles j’ai passé considérablement plus de temps sur l’implémentation.
L’idée initiale fonctionnait raisonnablement bien sur ordinateur de bureau, mais le mobile a révélé plusieurs problèmes liés à :
- l’interaction tactile
- le défilement de la modale
- le focus
- les menus imbriqués
- le composeur
- la visibilité des messages
- le chargement des images
- les performances
L’implémentation finale évite donc de traiter la modale comme un mini-forum complètement séparé.
Au contraire, elle réutilise autant que possible l’infrastructure existante de Discourse.
Composants de message réels de Discourse
La modale ne recrée pas les messages en utilisant un modèle personnalisé simplifié.
Elle affiche les composants réels de Discourse :
Post
PostSmallAction
C’est important, car sinon l’aperçu deviendrait rapidement une seconde implémentation de l’interface utilisateur des messages.
Le composant passe les actions pertinentes aux composants de message normaux, y compris des choses comme :
- répondre
- modifier
- supprimer
- récupérer
- signaler
- historique
- signet
- wiki
- verrouiller/déverrouiller
- type de message
- changements de propriété
- badges
- messages masqués
- citation
- etc.
Le résultat est que l’aperçu peut se comporter beaucoup plus comme un sujet normal qu’un composant d’« aperçu » traditionnel.
Réponses et le composeur
Le composeur est l’une des parties plus compliquées.
L’aperçu peut ouvrir le composeur Discourse normal pour :
Répondre au sujet
Le composeur du sujet est ouvert avec le modèle du sujet et les informations de brouillon correctes.
Répondre à un message spécifique
Le message est passé au composeur afin que la réponse se comporte comme une réponse de message normale.
Citer du texte sélectionné
Le composant s’intègre également avec PostTextSelection.
Cela signifie que les utilisateurs peuvent sélectionner du texte dans l’aperçu et utiliser le flux de citation/réponse normal de Discourse.
Modales imbriquées
Une autre partie délicate était le système de modales de Discourse.
Les messages peuvent ouvrir d’autres modales et dialogues :
- signalement
- historique
- dialogues liés aux badges
- changements de propriété
- confirmations de suppression
- etc.
Si ceux-ci étaient autorisés à interagir normalement avec le service de modales global, l’ouverture de l’un d’entre eux pourrait fermer tout l’aperçu du sujet.
Pour éviter cela, le composant crée un mécanisme de sous-modale local.
Conceptuellement :
Modale d'aperçu du sujet
│
├── Modale de signalement
├── Modale d'historique
├── Confirmation de suppression
├── Modale de badge
└── autre modale liée au message
L’aperçu reste monté en dessous.
Le composant applique temporairement des correctifs (patches) aux méthodes pertinentes du service de modales pendant qu’il est actif et les restaure lorsqu’il est détruit.
Routage dans la modale
Un autre détail important concerne les liens vers des messages au sein du même sujet.
Par exemple, si un message contient un lien vers :
/t/my-topic/123
l’aperçu n’a pas besoin de se fermer et de naviguer ailleurs.
Au lieu de cela, le composant intercepte la navigation du même sujet et saute au message demandé dans la modale.
Cela s’applique également aux liens ciblant le sujet sans numéro de message spécifique.
Cela garde l’utilisateur dans l’aperçu.
Si le lien pointe vers un sujet réellement différent, le composant restaure d’abord ses correctifs de service temporaires et se ferme avant d’autoriser la transition de route Discourse normale.
Ce nettoyage est important, car sinon les abonnements et le suivi de temps de l’aperçu pourraient rester actifs pendant que la route du sujet réel est initialisée.
Suivi de lecture et suivi du temps
Je voulais aussi que l’aperçu se comporte correctement du point de vue de Discourse.
Ouvrir un aperçu ne devrait pas signifier que le suivi de lecture est complètement contourné.
Le composant gère donc :
- le suivi des visites de sujet
- le suivi des messages visibles
- la temporisation du sujet
- les mises à jour du dernier message lu
Le suivi de la temporisation utilise un IntersectionObserver pour déterminer quels messages sont réellement visibles.
Toutes les 5 secondes, la temporisation des messages visibles est transmise vers :
/topics/timings
Lorsque la modale se ferme, une dernière transmission est effectuée afin que les dernières secondes ne soient pas perdues.
L’implémentation limite également un intervalle de temporisation unique à 60 secondes.
Maintenir l’état non lu de la liste des sujets synchronisé
Il y avait un autre problème subtil ici.
Mettre à jour l’état de suivi des sujets de Discourse seul ne suffit pas pour mettre à jour le badge non lu affiché directement sur une ligne de la liste des sujets.
Le composant met donc à jour l’objet de sujet réel associé à la ligne après que les informations de temporisation aient été transmises.
Il met à jour des valeurs telles que :
last_read_post_number
unread_posts
unread
new_posts
le cas échéant.
Cela signifie qu’après avoir lu un sujet dans la modale, la liste des sujets peut immédiatement refléter le nouvel état de lecture au lieu d’exiger un rechargement complet de la page.
Visibilité des messages
L’aperçu utilise un IntersectionObserver partagé pour déterminer quand des messages individuels deviennent visibles.
Il y a aussi une vérification de visibilité synchrone lorsque l’observateur est attaché.
Cela gère un cas limite où un message est déjà visible lorsqu’il est monté, mais que le premier rappel asynchrone de IntersectionObserver ne s’est pas encore déclenché.
C’est particulièrement pertinent pour les sujets très courts où l’ensemble du sujet peut déjà être visible lorsque la modale s’ouvre.
Considérations de performance
Un objectif majeur était d’éviter de transformer la modale en page de sujet miniature gourmande en ressources.
Quelques choses sont faites spécifiquement pour cela.
Rendu progressif
Le chargement initial n’affiche pas immédiatement chaque message.
Le composant affiche d’abord suffisamment de messages pour atteindre la position cible.
Les messages restants sont ensuite affichés progressivement en utilisant :
requestIdleCallback
lorsqu’il est disponible, avec un repli sur setTimeout.
Cela est particulièrement utile lors de l’ouverture d’un long sujet autour d’un message loin dans le flux.
Contenance CSS
Les messages utilisent :
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
Cela permet au navigateur d’éviter de faire des travaux de rendu inutiles pour les messages qui ne sont pas actuellement visibles.
Images paresseuses (Lazy images)
Les images qui n’ont pas déjà spécifié un mode de chargement reçoivent automatiquement :
loading="lazy"
decoding="async"
Cela empêche un long sujet avec de nombreuses images de tout charger immédiatement.
État de chargement
La modale n’affiche pas simplement une zone blanche/vide pendant que la demande est en cours.
Elle a une interface squelette (skeleton UI) avec :
- des placeholders d’avatar
- des placeholders de nom d’utilisateur/nom
- des placeholders de corps de message
- une animation scintillante (shimmer)
L’animation scintillante respecte :
prefers-reduced-motion
de sorte que l’animation est désactivée pour les utilisateurs qui ont demandé une réduction des animations.
Maintenir la position de défilement stable
Il y a quelques endroits où le composant doit manipuler manuellement la position de défilement.
Par exemple, lors du chargement de messages antérieurs, le contenu nouvellement inséré augmente la hauteur de défilement.
Préfixer simplement les messages ferait sauter la position actuelle de l’utilisateur.
Le composant enregistre donc la hauteur de défilement précédente et compense la différence après l’insertion des messages.
Cela garde le contenu actuellement visible approximativement au même endroit.
Cela s’applique également lors du saut à un message particulier.
Le composant effectue une étape de positionnement après le rendu et vérifie à nouveau la position sur les images suivantes pour tenir compte du contenu qui peut encore se stabiliser.
Présence du sujet
Lorsque les données de sujet pertinentes sont disponibles, l’aperçu peut également afficher les informations de présence du sujet de Discourse en bas de la modale.
Ainsi, les utilisateurs peuvent voir qui d’autre consulte actuellement le sujet sans avoir à quitter l’aperçu.
Interaction avec les menus mobiles et le focus
Le mobile a introduit une autre catégorie de problèmes.
Certains éléments d’interface utilisateur de Discourse utilisent des services de modale/menu partagés, et ces services ne savent pas nécessairement que l’aperçu du sujet agit actuellement comme un contexte de navigation imbriqué.
Le composant a donc une gestion supplémentaire autour de :
modal.close()- les menus Float Kit
- la restauration du focus
- le composeur
- les contrôles clavier de la lightbox
- les verrous de défilement du corps (body scroll locks)
Par exemple, si un menu tente d’appeler la méthode de fermeture de modale globale en interne, cela ne devrait pas fermer accidentnellement tout l’aperçu du sujet.
De même, lorsque le composeur est ouvert, le focus doit rester à l’intérieur du composeur au lieu d’être ramené dans le contexte de focus de l’aperçu.
Configuration
Le composant expose actuellement les paramètres suivants :
| Paramètre | Par défaut | Description |
|---|---|---|
trigger_style |
row |
Rendre toute la ligne cliquable ou utiliser un bouton explicite |
plugin_outlet |
topic-list-after-title |
Prise en charge (outlet) utilisée par le déclencheur bouton |
enable_prefetch |
true |
Activer/désactiver le préchargement de sujets en arrière-plan |
max_concurrent_prefetches |
2 |
Nombre maximum de demandes de préchargement simultanées |
prefetch_debounce_ms |
400 |
Délai avant le démarrage d’un préchargement |
prefetch_root_margin_px |
50 |
Commencer le préchargement à ce nombre de pixels avant que la ligne n’entre dans le viewport |
max_prefetches_per_minute |
15 |
Nombre maximum de demandes spéculatives par minute |
Les contrôles de préchargement sont intentionnellement configurables car différentes communautés peuvent avoir des modèles de trafic et des caractéristiques d’hébergement/réseau très différents.
L’un des principaux objectifs de conception : ne pas casser Discourse normal
J’ai essayé de garder le composant aussi proche que possible de l’architecture existante de Discourse.
Il n’implémente pas son propre rendu de messages, son propre composeur, son propre modèle de sujet ou son propre flux de messages complètement séparé.
Au contraire, il construit un contexte de navigation temporaire autour des composants et services existants de Discourse.
C’est aussi pourquoi certaines parties de l’implémentation sont plus compliquées qu’elles ne pourraient sembler au premier abord.
Le défi le plus intéressant était :
Peut-on faire en sorte qu’un sujet se comporte presque comme un sujet Discourse normal alors qu’il est en réalité affiché dans un autre contexte d’interface utilisateur ?
Cela a nécessité de gérer les frontières entre les services globaux de Discourse et l’aperçu local.





