feat: render research Markdown and navigate citations to deck posts
This commit is contained in:
@@ -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()
|
||||
})
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user