Finestra modale anteprima argomento

Installa questo componente del tema

Modale anteprima argomento – apri e interagisci con gli argomenti senza lasciare l’elenco degli argomenti

Ho creato un nuovo componente del tema per Discourse chiamato Modale anteprima argomento.

L’idea è piuttosto semplice:

Apri un argomento direttamente dall’elenco degli argomenti in un modale nativo di Discourse, leggi e interagisci con l’argomento, e poi continua a sfogliare l’elenco senza allontanarti da esso.

È iniziato da Facebook-style Topic Modal - Is it better? , ma alla fine ha richiesto una discreta integrazione con i sistemi di Discourse per gli argomenti, il flusso dei post, il compositore, il modale, i segnalibri, il routing, la presenza, il tracciamento delle letture e il pre-caricamento.


Perché?

Il flusso normale di Discourse è:

  1. Stai sfogliando un elenco di argomenti.
  2. Clicchi su un argomento.
  3. Discourse naviga verso /t/....
  4. Leggi/rispondi/interagisci con l’argomento.
  5. Torni all’elenco degli argomenti.

Per molti flussi di lavoro, questo va perfettamente bene.

Tuttavia, quando si sfoglia un elenco di argomenti molto attivo, a volte voglio solo ispezionare rapidamente un argomento, leggere alcuni post, controllare le ultime risposte, reagire a qualcosa o rispondere a una domanda veloce.

Per questo caso d’uso, lasciare l’elenco degli argomenti sembra un costo inutile.

L’obiettivo di questo componente era quindi rendere l’elenco degli argomenti simile a una casella di posta:

elenco argomenti → anteprima → interazione → chiusura → continua esattamente da dove eri rimasto.


Cosa fa

L’anteprima non è solo un estratto statico.

Rende effettivi i componenti dei post di Discourse all’interno di un DModal nativo.

Questo significa che gli utenti possono:

  • leggere i post
  • scorrere l’argomento
  • caricare post precedenti
  • caricare più post in basso
  • reagire ai post
  • segnalare i post con segnalibro
  • citare il testo
  • rispondere all’argomento
  • rispondere a singoli post
  • modificare i post quando consentito
  • eliminare/recuperare i post quando consentito
  • segnalare i post
  • visualizzare la cronologia dei post
  • eseguire varie azioni normali sui post
  • vedere la presenza nell’argomento
  • seguire i link ad altri post nello stesso argomento
  • saltare direttamente al post rilevante
  • aprire l’argomento completo quando necessario

L’intenzione è che l’anteprima si senta il più possibile simile all’apertura effettiva dell’argomento.


Due modalità di attivazione

Ci sono due modi per aprire l’anteprima.

1. Riga dell’elenco argomenti intera

Questa è l’impostazione predefinita.

L’intera riga dell’elenco degli argomenti diventa cliccabile, mentre elementi interattivi comuni come:

  • schede utente
  • partecipanti
  • link alle categorie
  • tag
  • link allo stato dell’argomento
  • selezione multipla

sono esclusi dall’attivazione del modale.

Questo rende l’esperienza molto veloce quando si sfoglia un elenco di argomenti.

2. Pulsante di espansione esplicito

In alternativa, il componente può rendere un’icona di espansione piccola tramite un’uscita del plugin Discourse. I temi personalizzati possono semplicemente creare un nuovo <PluginOutlet /> per mostrare l’attivatore.

In questa modalità, il comportamento normale dell’elenco degli argomenti rimane completamente intatto.

L’utente clicca sull’icona di espansione per aprire l’anteprima, mentre cliccare sul titolo dell’argomento esegue ancora la navigazione normale di Discourse.

Questo è utile se un sito vuole preservare il modello di interazione standard dell’elenco degli argomenti.

L’impostazione è:

trigger_style:
  row

oppure:

trigger_style:
  button

Quando si utilizza la modalità pulsante, anche l’uscita è configurabile.


L’anteprima inizia dalla posizione non letta dell’utente

Uno dei dettagli importanti è che il modale non carica semplicemente il primo post.

Quando un argomento è già stato letto parzialmente, l’anteprima calcola:

last_read_post_number + 1

e si apre intorno a quel post.

Quindi, se un argomento ha 200 post e l’utente ha letto fino al post #165, l’apertura dell’anteprima inizia intorno al #166.

Questo rende l’anteprima molto più utile per la navigazione reale.

Significa anche che il componente deve gestire entrambi i lati del flusso dei post:

  • caricare post precedenti quando necessario
  • caricare post più recenti in basso

Il pulsante Post precedenti viene visualizzato quando ci sono post sopra l’intervallo attualmente caricato, mentre un sentinel IntersectionObserver carica automaticamente più post quando l’utente raggiunge il fondo.


Pre-caricamento

Una delle parti più grandi del componente è il suo sistema di pre-caricamento.

Il problema con un modale del genere è che l’utente si aspetta che sia istantaneo.

Se iniziamo a caricare l’argomento solo dopo che l’utente ha cliccato, il modale può ancora impiegare un tempo notevole ad aspettare la rete.

Invece, il componente può pre-caricare proattivamente gli argomenti mentre l’utente sfoglia l’elenco.

Quando una riga dell’argomento si avvicina alla viewport, un IntersectionObserver può pianificare un pre-caricamento.

Ci sono diverse salvaguardie per impedire che questo si trasformi in traffico di background incontrollato.

Debouncing

Un argomento non attiva immediatamente una richiesta solo perché è apparso brevemente nella viewport.

Il componente aspetta per il periodo di debounce configurato.

Predefinito:

400 ms

Questo è particolarmente utile quando si scorre rapidamente un lungo elenco di argomenti.

Margine radice

Il pre-caricamento può iniziare leggermente prima che l’argomento entri effettivamente nella viewport.

Predefinito:

50 px

Questo dà alla richiesta un piccolo vantaggio iniziale.

Limite di richieste simultanee

Il numero di pre-caricamenti simultanei è limitato.

Predefinito:

2

L’impostazione consente tra 1 e 6 pre-caricamenti simultanei.

Budget per minuto

C’è anche un secondo meccanismo di protezione:

max_prefetches_per_minute

Il predefinito è:

15

Quindi, anche se l’utente continua a scorrere centinaia di argomenti, il componente non genererà continuamente richieste speculative.

0 disabilita il limite.

Il pre-caricamento può essere disabilitato completamente

Se un sito non vuole alcun traffico di rete speculativo:

enable_prefetch = false

Il componente continua a funzionare normalmente. Gli argomenti vengono semplicemente caricati quando l’anteprima viene aperta.


I dati pre-caricati sono mantenuti separati dalla navigazione normale degli argomenti

C’è un importante dettaglio di implementazione qui.

La risposta pre-caricata non viene scritta immediatamente nella chiave di preload normale topic_<id> di Discourse.

Invece, il componente utilizza il proprio namespace:

topic-preview-modal:prefetch:<topicId>

Solo quando l’utente apre effettivamente l’anteprima, la promessa pre-caricata viene promossa alla chiave di preload dell’argomento principale.

Questo è intenzionale.

L’anteprima potrebbe stare caricando un argomento partendo da last_read_post_number + 1, e non voglio che quella risposta specifica dell’anteprima finisca nella navigazione di una rotta argomento normale.

Quindi il ciclo di vita è essenzialmente:

l'argomento entra nella viewport
        ↓
pre-caricamento
        ↓
magazzinaggio preload privato
        ↓
l'utente apre l'anteprima
        ↓
promuovi il preload
        ↓
Topic.find()/PostStream usa la stessa promessa

Questo significa anche che il modale non deve aspettare che la richiesta di pre-caricamento finisca prima di aprirsi.

Il modale può aprirsi immediatamente con il suo scheletro mentre la stessa promessa continua a risolversi.


Supporto mobile

Questo è stato in realtà uno dei motivi per cui ho dedicato considerevolmente più tempo all’implementazione.

L’idea iniziale funzionava ragionevolmente bene sul desktop, ma il mobile ha esposto diversi problemi legati a:

  • interazione touch
  • scorrimento del modale
  • focus
  • menu nidificati
  • il compositore
  • visibilità dei post
  • caricamento immagini
  • prestazioni

L’implementazione finale evita quindi di trattare il modale come un mini forum completamente separato.

Invece, riutilizza il più possibile l’infrastruttura esistente di Discourse.


Componenti post reali di Discourse

Il modale non ricrea i post utilizzando un modello personalizzato semplificato.

Rende i componenti effettivi di Discourse:

Post
PostSmallAction

Questo è importante perché altrimenti l’anteprima diventerebbe rapidamente una seconda implementazione dell’interfaccia utente del post.

Il componente passa le azioni rilevanti ai normali componenti dei post, includendo cose come:

  • risposta
  • modifica
  • eliminazione
  • recupero
  • segnalazione
  • cronologia
  • segnalibro
  • wiki
  • blocco/sblocco
  • tipo di post
  • cambiamenti di proprietà
  • badge
  • post nascosti
  • citazione
  • ecc.

Il risultato è che l’anteprima può comportarsi molto più come un argomento normale che come un componente “anteprima” tradizionale.


Risposte e il compositore

Il compositore è una delle parti più complicate.

L’anteprima può aprire il compositore normale di Discourse per:

Rispondere all’argomento

Il compositore dell’argomento viene aperto con il modello dell’argomento e le informazioni della bozza corrette.

Rispondere a un post specifico

Il post viene passato al compositore in modo che la risposta si comporti come una risposta normale a un post.

Citare il testo selezionato

Il componente si integra anche con PostTextSelection.

Questo significa che gli utenti possono selezionare il testo all’interno dell’anteprima e utilizzare il flusso normale di citazione/risposta di Discourse.


Modali nidificati

Un’altra parte delicata è stato il sistema di modali di Discourse.

I post possono aprire altri modali e dialoghi:

  • segnalazione
  • cronologia
  • dialoghi relativi ai badge
  • cambiamenti di proprietà
  • conferme di eliminazione
  • ecc.

Se questi fossero stati lasciati interagire normalmente con il servizio modale globale, l’apertura di uno di essi avrebbe potuto chiudere l’intero anteprima dell’argomento.

Per evitare questo, il componente crea un meccanismo di sotto-modale locale.

Concettualmente:

Modale anteprima argomento
        │
        ├── Modale segnalazione
        ├── Modale cronologia
        ├── Conferma eliminazione
        ├── Modale badge
        └── altro modale relativo al post

L’anteprima rimane montata sotto.

Il componente patcha temporaneamente i metodi rilevanti del servizio modale mentre è attivo e li ripristina quando viene distrutto.


Routing all’interno del modale

Un altro dettaglio importante sono i link ai post all’interno dello stesso argomento.

Ad esempio, se un post contiene un link a:

/t/my-topic/123

l’anteprima non ha bisogno di chiudersi e navigare altrove.

Invece, il componente intercetta la navigazione dello stesso argomento e salta al post richiesto all’interno del modale.

Lo stesso vale per i link che puntano all’argomento senza un numero di post specifico.

Questo mantiene l’utente all’interno dell’anteprima.

Se il link punta a un argomento genuinamente diverso, il componente prima ripristina le sue patch di servizio temporanee e si chiude prima di permettere la transizione di rotta normale di Discourse.

Questa pulizia è importante perché altrimenti le sottoscrizioni dell’anteprima e il tracker di tempo potrebbero rimanere vive mentre la rotta dell’argomento reale viene inizializzata.


Tracciamento delle letture e tracciamento del tempo

Volevo anche che l’anteprima si comportasse correttamente dal punto di vista di Discourse.

Aprire un’anteprima non dovrebbe significare che il tracciamento delle letture è completamente ignorato.

Il componente gestisce quindi:

  • tracciamento delle visite all’argomento
  • tracciamento dei post visibili
  • tempistica dell’argomento
  • aggiornamenti dell’ultimo post letto

Il tracker di tempistica utilizza un IntersectionObserver per determinare quali post sono effettivamente visibili.

Ogni 5 secondi, la tempistica dei post visibili viene inviata a:

/topics/timings

Quando il modale si chiude, viene eseguita un’ultima invio in modo che gli ultimi secondi non vengano persi.

L’implementazione limita anche un singolo intervallo di tempistica a 60 secondi.


Mantenere sincronizzato lo stato non letto dell’elenco degli argomenti

C’era un altro problema sottile qui.

Aggiornare lo stato di tracciamento degli argomenti di Discourse da solo non è sufficiente per aggiornare il badge non letto visualizzato direttamente su una riga dell’elenco degli argomenti.

Il componente aggiorna quindi l’oggetto argomento effettivo associato alla riga dopo che le informazioni di tempistica sono state inviate.

Aggiorna valori come:

last_read_post_number
unread_posts
unread
new_posts

quando appropriato.

Questo significa che dopo aver letto un argomento all’interno del modale, l’elenco degli argomenti può riflettere immediatamente il nuovo stato di lettura invece di richiedere un aggiornamento completo della pagina.


Visibilità dei post

L’anteprima utilizza un IntersectionObserver condiviso per determinare quando i singoli post diventano visibili.

C’è anche un controllo di visibilità sincrono quando l’osservatore è collegato.

Questo gestisce un caso limite in cui un post è già visibile quando viene montato, ma il primo callback asincrono di IntersectionObserver non è ancora stato attivato.

Questo è particolarmente rilevante per argomenti molto brevi in cui l’intero argomento potrebbe già essere visibile quando il modale si apre.


Considerazioni sulle prestazioni

Un obiettivo principale era evitare di trasformare il modale in una pagina argomento miniatura pesante per le prestazioni.

Alcune cose sono fatte specificamente per questo.

Rendering progressivo

Il caricamento iniziale non rende immediatamente ogni post.

Il componente rende prima abbastanza post per raggiungere la posizione target.

I post rimanenti vengono quindi resi progressivamente utilizzando:

requestIdleCallback

quando disponibile, con un fallback a setTimeout.

Questo è particolarmente utile quando si apre un argomento lungo intorno a un post lontano nel flusso.

Contenimento CSS

I post utilizzano:

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

Questo permette al browser di evitare di fare lavoro di rendering non necessario per i post che non sono attualmente visibili.

Immagini pigre

Le immagini che non hanno già specificato una modalità di caricamento ricevono automaticamente:

loading="lazy"
decoding="async"

Questo impedisce a un argomento lungo con molte immagini di caricare tutto immediatamente.


Stato di caricamento

Il modale non mostra solo un’area bianca/vuota mentre la richiesta viene effettuata.

Ha un’interfaccia utente scheletrica con:

  • placeholder avatar
  • placeholder nome utente/nome
  • placeholder corpo del post
  • animazione shimmer

Lo shimmer rispetta:

prefers-reduced-motion

quindi l’animazione è disabilitata per gli utenti che hanno richiesto una riduzione del movimento.


Mantenere la posizione di scorrimento stabile

Ci sono alcuni punti in cui il componente deve manipolare manualmente la posizione di scorrimento.

Ad esempio, quando si caricano post precedenti, il contenuto appena inserito aumenta l’altezza di scorrimento.

Prependere semplicemente i post farebbe saltare la posizione attuale dell’utente.

Il componente registra quindi l’altezza di scorrimento precedente e compensa la differenza dopo che i post sono stati inseriti.

Questo mantiene il contenuto attualmente visibile approssimativamente nello stesso posto.

Lo stesso vale quando si salta a un post particolare.

Il componente esegue un passaggio di posizionamento post-rendering e verifica nuovamente la posizione nei frame successivi per tenere conto del contenuto che potrebbe ancora stabilizzarsi.


Presenza nell’argomento

Quando i dati rilevanti dell’argomento sono disponibili, l’anteprima può anche visualizzare le informazioni sulla presenza dell’argomento di Discourse in fondo al modale.

Quindi gli utenti possono vedere chi altro sta attualmente visualizzando l’argomento senza dover lasciare l’anteprima.


Interazione con menu mobili e focus

Il mobile ha introdotto un’altra categoria di problemi.

Alcuni elementi UI di Discourse utilizzano servizi modale/menu condivisi, e questi servizi non sanno necessariamente che l’anteprima dell’argomento sta attualmente agendo come un contesto di navigazione nidificato.

Il componente ha quindi una gestione aggiuntiva intorno a:

  • modal.close()
  • menu Float Kit
  • ripristino del focus
  • il compositore
  • controlli della tastiera lightbox
  • blocchi di scorrimento del corpo

Ad esempio, se un menu cerca internamente di chiamare il metodo di chiusura modale globale, questo non dovrebbe accidentalmente chiudere l’intera anteprima dell’argomento.

Similmente, quando il compositore è aperto, il focus deve rimanere all’interno del compositore invece di essere tirato indietro nel contesto di focus dell’anteprima.


Configurazione

Il componente espone attualmente le seguenti impostazioni:

Impostazione Predefinito Descrizione
trigger_style row Rendi l’intera riga cliccabile o usa un pulsante esplicito
plugin_outlet topic-list-after-title Uscita utilizzata dall’attivatore del pulsante
enable_prefetch true Abilita/disabilita il pre-caricamento di background degli argomenti
max_concurrent_prefetches 2 Numero massimo di richieste di pre-caricamento simultanee
prefetch_debounce_ms 400 Ritardo prima di iniziare un pre-caricamento
prefetch_root_margin_px 50 Inizia il pre-caricamento questo numero di pixel prima che la riga entri nella viewport
max_prefetches_per_minute 15 Numero massimo di richieste speculative al minuto

I controlli di pre-caricamento sono intenzionalmente configurabili perché comunità diverse possono avere modelli di traffico e caratteristiche di hosting/rete molto diversi.


Uno degli obiettivi di design principali: non rompere Discourse normale

Ho cercato di mantenere il componente il più vicino possibile all’architettura esistente di Discourse.

Non implementa il suo proprio renderer di post, il suo compositore, il suo modello argomento o il suo flusso di post completamente separato.

Invece, costruisce un contesto di navigazione temporaneo intorno ai componenti e servizi esistenti di Discourse.

Questo è anche il motivo per cui alcune parti dell’implementazione sono più complicate di quanto potrebbero apparire inizialmente.

La sfida più interessante è stata:

Un argomento può comportarsi quasi come un argomento normale di Discourse mentre viene effettivamente visualizzato all’interno di un altro contesto UI?

Questo ha richiesto di gestire i confini tra i servizi globali di Discourse e l’anteprima locale.

5 Mi Piace

Per tua informazione:

Ha problemi con la matematica. Tuttavia, questo potrebbe essere un altro caso limite.

1 Mi Piace

Assolutamente leggenda :slight_smile: ora devo capire come far funzionare tutto questo con la mia configurazione :slight_smile: @awesomerobot da cosa dipende il tuo tema per il click su tutta la riga?

api.renderInOutlet("topic-list-before-link", TopicListItemClick);
2 Mi Piace

Per chiunque utilizzi il tema simile a Reddit, ecco una soluzione che ho trovato funzionante.

Compatibilità con il tema simile a Reddit

Una nota per chi utilizza il tema simile a Reddit: il pulsante della finestra modale funziona, ma il trigger di riga predefinito no.

Il problema è che il tema simile a Reddit sostituisce il comportamento standard della riga dell’elenco degli argomenti e gestisce i clic sull’intera scheda dell’argomento. Di conseguenza, la gestione normale dei clic sulla riga della finestra modale non funziona come previsto.

Modificando l’impostazione della finestra modale di anteprima dell’argomento in:

Stile trigger: pulsante
Presa del plugin: topic-list-after-title

funziona correttamente, perché il tema simile a Reddit include già la presa topic-list-after-title.

Per mantenere il comportamento del clic sull’intera scheda, ho lasciato la finestra modale di anteprima dell’argomento in modalità pulsante e ho modificato l’azione openTopic() esistente del tema simile a Reddit in modo che attivi il pulsante funzionante della finestra modale.

L’azione originale del tema simile a Reddit è:

@action
openTopic(event) {
  if (
    (event.target.nodeName === "A" && !event.target.closest(".raw-link")) ||
    event.target.closest(".badge-wrapper")
  ) {
    return;
  }

  const { navigateToTopic, topic } = this.args.outletArgs;

  if (wantsNewWindow(event)) {
    window.open(topic.lastUnreadUrl, "_blank");
  } else {
    navigateToTopic(topic, topic.lastUnreadUrl);
  }
}

L’ho modificata in:

@action
openTopic(event) {
  if (
    (event.target.nodeName === "A" && !event.target.closest(".raw-link")) ||
    event.target.closest(".badge-wrapper") ||
    event.target.closest(".topic-preview-modal__trigger-wrapper")
  ) {
    return;
  }

  const { navigateToTopic, topic } = this.args.outletArgs;

  if (wantsNewWindow(event)) {
    window.open(topic.lastUnreadUrl, "_blank");
    return;
  }

  const previewButton = event.currentTarget.querySelector(
    ".topic-preview-modal__trigger-wrapper--button"
  );

  if (previewButton) {
    event.preventDefault();
    event.stopPropagation();
    previewButton.click();
    return;
  }

  navigateToTopic(topic, topic.lastUnreadUrl);
}

Il trigger del pulsante della finestra modale è reso come:

<div class="topic-preview-modal__trigger-wrapper">
  <span
    role="button"
    class="topic-preview-modal__trigger-wrapper--button"
  >

Quindi questo non ricrea alcuna logica della finestra modale. Semplicemente fa sì che il clic sulla scheda del tema simile a Reddit attivi il pulsante di anteprima esistente e funzionante.

Il risultato è:

  • Il clic sulla scheda dell’argomento apre la finestra modale di anteprima.

  • Il clic sul titolo dell’argomento apre la finestra modale di anteprima.

  • Il pulsante di anteprima continua a funzionare.

  • Il clic con Cmd/Ctrl apre ancora l’argomento normale in una nuova scheda.

  • La categoria e gli altri link normali continuano a comportarsi normalmente.

  • Se il pulsante di anteprima non è presente, il tema simile a Reddit torna alla normale navigazione degli argomenti.

Quindi la finestra modale sottostante funziona bene con il tema simile a Reddit; l’incompatibilità è specifica con il trigger di riga predefinito.

Ho anche nascosto il pulsante utilizzando

.topic-preview-modal__trigger-wrapper {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
}
2 Mi Piace

Ora provo a far funzionare le risposte annidate nella finestra modale :slight_smile:

1 Mi Piace