Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01d808381b | ||
|
|
c43278f785 |
@@ -24,6 +24,10 @@ test('requires intent and infinitely loads a user timeline', async ({
|
|||||||
}) => {
|
}) => {
|
||||||
await homePage.goTo()
|
await homePage.goTo()
|
||||||
await expect(page).toHaveURL(/\/$/)
|
await expect(page).toHaveURL(/\/$/)
|
||||||
|
await expect(page.locator('meta[name="referrer"]')).toHaveAttribute(
|
||||||
|
'content',
|
||||||
|
'no-referrer',
|
||||||
|
)
|
||||||
await expect(homePage.articlesLocator).toHaveCount(0)
|
await expect(homePage.articlesLocator).toHaveCount(0)
|
||||||
await expect(homePage.ambientLinksLocator).toHaveCount(0)
|
await expect(homePage.ambientLinksLocator).toHaveCount(0)
|
||||||
|
|
||||||
@@ -96,6 +100,18 @@ test('resolves a reply root and retries only the failed continuation', async ({
|
|||||||
await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
|
await expect(threadPage.mutationButtonsLocator).toHaveCount(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('ends a thread without loading a continuation when there are no replies', async ({
|
||||||
|
page,
|
||||||
|
threadPage,
|
||||||
|
}) => {
|
||||||
|
await threadPage.goTo('yuta', '3001')
|
||||||
|
|
||||||
|
await expect(page.getByText('no replies')).toHaveCount(1)
|
||||||
|
await expect(page.getByText('thread root')).toBeVisible()
|
||||||
|
await expect(threadPage.endOfFeedLocator).toBeVisible()
|
||||||
|
await expect(threadPage.continuationErrorLocator).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
test('searches Top posts from followed accounts', async ({
|
test('searches Top posts from followed accounts', async ({
|
||||||
page,
|
page,
|
||||||
searchPage,
|
searchPage,
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 57 KiB |
@@ -11,10 +11,12 @@ const failedRetryQueries = new Set()
|
|||||||
const rootByTarget = new Map([
|
const rootByTarget = new Map([
|
||||||
['1001', '1000'],
|
['1001', '1000'],
|
||||||
['2001', '2000'],
|
['2001', '2000'],
|
||||||
|
['3001', '3000'],
|
||||||
])
|
])
|
||||||
const targetByRoot = new Map([
|
const targetByRoot = new Map([
|
||||||
['1000', '1001'],
|
['1000', '1001'],
|
||||||
['2000', '2001'],
|
['2000', '2001'],
|
||||||
|
['3000', '3001'],
|
||||||
])
|
])
|
||||||
const failedThreadRoots = new Set()
|
const failedThreadRoots = new Set()
|
||||||
|
|
||||||
@@ -25,13 +27,13 @@ const tweetResult = (
|
|||||||
id,
|
id,
|
||||||
text,
|
text,
|
||||||
username = 'yuta',
|
username = 'yuta',
|
||||||
{ conversationId = id, inReplyTo } = {},
|
{ conversationId = id, inReplyTo, replyCount = 1 } = {},
|
||||||
) => ({
|
) => ({
|
||||||
rest_id: id,
|
rest_id: id,
|
||||||
legacy: {
|
legacy: {
|
||||||
full_text: text,
|
full_text: text,
|
||||||
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
created_at: 'Mon Jul 13 00:00:00 +0000 2026',
|
||||||
reply_count: 1,
|
reply_count: replyCount,
|
||||||
retweet_count: 2,
|
retweet_count: 2,
|
||||||
favorite_count: 3,
|
favorite_count: 3,
|
||||||
conversation_id_str: conversationId,
|
conversation_id_str: conversationId,
|
||||||
@@ -288,10 +290,16 @@ const server = createServer(async (request, response) => {
|
|||||||
|
|
||||||
const targetRoot = rootByTarget.get(focalTweetId)
|
const targetRoot = rootByTarget.get(focalTweetId)
|
||||||
if (targetRoot && variables.cursor === undefined) {
|
if (targetRoot && variables.cursor === undefined) {
|
||||||
const text = focalTweetId === '1001' ? 'user page 1' : 'retry focal'
|
const noReplies = focalTweetId === '3001'
|
||||||
|
const text = noReplies
|
||||||
|
? 'no replies'
|
||||||
|
: focalTweetId === '1001'
|
||||||
|
? 'user page 1'
|
||||||
|
: 'retry focal'
|
||||||
const result = tweetResult(focalTweetId, text, 'focus', {
|
const result = tweetResult(focalTweetId, text, 'focus', {
|
||||||
conversationId: targetRoot,
|
conversationId: targetRoot,
|
||||||
inReplyTo: targetRoot,
|
inReplyTo: targetRoot,
|
||||||
|
replyCount: noReplies ? 0 : 1,
|
||||||
})
|
})
|
||||||
send(response, {
|
send(response, {
|
||||||
data: {
|
data: {
|
||||||
@@ -303,6 +311,7 @@ const server = createServer(async (request, response) => {
|
|||||||
tweet(focalTweetId, text, 'focus', {
|
tweet(focalTweetId, text, 'focus', {
|
||||||
conversationId: targetRoot,
|
conversationId: targetRoot,
|
||||||
inReplyTo: targetRoot,
|
inReplyTo: targetRoot,
|
||||||
|
replyCount: noReplies ? 0 : 1,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -323,6 +332,10 @@ const server = createServer(async (request, response) => {
|
|||||||
fail(response, 400, 'invalid thread cursor')
|
fail(response, 400, 'invalid thread cursor')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (variables.cursor && focalTweetId === '3000') {
|
||||||
|
fail(response, 500, 'zero-reply thread must not request a continuation')
|
||||||
|
return
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
variables.cursor &&
|
variables.cursor &&
|
||||||
focalTweetId === '2000' &&
|
focalTweetId === '2000' &&
|
||||||
@@ -340,25 +353,37 @@ const server = createServer(async (request, response) => {
|
|||||||
inReplyTo: targetId,
|
inReplyTo: targetId,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: [
|
: focalTweetId === '3000'
|
||||||
tweet(focalTweetId, 'thread root', 'root', {
|
? [
|
||||||
conversationId: focalTweetId,
|
tweet(focalTweetId, 'thread root', 'root', {
|
||||||
}),
|
conversationId: focalTweetId,
|
||||||
tweet(
|
}),
|
||||||
targetId,
|
tweet(targetId, 'no replies', 'focus', {
|
||||||
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
|
||||||
'focus',
|
|
||||||
{
|
|
||||||
conversationId: focalTweetId,
|
conversationId: focalTweetId,
|
||||||
inReplyTo: focalTweetId,
|
inReplyTo: focalTweetId,
|
||||||
},
|
replyCount: 0,
|
||||||
),
|
}),
|
||||||
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
cursor(expectedCursor),
|
||||||
conversationId: focalTweetId,
|
]
|
||||||
inReplyTo: targetId,
|
: [
|
||||||
}),
|
tweet(focalTweetId, 'thread root', 'root', {
|
||||||
cursor(expectedCursor),
|
conversationId: focalTweetId,
|
||||||
]
|
}),
|
||||||
|
tweet(
|
||||||
|
targetId,
|
||||||
|
focalTweetId === '1000' ? 'user page 1' : 'retry focal',
|
||||||
|
'focus',
|
||||||
|
{
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
inReplyTo: focalTweetId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
tweet(`${focalTweetId}2`, 'thread page 1', 'reply1', {
|
||||||
|
conversationId: focalTweetId,
|
||||||
|
inReplyTo: targetId,
|
||||||
|
}),
|
||||||
|
cursor(expectedCursor),
|
||||||
|
]
|
||||||
send(response, {
|
send(response, {
|
||||||
data: {
|
data: {
|
||||||
tweetResult: {
|
tweetResult: {
|
||||||
|
|||||||
@@ -74,11 +74,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs_2": {
|
"nixpkgs_2": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1783776592,
|
"lastModified": 1784796856,
|
||||||
"narHash": "sha256-UgCQzxeWI75XM8G+hPrPh+MKzEPjG3SpAj7dtqSbksA=",
|
"narHash": "sha256-wWFrV5/Qbm+lyt5x20E/bSbfJiGKMo4RCxZV8cl/WZI=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "e7a3ca8092b61ff85b6a45bf863ea2b2d6a661b3",
|
"rev": "e2587caef70cea85dd97d7daab492899902dbf5d",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
inherit (finalAttrs) pname version src;
|
inherit (finalAttrs) pname version src;
|
||||||
pnpm = pkgs.pnpm_11;
|
pnpm = pkgs.pnpm_11;
|
||||||
fetcherVersion = 4;
|
fetcherVersion = 4;
|
||||||
hash = "sha256-XWzEz145/ZJ9rv5vjLiRXlyVXr42URlcouiLVwhRwtE=";
|
hash = "sha256-ai71NY11VUogMi8SX45LrEYRy5amSMbYbnSwko4O72A=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
nativeBuildInputs = with pkgs; [
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ describe('PostCard', () => {
|
|||||||
expect(container.querySelectorAll('video')).toHaveLength(2)
|
expect(container.querySelectorAll('video')).toHaveLength(2)
|
||||||
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
|
expect(container.querySelectorAll('video')[0]).toHaveAttribute('controls')
|
||||||
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
|
expect(container.querySelectorAll('video')[1]).toHaveAttribute('loop')
|
||||||
|
expect(container.querySelectorAll('video')[0]).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://media.invalid/video.mp4',
|
||||||
|
)
|
||||||
expect(screen.getByText('Article title')).toBeInTheDocument()
|
expect(screen.getByText('Article title')).toBeInTheDocument()
|
||||||
expect(screen.getByText('Preview')).toBeInTheDocument()
|
expect(screen.getByText('Preview')).toBeInTheDocument()
|
||||||
expect(screen.getByText('quoted')).toBeInTheDocument()
|
expect(screen.getByText('quoted')).toBeInTheDocument()
|
||||||
@@ -80,12 +84,39 @@ describe('PostCard', () => {
|
|||||||
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
expect(screen.getByText('@yuta').closest('a')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('decodes HTML entities in the post body before linking URLs', () => {
|
||||||
|
render(
|
||||||
|
<PostCard
|
||||||
|
post={{
|
||||||
|
...richPost,
|
||||||
|
text: 'A > B & C 'D' https://example.com/?a=1&b=2',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText((_, element) =>
|
||||||
|
Boolean(
|
||||||
|
element?.classList.contains('post-text') &&
|
||||||
|
element.textContent ===
|
||||||
|
"A > B & C 'D' https://example.com/?a=1&b=2",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('link', { name: 'https://example.com/?a=1&b=2' }),
|
||||||
|
).toHaveAttribute('href', 'https://example.com/?a=1&b=2')
|
||||||
|
})
|
||||||
|
|
||||||
it('links deliberately to the internal detail page', () => {
|
it('links deliberately to the internal detail page', () => {
|
||||||
render(<PostCard post={richPost} />)
|
render(<PostCard post={richPost} />)
|
||||||
|
|
||||||
|
const detail = screen.getByRole('link', { name: '詳細・スレッド' })
|
||||||
|
expect(detail).toHaveAttribute('href', '/yuta/status/123')
|
||||||
|
expect(detail.parentElement).toHaveClass('post-actions')
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('link', { name: '詳細・スレッド' }),
|
screen.getByRole('link', { name: '元の投稿を開く' }).parentElement,
|
||||||
).toHaveAttribute('href', '/yuta/status/123')
|
).toBe(detail.parentElement)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('marks the focal post without linking to its current page', () => {
|
it('marks the focal post without linking to its current page', () => {
|
||||||
@@ -103,4 +134,26 @@ describe('PostCard', () => {
|
|||||||
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
expect(screen.getByText('Yuta')).toBeInTheDocument()
|
||||||
expect(container.querySelector('time')).not.toBeInTheDocument()
|
expect(container.querySelector('time')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not try to play a poster image when Bird has no video URL', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<PostCard
|
||||||
|
post={{
|
||||||
|
...richPost,
|
||||||
|
media: [
|
||||||
|
{
|
||||||
|
type: 'video',
|
||||||
|
url: 'https://media.invalid/video-poster.jpg',
|
||||||
|
previewUrl: 'https://media.invalid/video-preview.jpg',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(container.querySelector('video')).not.toBeInTheDocument()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('img', { name: '動画のプレビュー' }),
|
||||||
|
).toHaveAttribute('src', 'https://media.invalid/video-preview.jpg')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,19 +3,39 @@ import type { Post } from '../types'
|
|||||||
import { PostMedia } from './post-media'
|
import { PostMedia } from './post-media'
|
||||||
|
|
||||||
const URL = /(https?:\/\/[^\s]+)/g
|
const URL = /(https?:\/\/[^\s]+)/g
|
||||||
|
const HTML_ENTITY = /&(?:#(\d+)|#x([\da-f]+)|amp|apos|gt|lt|quot);/gi
|
||||||
|
const NAMED_ENTITIES: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
''': "'",
|
||||||
|
'>': '>',
|
||||||
|
'<': '<',
|
||||||
|
'"': '"',
|
||||||
|
}
|
||||||
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
const dateFormatter = new Intl.DateTimeFormat('ja-JP', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
timeZone: 'Asia/Tokyo',
|
timeZone: 'Asia/Tokyo',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function decodeHtmlEntities(text: string): string {
|
||||||
|
return text.replace(HTML_ENTITY, (entity, decimal, hexadecimal) => {
|
||||||
|
if (!decimal && !hexadecimal) {
|
||||||
|
return NAMED_ENTITIES[entity.toLowerCase()] ?? entity
|
||||||
|
}
|
||||||
|
|
||||||
|
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
|
||||||
|
return codePoint <= 0x10ffff ? String.fromCodePoint(codePoint) : entity
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function linkedText(text: string): ReactNode[] {
|
function linkedText(text: string): ReactNode[] {
|
||||||
|
const decoded = decodeHtmlEntities(text)
|
||||||
const parts: ReactNode[] = []
|
const parts: ReactNode[] = []
|
||||||
let cursor = 0
|
let cursor = 0
|
||||||
for (const match of text.matchAll(URL)) {
|
for (const match of decoded.matchAll(URL)) {
|
||||||
const href = match[0]
|
const href = match[0]
|
||||||
const start = match.index
|
const start = match.index
|
||||||
if (start > cursor) parts.push(text.slice(cursor, start))
|
if (start > cursor) parts.push(decoded.slice(cursor, start))
|
||||||
parts.push(
|
parts.push(
|
||||||
<a
|
<a
|
||||||
href={href}
|
href={href}
|
||||||
@@ -28,7 +48,7 @@ function linkedText(text: string): ReactNode[] {
|
|||||||
)
|
)
|
||||||
cursor = start + href.length
|
cursor = start + href.length
|
||||||
}
|
}
|
||||||
if (cursor < text.length) parts.push(text.slice(cursor))
|
if (cursor < decoded.length) parts.push(decoded.slice(cursor))
|
||||||
return parts
|
return parts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,16 +110,18 @@ export function PostCard({
|
|||||||
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
返信 {post.replyCount ?? 0} 再投稿 {post.retweetCount ?? 0} いいね{' '}
|
||||||
{post.likeCount ?? 0}
|
{post.likeCount ?? 0}
|
||||||
</span>
|
</span>
|
||||||
{!current ? (
|
<span className="post-actions">
|
||||||
<a
|
{!current ? (
|
||||||
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
<a
|
||||||
>
|
href={`/${encodeURIComponent(post.author.username)}/status/${encodeURIComponent(post.id)}`}
|
||||||
詳細・スレッド
|
>
|
||||||
|
詳細・スレッド
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
<a href={original} rel="noreferrer noopener" target="_blank">
|
||||||
|
元の投稿を開く
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
</span>
|
||||||
<a href={original} rel="noreferrer noopener" target="_blank">
|
|
||||||
元の投稿を開く
|
|
||||||
</a>
|
|
||||||
</footer>
|
</footer>
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -7,30 +7,34 @@ export function PostMedia({ media }: { media: Media[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="media-grid">
|
<div className="media-grid">
|
||||||
{media.map((item) =>
|
{media.map((item) => {
|
||||||
item.type === 'photo' ? (
|
if (item.type === 'photo' || !item.videoUrl) {
|
||||||
<img
|
return (
|
||||||
alt="投稿画像"
|
<img
|
||||||
className="media"
|
alt={item.type === 'photo' ? '投稿画像' : '動画のプレビュー'}
|
||||||
height={item.height}
|
className="media"
|
||||||
key={item.url}
|
height={item.height}
|
||||||
loading="lazy"
|
key={item.url}
|
||||||
src={item.url}
|
loading="lazy"
|
||||||
width={item.width}
|
src={item.previewUrl ?? item.url}
|
||||||
/>
|
width={item.width}
|
||||||
) : (
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
// biome-ignore lint/a11y/useMediaCaption: Bird does not provide caption track URLs.
|
||||||
<video
|
<video
|
||||||
className="media"
|
className="media"
|
||||||
controls
|
controls
|
||||||
key={item.videoUrl ?? item.url}
|
key={item.videoUrl}
|
||||||
loop={item.type === 'animated_gif'}
|
loop={item.type === 'animated_gif'}
|
||||||
poster={item.previewUrl ?? item.url}
|
poster={item.previewUrl ?? item.url}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
src={item.videoUrl ?? item.url}
|
src={item.videoUrl}
|
||||||
/>
|
/>
|
||||||
),
|
)
|
||||||
)}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,64 @@ describe('loadThreadPage', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('does not expose a continuation when the focal post has no replies', async () => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getTweet).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweet: {
|
||||||
|
id: '123',
|
||||||
|
text: 'focal',
|
||||||
|
conversationId: '100',
|
||||||
|
replyCount: 0,
|
||||||
|
author: { username: 'focus', name: 'Focus' },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await loadThreadPage(client, { tweetId: '123' })
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
page: { nextCursor: undefined },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'an empty final page',
|
||||||
|
tweets: [],
|
||||||
|
returnedCursor: 'thread-final',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'the same cursor again',
|
||||||
|
tweets: [
|
||||||
|
{
|
||||||
|
id: '101',
|
||||||
|
text: 'last reply',
|
||||||
|
author: { username: 'reply', name: 'Reply' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returnedCursor: 'thread-next',
|
||||||
|
},
|
||||||
|
])('stops after $name', async ({ tweets, returnedCursor }) => {
|
||||||
|
const client = reader()
|
||||||
|
vi.mocked(client.getThreadPaged).mockResolvedValue({
|
||||||
|
success: true,
|
||||||
|
tweets,
|
||||||
|
nextCursor: returnedCursor,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await loadThreadPage(client, {
|
||||||
|
tweetId: '123',
|
||||||
|
conversationId: '100',
|
||||||
|
cursor: 'thread-next',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
page: { nextCursor: undefined },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('removes raw data from focal, conversation, and quotes', async () => {
|
it('removes raw data from focal, conversation, and quotes', async () => {
|
||||||
const client = reader()
|
const client = reader()
|
||||||
vi.mocked(client.getTweet).mockResolvedValue({
|
vi.mocked(client.getTweet).mockResolvedValue({
|
||||||
|
|||||||
@@ -215,14 +215,22 @@ export async function loadThreadPage(
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return upstreamFailure(result.error, 'post')
|
return upstreamFailure(result.error, 'post')
|
||||||
}
|
}
|
||||||
|
const tweets = result.tweets ?? []
|
||||||
|
const nextCursor =
|
||||||
|
result.nextCursor &&
|
||||||
|
tweets.length > 0 &&
|
||||||
|
result.nextCursor !== input.cursor &&
|
||||||
|
focalPost?.replyCount !== 0
|
||||||
|
? result.nextCursor
|
||||||
|
: undefined
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
page: {
|
page: {
|
||||||
tweets: (result.tweets ?? []).map(publicPost),
|
tweets: tweets.map(publicPost),
|
||||||
focalPost,
|
focalPost,
|
||||||
conversationId,
|
conversationId,
|
||||||
nextCursor: result.nextCursor,
|
nextCursor,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export const Route = createRootRouteWithContext<RouterContext>()({
|
|||||||
name: 'viewport',
|
name: 'viewport',
|
||||||
content: 'width=device-width, initial-scale=1',
|
content: 'width=device-width, initial-scale=1',
|
||||||
},
|
},
|
||||||
|
{ name: 'referrer', content: 'no-referrer' },
|
||||||
{ title: 'Twitter Lite' },
|
{ title: 'Twitter Lite' },
|
||||||
],
|
],
|
||||||
links: [
|
links: [
|
||||||
|
|||||||
@@ -1023,6 +1023,15 @@ button[data-state="success"] {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm) var(--space-lg);
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.state {
|
.state {
|
||||||
margin: var(--space-xl) 0 0;
|
margin: var(--space-xl) 0 0;
|
||||||
padding: var(--space-lg);
|
padding: var(--space-lg);
|
||||||
@@ -1111,6 +1120,10 @@ button[data-state="success"] {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.post-actions {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 52rem) {
|
@media (min-width: 52rem) {
|
||||||
|
|||||||