Modal de vista previa del tema

Instalar este componente de tema

Modal de vista previa de temas: abre e interactúa con los temas sin salir de la lista de temas

He creado un nuevo componente de tema de Discourse llamado Modal de vista previa de temas.

La idea es bastante simple:

Abrir un tema directamente desde la lista de temas en un modal nativo de Discourse, leer e interactuar con el tema y, a continuación, continuar navegando por la lista sin salir de ella.

Todo comenzó en Facebook-style Topic Modal - Is it better?, pero al final requirió una integración bastante extensa con los sistemas de temas, flujo de publicaciones, compositor, modal, marcadores, enrutamiento, presencia, seguimiento de lectura y precarga de Discourse.


¿Por qué?

El flujo normal de Discourse es:

  1. Estás navegando por una lista de temas.
  2. Haces clic en un tema.
  3. Discourse navega a /t/....
  4. Lees/contestas/interactúas con el tema.
  5. Vuelves a la lista de temas.

Para muchos flujos de trabajo, esto es perfectamente adecuado.

Sin embargo, cuando se navega por una lista de temas con mucho tráfico, a veces solo quiero inspeccionar rápidamente un tema, leer algunas publicaciones, comprobar las últimas respuestas, reaccionar a algo o responder a una pregunta rápida.

Para ese caso de uso, salir de la lista de temas resulta innecesariamente costoso.

Por lo tanto, el objetivo de este componente era hacer que la lista de temas se comportara más como una bandeja de entrada:

lista de temas → vista previa → interacción → cerrar → continuar exactamente donde te habías quedado.


Qué hace

La vista previa no es solo un extracto estático.

Representa los componentes de publicación reales de Discourse dentro de un DModal nativo.

Esto significa que los usuarios pueden:

  • leer publicaciones
  • desplazarse por el tema
  • cargar publicaciones anteriores
  • cargar más publicaciones debajo
  • reaccionar a las publicaciones
  • marcar publicaciones con marcador
  • citar texto
  • responder al tema
  • responder a publicaciones individuales
  • editar publicaciones cuando esté permitido
  • eliminar/recuperar publicaciones cuando esté permitido
  • reportar publicaciones
  • ver el historial de publicaciones
  • realizar varias acciones normales de publicación
  • ver la presencia del tema
  • seguir enlaces a otras publicaciones dentro del mismo tema
  • saltar directamente a la publicación correspondiente
  • abrir el tema completo cuando sea necesario

La intención es que la vista previa se sienta lo más parecida posible a abrir realmente el tema.


Dos modos de activación

Hay dos formas de abrir la vista previa.

1. Fila completa de la lista de temas

Este es el valor predeterminado.

Toda la fila de la lista de temas se vuelve clicable, mientras que los elementos interactivos comunes como:

  • tarjetas de usuario
  • participantes
  • enlaces de categoría
  • etiquetas
  • enlaces de estado del tema
  • selección masiva

quedan excluidos del activador del modal.

Esto hace que la experiencia sea muy rápida al navegar por una lista de temas.

2. Botón de expansión explícito

Alternativamente, el componente puede representar un pequeño icono de expansión a través de una salida de complemento de Discourse. Los temas personalizados pueden simplemente crear una nueva <PluginOutlet /> para mostrar el activador.

En este modo, el comportamiento normal de la lista de temas permanece completamente intacto.

El usuario hace clic en el icono de expansión para abrir la vista previa, mientras que hacer clic en el título del tema sigue realizando la navegación normal de Discourse.

Esto es útil si un sitio quiere preservar el modelo de interacción estándar de la lista de temas.

La configuración es:

trigger_style:
  row

o:

trigger_style:
  button

Al usar el modo de botón, la salida también es configurable.


La vista previa comienza en la posición no leída del usuario

Uno de los detalles importantes es que el modal no carga simplemente la primera publicación.

Cuando un tema ya ha sido leído parcialmente, la vista previa calcula:

last_read_post_number + 1

y se abre alrededor de esa publicación.

Así que si un tema tiene 200 publicaciones y el usuario ha leído hasta la publicación #165, abrir la vista previa comienza alrededor de la #166.

Esto hace que la vista previa sea mucho más útil para la navegación del mundo real.

También significa que el componente tiene que lidiar con ambos lados del flujo de publicaciones:

  • cargar publicaciones anteriores cuando sea necesario
  • cargar publicaciones más recientes debajo

El botón Publicaciones anteriores se muestra cuando hay publicaciones por encima del rango actualmente cargado, mientras que un centinela IntersectionObserver carga automáticamente más publicaciones cuando el usuario llega al final.


Precarga

Una de las partes más grandes del componente es su sistema de precarga.

El problema con un modal como este es que el usuario espera que se sienta instantáneo.

Si solo comenzamos a cargar el tema después de que el usuario hace clic, el modal aún puede pasar un tiempo notable esperando a la red.

En su lugar, el componente puede precargar proactivamente los temas mientras el usuario navega por la lista.

Cuando una fila de tema se acerca al viewport, un IntersectionObserver puede programar una precarga.

Hay varias salvaguardas para evitar que esto se convierta en tráfico de fondo descontrolado.

Retardo (Debouncing)

Un tema no desencadena inmediatamente una solicitud solo porque apareció brevemente en el viewport.

El componente espera el período de retardo configurado.

Predeterminado:

400 ms

Esto es particularmente útil al desplazarse rápidamente por una lista de temas larga.

Margen raíz

La precarga puede comenzar ligeramente antes de que el tema entre realmente en el viewport.

Predeterminado:

50 px

Esto le da a la solicitud un pequeño adelanto.

Límite de solicitudes concurrentes

El número de precargas simultáneas está limitado.

Predeterminado:

2

La configuración permite entre 1 y 6 precargas concurrentes.

Presupuesto por minuto

También hay un segundo mecanismo de protección:

max_prefetches_per_minute

El valor predeterminado es:

15

Así que incluso si el usuario continúa desplazándose por cientos de temas, el componente no generará continuamente solicitudes especulativas.

0 deshabilita el límite.

La precarga se puede deshabilitar por completo

Si un sitio no quiere ningún tráfico de red especulativo:

enable_prefetch = false

El componente continúa funcionando normalmente. Los temas simplemente se cargan cuando se abre la vista previa.


Los datos de precarga se mantienen separados de la navegación normal de temas

Aquí hay un detalle de implementación importante.

La respuesta precargada no se escribe inmediatamente en la clave de precarga normal topic_<id> de Discourse.

En su lugar, el componente utiliza su propio espacio de nombres:

topic-preview-modal:prefetch:<topicId>

Solo cuando el usuario abre realmente la vista previa, la promesa precargada se promueve a la clave de precarga central del tema.

Esto es intencional.

La vista previa puede estar cargando un tema comenzando desde last_read_post_number + 1, y no quiero que esa respuesta específica de la vista previa se filtre en una navegación de ruta de tema normal.

Así que el ciclo de vida es esencialmente:

el tema entra en el viewport
        ↓
precarga
        ↓
almacenamiento de precarga privada
        ↓
el usuario abre la vista previa
        ↓
promover precarga
        ↓
Topic.find()/PostStream utiliza la misma promesa

Esto también significa que el modal no tiene que esperar a que termine la solicitud de precarga antes de abrirse.

El modal puede abrirse inmediatamente con su esqueleto mientras la misma promesa continúa resolviéndose.


Compatibilidad con móviles

De hecho, esta fue una de las razones por las que pasé considerablemente más tiempo en la implementación.

La idea inicial funcionaba razonablemente bien en escritorio, pero los dispositivos móviles revelaron varios problemas relacionados con:

  • interacción táctil
  • desplazamiento del modal
  • enfoque (focus)
  • menús anidados
  • el compositor
  • visibilidad de publicaciones
  • carga de imágenes
  • rendimiento

Por lo tanto, la implementación final evita tratar el modal como un foro miniatura completamente separado.

En su lugar, reutiliza la mayor parte de la infraestructura existente de Discourse.


Componentes de publicación reales de Discourse

El modal no recrea publicaciones utilizando una plantilla personalizada simplificada.

Representa los componentes reales de Discourse:

Post
PostSmallAction

Esto es importante porque, de lo contrario, la vista previa se convertiría rápidamente en una segunda implementación de la interfaz de usuario de publicaciones.

El componente pasa las acciones relevantes a los componentes de publicación normales, incluyendo cosas como:

  • responder
  • editar
  • eliminar
  • recuperar
  • reportar
  • historial
  • marcador
  • wiki
  • bloquear/desbloquear
  • tipo de publicación
  • cambios de propiedad
  • insignias
  • publicaciones ocultas
  • citas
  • etc.

El resultado es que la vista previa puede comportarse mucho más como un tema normal que como un componente de “vista previa” tradicional.


Respuestas y el compositor

El compositor es una de las partes más complicadas.

La vista previa puede abrir el compositor normal de Discourse para:

Responder al tema

El compositor del tema se abre con el modelo de tema y la información de borrador correcta.

Responder a una publicación específica

La publicación se pasa al compositor para que la respuesta se comporte como una respuesta de publicación normal.

Citar texto seleccionado

El componente también se integra con PostTextSelection.

Esto significa que los usuarios pueden seleccionar texto dentro de la vista previa y utilizar el flujo normal de cita/respuesta de Discourse.


Modales anidados

Otra parte complicada fue el sistema de modales de Discourse.

Las publicaciones pueden abrir otros modales y diálogos:

  • reportar
  • historial
  • diálogos relacionados con insignias
  • cambios de propiedad
  • confirmaciones de eliminación
  • etc.

Si se permitiera que interactuaran con el servicio global de modales normalmente, abrir uno de ellos podría cerrar toda la vista previa del tema.

Para evitar eso, el componente crea un mecanismo de submodal local.

Conceptualmente:

Modal de vista previa de tema
        │
        ├── Modal de reporte
        ├── Modal de historial
        ├── Confirmación de eliminación
        ├── Modal de insignia
        └── otros modales relacionados con publicaciones

La vista previa permanece montada debajo.

El componente parchea temporalmente los métodos relevantes del servicio de modales mientras está activo y los restaura cuando se destruye.


Enrutamiento dentro del modal

Otro detalle importante son los enlaces a publicaciones dentro del mismo tema.

Por ejemplo, si una publicación contiene un enlace a:

/t/my-topic/123

la vista previa no necesita cerrarse y navegar lejos.

En su lugar, el componente intercepta la navegación del mismo tema y salta a la publicación solicitada dentro del modal.

Lo mismo aplica a los enlaces que apuntan al tema sin un número de publicación específico.

Esto mantiene al usuario dentro de la vista previa.

Si el enlace apunta a un tema genuinamente diferente, el componente primero restaura sus parches de servicio temporales y se cierra a sí mismo antes de permitir la transición de ruta normal de Discourse.

Esa limpieza es importante porque, de lo contrario, las suscripciones y el rastreador de tiempo de la vista previa podrían permanecer activas mientras se inicializa la ruta de tema real.


Seguimiento de lectura y seguimiento de tiempo

También quería que la vista previa se comportara correctamente desde la perspectiva de Discourse.

Abrir una vista previa no debería significar que el seguimiento de lectura se omita por completo.

Por lo tanto, el componente maneja:

  • seguimiento de visitas al tema
  • seguimiento de publicaciones visibles
  • tiempo de tema
  • actualizaciones de la última publicación leída

El rastreador de tiempo utiliza un IntersectionObserver para determinar qué publicaciones son realmente visibles.

Cada 5 segundos, el tiempo de publicaciones visibles se vacía a:

/topics/timings

Cuando se cierra el modal, se realiza una vaciado final para que no se pierdan los últimos segundos.

La implementación también limita un intervalo de tiempo único a 60 segundos.


Mantener sincronizado el estado no leído de la lista de temas

Aquí había otro problema sutil.

Actualizar el estado de seguimiento de temas de Discourse por sí solo no es suficiente para actualizar la insignia no leída que se muestra directamente en una fila de la lista de temas.

Por lo tanto, el componente actualiza el objeto de tema real asociado con la fila después de que se haya vaciado la información de tiempo.

Actualiza valores como:

last_read_post_number
unread_posts
unread
new_posts

cuando corresponde.

Esto significa que, después de leer un tema dentro del modal, la lista de temas puede reflejar inmediatamente el nuevo estado de lectura en lugar de requerir una actualización completa de la página.


Visibilidad de publicaciones

La vista previa utiliza un IntersectionObserver compartido para determinar cuándo se vuelven visibles las publicaciones individuales.

También hay una verificación de visibilidad síncrona cuando se adjunta el observador.

Esto maneja un caso límite donde una publicación ya es visible cuando se monta, pero la primera devolución de llamada asíncrona de IntersectionObserver aún no se ha activado.

Esto es especialmente relevante para temas muy cortos donde todo el tema ya puede ser visible cuando se abre el modal.


Consideraciones de rendimiento

Un objetivo principal era evitar convertir el modal en una página de tema miniatura con un alto consumo de recursos.

Se hacen algunas cosas específicamente para eso.

Representación progresiva

La carga inicial no representa inmediatamente cada publicación.

El componente primero representa suficientes publicaciones para alcanzar la posición objetivo.

Las publicaciones restantes se representan progresivamente utilizando:

requestIdleCallback

cuando está disponible, con una alternativa a setTimeout.

Esto es particularmente útil al abrir un tema largo alrededor de una publicación muy abajo en el flujo.

Contención CSS

Las publicaciones utilizan:

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

Esto permite que el navegador evite realizar trabajo de representación innecesario para publicaciones que no son visibles actualmente.

Imágenes diferidas

A las imágenes que aún no han especificado un modo de carga se les asigna automáticamente:

loading="lazy"
decoding="async"

Esto evita que un tema largo con muchas imágenes cargue todo de inmediato.


Estado de carga

El modal no solo muestra un área blanca/vacía mientras se realiza la solicitud.

Tiene una interfaz de usuario de esqueleto con:

  • marcadores de posición de avatar
  • marcadores de posición de nombre de usuario/nombre
  • marcadores de posición del cuerpo de la publicación
  • animación de brillo (shimmer)

El brillo respeta:

prefers-reduced-motion

por lo que la animación se deshabilita para los usuarios que han solicitado movimiento reducido.


Mantener estable la posición de desplazamiento

Hay algunos lugares donde el componente necesita manipular manualmente la posición de desplazamiento.

Por ejemplo, al cargar publicaciones anteriores, el contenido recién insertado aumenta la altura de desplazamiento.

Simplemente añadir las publicaciones al principio haría que la posición actual del usuario saltara.

Por lo tanto, el componente registra la altura de desplazamiento anterior y compensa la diferencia después de insertar las publicaciones.

Esto mantiene el contenido actualmente visible aproximadamente en el mismo lugar.

Lo mismo aplica al saltar a una publicación en particular.

El componente realiza un paso de posicionamiento posterior a la representación y verifica la posición nuevamente en fotogramas posteriores para tener en cuenta el contenido que aún puede estar estabilizándose.


Presencia del tema

Cuando están disponibles los datos relevantes del tema, la vista previa también puede mostrar la información de presencia del tema de Discourse en la parte inferior del modal.

Así que los usuarios pueden ver quién más está viendo actualmente el tema sin tener que salir de la vista previa.


Interacción con menús móviles y enfoque

Los dispositivos móviles introdujeron otra categoría de problemas.

Algunos elementos de la interfaz de usuario de Discourse utilizan servicios compartidos de modales/menús, y esos servicios no necesariamente saben que la vista previa del tema está actuando actualmente como un contexto de navegación anidado.

Por lo tanto, el componente tiene un manejo adicional alrededor de:

  • modal.close()
  • menús de Float Kit
  • restauración del enfoque
  • el compositor
  • controles de teclado de lightbox
  • bloqueos de desplazamiento del cuerpo

Por ejemplo, si un menú intenta internamente llamar al método de cierre global del modal, eso no debería cerrar accidentalmente toda la vista previa del tema.

De manera similar, cuando el compositor está abierto, el enfoque debe permanecer dentro del compositor en lugar de ser devuelto al contexto de enfoque de la vista previa.


Configuración

El componente actualmente expone las siguientes configuraciones:

Configuración Predeterminado Descripción
trigger_style row Hacer que toda la fila sea clicable o usar un botón explícito
plugin_outlet topic-list-after-title Salida utilizada por el activador de botón
enable_prefetch true Habilitar/deshabilitar la precarga de temas en segundo plano
max_concurrent_prefetches 2 Máximo de solicitudes de precarga simultáneas
prefetch_debounce_ms 400 Retardo antes de iniciar una precarga
prefetch_root_margin_px 50 Comenzar la precarga tantos píxeles antes de que la fila entre en el viewport
max_prefetches_per_minute 15 Máximo de solicitudes especulativas por minuto

Los controles de precarga son intencionalmente configurables porque diferentes comunidades pueden tener patrones de tráfico y características de alojamiento/red muy diferentes.


Uno de los principales objetivos de diseño: no romper Discourse normal

Intenté mantener el componente lo más cerca posible de la arquitectura existente de Discourse.

No implementa su propio renderizador de publicaciones, su propio compositor, su propio modelo de tema o su propio flujo de publicaciones completamente separado.

En su lugar, construye un contexto de navegación temporal alrededor de los componentes y servicios existentes de Discourse.

Esta es también la razón por la que algunas partes de la implementación son más complicadas de lo que podrían parecer inicialmente.

El desafío más interesante fue:

¿Puede un tema comportarse casi como un tema normal de Discourse mientras se muestra realmente dentro de otro contexto de interfaz de usuario?

Eso requirió lidiar con los límites entre los servicios globales de Discourse y la vista previa local.

5 Me gusta

Por si lo necesitas:

Tiene problemas con las matemáticas. Pero esto podría ser otro caso extremo, aunque.

Absoluta leyenda :slight_smile: ahora tengo que averiguar cómo hacer que esto funcione con mi configuración :slight_smile: @awesomerobot ¿de qué depende tu tema para que se pueda hacer clic en toda la fila?

api.renderInOutlet("topic-list-before-link", TopicListItemClick);
1 me gusta

Para cualquiera que use el tema estilo Reddit, aquí hay una solución que me funcionó.

Compatibilidad con el tema estilo Reddit

Solo una nota para quienes usan el tema estilo Reddit: el botón del modal funciona, pero el disparador de fila predeterminado no.

El problema es que el tema estilo Reddit reemplaza el comportamiento estándar de la fila de la lista de temas y maneja los clics en toda la tarjeta del tema. Debido a eso, el manejo normal de clics en filas del modal no funciona como se espera.

Cambiar la configuración de Vista Previa del Tema a:

Estilo de disparador: botón
Salida del plugin: topic-list-after-title

funciona correctamente, porque el tema estilo Reddit ya incluye la salida topic-list-after-title.

Para mantener el comportamiento de clic en toda la tarjeta, dejé la Vista Previa del Tema en modo botón y cambié la acción openTopic() existente del tema estilo Reddit para que active el botón funcional del modal.

La acción original del tema estilo Reddit es:

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

La cambié a:

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

El disparador del botón del modal se renderiza como:

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

Así que esto no recrea ninguna de la lógica del modal. Simplemente hace que el clic en la tarjeta del tema estilo Reddit active el botón de vista previa existente y funcional.

El resultado es:

  • El clic en la tarjeta del tema abre el modal de vista previa.

  • El clic en el título del tema abre el modal de vista previa.

  • El botón de vista previa sigue funcionando.

  • El clic con Cmd/Ctrl sigue abriendo el tema normal en una nueva pestaña.

  • La categoría y otros enlaces normales continúan comportándose normalmente.

  • Si el botón de vista previa no está presente, el tema estilo Reddit vuelve a su navegación de temas normal.

Así que el modal subyacente funciona bien con el tema estilo Reddit; la incompatibilidad es específicamente con el disparador de fila predeterminado.

También oculté el botón usando

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