diff --git a/.github/workflows/home-first-read.yml b/.github/workflows/home-first-read.yml new file mode 100644 index 00000000..40258d6d --- /dev/null +++ b/.github/workflows/home-first-read.yml @@ -0,0 +1,79 @@ +name: Browser first read +on: + pull_request: + paths: + - 'src/msg/transports/**' + - 'src/msg/plugins/discovery*' + - 'tests/test_home*' + - 'tests/test_browser*' + - 'tests/test_thread*' + - 'scripts/check_home_first_read_browser.py' + - '.github/workflows/home-first-read.yml' + push: + branches: [main] + paths: + - 'src/msg/transports/**' + - 'src/msg/plugins/discovery*' + - 'tests/test_home*' + - 'tests/test_browser*' + - 'tests/test_thread*' + - 'scripts/check_home_first_read_browser.py' + - '.github/workflows/home-first-read.yml' + workflow_dispatch: +permissions: + contents: read +concurrency: + group: browser-first-read-${{ github.ref }} + cancel-in-progress: true +jobs: + verify: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: msgtest + POSTGRES_PASSWORD: msgtest + POSTGRES_DB: postgres + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U msgtest -d postgres" + --health-interval 10s --health-timeout 5s --health-retries 5 + env: + MSG_TEST_POSTGRES_URL_TEMPLATE: postgresql://msgtest:msgtest@127.0.0.1:5432/{database} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-python@v7 + with: + python-version: '3.15' + allow-prereleases: true + - name: Install test tools + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq age libpcsclite-dev swig + python -m pip install uv==0.12.20 + uv sync --locked --extra dev --python "$(command -v python)" + uv run python -m playwright install --with-deps chromium + - name: Verify first reads, authority and existing browser routes + run: | + mkdir -p artifacts + uv run pytest tests/test_home_first_read.py tests/test_home_cache.py tests/test_browser_refinement.py tests/test_browser_html.py tests/test_browser_languages.py tests/test_thread_panel.py tests/test_thread_panel_http.py tests/test_board_presentation.py --junitxml=artifacts/browser-first-read-tests.xml + - name: Verify real navigation and responsive rendering under CSP + env: + PYTHONPATH: src + run: uv run python scripts/check_home_first_read_browser.py + - name: Check changed source and tests + run: | + uv run ruff check src/msg/transports/{browser_style,home_page,http_routes,thread_browser}.py tests/{test_home_first_read,test_browser_refinement,test_thread_panel}.py scripts/check_home_first_read_browser.py + uv run ruff format --check src/msg/transports/{browser_style,home_page,http_routes,thread_browser}.py tests/{test_home_first_read,test_browser_refinement,test_thread_panel}.py scripts/check_home_first_read_browser.py + uv run python -W error -m compileall -q src + git diff --check + - uses: actions/upload-artifact@v7.0.1 + if: always() + with: + name: browser-first-read-evidence + path: artifacts/ + retention-days: 7 diff --git a/scripts/check_home_first_read_browser.py b/scripts/check_home_first_read_browser.py new file mode 100644 index 00000000..c7fa34c5 --- /dev/null +++ b/scripts/check_home_first_read_browser.py @@ -0,0 +1,227 @@ +"""Verify first-view readability and navigation with actual HTML/CSP fixtures. + +The HTTP integration tests own cold-cache and authorization behavior. This +isolated browser server uses sample data, not live identities or counters. +""" + +from __future__ import annotations + +import json +import os +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlsplit + +from playwright.sync_api import expect, sync_playwright + +from msg.plugins.public_board import default +from msg.transports.home_page import HOME_BROWSER_HEADERS, document_html, home_html + +OUTPUT = Path('artifacts/home-first-read') +POSTS = [ + { + 'name': 'first-read', + 'path': '/main/first-read', + 'title': '让第一次打开,就能读到内容', + 'excerpt': '页面不必等待全站统计。先读帖子,再继续讨论;每次读取仍检查当前权限。', + 'created_at': '2026-10-09T14:00:00Z', + 'view_count': 12, + }, + { + 'name': 'small-changes', + 'path': '/main/small-changes', + 'title': '留白,让讨论更清楚', + 'excerpt': '减少边框,保留内容。把标题、摘要和回复信息放到各自清楚的位置。', + 'created_at': '2026-10-09T13:00:00Z', + 'view_count': 7, + }, + { + 'name': 'long-title', + 'path': '/main/long-title', + 'title': '一条比较长的中英文标题:Reliable first reads, without refreshing the whole page', + 'excerpt': '这是一组独立的浏览器测试数据,不是线上用户、帖子或统计。', + 'created_at': '2026-10-09T12:00:00Z', + 'view_count': 3, + }, +] +DATA = { + 'posts': 22, + 'posts_today': 3, + 'users': 8, + 'latest': [], + 'date': '2026-10-09', + 'timezone': 'Asia/Taipei', + 'channels': [ + { + 'name': 'main', + 'path': '/main', + 'about': '公开讨论 / Public discussions', + 'posts': 22, + 'mode': '0755', + 'posting': 'identity', + }, + ], +} +POST_BODY = ( + '---\nid: browser-fixture\n---\n\n# 让第一次打开,就能读到内容\n\n' + '正文在第一次访问时可见。没有刷新,没有隐藏的加载占位。\n\n' + '## 阅读优先\n\n帖子列表与全站统计独立读取。统计暂时不可用时,帖子仍可阅读。\n\n' + '## 小范围的修改\n\n保留现有登录、主题和权限。\n\n' + '```python\nprint("read on the first visit")\n```\n' +) +BOARD = default() + + +def html(path): + if path in {'/', '/cold'}: + return home_html( + DATA if path == '/' else None, + post_index={'items': POSTS}, + login_enabled=True, + public_board=BOARD, + ) + if path == '/topics': + return document_html( + '# Topics\n\n## [让第一次打开,就能读到内容](/main/first-read)\n\n' + '公开讨论。\n\n## [留白,让讨论更清楚](/main/small-changes)\n\n' + '减少边框,保留内容。', + resource={'type': 'topic', '_post_index_next': None}, + raw_path=path, + ) + return document_html( + POST_BODY, + raw_path=path, + resource={ + 'type': 'post', + 'id': 'r_browser_fixture', + 'revision': 'rev_fixture', + 'created_at': '2026-10-09T14:00:00Z', + 'view_count': 12, + 'links': {'a': {'path': '/@fixture'}, 't': {'path': '/main'}}, + }, + ) + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + path = urlsplit(self.path).path + if path == '/favicon.png': + self.send_response(204) + self.end_headers() + return + image = path == '/_public-board/art.svg' + payload = BOARD['svg'].encode() if image else html(path) + self.send_response(200) + self.send_header('Content-Type', 'image/svg+xml' if image else 'text/html; charset=utf-8') + for key, value in HOME_BROWSER_HEADERS.items(): + self.send_header(key, value) + self.send_header('Content-Length', str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + +def verify(page): + assert not page.evaluate('window.cspErrors'), page.evaluate('window.cspErrors') + assert page.evaluate('document.documentElement.scrollWidth <= innerWidth') + assert page.locator('#content').is_visible() + + +def main(): + OUTPUT.mkdir(parents=True, exist_ok=True) + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + origin = f'http://127.0.0.1:{server.server_port}' + results = [] + try: + with sync_playwright() as playwright: + options = ( + {'executable_path': os.environ['MSG_BROWSER_EXECUTABLE']} + if os.environ.get('MSG_BROWSER_EXECUTABLE') + else {} + ) + browser = playwright.chromium.launch(**options) + for width in (360, 768, 1440): + for theme in ('light', 'dark'): + context = browser.new_context( + viewport={'width': width, 'height': 960}, + color_scheme=theme, + locale='zh-CN', + reduced_motion='reduce', + ) + page = context.new_page() + errors = [] + page.on('pageerror', lambda error, errors=errors: errors.append(str(error))) + page.add_init_script( + "window.cspErrors=[];document.addEventListener('securitypolicyviolation'," + 'e=>cspErrors.push(e.violatedDirective));' + ) + page.goto(origin + '/cold', wait_until='networkidle') + first = page.locator('.post-title a').first + expect(first).to_have_text(POSTS[0]['title']) + verify(page) + assert page.locator('.posts .post-excerpt').count() == len(POSTS) + # Direct navigation, browser back and forward must not lose the body. + first.click() + expect(page.locator('#content')).to_contain_text('正文在第一次访问时可见。') + verify(page) + page.go_back(wait_until='networkidle') + expect(page.locator('.post-title a').first).to_be_visible() + verify(page) + page.go_forward(wait_until='networkidle') + expect(page.locator('#content')).to_contain_text('正文在第一次访问时可见。') + verify(page) + page.screenshot(path=str(OUTPUT / f'post-{width}-{theme}.png'), full_page=True) + page.goto(origin + '/', wait_until='networkidle') + verify(page) + expect(page.locator('.latest')).to_be_visible() + assert ( + page.locator('.latest').bounding_box()['y'] + < page.locator('.activity').bounding_box()['y'] + ) + for target in page.locator('.post-title a, .section-link').all(): + assert target.bounding_box()['height'] >= 44 + page.screenshot(path=str(OUTPUT / f'home-{width}-{theme}.png'), full_page=True) + page.locator('.section-link').click() + expect(page.locator('.page-topic-index')).to_be_visible() + verify(page) + page.screenshot( + path=str(OUTPUT / f'topics-{width}-{theme}.png'), full_page=True + ) + assert not errors, errors + assert not page.evaluate('window.cspErrors'), page.evaluate('window.cspErrors') + results.append({'width': width, 'theme': theme, 'passed': True}) + context.close() + # Also exercise normal-motion navigation; reduced motion must not hide a failure. + context = browser.new_context(reduced_motion='no-preference') + page = context.new_page() + page.goto(origin + '/cold', wait_until='networkidle') + page.locator('.post-title a').first.click() + expect(page.locator('#content')).to_contain_text('正文在第一次访问时可见。') + page.go_back(wait_until='networkidle') + expect(page.locator('.post-title a').first).to_be_visible() + page.go_forward(wait_until='networkidle') + expect(page.locator('#content')).to_contain_text('正文在第一次访问时可见。') + context.close() + # The repair is server-rendered, not dependent on browser scripting. + context = browser.new_context(java_script_enabled=False) + page = context.new_page() + page.goto(origin + '/cold') + expect(page.locator('.post-title a').first).to_have_text(POSTS[0]['title']) + verify(page) + context.close() + browser.close() + finally: + server.shutdown() + server.server_close() + (OUTPUT / 'results.json').write_text(json.dumps(results, ensure_ascii=False, indent=2) + '\n') + print( + f'{len(results)} viewport/theme cases plus normal-motion navigation and JavaScript-disabled first read passed.' + ) + + +if __name__ == '__main__': + main() diff --git a/src/msg/transports/browser_style.py b/src/msg/transports/browser_style.py index 2e8b6233..d623c4cc 100644 --- a/src/msg/transports/browser_style.py +++ b/src/msg/transports/browser_style.py @@ -270,17 +270,26 @@ .account-groups summary { color: var(--muted); font-size: 13px; } .page-home #content > section:is(.activity, .latest, .channels) { display: grid; grid-template-columns: 200px minmax(0, 1fr); column-gap: 56px; - margin-block: 0; padding-block: 44px 52px; border-top: 1px solid var(--line); + margin-block: 0; padding-block: 40px 48px; } -.page-home #content > section:is(.activity, .latest, .channels) > h2 { +.page-home #content > section:is(.activity, .latest, .channels) > :is(h2, .section-heading) { grid-column: 1; grid-row: 1 / span 6; align-self: start; position: sticky; top: calc(var(--header-height) + 24px); - display: flex; align-items: center; gap: 10px; margin: 2px 0 24px; font-size: 15px; letter-spacing: -.01em; + margin: 2px 0 24px; font-size: 15px; letter-spacing: -.01em; } -.page-home #content > section:is(.activity, .latest, .channels) > h2::before { +.page-home #content > section:is(.activity, .latest, .channels) > h2::before, .section-heading h2::before { content: ''; flex: none; width: 7px; height: 7px; border-radius: 50%; border: 1.5px solid var(--accent); } -.page-home #content > section:is(.activity, .latest, .channels) > :not(h2) { grid-column: 2; } +.page-home #content > section:is(.activity, .latest, .channels) > :not(h2, .section-heading) { grid-column: 2; } +.page-home #content > section:is(.activity, .latest, .channels) > h2 { display: flex; align-items: center; gap: 10px; } +.section-heading { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; } +.section-heading h2 { display: flex; align-items: center; gap: 10px; margin: 0; font: inherit; } +.section-link { + display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding-inline: 17px 8px; + color: var(--muted); font: 12px/1.5 var(--mono); text-decoration: none; +} +.section-link:hover { color: var(--accent); } +.activity-unavailable { margin: 8px 0 32px; color: var(--muted); font-size: 13px; } .stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); } .stats > div { padding: 0 24px; border-inline-start: 1px solid var(--line); } .stats > div:first-child { padding-inline-start: 0; border: 0; } @@ -291,37 +300,26 @@ .stats span { display: block; margin-top: 6px; font-size: 13px; color: var(--muted); } .activity-note { margin: 20px 0 0; font: 12px/1.6 var(--mono); } -/* Latest posts ride one signal wire; each post is a node on it. */ -.posts { position: relative; list-style: none; margin: 0; padding: 0; } -.posts li { padding-block: 16px 22px; } -.page-home .posts { padding-inline-start: 34px; } -.page-home .posts::before { - content: ''; position: absolute; inset-inline-start: 5px; top: 30px; bottom: 0; width: 1px; - background: linear-gradient(var(--line-strong) calc(100% - 64px), transparent); -} -.page-home .posts::after { - content: ''; position: absolute; inset-inline-start: 5px; top: 30px; width: 1px; height: 72px; - background: linear-gradient(transparent, var(--accent)); opacity: 0; pointer-events: none; -} -.page-home .posts li { position: relative; } -.page-home .posts li::before { - content: ''; position: absolute; inset-inline-start: -34px; top: 24px; width: 11px; height: 11px; - border-radius: 50%; background: var(--bg); border: 1.5px solid var(--line-strong); -} -.page-home .posts li:hover::before { background: var(--accent); border-color: var(--accent); } -.post-title { display: flex; justify-content: space-between; align-items: baseline; gap: 8px 28px; } +/* Reading-first index: whitespace, a strong title and one quiet metadata row. */ +.posts { list-style: none; margin: 0; padding: 0; min-width: 0; } +.posts li { padding: 0; } +.posts li + li { margin-top: 30px; } +.post-preview { position: relative; padding: 4px 0; } +.post-title { margin: 0; font-size: inherit; } .post-title a { - display: inline-block; padding-block: 10px; margin-block: -10px; - color: var(--fg); font: 600 19px/1.4 var(--sans); letter-spacing: -.015em; text-decoration: none; + display: inline-flex; align-items: center; min-height: 44px; max-width: 100%; + color: var(--fg); font: 600 clamp(19px, 2vw, 23px)/1.5 var(--sans); + letter-spacing: -.025em; text-decoration: none; text-wrap: pretty; } .post-title a:hover { color: var(--accent); } -.post-engagement { color: var(--muted); font-size: 13px; } -.post-engagement .muted { font-size: 13px; } -.posts p { color: var(--muted); font-size: 15px; line-height: 1.7; margin: 4px 0 0; max-width: 68ch; } +.post-engagement { color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; } +.post-engagement .muted, .post-engagement .reply-link { color: var(--muted); font-size: 12px; } +.post-engagement .reply-link:hover { color: var(--accent); } +.post-engagement time { font-family: var(--mono); font-size: 11.5px; } +.posts .post-excerpt { color: var(--muted); font-size: 15px; line-height: 1.8; margin: 5px 0 4px; max-width: 66ch; } .empty-state { - margin: 0; padding: 28px; border: 1px dashed var(--line-strong); border-radius: var(--radius-panel); - color: var(--muted); font-size: 15px; text-align: center; + margin: 0; padding: 24px 0; color: var(--muted); font-size: 15px; line-height: 1.8; } .notice { display: flex; align-items: flex-start; gap: 12px; margin-block: 16px; padding: 14px 18px; @@ -360,7 +358,7 @@ footer nav a { min-width: 44px; justify-content: center; padding-inline: 10px; color: var(--muted); font-size: 13px; } footer p { margin: 0; font: 12px/1.6 var(--mono); } -.page-topic-index .prose > h2 { font-size: 1.25rem; font-weight: 600; line-height: 1.45; margin-block: 44px 6px; } +.page-topic-index .prose > h2 { font-size: clamp(20px, 2.2vw, 25px); font-weight: 600; line-height: 1.5; letter-spacing: -.02em; margin-block: 40px 6px; } .page-topic-index .prose > h2 > a, .page-topic-index .prose > p > a[href*="post_cursor="] { display: inline-flex; align-items: center; min-height: 44px; min-width: 44px; max-width: 100%; } @@ -398,7 +396,7 @@ /* The byline sits under the title as one quiet row. */ .post-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 0 20px; - margin: -6px 0 36px; padding-bottom: 14px; border-bottom: 1px solid var(--line); + margin: -2px 0 40px; padding-bottom: 4px; color: var(--muted); font-size: 13.5px; } .post-meta p { margin: 0; line-height: 44px; } @@ -718,7 +716,8 @@ @media (max-width: 860px) { .page-home #content > section:is(.activity, .latest, .channels) { display: block; padding-block: 36px 40px; } - .page-home #content > section:is(.activity, .latest, .channels) > h2 { position: static; margin-bottom: 24px; } + .page-home #content > section:is(.activity, .latest, .channels) > :is(h2, .section-heading) { position: static; margin-bottom: 20px; } + .section-heading { flex-direction: row; align-items: center; justify-content: space-between; } } @media (max-width: 640px) { :root { --header-height: 60px; } @@ -740,9 +739,9 @@ .account:has(> .primary) { flex-direction: column-reverse; } .stats > div { padding-inline: 14px; } .stats strong { font-size: 28px; } - .page-home .posts { padding-inline-start: 26px; } - .page-home .posts li::before { inset-inline-start: -26px; } - .post-title { align-items: start; flex-direction: column; gap: 2px; } + .posts li + li { margin-top: 24px; } + .posts .post-excerpt { font-size: 14px; } + .post-engagement { gap: 0 14px; } .preference-row select { width: 150px; } .post-meta { gap: 0 14px; } .post-meta dl { grid-template-columns: 1fr; gap: 2px; } @@ -775,14 +774,9 @@ transition: color .18s ease, background-color .18s ease, border-color .18s ease, box-shadow .18s ease; } .primary::after, .thread-branch-chevron, .settings-chevron { transition: transform .28s var(--ease-out); } - .thread-node::before, .page-home .posts li::before { transition: background-color .2s ease, border-color .2s ease, box-shadow .2s ease; } + .thread-node::before { transition: background-color .2s ease, border-color .2s ease, box-shadow .2s ease; } .brand:is(:hover, :focus-visible) > svg { animation: msg-logo-turn .7s cubic-bezier(.22, 1, .36, 1); } .preferences[open] .preference-fields, .account-menu[open] .account-menu-links { animation: msg-pop .26s var(--ease-out); } - .page-home .posts::after { animation: msg-signal-run 1.9s linear .35s 1 both; } - .page-home .posts li::before { - animation: msg-node-ping 1.1s var(--ease-out) backwards; - animation-delay: calc(.3s + 1.9s * (var(--i, 0) + .15) / var(--n, 4)); - } .token-art[data-running] .token { animation: token-assemble 12s cubic-bezier(.22, 1, .36, 1) infinite; animation-delay: var(--phase); animation-play-state: paused; @@ -796,16 +790,6 @@ @keyframes msg-pop { from { opacity: 0; transform: translateY(-6px) scale(.98); } } -@keyframes msg-signal-run { - 0% { top: 0; opacity: 0; } - 12%, 82% { opacity: 1; } - 100% { top: calc(100% - 72px); opacity: 0; } -} -@keyframes msg-node-ping { - 0% { background: var(--bg); border-color: var(--line-strong); } - 25% { background: var(--accent); border-color: var(--accent); box-shadow: 0 0 0 5px var(--accent-soft); } - 100% { background: var(--bg); border-color: var(--line-strong); box-shadow: 0 0 0 0 transparent; } -} @keyframes token-assemble { 0%, 12%, 100% { transform: translate(var(--dx), var(--dy)) rotate(var(--turn)); opacity: .38; } 38%, 65% { transform: translate(0, 0) rotate(0deg); opacity: 1; } diff --git a/src/msg/transports/home_page.py b/src/msg/transports/home_page.py index 54fe4c82..25c1a98d 100644 --- a/src/msg/transports/home_page.py +++ b/src/msg/transports/home_page.py @@ -97,6 +97,43 @@ def account_navigation(account, *, compact=False): return links +def latest_posts_html(index, statuses=None): + """Render the independent, already-authorized post index, never false empty data.""" + parts = [ + '
' + '

Latest posts

' + 'Topics' + '
' + ] + if index.get('error'): + parts.append('

主帖列表暂时无法读取,请稍后刷新。

') + elif not index['items']: + parts.append('

No public posts yet.

') + else: + parts.append('') + parts.append('
') + return ''.join(parts) + + def home_html( data=None, *, @@ -107,6 +144,7 @@ def home_html( public_board=None, csrf_token='', reply_status=None, + post_index=None, ): def link(label, path): keys = { @@ -186,10 +224,18 @@ def link(label, path): '
Sign in' '

Sign in to view your inbox and direct messages. Confirm the approval code using your CLI.

' ) + # A bounded post index can be ready while the full-site summary is still cold. + if post_index is None: + post_index = ( + {'items': data['latest'], 'error': data.get('latest_unavailable')} + if data is not None + else {'items': [], 'error': 'unavailable'} + ) + parts.append(latest_posts_html(post_index, reply_status)) if data is None: parts.append( - '

' - 'Statistics and latest posts are temporarily unavailable.

' + '

' + '统计暂时无法读取。 / Site activity is temporarily unavailable.

' ) else: parts.append( @@ -206,35 +252,6 @@ def link(label, path): parts.append( f'

{escape(data["date"])} · {escape(data["timezone"])}

' ) - parts.append( - '

Latest posts

' - f'') - if not data['latest']: - parts.append( - '

主帖列表暂时无法读取,请稍后刷新。

' - if data.get('latest_unavailable') - else '

No public posts yet.

' - ) - parts.append('
') if data.get('channels'): parts.append( '

Channels

Only channels you can read are listed. Sign in to include your private channels. ' diff --git a/src/msg/transports/http_routes.py b/src/msg/transports/http_routes.py index d80dde40..e9e3f700 100644 --- a/src/msg/transports/http_routes.py +++ b/src/msg/transports/http_routes.py @@ -2989,18 +2989,15 @@ async def decorate(value): browser_html = 'text/html' in request.headers.get('accept', '').casefold() renderer = home_html if browser_html else home_markdown reply_status = {} - if browser_html and home_data: + index = None + if browser_html: from msg.transports.thread_browser import reply_statuses, root_post_index index = await root_post_index( service.executor.execute, service.settings.service_url, limit=5 ) - home_data = { - **home_data, - 'latest': index['items'], - 'latest_unavailable': bool(index.get('error')), - } - latest = home_data.get('latest', [])[:5] + # Post reads must not depend on a cold/failed full-site summary. + latest = index['items'][:5] statuses = await reply_statuses( execute_packet, [item['path'] for item in latest], @@ -3021,14 +3018,14 @@ async def decorate(value): getattr(service.settings, 'oauth', None), 'enabled', False ), expired=request.scope.get('state', {}).get('msg_browser_expired', False), - **({'reply_status': reply_status} if browser_html else {}), + **({'reply_status': reply_status, 'post_index': index} if browser_html else {}), ) headers = { **(HOME_BROWSER_HEADERS if browser_html else BASE_HEADERS), 'Content-Length': str(len(payload)), 'Cache-Control': 'private, no-store' if request.headers.get('cookie') - or (browser_html and (home_data or {}).get('latest_unavailable')) + or (browser_html and (home_data is None or index.get('error'))) else 'private, no-cache', 'Vary': 'Accept, Cookie', 'X-Msg-Home-Snapshot': 'unavailable' diff --git a/src/msg/transports/thread_browser.py b/src/msg/transports/thread_browser.py index 6b4345ed..d0a16652 100644 --- a/src/msg/transports/thread_browser.py +++ b/src/msg/transports/thread_browser.py @@ -124,7 +124,7 @@ async def titled(item): source='manual', ) ) - title = item['name'] + preview = {'title': item['name'], 'excerpt': ''} if response.error and response.error.code in { 'permission_denied', 'not_found', @@ -134,10 +134,10 @@ async def titled(item): }: return None if not response.error: - title = thread_preview({**item, 'content': wire(response.data)['text']})['title'] - if title == 'Untitled post': - title = '未命名主帖' - return {**item, 'path': '/*' + hex_id(item['id']), 'title': title} + preview = thread_preview({**item, 'content': wire(response.data)['text']}) + if preview['title'] == 'Untitled post': + preview['title'] = '未命名主帖' + return {**item, 'path': '/*' + hex_id(item['id']), **preview} return { **page, diff --git a/tests/test_browser_refinement.py b/tests/test_browser_refinement.py index d8812a80..93dc330c 100644 --- a/tests/test_browser_refinement.py +++ b/tests/test_browser_refinement.py @@ -92,14 +92,12 @@ def test_home_counters_are_translated_without_invented_activity(): parsed = Elements(html) markers = {a.get('data-i18n') for _, a in parsed.tags} assert {'public_posts', 'today', 'users', 'no_posts'} <= markers - assert ( - 'Statistics and latest posts are temporarily unavailable.' not in html.split('', + 'excerpt': '', + 'path': '/main/post', + 'created_at': '2026-10-09T14:00:00Z', + 'view_count': 2, + } + page = home_html(None, post_index={'items': [item]}).decode().split('