Modale Argomenti stile Facebook: è meglio?

Ho smanettato un po’ cercando di dare un aspetto diverso rispetto ad altri siti e ho creato una modale per gli argomenti in stile Facebook. Sembra caricarsi più velocemente per me ed essere più fluida sul desktop.

Cosa ne pensate?

Il link per il test è https://www.carptalk-online.co.uk/ (funziona solo su desktop)

bel fatto! mi piace molto, anche se non sono un utente di Facebook. hai un sito fantastico. :star_struck:

inoltre, non sapevo che i carp potessero diventare così grandi! :fishing_pole:

Molto figo! L’hai testato in argomenti con 20 o più risposte? È lì che carichiamo più “pagine” di messaggi durante lo scorrimento e, per esperienza, è una delle complicazioni più grandi con questo tipo di visualizzazione alternativa.

No, lo farò ora e ti farò sapere

Funziona bene - URL ELIMINATO

Posso vedere che la pagina si carica come dici, ma funziona come previsto

L’argomento non viene visualizzato in una finestra modale quando si fa clic sul collegamento diretto. Dalla lista degli argomenti, invece, funziona.

Sì, è la prossima cosa che devo esaminare, ma ci ho riflettuto: fa davvero differenza? È lo stesso comportamento di Facebook: il link diretto porta al post effettivo, mentre se cliccato durante la navigazione si apre una modale.

Bel concetto, riesci a mantenere una connessione costante con il message bus per i nuovi messaggi mentre carichi i messaggi più vecchi?

Sì, perché la modale carica l’applicazione normale dell’argomento Discourse all’interno di un iframe, invece di renderizzare una copia personalizzata dell’argomento. L’iframe stabilisce la propria connessione MessageBus e Discourse continua a gestire il flusso dei post normalmente, includendo il caricamento dei post più vecchi e la ricezione dei post appena pubblicati.

La pagina principale rimane attiva sullo sfondo, quindi tecnicamente ci sono due istanze Discourse e due connessioni MessageBus mentre la modale è aperta. Questo funziona, anche se è leggermente più pesante rispetto a un’implementazione completamente nativa. Chiudere la modale rimuove l’iframe e la sua connessione.

Non so se abbia importanza, e non uso Facebook :stuck_out_tongue:

Mi sono solo sorpreso che cliccando sul tuo link non sia apparso l’argomento in una finestra modale, dato che l’avevi pubblicato proprio per dimostrare che funzionava in una finestra modale.

Il mio post originale era un link alla homepage, hai cliccato sulla mia risposta a Kris che testava le categorie, hahaha. Com’è che va comunque? Non ti vedo da un po’

Ho appena terminato la versione mobile e sono soddisfatto del funzionamento generale; ora devo trovare un modo migliore per realizzarla e ridurre i tempi di caricamento. Qualcuno ha qualche suggerimento?

Dopo ulteriori test, ho utilizzato la versione non-JS che vede Google e si carica istantaneamente nella modale. Se potessi in qualche modo utilizzare i contenuti di quella, renderebbe tutto molto più veloce.

Sulla versione desktop sembra molto buona,

La velocità di caricamento sulla versione mobile è un po’ lenta; dopo aver cliccato, si vede solo una pagina vuota con il pulsante di chiusura in alto a destra, e sembra che si debba aspettare a lungo davanti a una pagina bianca.

Ecco su cosa sto lavorando ora :slight_smile: Al momento è solo un concetto che voglio rendere realtà. Tuttavia, sul mio dispositivo si carica in 2 secondi su mobile con l’icona di caricamento di Discourse, quindi non sono sicuro di cosa stia succedendo. Probabilmente perché il server è a Londra, nel Regno Unito, e non ha ancora un CDN.

È vero, la distanza è troppo grande, quindi la rete richiede tempo per trasmettere i dati.

Ora i topic si caricano istantaneamente nella modale, ora devo trovare un modo per usare la prima istanza di Discourse caricata per le risposte, ecc. (questo è sul server di test)

Ho disattivato questa funzionalità sul sito principale, poiché sta ricevendo traffico con utenti che si registrano, quindi è stata spostata su un server di test. Spero di averla completata entro metà agosto, se qualcuno è interessato a una modale.

Ciao :waving_hand:

Un approccio diverso da quello di @Damian_Boon: nessun iframe, tutto funziona come componenti Glimmer nativi riutilizzando il meccanismo Post/post-stream di Discourse. Dato che nel thread sono emerse alcune domande specifiche, ecco come ho risolto ciascuna di esse.


Perché non un iframe

Un iframe è di gran lunga il modo più veloce per ottenere questo risultato: route completa per l’argomento, composer, moderazione, tutto gratis. Il costo, che è ciò che la domanda di @nicolsdennis sul MessageBus mette in luce, è che alla fine ti ritrovi con due istanze di Discourse attive nella pagina: due post-stream, due connessioni MessageBus, due composer.

Ho scelto la strada opposta. Il modale è un DModal che renderizza i veri componenti Post/PostSmallAction contro un postStream costruito da store.createRecord("topic", …), stessa app, stessa connessione MessageBus, stesso composer. Ciò significa che un po’ di servizi singleton di base (modal, bookmarkApi, DiscourseURL.routeTo, il controller:topic ambientale) devono essere indirizzati all’argomento del modale per tutto il tempo in cui è aperto:

// I componenti di base (es. PostBookmarkManager) leggono/scrivono topic.bookmarks
// da controller:topic. Indirizzalo al nostro topicModel mentre il modale è aperto.
this.topicController = getOwner(this).lookup("controller:topic");
if (this.topicController) {
  this.originalTopicControllerModel = this.topicController.model;
}

Ogni patch viene annullata da una restoreServicePatches() idempotente alla chiusura, alla distruzione o alla navigazione via, così nulla fuoriesce nel resto dell’app una volta che il modale è scomparso.


Link diretti e routing interno

Due aspetti. Primo, il modale dovrebbe aprirsi dove il lettore si era effettivamente fermato, non sempre al 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)
);

Secondo, i link all’interno dei post che puntano nuovamente allo stesso argomento non dovrebbero uscire dal modale. DiscourseURL.routeTo viene patchato per la durata in cui è aperto:

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

Qualsiasi cosa che punti altrove ripristina prima le patch e passa a una navigazione normale, così uscire dal modale non lascia mai routing obsoleti.


Il caso della risposta (20+ risposte)

Scroll infinito in basso tramite un sentinella + IntersectionObserver, un simmetrico “carica precedenti” in alto (una anteprima può aprirsi a metà thread), entrambi che guidano esattamente postStream.appendMore() / prependMore() che la route reale dell’argomento utilizza:

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

Primo rendering

Uno scheletro (avatar + placeholder di linea, shimmer) riempie il primo rendering invece di una rotellina, e la lista dei post stessa si renderizza in fasi invece che tutta insieme, abbastanza post per raggiungere prima il post target, poi altri cinque alla volta tramite requestIdleCallback finché il resto non si aggiorna:

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

Ogni wrapper di post riceve anche content-visibility: auto con un contain-intrinsic-size approssimativo, così il browser salta completamente il layout per i post che sono fuori schermo:

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

Questa è la parte su cui insisterei di più. Invece di riutilizzare un’istanza dopo il click, faccio il prefetch prima del click. Ogni riga della lista degli argomenti monitora la propria visibilità, applica un debounce di 200ms così uno scroll veloce non innesca una dozzina di richieste, e passa a una piccola coda condivisa limitata a 2 fetch concorrenti:

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

Il prefetch viene memorizzato sotto una chiave privata PreloadStore, deliberatamente non la chiave core topic_${id}, scriverci fuoriuscirebbe un JSON parziale limitato a last_read+1 in una navigazione reale a argomento completo. Al click effettivo viene promosso nella chiave che il loader del modale si aspetta:

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 {
    // best-effort - il modale ricade a un caricamento fresco
  }
}

Se una riga scorre fuori vista prima di essere cliccata, il fetch in attesa viene cancellato e qualsiasi risultato in cache scartato, così tiene solo dati per gli argomenti effettivamente sullo schermo in quel momento.


Un altro, non sollevato nel thread: modali annidati

Alcuni flussi di modale (quelli che il core apre da solo piuttosto che tramite una prop di callback esplicita cablata a showSubModal()) chiamano direttamente il servizio modal. Piuttosto che lasciare che quelli chiudano il modale anteprima sotto il lettore, modal.show() viene patchato per reindirizzare selettivamente i modali target a una piccola slot di sotto-modale locale per tutto il tempo in cui l’anteprima è aperta:

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

così quei flussi target si sovrappongono all’anteprima invece di sostituirla, mentre il tracciamento delle letture (/topics/timings) continua a funzionare sotto tramite un flush periodico di un insieme di numeri di post visibili tracciato da IntersectionObserver, così i conteggi dei non letti sono ancora corretti dopo la chiusura, come una visita normale a un argomento.


È un bel pezzo di codice distribuito tra il modale, il trigger della lista degli argomenti e lo stile (~1700 righe in totale), questo copre circa un terzo. Le parti più complesse di gran lunga sono state il patching dei servizi e far comportare correttamente content-visibility una volta che le altezze dei post cambiano dopo che le immagini finiscono di caricare. Sono felice di approfondire i dettagli su qualsiasi aspetto, o renderlo open-source, se c’è interesse.