Modale d'aperçu du sujet

Installer ce composant de thème

Modale d’aperçu de sujet – ouvrir et interagir avec des sujets sans quitter la liste des sujets

J’ai créé un nouveau composant de thème Discourse appelé Modale d’aperçu de sujet.

L’idée est assez simple :

Ouvrir un sujet directement depuis la liste des sujets dans une modale native Discourse, lire et interagir avec le sujet, puis continuer à parcourir la liste sans en sortir.

Cela a commencé par Facebook-style Topic Modal - Is it better? , mais cela s’est avéré nécessiter une intégration assez importante avec les systèmes de sujet, de flux de messages, de rédacteur, de modale, de signet, de routage, de présence, de suivi de lecture et de préchargement de Discourse.


Pourquoi ?

Le flux normal de Discourse est :

  1. Vous parcourez une liste de sujets.
  2. Vous cliquez sur un sujet.
  3. Discourse navigue vers /t/....
  4. Vous lisez/répondez/interagissez avec le sujet.
  5. Vous retournez à la liste des sujets.

Pour de nombreux flux de travail, cela est tout à fait acceptable.

Cependant, lors de la navigation dans une liste de sujets très active, parfois je veux simplement 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 simplement un extrait statique.

Il rend les composants de message Discourse réels à l’intérieur d’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 ci-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 lorsque c’est autorisé
  • supprimer/récupérer des messages lorsque c’est autorisé
  • signaler des messages
  • afficher l’historique des messages
  • effectuer diverses actions normales sur les messages
  • voir la présence du sujet
  • suivre des liens vers d’autres messages dans le même sujet
  • passer 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 existe deux façons d’ouvrir l’aperçu.

1. Ligne entière de la liste des sujets

C’est le comportement par défaut.

Toute la ligne de la liste des sujets devient cliquable, tandis que les éléments interactifs courants tels que :

  • cartes utilisateur
  • participants
  • liens de catégorie
  • balises
  • liens de statut du sujet
  • sélection en masse

sont exclus du déclenchement de la modale.

Cela rend l’expérience très rapide lors de la navigation dans une liste de sujets.

2. Bouton d’extension explicite

Alternativement, le composant peut rendre une petite icône d’extension via une prise 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 complètement inchangé.

L’utilisateur clique sur l’icône d’extension 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 conserver 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 est également configurable.


L’aperçu commence à 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 #165, l’ouverture de l’aperçu commence autour du #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 plus de messages ci-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

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 semble instantanée.

Si nous ne commençons à charger le sujet qu’après que l’utilisateur a cliqué, la modale peut encore passer un temps notable à attendre le réseau.

Au lieu de cela, le composant peut précharger activement les sujets pendant que l’utilisateur parcourt la liste.

Lorsqu’une ligne de sujet approche de la zone de visualisation, un IntersectionObserver peut planifier un préchargement.

Il existe plusieurs garde-fous pour empêcher cela de se transformer en trafic en arrière-plan incontrôlé.

Temporisation (Debouncing)

Un sujet ne déclenche pas immédiatement une requête simplement parce qu’il est brièvement apparu dans la zone de visualisation.

Le composant attend la période de temporisation configurée.

Par défaut :

400 ms

Cela est particulièrement utile lors du défilement rapide à travers 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 la zone de visualisation.

Par défaut :

50 px

Cela donne à la requête une petite avance.

Limite de requêtes concurrentes

Le nombre de préchargements simultanés est limité.

Par défaut :

2

Le paramètre permet entre 1 et 6 préchargements concurrents.

Budget par minute

Il y a aussi un deuxième mécanisme de protection :

max_prefetches_per_minute

La valeur par défaut est :

15

Donc, même si l’utilisateur continue à faire défiler des centaines de sujets, le composant ne générera pas continuellement de requêtes spéculatives.

0 désactive la limite.

Le préchargement peut être désactivé complètement

Si un site ne veut aucun trafic réseau spéculatif :

enable_prefetch = false

Le composant continue de fonctionner normalement. Les sujets se chargent simplement lorsque l’aperçu est ouvert.


Les données préchargées sont conservées séparément 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>

Ce n’est que lorsque l’utilisateur ouvre réellement l’aperçu que la promesse préchargée est promue vers la clé de préchargement de sujet principale.

C’est intentionnel.

L’aperçu peut charger un sujet à partir de last_read_post_number + 1, et je ne veux pas que cette réponse spécifique à l’aperçu fuite dans une navigation de route de sujet normale.

Donc le cycle de vie est essentiellement :

le sujet entre dans la zone de visualisation
        ↓
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 que la requête de préchargement se termine avant de s’ouvrir.

La modale peut s’ouvrir immédiatement avec son squelette tandis 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 bureau, mais le mobile a exposé plusieurs problèmes autour de :

  • interaction tactile
  • défilement de la modale
  • focus
  • menus imbriqués
  • le rédacteur
  • visibilité des messages
  • chargement des images
  • performance

L’implémentation finale évite donc de traiter la modale comme un mini-forum complètement séparé.

Au lieu de cela, elle réutilise autant que possible l’infrastructure existante de Discourse.


Composants de message Discourse réels

La modale ne recrée pas les messages en utilisant un modèle personnalisé simplifié.

Elle rend les composants Discourse réels :

Post
PostSmallAction
```\n
Ceci est important car sinon l'aperçu deviendrait rapidement une deuxième implémentation de l'interface utilisateur des messages.

Le composant passe les actions pertinentes dans les 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 rédacteur

Le rédacteur est l'une des parties plus compliquées.

L'aperçu peut ouvrir le rédacteur Discourse normal pour :

### Répondre au sujet

Le rédacteur du sujet est ouvert avec le modèle de sujet et les informations de brouillon correctes.

### Répondre à un message spécifique

Le message est passé au rédacteur 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 à l'intérieur de 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 boîtes de dialogue :

* signalement
* historique
* boîtes de dialogue liées aux badges
* changements de propriété
* confirmations de suppression
* etc.

Si celles-ci étaient autorisées à interagir normalement avec le service de modale global, l'ouverture de l'une d'elles pourrait fermer l'aperçu de sujet entier.

Pour éviter cela, le composant crée un mécanisme de sous-modale local.

Conceptuellement :

```text
Modale d'aperçu de 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 corrige temporairement les méthodes pertinentes du service de modale pendant qu’il est actif et les restaure lorsqu’il est détruit.


Routage à l’intérieur de la modale

Un autre détail important concerne les liens vers des messages dans le 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 dans le même sujet et passe au message demandé à l’intérieur de la modale.

La même chose s’applique aux liens ciblant le sujet sans numéro de message spécifique.

Cela garde l’utilisateur à l’intérieur de l’aperçu.

Si le lien pointe vers un sujet véritablement différent, le compos restaure d’abord ses correctifs de service temporaires et se ferme avant de permettre la transition de route Discourse normale.

Ce nettoyage est important car sinon les abonnements de l’aperçu et le traqueur de temps pourraient rester actifs pendant que la route de sujet réelle est initialisée.


Suivi de lecture et suivi du temps

Je voulais également 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 :

  • suivi des visites de sujet
  • suivi des messages visibles
  • chronométrage du sujet
  • mises à jour du dernier message lu

Le traqueur de temps utilise un IntersectionObserver pour déterminer quels messages sont réellement visibles.

Toutes les 5 secondes, le chronométrage des messages visibles est vidé vers :

/topics/timings

Lorsque la modale se ferme, un vidage final est effectué afin que les dernières secondes ne soient pas perdues.

L’implémentation limite également un intervalle de temps 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 sujet réel associé à la ligne après que les informations de chronométrage ont été vidées.

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 à l’intérieur de la modale, la liste des sujets peut immédiatement refléter le nouvel état de lecture au lieu de nécessiter un rafraîchissement complet de la page.


Visibilité des messages

L’aperçu utilise un IntersectionObserver partagé pour déterminer quand les 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 le premier rappel asynchrone IntersectionObserver ne s’est pas encore déclenché.

Cela 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 une page de sujet miniature lourde en termes de performance.

Quelques choses sont faites spécifiquement pour cela.

Rendu progressif

Le chargement initial ne rend pas immédiatement chaque message.

Le composant rend d’abord suffisamment de messages pour atteindre la position cible.

Les messages restants sont ensuite rendus progressivement en utilisant :

requestIdleCallback

lorsqu’il est disponible, avec une rétroaction vers setTimeout.

Cela est particulièrement utile lors de l’ouverture d’un long sujet autour d’un message loin dans le flux.

Contention CSS

Les messages utilisent :

contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;

Cela permet au navigateur d’éviter de faire un travail de rendu inutile pour les messages qui ne sont pas actuellement visibles.

Images paresseuses

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 charger tout immédiatement.


État de chargement

La modale n’affiche pas simplement une zone blanche/vide pendant que la requête est en cours.

Elle a une interface utilisateur squelette avec :

  • espaces réservés d’avatar
  • espaces réservés de nom d’utilisateur/nom
  • espaces réservés de corps de message
  • animation de scintillement

Le scintillement respecte :

prefers-reduced-motion

donc l’animation est désactivée pour les utilisateurs qui ont demandé une réduction du mouvement.


Garder 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.

La même chose s’applique lors du passage à un message particulier.

Le composant effectue une étape de positionnement post-rendu et vérifie à nouveau la position sur les frames 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 de sujet de Discourse en bas de la modale.

Donc 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 Discourse utilisent des services de modale/menu partagés, et ces services ne savent pas nécessairement que l’aperçu de sujet agit actuellement comme un contexte de navigation imbriqué.

Le composant a donc une gestion supplémentaire autour de :

  • modal.close()
  • menus Float Kit
  • restauration du focus
  • le rédacteur
  • contrôles clavier de la lightbox
  • verrouillages de défilement du corps

Par exemple, si un menu tente internement d’appeler la méthode de fermeture de modale globale, cela ne devrait pas accidentellement fermer l’aperçu de sujet entier.

De même, lorsque le rédacteur est ouvert, le focus doit rester à l’intérieur du rédacteur au lieu d’être tiré vers le contexte de focus de l’aperçu.


Configuration

Le composant expose actuellement les paramètres suivants :

Paramètre Défaut Description
trigger_style row Rendre toute la ligne cliquable ou utiliser un bouton explicite
plugin_outlet topic-list-after-title Prise utilisée par le déclencheur bouton
enable_prefetch true Activer/désactiver le préchargement de sujet en arrière-plan
max_concurrent_prefetches 2 Nombre maximum de requêtes de préchargement simultanées
prefetch_debounce_ms 400 Délai avant de commencer un préchargement
prefetch_root_margin_px 50 Commencer le préchargement à ce nombre de pixels avant que la ligne n’entre dans la zone de visualisation
max_prefetches_per_minute 15 Nombre maximum de requêtes 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 message, son propre rédacteur, son propre modèle de sujet ou son propre flux de message complètement séparé.

Au lieu de cela, 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 initialement.

Le défi plus intéressant était :

Un sujet peut-il se comporter presque comme un sujet Discourse normal alors qu’il est en fait affiché à l’intérieur d’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.

3 « J'aime »

Pour information :

Il a des problèmes avec les mathématiques. Mais cela pourrait être un autre cas limite, bien que.