feat: render research Markdown and navigate citations to deck posts

This commit is contained in:
2026-09-24 18:18:15 +09:00
parent 9c3917541b
commit bd9c1b77b7
26 changed files with 1970 additions and 20 deletions
+115
View File
@@ -0,0 +1,115 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it, vi } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import type { CitationTarget } from '../research/use-citation-navigation'
import { ResearchColumn } from './deck-column'
const feed = vi.hoisted(() => ({ use: vi.fn(), next: vi.fn() }))
vi.mock('../platforms/use-research-feed', async (importOriginal) => ({
...(await importOriginal<typeof import('../platforms/use-research-feed')>()),
useResearchFeed: feed.use,
}))
vi.mock('./research-post-card', () => ({
ResearchPostCard: ({
post,
citationSnapshot,
}: {
post: ResearchPost
citationSnapshot?: boolean
}) => (
<article>
{post.text}
{citationSnapshot ? '調査時に取得した投稿' : ''}
</article>
),
}))
const citation: CitationTarget = {
deckId: 'deck',
request: 1,
column: {
id: 'column',
title: 'Research',
connectionId: 'account',
source: {
platform: 'twitter',
kind: 'search',
query: 'WebMCP',
product: 'Latest',
following: false,
},
},
post: {
key: 'twitter:123',
platform: 'twitter',
nativeId: '123',
url: 'https://x.com/alice/status/123',
text: 'Snapshot',
author: { name: 'Alice', handle: 'alice' },
},
}
it('temporarily renders missing evidence without inserting it into feed pages or changing pagination', () => {
const page = Object.freeze({
posts: Object.freeze([]),
nextCursor: 'original-cursor',
})
feed.use.mockReturnValue({
data: { pages: [page] },
hasNextPage: true,
fetchNextPage: feed.next,
})
render(
<ResearchColumn
column={citation.column}
citation={citation}
registry={new Map()}
first
last
onEdit={vi.fn()}
onMove={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getByRole('article')).toHaveTextContent(
'Snapshot調査時に取得した投稿',
)
expect(page.posts).toHaveLength(0)
expect(page.nextCursor).toBe('original-cursor')
fireEvent.click(screen.getByRole('button', { name: '続きを読み込む' }))
expect(feed.next).toHaveBeenCalledWith({ cancelRefetch: false })
})
it('keeps the live post instead of a duplicate snapshot with a Twitter URL alias', () => {
feed.use.mockReturnValue({
data: {
pages: [
{
posts: [
{
...citation.post,
url: 'https://mobile.twitter.com/alice/status/123',
text: 'Live',
},
],
},
],
},
})
render(
<ResearchColumn
column={citation.column}
citation={citation}
registry={new Map()}
first
last
onEdit={vi.fn()}
onMove={vi.fn()}
onRemove={vi.fn()}
/>,
)
expect(screen.getAllByRole('article')).toHaveLength(1)
expect(screen.getByRole('article')).toHaveTextContent('Live')
expect(
screen.queryByText(/Snapshot|調査時に取得した投稿/),
).not.toBeInTheDocument()
})
+21 -3
View File
@@ -4,6 +4,8 @@ import {
useResearchFeed,
} from '#/features/platforms/use-research-feed'
import { PostLoadError } from '#/features/posts/use-post-feed'
import { citationUrlKey } from '#/features/research/citation-url'
import type { CitationTarget } from '#/features/research/use-citation-navigation'
import { type ColumnRegistry, useRegisterColumn } from './column-tools'
import type { DeckColumn } from './model'
import { ResearchPostCard } from './research-post-card'
@@ -18,6 +20,7 @@ export function ResearchColumn({
first,
last,
registry,
citation,
}: {
column: DeckColumn
connectionLabel?: string
@@ -27,10 +30,16 @@ export function ResearchColumn({
first: boolean
last: boolean
registry: ColumnRegistry
citation?: CitationTarget
}) {
const query = useResearchFeed(column)
useRegisterColumn(registry, column, query)
const posts = flattenResearchPages(query.data?.pages ?? [])
const citedKey = citation ? citationUrlKey(citation.post.url) : null
const visiblePosts =
citation && !posts.some((post) => citationUrlKey(post.url) === citedKey)
? [citation.post, ...posts]
: posts
const error =
query.error instanceof PostLoadError
? query.error.detail
@@ -123,8 +132,17 @@ export function ResearchColumn({
tabIndex={0}
aria-label={`${column.title}の投稿`}
>
{posts.map((post) => (
<ResearchPostCard key={post.key} post={post} />
{visiblePosts.map((post) => (
<ResearchPostCard
key={post.key}
post={post}
citationRequest={
citation && citationUrlKey(post.url) === citedKey
? citation.request
: undefined
}
citationSnapshot={citation?.post === post && !posts.includes(post)}
/>
))}
{query.isError ? (
<div className="deck-message" role="alert">
@@ -144,7 +162,7 @@ export function ResearchColumn({
) : null}
</div>
) : null}
{!query.isPending && !query.isError && posts.length === 0 ? (
{!query.isPending && !query.isError && visiblePosts.length === 0 ? (
<p className="deck-message">条件に一致する投稿はありません。</p>
) : null}
{query.hasNextPage && !query.isFetchNextPageError ? (
+18
View File
@@ -9,6 +9,10 @@ import { ConnectionManager } from '#/features/connections/connection-manager'
import { loadConnections } from '#/features/connections/server-functions'
import { syncResearchDeck } from '#/features/research/research-deck-sync'
import { ResearchPanel } from '#/features/research/research-panel'
import {
sameCitationSource,
useCitationNavigation,
} from '#/features/research/use-citation-navigation'
import { ColumnEditor } from './column-editor'
import { type ColumnRegistry, useColumnTools } from './column-tools'
import { ResearchColumn } from './deck-column'
@@ -46,6 +50,12 @@ export function DeckPage() {
remove,
getWorkspace,
} = useDeck(editing !== null || renaming || switcherEditing)
const citationNavigation = useCitationNavigation({
registry,
getWorkspace,
select,
createTemporary,
})
const fetchProfiles = useServerFn(loadConnections)
const profiles = useQuery({
queryKey: ['connections'],
@@ -96,6 +106,7 @@ export function DeckPage() {
<AppShell
researchChat={
<ResearchPanel
onNavigateCitation={citationNavigation.navigate}
ready={ready}
contextDeck={{
id: deck.id,
@@ -388,6 +399,13 @@ export function DeckPage() {
key={`${deck.id}:${column.id}`}
registry={registry}
column={column}
citation={
citationNavigation.target?.deckId === deck.id &&
citationNavigation.target.column.id === column.id &&
sameCitationSource(citationNavigation.target.column, column)
? citationNavigation.target
: undefined
}
connectionLabel={
profiles.data?.connections.find(
(connection) => connection.id === column.connectionId,
+54 -2
View File
@@ -1,8 +1,60 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { expect, it } from 'vitest'
import { act, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import type { ResearchPost } from '../platforms/types'
import { ResearchPostCard } from './research-post-card'
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('focuses and briefly highlights a cited card without revealing sensitive content', () => {
vi.useFakeTimers()
vi.stubGlobal('matchMedia', () => ({ matches: true }))
const post: ResearchPost = {
key: 'mastodon:1',
platform: 'mastodon',
nativeId: '1',
url: 'https://social.invalid/@a/1',
text: 'Hidden content',
author: { name: 'Alice', handle: '[email protected]' },
contentWarning: 'Spoiler',
sensitive: true,
}
const { rerender } = render(<ResearchPostCard post={post} />)
const article = screen.getByRole('article')
const scroll = vi.fn()
article.scrollIntoView = scroll
rerender(
<ResearchPostCard post={post} citationRequest={1} citationSnapshot />,
)
expect(article).toHaveFocus()
expect(article).toHaveClass('citation-highlight')
expect(scroll).toHaveBeenCalledWith({
block: 'center',
inline: 'center',
behavior: 'instant',
})
expect(screen.getByText('調査時に取得した投稿')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '内容を表示' })).toHaveAttribute(
'aria-expanded',
'false',
)
expect(screen.queryByText('Hidden content')).not.toBeInTheDocument()
rerender(<ResearchPostCard post={post} citationRequest={1} />)
expect(scroll).toHaveBeenCalledTimes(2)
expect(article).toHaveFocus()
expect(screen.queryByText('調査時に取得した投稿')).not.toBeInTheDocument()
act(() => {
vi.advanceTimersByTime(2500)
})
expect(article).not.toHaveClass('citation-highlight')
rerender(<ResearchPostCard post={post} citationRequest={2} />)
expect(article).toHaveClass('citation-highlight')
rerender(<ResearchPostCard post={post} />)
expect(article).not.toHaveClass('citation-highlight')
})
it('waits for reveal before mounting CW content or sensitive media', () => {
const post: ResearchPost = {
key: 'mastodon:1',
+37 -3
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { Icon } from '#/components/icon'
import { PostText } from '#/components/post-text'
import type { ResearchPost } from '#/features/platforms/types'
@@ -11,11 +11,45 @@ const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
timeZone: 'Asia/Tokyo',
})
export function ResearchPostCard({ post }: { post: ResearchPost }) {
export function ResearchPostCard({
post,
citationRequest,
citationSnapshot,
}: {
post: ResearchPost
citationRequest?: number
citationSnapshot?: boolean
}) {
const [revealed, setRevealed] = useState(false)
const [highlighted, setHighlighted] = useState(false)
const article = useRef<HTMLElement>(null)
// biome-ignore lint/correctness/useExhaustiveDependencies: Re-scroll when a snapshot moves to its live feed position.
useEffect(() => {
if (citationRequest === undefined) {
setHighlighted(false)
return
}
setHighlighted(true)
article.current?.focus({ preventScroll: true })
article.current?.scrollIntoView({
block: 'center',
inline: 'center',
behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
? 'instant'
: 'smooth',
})
const timer = setTimeout(() => setHighlighted(false), 2500)
return () => clearTimeout(timer)
}, [citationRequest, citationSnapshot])
const concealed = Boolean(post.contentWarning || post.sensitive)
return (
<article className="research-post">
<article
ref={article}
tabIndex={-1}
className={`research-post${highlighted ? ' citation-highlight' : ''}`}
data-post-url={post.url}
>
{citationSnapshot ? <p className="handle">調査時に取得した投稿</p> : null}
{post.boostedBy ? (
<p className="handle">{post.boostedBy.name} がブースト</p>
) : null}