| 요약 | 주제 미리보기 모달 – 주제 목록에서 벗어나지 않고 주제를 열고 상호작용할 수 있습니다 | |
| 미리보기 | Theme Creator | |
| 저장소 | 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 | |
| 유용하셨나요? | > ./support --coffee | |
| 설치 가이드 | 테마 또는 테마 컴포넌트 설치 방법 | |
| Discourse 테마 초보자이신가요? | Discourse 테마 사용 입문 가이드 |
이 테마 컴포넌트 설치
주제 미리보기 모달 – 주제 목록에서 벗어나지 않고 주제를 열고 상호작용
**주제 미리보기 모달(Topic Preview Modal)**이라는 새로운 Discourse 테마 컴포넌트를 만들었습니다.
개념은 꽤 단순합니다:
주제 목록에서 주제를 직접 네이티브 Discourse 모달로 열고, 주제를 읽으며 상호작용한 후, 목록에서 벗어나지 않고 그대로 탐색을 계속합니다.
이것은 Facebook-style Topic Modal - Is it better? 에서 시작되었지만, Discourse의 주제, 게시물 스트림, 컴포저, 모달, 북마크, 라우팅, 존재 상태, 읽음 추적 및 선취(prefetching) 시스템과 상당한 통합이 필요하게 되었습니다.
왜 만들었나요?
일반적인 Discourse 흐름은 다음과 같습니다:
- 주제 목록을 탐색합니다.
- 주제를 클릭합니다.
- Discourse가
/t/...로 이동합니다. - 주제를 읽거나 답글을 쓰거나 상호작용합니다.
- 주제 목록으로 돌아갑니다.
많은 워크플로에서 이것은 완벽하게 괜찮습니다.
그러나 바쁜 주제 목록을 탐색할 때, 때로는 주제를 빠르게 검사하고, 몇 개의 게시물을 읽고, 최신 답글을 확인하거나, 무언가에 반응하거나, 간단한 질문에 답하고 싶을 뿐입니다.
이러한 사용 사례에서 주제 목록을 떠나는 것은 불필요하게 비용이 많이 듭니다.
따라서 이 컴포넌트의 목표는 주제 목록이 더 *수신함(inbox)*처럼 동작하도록 만드는 것이었습니다:
주제 목록 → 미리보기 → 상호작용 → 닫기 → 정확히 있던 곳에서 계속.
동작 방식
미리보기는 단순히 정적인 발췌문이 아닙니다.
네이티브 DModal 내부에서 실제 Discourse 게시물 컴포넌트를 렌더링합니다.
즉, 사용자는 다음을 수행할 수 있습니다:
- 게시물 읽기
- 주제 내 스크롤
- 이전 게시물 로드
- 아래에 더 많은 게시물 로드
- 게시물에 반응
- 게시물 북마크
- 텍스트 인용
- 주제에 답글
- 개별 게시물에 답글
- 허용되는 경우 게시물 편집
- 허용되는 경우 게시물 삭제/복구
- 게시물 플래그 설정
- 게시물 히스토리 보기
- 다양한 일반 게시물 작업 수행
- 주제 존재 상태 확인
- 같은 주제 내 다른 게시물로 링크 이동
- 관련 게시물로 직접 점프
- 필요 시 전체 주제 열기
미리보기가 실제 주제를 여는 것과 최대한 유사하게 느껴지도록 하는 것이 의도입니다.
두 가지 트리거 모드
미리보기를 여는 두 가지 방법이 있습니다.
1. 전체 주제 목록 행
이것이 기본값입니다.
주제 목록 행 전체가 클릭 가능해지지만, 다음과 같은 일반적인 상호작용 요소는 모달 트리거에서 제외됩니다:
- 사용자 카드
- 참여자
- 카테고리 링크
- 태그
- 주제 상태 링크
- 일괄 선택
이로 인해 주제 목록을 탐색할 때 경험이 매우 빨라집니다.
2. 명시적 확장 버튼
대신, 컴포넌트는 Discourse 플러그인 아웃렛을 통해 작은 확장 아이콘을 렌더링할 수 있습니다. 커스텀 테마는 트리거를 표시하기 위해 간단히 새로운 <PluginOutlet />를 생성할 수 있습니다.
이 모드에서는 일반적인 주제 목록 동작이 완전히 그대로 유지됩니다.
사용자는 확장 아이콘을 클릭하여 미리보기를 열고, 주제 제목을 클릭하면 여전히 일반적인 Discourse 네비게이션이 수행됩니다.
사이트가 표준 주제 목록 상호작용 모델을 유지하려는 경우에 유용합니다.
설정은 다음과 같습니다:
trigger_style:
row
또는:
trigger_style:
button
버튼 모드를 사용할 경우, 아웃렛도 설정할 수 있습니다.
미리보기는 사용자의 읽지 않은 위치에서 시작됩니다
중요한 세부 사항 중 하나는 모달이 단순히 첫 번째 게시물을 로드하지 않는다는 점입니다.
주제가 이미 부분적으로 읽혔다면, 미리보기는 다음을 계산합니다:
last_read_post_number + 1
그리고 해당 게시물 주변에서 엽니다.
따라서 주제에 200개의 게시물이 있고 사용자가 165번 게시물까지 읽었다면, 미리보기를 열면 166번 게시물 주변에서 시작됩니다.
이것은 실제 탐색에서 미리보기를 훨씬 유용하게 만듭니다.
또한 컴포넌트가 게시물 스트림의 양쪽을 처리해야 함을 의미합니다:
- 필요할 때 이전 게시물 로드
- 아래에 더 새로운 게시물 로드
이전 게시물 버튼은 현재 로드된 범위 위에 게시물이 있을 때 표시되며, IntersectionObserver 센티넬이 사용자가 하단에 도달하면 자동으로 더 많은 게시물을 로드합니다.
선취(Prefetching)
컴포넌트의 가장 큰 부분 중 하나는 선취 시스템입니다.
이러한 모달의 문제는 사용자가 즉각적인 느낌을 기대한다는 것입니다.
사용자가 클릭한 후에만 주제 로드를 시작하면, 모달이 네트워크를 기다리는 눈에 띄는 시간을 소비할 수 있습니다.
대신, 컴포넌트는 사용자가 목록을 탐색하는 동안 능동적으로 주제를 선취할 수 있습니다.
주제 행이 뷰포트에 접근하면, IntersectionObserver가 선취를 예약할 수 있습니다.
이것이 통제되지 않은 백그라운드 트래픽으로 변하지 않도록 몇 가지 안전 장치가 있습니다.
디바운싱
주제가 뷰포트에 잠시 나타났다고 해서 즉시 요청을 트리거하지 않습니다.
컴포넌트는 설정된 디바운스 기간을 기다립니다.
기본값:
400 ms
이것은 긴 주제 목록을 빠르게 스크롤할 때 특히 유용합니다.
루트 마진
선취는 주제가 실제로 뷰포트에 들어오기 전에 약간 시작될 수 있습니다.
기본값:
50 px
이것은 요청에 작은 선두 출발을 제공합니다.
동시 요청 제한
동시 선취의 수는 제한됩니다.
기본값:
2
설정은 1에서 6개의 동시 선취를 허용합니다.
분당 예산
또한 두 번째 보호 메커니즘이 있습니다:
max_prefetches_per_minute
기본값은 다음과 같습니다:
15
따라서 사용자가 수백 개의 주제를 계속 스크롤하더라도, 컴포넌트는 지속적으로 추측적 요청을 생성하지 않습니다.
0은 제한을 비활성화합니다.
선취를 완전히 비활성화할 수 있습니다
사이트가 추측적 네트워크 트래픽을 원하지 않는다면:
enable_prefetch = false
컴포넌트는 정상적으로 작동합니다. 주제는 단순히 미리보기가 열릴 때 로드됩니다.
선취 데이터는 일반 주제 네비게이션과 별도로 유지됩니다
여기에는 중요한 구현 세부 사항이 있습니다.
선취된 응답은 즉시 Discourse의 일반 topic_<id> 프리로드 키에 기록되지 않습니다.
대신, 컴포넌트는 자체 네임스페이스를 사용합니다:
topic-preview-modal:prefetch:<topicId>
사용자가 실제로 미리보기를 열었을 때만 선취된 프롬이즈가 코어 주제 프리로드 키로 승격됩니다.
이것은 의도적인 것입니다.
미리보기는 last_read_post_number + 1부터 시작하여 주제를 로드할 수 있으며, 그 미리보기 전용 응답이 일반 주제 루트 네비게이션으로 누출되는 것을 원하지 않습니다.
따라서 라이프사이클은 기본적으로 다음과 같습니다:
주제가 뷰포트에 진입
↓
선취
↓
비공개 프리로드 저장소
↓
사용자가 미리보기 열기
↓
프리로드 승격
↓
Topic.find()/PostStream이 동일한 프롬이즈 사용
이것은 또한 모달이 열기 전에 선취 요청이 완료될 때까지 기다릴 필요가 없음을 의미합니다.
모달은 동일한 프롬이즈가 계속 해결되는 동안 스켈레톤과 함께 즉시 열릴 수 있습니다.
모바일 지원
사실 이것이 구현에 훨씬 더 많은 시간을 들인 이유 중 하나였습니다.
초기 아이디어는 데스크톱에서 꽤 잘 작동했지만, 모바일에서는 다음과 관련된 여러 문제를 드러냈습니다:
- 터치 상호작용
- 모달 스크롤링
- 포커스
- 중첩 메뉴
- 컴포저
- 게시물 가시성
- 이미지 로드
- 성능
따라서 최종 구현은 모달을 완전히 별개의 미니 포럼으로 취급하는 것을 피합니다.
대신, 가능한 한 Discourse의 기존 인프라를 재사용합니다.
실제 Discourse 게시물 컴포넌트
모달은 단순화된 커스텀 템플릿을 사용하여 게시물을 재생성하지 않습니다.
Discourse의 실제 다음을 렌더링합니다:
Post
PostSmallAction
컴포넌트
이것은 중요합니다. 그렇지 않으면 미리보기가 곧 게시물 UI의 두 번째 구현이 될 것이기 때문입니다.
컴포넌트는 다음과 같은 것들을 포함한 관련 작업을 일반 게시물 컴포넌트로 전달합니다:
- 답글
- 편집
- 삭제
- 복구
- 플래그
- 히스토리
- 북마크
- 위키
- 잠금/잠금 해제
- 게시물 유형
- 소유권 변경
- 배지
- 숨겨진 게시물
- 인용
- 등
그 결과, 미리보기는 전통적인 “미리보기” 컴포넌트보다 일반 주제에 훨씬 더 유사하게 동작할 수 있습니다.
답글과 컴포저
컴포저는 더 복잡한 부분 중 하나입니다.
미리보기는 다음을 위해 일반 Discourse 컴포저를 열 수 있습니다:
주제에 답글
주제 컴포저는 주제 모델과 올바른 초안 정보와 함께 열립니다.
특정 게시물에 답글
게시물이 컴포저로 전달되므로 답글이 일반 게시물 답글처럼 동작합니다.
선택된 텍스트 인용
컴포넌트는 또한 PostTextSelection과 통합됩니다.
즉, 사용자는 미리보기 내에서 텍스트를 선택하고 Discourse의 일반 인용/답글 흐름을 사용할 수 있습니다.
중첩 모달
또 다른 까다로운 부분은 Discourse의 모달 시스템이었습니다.
게시물은 다른 모달과 다이얼로그를 열 수 있습니다:
- 플래그 설정
- 히스토리
- 배지 관련 다이얼로그
- 소유권 변경
- 삭제 확인
- 등
이들이 정상적으로 전역 모달 서비스와 상호작용하도록 허용된다면, 그 중 하나를 여는 것이 전체 주제 미리보기를 닫을 수 있습니다.
이를 피하기 위해, 컴포넌트는 로컬 서브-모달 메커니즘을 생성합니다.
개념적으로:
주제 미리보기 모달
│
├── 플래그 모달
├── 히스토리 모달
├── 삭제 확인
├── 배지 모달
└── 기타 게시물 관련 모달
미리보기는 아래에 마운트된 상태로 유지됩니다.
컴포넌트는 활성인 동안 관련 모달 서비스 메서드를 일시적으로 패치하고, 파괴될 때 복원합니다.
모달 내부 라우팅
또 다른 중요한 세부 사항은 같은 주제 내 게시물로 가는 링크입니다.
예를 들어, 게시물이 다음으로 가는 링크를 포함한다면:
/t/my-topic/123
미리보기는 닫고 떠나야 할 필요가 없습니다.
대신, 컴포넌트는 같은 주제 내 네비게이션을 가로채고 모달 내부에서 요청된 게시물로 점프합니다.
특정 게시물 번호 없이 주제를 대상으로 하는 링크에도 동일하게 적용됩니다.
이것은 사용자를 미리보기 안에 유지시킵니다.
링크가 실제로 다른 주제로 가리킨다면, 컴포넌트는 먼저 일시적인 서비스 패치를 복원하고 닫은 후, 일반 Discourse 루트 전이를 허용합니다.
이 클리업이 중요한 이유는, 그렇지 않으면 실제 주제 루트가 초기화되는 동안 미리보기의 구독과 타이밍 트래커가 살아남을 수 있기 때문입니다.
읽음 추적 및 시간 추적
또한 미리보기가 Discourse의 관점에서 올바르게 동작하기를 원했습니다.
미리보기를 여는 것이 읽음 추적이 완전히 우회됨을 의미해서는 안 됩니다.
따라서 컴포넌트는 다음을 처리합니다:
- 주제 방문 추적
- 가시 게시물 추적
- 주제 타이밍
- 마지막 읽은 게시물 업데이트
타이밍 트래커는 실제로 어떤 게시물이 보이는지 결정하기 위해 IntersectionObserver를 사용합니다.
5초마다, 가시 게시물 타이밍이 다음으로 플러시됩니다:
/topics/timings
모달이 닫힐 때, 마지막 몇 초가 손실되지 않도록 마지막 플러시가 수행됩니다.
구현은 단일 타이밍 간격을 60초로 제한합니다.
주제 목록의 읽지 않은 상태 동기화 유지
여기에는 또 다른 미묘한 문제가 있었습니다.
Discourse의 주제 추적 상태만 업데이트하는 것으로 주제 목록 행에 직접 표시되는 읽지 않은 배지를 업데이트하는 데 충분하지 않습니다.
따라서 컴포넌트는 타이밍 정보가 플러시된 후 행과 연관된 실제 주제 객체를 업데이트합니다.
적절한 경우 다음과 같은 값을 업데이트합니다:
last_read_post_number
unread_posts
unread
new_posts
즉, 모달 내에서 주제를 읽은 후, 전체 페이지 새로 고침 없이 주제 목록이 즉시 새로운 읽음 상태를 반영할 수 있습니다.
게시물 가시성
미리보기는 개별 게시물이 언제 보이는지 결정하기 위해 공유된 IntersectionObserver를 사용합니다.
옵저버가 연결될 때 동기 가시성 검사도 있습니다.
이것은 게시물이 마운트될 때 이미 보이는 경우, 그러나 비동기 첫 번째 IntersectionObserver 콜백이 아직 트리거되지 않은 엣지 케이스를 처리합니다.
이것은 모달이 열릴 때 전체 주제가 이미 보이는 매우 짧은 주제에서 특히 관련이 있습니다.
성능 고려 사항
주요 목표 중 하나는 모달이 성능 집약적인 미니 주제 페이지가 되는 것을 피하는 것이었습니다.
이를 위해 몇 가지 특정 조치가 취해집니다.
점진적 렌더링
초기 로드는 즉시 모든 게시물을 렌더링하지 않습니다.
컴포넌트는 먼저 대상 위치에 도달할 만큼 충분한 게시물을 렌더링합니다.
나머지 게시물은 다음을 사용하여 점진적으로 렌더링됩니다:
requestIdleCallback
사용 가능한 경우, setTimeout으로 폴백합니다.
이것은 스트림 아래쪽에 있는 게시물 주변에서 긴 주제를 열 때 특히 유용합니다.
CSS 컨테이닝
게시물은 다음을 사용합니다:
contain: layout;
content-visibility: auto;
contain-intrinsic-size: 1px 180px;
이것은 브라우저가 현재 표시되지 않는 게시물에 대해 불필요한 렌더링 작업을 피할 수 있게 합니다.
지연 이미지
이미 로딩 모드가 지정되지 않은 이미지는 자동으로 다음이 부여됩니다:
loading="lazy"
decoding="async"
이것은 많은 이미지를 가진 긴 주제가 즉시 모든 것을 로드하는 것을 방지합니다.
로딩 상태
모달은 요청이 진행되는 동안 단순히 빈 흰색/빈 영역을 표시하지 않습니다.
다음과 같은 스켈레톤 UI가 있습니다:
- 아바타 플레이스홀더
- 사용자 이름/이름 플레이스홀더
- 게시물 본문 플레이스홀더
- 시머 애니메이션
시머는 다음을 존중합니다:
prefers-reduced-motion
따라서 감소된 모션을 요청한 사용자는 애니메이션이 비활성화됩니다.
스크롤 위치 안정성 유지
컴포넌트가 스크롤 위치를 수동으로 조작해야 하는 몇 가지 장소가 있습니다.
예를 들어, 이전 게시물을 로드할 때, 새로 삽입된 콘텐츠가 스크롤 높이를 증가시킵니다.
단순히 게시물을 앞에 추가하면 사용자의 현재 위치가 점프하게 됩니다.
따라서 컴포넌트는 이전 스크롤 높이를 기록하고 게시물이 삽입된 후 차이를 보상합니다.
이것은 현재 보이는 콘텐츠가 대략 같은 위치에 유지되도록 합니다.
특정 게시물로 점프할 때도 동일하게 적용됩니다.
컴포넌트는 렌더링 후 위치 결정 단계를 수행하고, 여전히 안정화될 수 있는 콘텐츠를 고려하여 후속 프레임에서 위치를 다시 검증합니다.
주제 존재 상태
관련 주제 데이터가 사용 가능한 경우, 미리보기는 모달 하단에 Discourse의 주제 존재 상태 정보도 표시할 수 있습니다.
따라서 사용자는 미리보기를 떠나지 않고도 현재 주제를 보고 있는 다른 사람이 누구인지 볼 수 있습니다.
모바일 메뉴 및 포커스와의 상호작용
모바일은 또 다른 문제 카테고리를 도입했습니다.
일부 Discourse UI 요소는 공유 모달/메뉴 서비스를 사용하며, 이러한 서비스는 주제 미리보기가 현재 중첩 탐색 컨텍스트로 작용하고 있다는 것을 반드시 알지 못합니다.
따라서 컴포넌트는 다음에 대한 추가 처리를 가지고 있습니다:
modal.close()- Float Kit 메뉴
- 포커스 복원
- 컴포저
- 라이트박스 키보드 제어
- 바디 스크롤 잠금
예를 들어, 메뉴가 내부적으로 전역 모달 닫기 메서드를 호출하려고 하면, 그것이 실수로 전체 주제 미리보기를 닫아서는 안 됩니다.
마찬가지로, 컴포저가 열려 있을 때, 포커스는 미리보기의 포커스 컨텍스트로 끌려가야 할 것이 아니라 컴포저 안에 남아 있어야 합니다.
설정
컴포넌트는 현재 다음 설정을 노출합니다:
| 설정 | 기본값 | 설명 |
|---|---|---|
trigger_style |
row |
전체 행을 클릭 가능하게 하거나 명시적 버튼 사용 |
plugin_outlet |
topic-list-after-title |
버튼 트리거가 사용하는 아웃렛 |
enable_prefetch |
true |
백그라운드 주제 선취 활성화/비활성화 |
max_concurrent_prefetches |
2 |
최대 동시 선취 요청 수 |
prefetch_debounce_ms |
400 |
선취 시작 전 지연 시간 |
prefetch_root_margin_px |
50 |
행이 뷰포트에 들어오기 전 이 픽셀 수만큼 선취 시작 |
max_prefetches_per_minute |
15 |
분당 최대 추측적 요청 수 |
선취 제어는 의도적으로 설정 가능하게 만들어졌는데, 이는 다른 커뮤니티가 매우 다른 트래픽 패턴과 호스팅/네트워크 특성을 가질 수 있기 때문입니다.
주요 디자인 목표 중 하나: 일반 Discourse를 깨지 않기
컴포넌트를 Discourse의 기존 아키텍처에 최대한 가깝게 유지하려고 했습니다.
자체 게시물 렌더러, 자체 컴포저, 자체 주제 모델 또는 완전히 별개의 게시물 스트림을 구현하지 않습니다.
대신, Discourse의 기존 컴포넌트와 서비스 주위에 임시 탐색 컨텍스트를 구축합니다.
이것이 구현의 일부가 처음에 보이는 것보다 더 복잡한 이유이기도 합니다.
더 흥미로운 도전은 다음과 같았습니다:
주제가 실제로 다른 UI 컨텍스트 안에 표시되고 있으면서도, 거의 일반 Discourse 주제처럼 동작할 수 있는가?
이를 위해 Discourse의 전역 서비스와 로컬 미리보기 사이의 경계를 처리해야 했습니다.





