| Resumen | Modal de vista previa de temas – abre e interactúa con los temas sin salir de la lista de temas | |
| Vista previa | Theme Creator | |
| Repositorio | 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 | |
| ¿Te fue útil? | > ./support --coffee | |
| Guía de instalación | Cómo instalar un tema o componente de tema | |
| ¿Nuevo en temas de Discourse? | Guía para principiantes sobre el uso de temas de Discourse |
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 Topic Preview Modal (Modal de vista previa de temas).
La idea es bastante simple:
Abre un tema directamente desde la lista de temas en un modal nativo de Discourse, lee e interactúa con el tema y luego continúa navegando por la lista sin salir de ella.
Todo comenzó con Facebook-style Topic Modal - Is it better? , pero al final requirió bastante integración con los sistemas de temas, flujo de publicaciones, compositor, modales, marcadores, enrutamiento, presencia, seguimiento de lectura y preobtención (prefetching) de Discourse.
¿Por qué?
El flujo normal de Discourse es:
- Estás navegando por una lista de temas.
- Haces clic en un tema.
- Discourse navega a
/t/.... - Lees/responde/interactúas con el tema.
- Vuelves a la lista de temas.
Para muchos flujos de trabajo, esto es perfectamente aceptable.
Sin embargo, al navegar por una lista de temas con mucho tráfico, a veces solo quiero inspeccionar rápidamente un tema, leer algunas publicaciones, revisar las últimas respuestas, reaccionar a algo o responder a una pregunta rápida.
Para ese caso de uso, salir de la lista de temas se siente innecesariamente costoso.
El objetivo de este componente era, por lo tanto, hacer que la lista de temas se comportara más como un buzón de entrada:
lista de temas → vista previa → interactuar → cerrar → continuar exactamente donde estabas.
Qué hace
La vista previa no es solo un extracto estático.
Renderiza los componentes de publicación reales de Discourse dentro de un DModal nativo.
Esto significa que los usuarios pueden:
- leer publicaciones
- hacer desplazamiento por el tema
- cargar publicaciones anteriores
- cargar más publicaciones abajo
- reaccionar a publicaciones
- marcar publicaciones
- citar texto
- responder al tema
- responder a publicaciones individuales
- editar publicaciones cuando se permite
- eliminar/recuperar publicaciones cuando se permite
- señalar (flag) 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 relevante
- abrir el tema completo cuando sea necesario
La intención es que la vista previa se sienta lo más cercana posible a abrir el tema realmente.
Dos modos de activación
Hay dos formas de abrir la vista previa.
1. Fila completa de la lista de temas
Este es el modo 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 (tags)
- enlaces de estado del tema
- selección en lote
se excluyen 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 renderizar un pequeño icono de expansión a través de una salida de plugin (plugin outlet) 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 aún realiza 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 (outlet) también es configurable.
La vista previa comienza en la posición de no leídos 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í, 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 debe manejar ambos lados del flujo de publicaciones:
- cargar publicaciones anteriores cuando sea necesario
- cargar publicaciones más nuevas abajo
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.
Preobtención (Prefetching)
Una de las partes más grandes del componente es su sistema de preobtención.
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 la red.
En cambio, el componente puede preobtener temas proactivamente mientras el usuario navega por la lista.
Cuando una fila de tema se acerca al área visible (viewport), un IntersectionObserver puede programar una preobtención.
Hay varias salvaguardas para evitar que esto se convierta en tráfico de fondo descontrolado.
Debounce (Retraso de eventos)
Un tema no desencadena inmediatamente una solicitud solo porque apareció brevemente en el área visible.
El componente espera el período de debounce configurado.
Predeterminado:
400 ms
Esto es particularmente útil al hacer desplazamiento rápido por una lista de temas larga.
Margen de raíz (Root margin)
La preobtención puede comenzar ligeramente antes de que el tema entre realmente en el área visible.
Predeterminado:
50 px
Esto le da a la solicitud una pequeña ventaja.
Límite de solicitudes simultáneas
El número de preobteniones simultáneas está limitado.
Predeterminado:
2
La configuración permite entre 1 y 6 preobteniones simultáneas.
Presupuesto por minuto
También hay un segundo mecanismo de protección:
max_prefetches_per_minute
El predeterminado es:
15
Por lo tanto, incluso si el usuario continúa haciendo desplazamiento por cientos de temas, el componente no generará continuamente solicitudes especulativas.
0 deshabilita el límite.
La preobtención 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 preobtenidos se mantienen separados de la navegación normal de temas
Hay un detalle de implementación importante aquí.
La respuesta preobtenida no se escribe inmediatamente en la clave de precarga normal topic_<id> de Discourse.
En cambio, el componente usa su propio espacio de nombres:
topic-preview-modal:prefetch:<topicId>
Solo cuando el usuario realmente abre la vista previa, la promesa preobtenida se promueve a la clave de precarga del tema principal.
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.
Por lo tanto, el ciclo de vida es esencialmente:
el tema entra en el área visible
↓
preobtención
↓
almacenamiento de precarga privado
↓
el usuario abre la vista previa
↓
promover precarga
↓
Topic.find()/PostStream usa la misma promesa
Esto también significa que el modal no tiene que esperar a que la solicitud de preobtención termine antes de abrirse.
El modal puede abrirse inmediatamente con su esqueleto (skeleton) mientras la misma promesa continúa resolviéndose.
Soporte para móviles
Esto fue en realidad una de las razones por las que dediqué considerablemente más tiempo a la implementación.
La idea inicial funcionaba razonablemente bien en escritorio, pero los móviles expusieron varios problemas en torno a:
- interacción táctil
- desplazamiento del modal
- foco
- menús anidados
- el compositor
- visibilidad de publicaciones
- carga de imágenes
- rendimiento
La implementación final, por lo tanto, evita tratar el modal como un foro en miniatura completamente separado.
En cambio, reutiliza la mayor parte de la infraestructura existente de Discourse posible.
Componentes de publicación reales de Discourse
El modal no recrea las publicaciones usando una plantilla personalizada simplificada.
Renderiza los componentes reales de:
Post
PostSmallAction
de Discourse.
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
- señalar (flag)
- historial
- marcar
- wiki
- bloquear/desbloquear
- tipo de publicación
- cambios de propiedad
- insignias
- publicaciones ocultas
- citar
- etc.
El resultado es que la vista previa puede comportarse mucho más como un tema normal que 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 del 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 usar el flujo normal de citar/responder de Discourse.
Modales anidados
Otra parte complicada fue el sistema de modales de Discourse.
Las publicaciones pueden abrir otros modales y diálogos:
- señalar (flagging)
- historial
- diálogos relacionados con insignias
- cambios de propiedad
- confirmaciones de eliminación
- etc.
Si se les permitía interactuar 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 señalización (flag)
├── Modal de historial
├── Confirmación de eliminación
├── Modal de insignias
└── otro modal relacionado con la publicación
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 fuera.
En cambio, el componente intercepta la navegación del mismo tema y salta a la publicación solicitada dentro del modal.
Lo mismo se 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 temporales de servicio y se cierra 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 activos mientras se inicializa la ruta del tema real.
Seguimiento de lectura y 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 del tema
- actualizaciones de la última publicación leída
El rastreador de tiempo usa un IntersectionObserver para determinar qué publicaciones están realmente visibles.
Cada 5 segundos, el tiempo de las publicaciones visibles se envía a:
/topics/timings
Cuando el modal se cierra, se realiza un envío final para que los últimos segundos no se pierdan.
La implementación también limita un solo intervalo de tiempo a 60 segundos.
Mantener el estado de no leídos de la lista de temas sincronizado
Hubo otro problema sutil aquí.
Actualizar el estado de seguimiento de temas de Discourse por sí solo no es suficiente para actualizar la insignia de no leídos mostrada 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 enviado la información de tiempo.
Actualiza valores como:
last_read_post_number
unread_posts
unread
new_posts
cuando sea apropiado.
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 usa un IntersectionObserver compartido para determinar cuándo las publicaciones individuales se vuelven visibles.
También hay una comprobación de visibilidad síncrona cuando se adjunta el observador.
Esto maneja un caso extremo donde una publicación ya es visible cuando se monta, pero la primera llamada de callback asíncrona de IntersectionObserver aún no se ha disparado.
Esto es especialmente relevante para temas muy cortos donde todo el tema puede estar ya visible cuando se abre el modal.
Consideraciones de rendimiento
Un objetivo principal fue evitar convertir el modal en una página de tema en miniatura con un alto consumo de rendimiento.
Se hacen algunas cosas específicamente para eso.
Renderizado progresivo
La carga inicial no renderiza inmediatamente cada publicación.
El componente primero renderiza suficientes publicaciones para alcanzar la posición objetivo.
Las publicaciones restantes se renderizan entonces progresivamente usando:
requestIdleCallback
cuando está disponible, con un respaldo a setTimeout.
Esto es particularmente útil al abrir un tema largo alrededor de una publicación bastante abajo en el flujo.
Contención CSS
Las publicaciones usan:
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
Esto permite al navegador evitar realizar trabajo de renderizado innecesario para publicaciones que no están actualmente visibles.
Imágenes perezosas (Lazy images)
Las imágenes que no hayan especificado ya un modo de carga se les da 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 muestra simplemente un área blanca/vacía mientras se realiza la solicitud.
Tiene una interfaz de usuario de esqueleto (skeleton UI) con:
- marcadores de posición de avatar
- marcadores de posición de nombre de usuario/nombre
- marcadores de posición de cuerpo de 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 la posición de desplazamiento estable
Hay algunos lugares donde el componente necesita manipular la posición de desplazamiento manualmente.
Por ejemplo, al cargar publicaciones anteriores, el contenido recién insertado aumenta la altura del desplazamiento.
Simplemente agregar 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 que se inserten las publicaciones.
Esto mantiene el contenido actualmente visible aproximadamente en el mismo lugar.
Lo mismo se aplica al saltar a una publicación en particular.
El componente realiza un paso de posicionamiento posterior al renderizado y verifica la posición nuevamente en los fotogramas subsiguientes para tener en cuenta el contenido que aún puede estar asentándose.
Presencia del tema
Cuando los datos relevantes del tema están disponibles, la vista previa también puede mostrar la información de presencia del tema de Discourse en la parte inferior del modal.
Así, 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 foco
Los móviles introdujeron otra categoría de problemas.
Algunos elementos de la interfaz de usuario de Discourse usan servicios compartidos de modal/menú, 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 en torno a:
modal.close()- menús de Float Kit
- restauración del foco
- 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 foco debe permanecer dentro del compositor en lugar de ser devuelto al contexto de foco 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 (outlet) utilizada por el activador de botón |
enable_prefetch |
true |
Habilitar/deshabilitar la preobtención de temas en segundo plano |
max_concurrent_prefetches |
2 |
Número máximo de solicitudes de preobtención simultáneas |
prefetch_debounce_ms |
400 |
Retraso antes de comenzar una preobtención |
prefetch_root_margin_px |
50 |
Comenzar la preobtención tantos píxeles antes de que la fila entre en el área visible |
max_prefetches_per_minute |
15 |
Número máximo de solicitudes especulativas por minuto |
Los controles de preobtención son intencionalmente configurables porque diferentes comunidades pueden tener patrones de tráfico y características de hosting/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 cambio, 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 en realidad dentro de otro contexto de interfaz de usuario?
Eso requirió lidiar con las fronteras entre los servicios globales de Discourse y la vista previa local.





