fix: 稳定推荐页运行态封面遮罩
This commit is contained in:
@@ -16,6 +16,14 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 2026-06-07 推荐页运行态先封面预载再 ready 渐隐
|
||||||
|
|
||||||
|
- 背景:移动端推荐页上下切换公开作品时,如果运行态和封面资源没有明确准备边界,用户会看到未加载完成的 runtime、黑底闪动,或切卡后反向回弹。
|
||||||
|
- 决策:推荐页拿到推荐作品列表后预加载每个作品的卡片封面、主封面和玩法兜底封面;嵌入 runtime 的启动遮罩必须复用带玩法标签和标题的作品卡面视觉,不能再切到一层单独的纯封面图。作品切换后遮罩接手当前卡面时必须瞬时显示,不允许从旧预览卡面再淡入到同一张卡面;runtime 统一通过 ready 门控等待 run / profile、lazy 组件和 runtime DOM 内图片资源准备完成,ready 返回 true 后再由外层露出游戏画面并只让卡面遮罩渐隐。推荐 rail 切换完成后归零不能走反向过渡动画。
|
||||||
|
- 影响范围:`src/components/rpg-entry/RpgEntryHomeView.tsx`、推荐页 runtime 生命周期、平台玩法链路文档。
|
||||||
|
- 验证方式:`npm run test -- src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx`。
|
||||||
|
- 关联文档:`docs/【玩法创作】平台入口与玩法链路-2026-05-15.md`。
|
||||||
|
|
||||||
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
|
## 2026-06-06 小程序微信绑定展示使用原生昵称组件
|
||||||
|
|
||||||
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
|
- 背景:账号信息面板需要显示“绑定的是哪个微信号”。微信小程序登录 `jscode2session` 不返回昵称或个人微信号,但小程序提供 `input type="nickname"` 原生昵称填写 / 选择能力,可在登录前收集微信昵称用于展示。
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ RPG / 拼图等运行态存档仍以 `/api/profile/save-archives` 的后端列
|
|||||||
|
|
||||||
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
|
跳一跳作品架删除入口必须走 `/api/creation/jump-hop/works/{profile_id}`,并通过 SpacetimeDB 同步删除 work profile、源 session、运行态 run 与事件,再刷新作品架和公开广场;不得只做前端本地隐藏。
|
||||||
|
|
||||||
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页启动或切换作品时先展示当前作品封面,嵌入 runtime 在封面下层加载;只有对应运行态 run / profile 已准备且 lazy runtime 组件完成挂载后,封面才渐隐,不在中途展示“加载中”文案。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
推荐页匿名游玩不再限定为跳一跳。移动端一级 `推荐` Tab 是内嵌运行态刷卡流,会自动选择推荐作品并启动对应玩法;桌面端首页不启动这套移动推荐运行态,而是渲染桌面发现壳,展示 `今日游戏`、`推荐`、`作品分类` 等桌面内容。推荐页候选顺序由前端轻量推荐算法 `platformRecommendation.ts` 统一生成:先按公开作品 key 去重,再使用公开读模型已有的精选来源、近 7 日游玩、点赞、改造、总游玩、发布时间新鲜度、封面和标签完整度做确定性评分,最后优先交错不同玩法类型;只要还有其它玩法候选,就不要连续推荐同一玩法,只有候选池已没有其它玩法时才允许同玩法相邻。该算法不得新增前端业务真相或绕过公开作品 read model。断点事实统一走 `platformEntryResponsive.ts` 的 `usePlatformDesktopLayout()`,平台壳和首页视图必须共用同一个判断,避免桌面发现页与移动推荐页同时挂载、重复触发请求或启动运行态。移动端推荐页拿到推荐作品列表后必须预加载每个作品的卡片封面、主封面和玩法兜底封面;启动或切换作品时先展示当前带玩法标签和标题的作品卡面遮罩,嵌入 runtime 在卡面下层加载,不得再从卡面闪切到另一层单独纯封面图。作品切换提交后,当前 runtime 遮罩接手已在屏幕上的卡面时必须瞬时贴合,不允许再执行“卡面到同一卡面”的淡入或重绘过渡;推荐页 runtime 必须通过统一 `ready` 门控等待对应运行态 run / profile、lazy runtime 组件和 runtime DOM 内图片资源都准备好,`ready` 返回 `true` 后才由外层放开游戏画面并只让卡面遮罩渐隐。ready 前不展示“加载中”文案,也不得把未准备好的运行态直接暴露给用户。推荐页内拼图通关后的“下一关”属于推荐页统一切卡入口,不能复用拼图 runtime 的跨作品 handoff,也不能直接把当前 run 改写到另一个作品;`activeRecommendEntryKey` 只能由推荐页统一选择下一作品后更新。推荐页嵌入运行态启动时按真实身份分流:已登录用户或本地已有 access token 时继续使用账号 Bearer,但请求选项必须是 local auth impact,避免单卡 401 清空整站登录态;只有确认为匿名访客时才申请短期 Runtime Guest Token,并只把它作为局部请求头传给运行态客户端,不写入全局登录态、不触发 refresh,也不把匿名流量伪装成普通用户。当前覆盖矩阵为:跳一跳、视觉小说、抓大鹅 Match3D、方洞挑战、拼图、敲木鱼、大鱼吃小鱼、汪汪声浪。每个模板的启动请求、推荐页内后续运行态动作以及需要上报的 play/finish/leaderboard/next-level 类请求,都必须继续按该身份分流;公开读取入口仍可匿名读取,创作、个人作品、删除、发布、Remix 等账号/所有权动作仍保持普通用户鉴权。
|
||||||
|
|
||||||
## 敲木鱼
|
## 敲木鱼
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
const {
|
const {
|
||||||
mockQrCodeToDataUrl,
|
mockQrCodeToDataUrl,
|
||||||
mockRedirectToPaymentUrl,
|
mockRedirectToPaymentUrl,
|
||||||
|
mockRequestJson,
|
||||||
mockBuildReferralCenter,
|
mockBuildReferralCenter,
|
||||||
mockBuildTaskCenter,
|
mockBuildTaskCenter,
|
||||||
mockClaimRpgProfileTaskReward,
|
mockClaimRpgProfileTaskReward,
|
||||||
@@ -128,6 +129,7 @@ const {
|
|||||||
return {
|
return {
|
||||||
mockQrCodeToDataUrl: qrCodeToDataUrl,
|
mockQrCodeToDataUrl: qrCodeToDataUrl,
|
||||||
mockRedirectToPaymentUrl: redirectToPaymentUrl,
|
mockRedirectToPaymentUrl: redirectToPaymentUrl,
|
||||||
|
mockRequestJson: vi.fn(),
|
||||||
mockBuildReferralCenter: buildReferralCenter,
|
mockBuildReferralCenter: buildReferralCenter,
|
||||||
mockBuildTaskCenter: buildTaskCenter,
|
mockBuildTaskCenter: buildTaskCenter,
|
||||||
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
|
mockGetRpgProfileReferralInviteCenter: vi.fn(async () =>
|
||||||
@@ -349,6 +351,10 @@ const {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../services/apiClient', () => ({
|
vi.mock('../../services/apiClient', () => ({
|
||||||
|
BACKGROUND_AUTH_REQUEST_OPTIONS: {
|
||||||
|
authImpact: 'background',
|
||||||
|
},
|
||||||
|
requestJson: mockRequestJson,
|
||||||
refreshStoredAccessToken: mockRefreshStoredAccessToken,
|
refreshStoredAccessToken: mockRefreshStoredAccessToken,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -3906,6 +3912,234 @@ test('mobile recommend startup keeps cover visible without loading copy', () =>
|
|||||||
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('mobile recommend preloads every fetched work cover', () => {
|
||||||
|
const preloadedSrcs: string[] = [];
|
||||||
|
mockRequestJson.mockResolvedValue({
|
||||||
|
read: {
|
||||||
|
objectKey: 'generated-recommend/current.png',
|
||||||
|
signedUrl: 'https://signed.example.com/current-cover.png',
|
||||||
|
expiresAt: '2099-01-01T00:10:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
class MockPreloadImage {
|
||||||
|
decoding = 'auto';
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
|
||||||
|
set src(value: string) {
|
||||||
|
preloadedSrcs.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('Image', MockPreloadImage);
|
||||||
|
|
||||||
|
const firstEntry = {
|
||||||
|
...puzzlePublicEntry,
|
||||||
|
workId: 'puzzle-work-feed-1',
|
||||||
|
profileId: 'puzzle-profile-feed-1',
|
||||||
|
ownerUserId: 'user-feed-1',
|
||||||
|
publicWorkCode: 'PZ-FEED1',
|
||||||
|
worldName: '当前拼图',
|
||||||
|
coverImageSrc: '/generated-recommend/current.png',
|
||||||
|
} satisfies PlatformPublicGalleryCard;
|
||||||
|
const secondEntry = {
|
||||||
|
...puzzlePublicEntry,
|
||||||
|
workId: 'puzzle-work-feed-2',
|
||||||
|
profileId: 'puzzle-profile-feed-2',
|
||||||
|
ownerUserId: 'user-feed-2',
|
||||||
|
publicWorkCode: 'PZ-FEED2',
|
||||||
|
worldName: '下一拼图',
|
||||||
|
coverImageSrc: 'next-cover.png',
|
||||||
|
} satisfies PlatformPublicGalleryCard;
|
||||||
|
const thirdEntry = {
|
||||||
|
...puzzlePublicEntry,
|
||||||
|
workId: 'puzzle-work-feed-3',
|
||||||
|
profileId: 'puzzle-profile-feed-3',
|
||||||
|
ownerUserId: 'user-feed-3',
|
||||||
|
publicWorkCode: 'PZ-FEED3',
|
||||||
|
worldName: '上一拼图',
|
||||||
|
coverImageSrc: 'previous-cover.png',
|
||||||
|
} satisfies PlatformPublicGalleryCard;
|
||||||
|
|
||||||
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
|
latestEntries: [firstEntry, secondEntry, thirdEntry],
|
||||||
|
activeRecommendEntryKey: 'puzzle:user-feed-1:puzzle-profile-feed-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preloadedSrcs).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'next-cover.png',
|
||||||
|
'previous-cover.png',
|
||||||
|
'/creation-type-references/puzzle.webp',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(preloadedSrcs).not.toContain('/generated-recommend/current.png');
|
||||||
|
return waitFor(() => {
|
||||||
|
expect(preloadedSrcs).toContain('https://signed.example.com/current-cover.png');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile recommend runtime cover keeps the labeled work card visual', () => {
|
||||||
|
const carouselEntry = buildCarouselPuzzleEntry(
|
||||||
|
'recommend-card-cover',
|
||||||
|
'轮播拼图',
|
||||||
|
'card-cover',
|
||||||
|
);
|
||||||
|
|
||||||
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
|
latestEntries: [carouselEntry],
|
||||||
|
activeRecommendEntryKey:
|
||||||
|
'puzzle:user-2:puzzle-profile-recommend-card-cover',
|
||||||
|
isStartingRecommendEntry: true,
|
||||||
|
recommendRuntimeContent: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cover = document.querySelector('.platform-recommend-runtime-cover');
|
||||||
|
const coverImage = cover?.querySelector('img');
|
||||||
|
|
||||||
|
expect(coverImage?.getAttribute('src')).toBe('card-cover-1.png');
|
||||||
|
expect(coverImage?.getAttribute('src')).not.toBe('card-cover-fallback.png');
|
||||||
|
expect(cover?.querySelector('.platform-recommend-runtime-preview')).toBeTruthy();
|
||||||
|
expect(cover?.textContent).toContain('拼图');
|
||||||
|
expect(cover?.textContent).toContain('轮播拼图');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile recommend runtime cover does not swap to a late signed cover', async () => {
|
||||||
|
const preloadImages: Array<{
|
||||||
|
srcValue: string;
|
||||||
|
onload: (() => void) | null;
|
||||||
|
}> = [];
|
||||||
|
mockRequestJson.mockResolvedValue({
|
||||||
|
read: {
|
||||||
|
objectKey: 'generated-recommend/late-current.png',
|
||||||
|
signedUrl: 'https://signed.example.com/late-current-cover.png',
|
||||||
|
expiresAt: '2099-01-01T00:10:00Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
class MockPreloadImage {
|
||||||
|
decoding = 'auto';
|
||||||
|
complete = false;
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
srcValue = '';
|
||||||
|
|
||||||
|
set src(value: string) {
|
||||||
|
this.srcValue = value;
|
||||||
|
preloadImages.push(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vi.stubGlobal('Image', MockPreloadImage);
|
||||||
|
|
||||||
|
const generatedCoverEntry = {
|
||||||
|
...puzzlePublicEntry,
|
||||||
|
workId: 'puzzle-work-late-cover',
|
||||||
|
profileId: 'puzzle-profile-late-cover',
|
||||||
|
ownerUserId: 'user-late-cover',
|
||||||
|
publicWorkCode: 'PZ-LATE1',
|
||||||
|
worldName: '迟到封面拼图',
|
||||||
|
coverImageSrc: '/generated-recommend/late-current.png',
|
||||||
|
} satisfies PlatformPublicGalleryCard;
|
||||||
|
|
||||||
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
|
latestEntries: [generatedCoverEntry],
|
||||||
|
activeRecommendEntryKey: 'puzzle:user-late-cover:puzzle-profile-late-cover',
|
||||||
|
isStartingRecommendEntry: true,
|
||||||
|
recommendRuntimeContent: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cover = document.querySelector('.platform-recommend-runtime-cover');
|
||||||
|
const coverImage = cover?.querySelector('img');
|
||||||
|
expect(coverImage?.getAttribute('src')).toBe(
|
||||||
|
'/creation-type-references/puzzle.webp',
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
preloadImages.some(
|
||||||
|
(image) =>
|
||||||
|
image.srcValue ===
|
||||||
|
'https://signed.example.com/late-current-cover.png',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
preloadImages
|
||||||
|
.filter(
|
||||||
|
(image) =>
|
||||||
|
image.srcValue ===
|
||||||
|
'https://signed.example.com/late-current-cover.png',
|
||||||
|
)
|
||||||
|
.forEach((image) => image.onload?.());
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockRequestJson).toHaveBeenCalled());
|
||||||
|
expect(
|
||||||
|
document
|
||||||
|
.querySelector('.platform-recommend-runtime-cover img')
|
||||||
|
?.getAttribute('src'),
|
||||||
|
).toBe('/creation-type-references/puzzle.webp');
|
||||||
|
expect(
|
||||||
|
Array.from(
|
||||||
|
document.querySelectorAll('.platform-recommend-runtime-cover img'),
|
||||||
|
).some(
|
||||||
|
(image) =>
|
||||||
|
image.getAttribute('src') ===
|
||||||
|
'https://signed.example.com/late-current-cover.png',
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mobile recommend cover waits until runtime images are ready', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const animationCallbacks: FrameRequestCallback[] = [];
|
||||||
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn((callback: FrameRequestCallback) => {
|
||||||
|
animationCallbacks.push(callback);
|
||||||
|
return animationCallbacks.length;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
renderLoggedOutHomeView(vi.fn(), {
|
||||||
|
latestEntries: [puzzlePublicEntry],
|
||||||
|
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
|
||||||
|
isRecommendRuntimeReady: true,
|
||||||
|
recommendRuntimeContent: (
|
||||||
|
<img src="/runtime-sprite.png" alt="runtime sprite" />
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
animationCallbacks.splice(0).forEach((callback) => callback(16));
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||||
|
).not.toContain('platform-recommend-runtime-cover--hidden');
|
||||||
|
|
||||||
|
fireEvent.load(screen.getByAltText('runtime sprite'));
|
||||||
|
act(() => {
|
||||||
|
animationCallbacks.splice(0).forEach((callback) => callback(32));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||||
|
).not.toContain('platform-recommend-runtime-cover--hidden');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
vi.advanceTimersByTime(520);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
document.querySelector('.platform-recommend-runtime-cover')?.className,
|
||||||
|
).toContain('platform-recommend-runtime-cover--hidden');
|
||||||
|
});
|
||||||
|
|
||||||
test('mobile recommend keeps runtime visual stable when active entry changes', async () => {
|
test('mobile recommend keeps runtime visual stable when active entry changes', async () => {
|
||||||
const animationCallbacks: FrameRequestCallback[] = [];
|
const animationCallbacks: FrameRequestCallback[] = [];
|
||||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||||
@@ -4119,7 +4353,9 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
|||||||
|
|
||||||
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
|
||||||
expect(
|
expect(
|
||||||
document.querySelectorAll('.platform-recommend-runtime-preview'),
|
document.querySelectorAll(
|
||||||
|
'.platform-recommend-runtime-preview:not(.platform-recommend-runtime-preview--cover)',
|
||||||
|
),
|
||||||
).toHaveLength(2);
|
).toHaveLength(2);
|
||||||
expect(
|
expect(
|
||||||
document.querySelectorAll('.platform-recommend-swipe-card'),
|
document.querySelectorAll('.platform-recommend-swipe-card'),
|
||||||
@@ -4170,6 +4406,10 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
|
|||||||
|
|
||||||
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
|
||||||
expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
|
expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
|
||||||
|
expect(rail?.style.transform).toBe('translate3d(0, 0px, 0)');
|
||||||
|
expect(rail?.className).toContain(
|
||||||
|
'platform-recommend-swipe-rail--resetting',
|
||||||
|
);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type PointerEvent,
|
type PointerEvent,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
Suspense,
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -79,6 +80,10 @@ import type {
|
|||||||
} from '../../../packages/shared/src/contracts/runtime';
|
} from '../../../packages/shared/src/contracts/runtime';
|
||||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||||
import { refreshStoredAccessToken } from '../../services/apiClient';
|
import { refreshStoredAccessToken } from '../../services/apiClient';
|
||||||
|
import {
|
||||||
|
isGeneratedLegacyPath,
|
||||||
|
resolveAssetReadUrl,
|
||||||
|
} from '../../services/assetReadUrlService';
|
||||||
import type { AuthUser } from '../../services/authService';
|
import type { AuthUser } from '../../services/authService';
|
||||||
import {
|
import {
|
||||||
getPublicAuthUserByCode,
|
getPublicAuthUserByCode,
|
||||||
@@ -261,6 +266,260 @@ const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
|
|||||||
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
const RECOMMEND_ENTRY_SWIPE_THRESHOLD_PX = 36;
|
||||||
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
const RECOMMEND_ENTRY_COMMIT_ANIMATION_MS = 180;
|
||||||
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
|
||||||
|
const RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS = 520;
|
||||||
|
|
||||||
|
type RecommendResolvedCoverUrlMap = ReadonlyMap<string, string>;
|
||||||
|
|
||||||
|
function resolveRecommendDisplayCoverImage(
|
||||||
|
imageSrc: string,
|
||||||
|
fallbackSrc: string,
|
||||||
|
resolvedCoverUrls?: RecommendResolvedCoverUrlMap,
|
||||||
|
) {
|
||||||
|
const normalizedImageSrc = imageSrc.trim();
|
||||||
|
const normalizedFallbackSrc = fallbackSrc.trim();
|
||||||
|
|
||||||
|
if (!normalizedImageSrc) {
|
||||||
|
return (
|
||||||
|
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedImageSrc = resolvedCoverUrls?.get(normalizedImageSrc);
|
||||||
|
if (resolvedImageSrc) {
|
||||||
|
return resolvedImageSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isGeneratedLegacyPath(normalizedImageSrc)) {
|
||||||
|
return (
|
||||||
|
resolvedCoverUrls?.get(normalizedFallbackSrc) ?? normalizedFallbackSrc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizedImageSrc;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRecommendCardCoverImage(entry: PlatformPublicGalleryCard) {
|
||||||
|
const cardCoverSlide = resolvePlatformWorldCoverSlides(entry)[0] ?? null;
|
||||||
|
return (
|
||||||
|
cardCoverSlide?.imageSrc.trim() || resolvePlatformWorldCoverImage(entry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectRecommendCoverPreloadUrls(entries: PlatformPublicGalleryCard[]) {
|
||||||
|
const urls = new Set<string>();
|
||||||
|
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
resolvePlatformWorldCoverSlides(entry).forEach((slide) => {
|
||||||
|
const slideImageSrc = slide.imageSrc.trim();
|
||||||
|
if (slideImageSrc) {
|
||||||
|
urls.add(slideImageSrc);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
[
|
||||||
|
resolveRecommendCardCoverImage(entry),
|
||||||
|
resolvePlatformWorldCoverImage(entry),
|
||||||
|
resolvePlatformWorldFallbackCoverImage(entry),
|
||||||
|
]
|
||||||
|
.map((url) => url.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((url) => urls.add(url));
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...urls];
|
||||||
|
}
|
||||||
|
|
||||||
|
function useResolvedRecommendCoverImages(
|
||||||
|
entries: PlatformPublicGalleryCard[],
|
||||||
|
): RecommendResolvedCoverUrlMap {
|
||||||
|
const preloadUrls = useMemo(
|
||||||
|
() => collectRecommendCoverPreloadUrls(entries),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
const preloadKey = preloadUrls.join('\n');
|
||||||
|
const [resolvedCoverUrls, setResolvedCoverUrls] = useState<
|
||||||
|
Map<string, string>
|
||||||
|
>(() => new Map());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const cleanupCallbacks: Array<() => void> = [];
|
||||||
|
const preloadCoverImage = (
|
||||||
|
imageSrc: string,
|
||||||
|
onLoaded?: (loadedImageSrc: string) => void,
|
||||||
|
) => {
|
||||||
|
if (!imageSrc || typeof Image === 'undefined') {
|
||||||
|
onLoaded?.(imageSrc);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = new Image();
|
||||||
|
const cleanupImage = () => {
|
||||||
|
image.onload = null;
|
||||||
|
image.onerror = null;
|
||||||
|
};
|
||||||
|
const finishImageLoad = () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanupImage();
|
||||||
|
onLoaded?.(imageSrc);
|
||||||
|
};
|
||||||
|
const finishImageError = () => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cleanupImage();
|
||||||
|
};
|
||||||
|
|
||||||
|
image.decoding = 'async';
|
||||||
|
image.onload = finishImageLoad;
|
||||||
|
image.onerror = finishImageError;
|
||||||
|
image.src = imageSrc;
|
||||||
|
|
||||||
|
if (image.complete) {
|
||||||
|
finishImageLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanupCallbacks.push(cleanupImage);
|
||||||
|
};
|
||||||
|
|
||||||
|
setResolvedCoverUrls((currentUrls) => {
|
||||||
|
const nextUrls = new Map<string, string>();
|
||||||
|
preloadUrls.forEach((url) => {
|
||||||
|
const cachedUrl = currentUrls.get(url);
|
||||||
|
if (cachedUrl) {
|
||||||
|
nextUrls.set(url, cachedUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isGeneratedLegacyPath(url)) {
|
||||||
|
nextUrls.set(url, url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return nextUrls;
|
||||||
|
});
|
||||||
|
|
||||||
|
preloadUrls.forEach((url) => {
|
||||||
|
if (!isGeneratedLegacyPath(url)) {
|
||||||
|
preloadCoverImage(url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void resolveAssetReadUrl(url)
|
||||||
|
.then((resolvedUrl) => {
|
||||||
|
if (cancelled || !resolvedUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
preloadCoverImage(resolvedUrl, (loadedUrl) => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setResolvedCoverUrls((currentUrls) => {
|
||||||
|
if (currentUrls.get(url) === loadedUrl) {
|
||||||
|
return currentUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrls = new Map(currentUrls);
|
||||||
|
nextUrls.set(url, loadedUrl);
|
||||||
|
return nextUrls;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
|
||||||
|
};
|
||||||
|
}, [preloadKey, preloadUrls]);
|
||||||
|
|
||||||
|
return resolvedCoverUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRecommendRuntimeReady(
|
||||||
|
signal: AbortSignal,
|
||||||
|
onReady: (value: boolean) => void,
|
||||||
|
) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.requestAnimationFrame(() => {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
onReady(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function readyRecommendRuntime(
|
||||||
|
root: HTMLElement | null,
|
||||||
|
signal: AbortSignal,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!root || signal.aborted) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingImages = Array.from(root.querySelectorAll('img')).filter(
|
||||||
|
(image) => !image.complete,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let animationFrameId: number | null = null;
|
||||||
|
let settled = false;
|
||||||
|
const cleanupCallbacks: Array<() => void> = [];
|
||||||
|
const finish = (value: boolean) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
cleanupCallbacks.splice(0).forEach((cleanup) => cleanup());
|
||||||
|
if (animationFrameId !== null) {
|
||||||
|
window.cancelAnimationFrame(animationFrameId);
|
||||||
|
}
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
const abort = () => finish(false);
|
||||||
|
signal.addEventListener('abort', abort, { once: true });
|
||||||
|
cleanupCallbacks.push(() => signal.removeEventListener('abort', abort));
|
||||||
|
|
||||||
|
if (pendingImages.length === 0) {
|
||||||
|
animationFrameId = scheduleRecommendRuntimeReady(signal, finish);
|
||||||
|
if (animationFrameId === null) {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = pendingImages.length;
|
||||||
|
const markImageReady = () => {
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
animationFrameId = scheduleRecommendRuntimeReady(signal, finish);
|
||||||
|
if (animationFrameId === null) {
|
||||||
|
finish(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pendingImages.forEach((image) => {
|
||||||
|
const cleanupImageListeners = () => {
|
||||||
|
image.removeEventListener('load', markImageReady);
|
||||||
|
image.removeEventListener('error', markImageReady);
|
||||||
|
};
|
||||||
|
image.addEventListener('load', markImageReady, { once: true });
|
||||||
|
image.addEventListener('error', markImageReady, { once: true });
|
||||||
|
cleanupCallbacks.push(cleanupImageListeners);
|
||||||
|
|
||||||
|
if (image.complete) {
|
||||||
|
cleanupImageListeners();
|
||||||
|
markImageReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
|
||||||
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
|
||||||
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
|
||||||
@@ -928,17 +1187,62 @@ function CreationLibraryCard({
|
|||||||
function RecommendRuntimePreviewCard({
|
function RecommendRuntimePreviewCard({
|
||||||
entry,
|
entry,
|
||||||
position,
|
position,
|
||||||
|
resolvedCoverUrls,
|
||||||
}: {
|
}: {
|
||||||
entry: PlatformPublicGalleryCard;
|
entry: PlatformPublicGalleryCard;
|
||||||
position: 'previous' | 'next';
|
position?: 'previous' | 'next' | 'cover';
|
||||||
|
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
|
||||||
}) {
|
}) {
|
||||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
const rawCoverImage = resolveRecommendCardCoverImage(entry);
|
||||||
|
const rawFallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
||||||
|
const resolvedCoverImage = resolveRecommendDisplayCoverImage(
|
||||||
|
rawCoverImage,
|
||||||
|
rawFallbackCoverImage,
|
||||||
|
resolvedCoverUrls,
|
||||||
|
);
|
||||||
|
const fallbackCoverImage =
|
||||||
|
resolvedCoverUrls?.get(rawFallbackCoverImage) ?? rawFallbackCoverImage;
|
||||||
|
const previewKey = `${buildPublicGalleryCardKey(entry)}:${position ?? 'preview'}`;
|
||||||
|
const shouldLockCoverImage = position === 'cover';
|
||||||
|
const [lockedCoverImage, setLockedCoverImage] = useState({
|
||||||
|
key: previewKey,
|
||||||
|
imageSrc: resolvedCoverImage,
|
||||||
|
fallbackSrc: fallbackCoverImage,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLockedCoverImage((currentValue) => {
|
||||||
|
if (shouldLockCoverImage) {
|
||||||
|
return currentValue;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: previewKey,
|
||||||
|
imageSrc: resolvedCoverImage,
|
||||||
|
fallbackSrc: fallbackCoverImage,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
fallbackCoverImage,
|
||||||
|
previewKey,
|
||||||
|
resolvedCoverImage,
|
||||||
|
shouldLockCoverImage,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const coverImage = shouldLockCoverImage
|
||||||
|
? lockedCoverImage.imageSrc
|
||||||
|
: resolvedCoverImage;
|
||||||
|
const displayFallbackCoverImage = shouldLockCoverImage
|
||||||
|
? lockedCoverImage.imageSrc
|
||||||
|
: fallbackCoverImage;
|
||||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||||
const typeLabel = describePublicGalleryCardKind(entry);
|
const typeLabel = describePublicGalleryCardKind(entry);
|
||||||
|
const previewClassName = `platform-recommend-runtime-preview ${
|
||||||
|
position === 'cover' ? 'platform-recommend-runtime-preview--cover' : ''
|
||||||
|
}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="platform-recommend-runtime-preview"
|
className={previewClassName}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
data-preview-position={position}
|
data-preview-position={position}
|
||||||
>
|
>
|
||||||
@@ -946,6 +1250,7 @@ function RecommendRuntimePreviewCard({
|
|||||||
<PlatformWorkCoverArtwork
|
<PlatformWorkCoverArtwork
|
||||||
entry={entry}
|
entry={entry}
|
||||||
imageSrc={coverImage}
|
imageSrc={coverImage}
|
||||||
|
fallbackSrc={displayFallbackCoverImage}
|
||||||
alt=""
|
alt=""
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
className="absolute inset-0 h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
@@ -966,43 +1271,48 @@ function RecommendRuntimePreviewCard({
|
|||||||
function RecommendRuntimeCover({
|
function RecommendRuntimeCover({
|
||||||
entry,
|
entry,
|
||||||
className = '',
|
className = '',
|
||||||
|
resolvedCoverUrls,
|
||||||
}: {
|
}: {
|
||||||
entry: PlatformPublicGalleryCard;
|
entry: PlatformPublicGalleryCard;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
|
||||||
}) {
|
}) {
|
||||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
|
||||||
const fallbackCoverImage = resolvePlatformWorldFallbackCoverImage(entry);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`platform-recommend-runtime-cover ${className}`}
|
className={`platform-recommend-runtime-cover ${className}`}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{coverImage || fallbackCoverImage ? (
|
<RecommendRuntimePreviewCard
|
||||||
<PlatformWorkCoverArtwork
|
key={buildPublicGalleryCardKey(entry)}
|
||||||
entry={entry}
|
entry={entry}
|
||||||
imageSrc={coverImage}
|
position="cover"
|
||||||
fallbackSrc={fallbackCoverImage}
|
resolvedCoverUrls={resolvedCoverUrls}
|
||||||
alt=""
|
/>
|
||||||
className="absolute inset-0 h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_22%_18%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
|
|
||||||
)}
|
|
||||||
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.05),rgba(0,0,0,0.34))]" />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecommendRuntimeMountedProbe({
|
function RecommendRuntimeReadyProbe({
|
||||||
onMounted,
|
rootRef,
|
||||||
|
onReady,
|
||||||
}: {
|
}: {
|
||||||
onMounted: () => void;
|
rootRef: RefObject<HTMLDivElement | null>;
|
||||||
|
onReady: () => void;
|
||||||
}) {
|
}) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const animationFrameId = window.requestAnimationFrame(onMounted);
|
const abortController = new AbortController();
|
||||||
return () => window.cancelAnimationFrame(animationFrameId);
|
|
||||||
}, [onMounted]);
|
void readyRecommendRuntime(
|
||||||
|
rootRef.current,
|
||||||
|
abortController.signal,
|
||||||
|
).then((ready) => {
|
||||||
|
if (ready) {
|
||||||
|
onReady();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [onReady, rootRef]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1012,15 +1322,55 @@ function RecommendRuntimeVisual({
|
|||||||
runtimeContent,
|
runtimeContent,
|
||||||
isStarting,
|
isStarting,
|
||||||
isRuntimeReady,
|
isRuntimeReady,
|
||||||
|
resolvedCoverUrls,
|
||||||
}: {
|
}: {
|
||||||
entry: PlatformPublicGalleryCard;
|
entry: PlatformPublicGalleryCard;
|
||||||
runtimeContent?: ReactNode;
|
runtimeContent?: ReactNode;
|
||||||
isStarting: boolean;
|
isStarting: boolean;
|
||||||
isRuntimeReady: boolean;
|
isRuntimeReady: boolean;
|
||||||
|
resolvedCoverUrls?: RecommendResolvedCoverUrlMap;
|
||||||
}) {
|
}) {
|
||||||
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
|
const [isRuntimeMounted, setIsRuntimeMounted] = useState(false);
|
||||||
|
const [isCoverMinVisible, setIsCoverMinVisible] = useState(true);
|
||||||
const activeEntryKey = buildPublicGalleryCardKey(entry);
|
const activeEntryKey = buildPublicGalleryCardKey(entry);
|
||||||
const previousEntryKeyRef = useRef(activeEntryKey);
|
const previousEntryKeyRef = useRef(activeEntryKey);
|
||||||
|
const runtimeVisibilityRef = useRef({
|
||||||
|
hasRuntimeContent: Boolean(runtimeContent),
|
||||||
|
isRuntimeMounted: false,
|
||||||
|
isRuntimeReady,
|
||||||
|
isStarting,
|
||||||
|
});
|
||||||
|
const runtimeViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
runtimeVisibilityRef.current = {
|
||||||
|
hasRuntimeContent: Boolean(runtimeContent),
|
||||||
|
isRuntimeMounted,
|
||||||
|
isRuntimeReady,
|
||||||
|
isStarting,
|
||||||
|
};
|
||||||
|
}, [isRuntimeMounted, isRuntimeReady, isStarting, runtimeContent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const currentRuntimeVisibility = runtimeVisibilityRef.current;
|
||||||
|
if (
|
||||||
|
previousEntryKeyRef.current !== activeEntryKey &&
|
||||||
|
currentRuntimeVisibility.hasRuntimeContent &&
|
||||||
|
currentRuntimeVisibility.isRuntimeMounted &&
|
||||||
|
currentRuntimeVisibility.isRuntimeReady &&
|
||||||
|
!currentRuntimeVisibility.isStarting
|
||||||
|
) {
|
||||||
|
setIsCoverMinVisible(false);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCoverMinVisible(true);
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setIsCoverMinVisible(false);
|
||||||
|
}, RECOMMEND_RUNTIME_COVER_MIN_VISIBLE_MS);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [activeEntryKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (previousEntryKeyRef.current === activeEntryKey) {
|
if (previousEntryKeyRef.current === activeEntryKey) {
|
||||||
@@ -1037,33 +1387,40 @@ function RecommendRuntimeVisual({
|
|||||||
});
|
});
|
||||||
}, [activeEntryKey, isRuntimeReady, isStarting]);
|
}, [activeEntryKey, isRuntimeReady, isStarting]);
|
||||||
|
|
||||||
const handleRuntimeMounted = useCallback(() => {
|
const handleRuntimeReady = useCallback(() => {
|
||||||
if (!isStarting && isRuntimeReady) {
|
if (!isStarting && isRuntimeReady) {
|
||||||
setIsRuntimeMounted(true);
|
setIsRuntimeMounted(true);
|
||||||
}
|
}
|
||||||
}, [isRuntimeReady, isStarting]);
|
}, [isRuntimeReady, isStarting]);
|
||||||
|
|
||||||
const shouldShowCover =
|
const shouldShowCover =
|
||||||
!runtimeContent || isStarting || !isRuntimeReady || !isRuntimeMounted;
|
isCoverMinVisible ||
|
||||||
|
!runtimeContent ||
|
||||||
|
isStarting ||
|
||||||
|
!isRuntimeReady ||
|
||||||
|
!isRuntimeMounted;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="platform-recommend-runtime-visual">
|
<div className="platform-recommend-runtime-visual">
|
||||||
{runtimeContent ? (
|
{runtimeContent ? (
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<div
|
<div
|
||||||
|
ref={runtimeViewportRef}
|
||||||
className="platform-recommend-runtime-viewport"
|
className="platform-recommend-runtime-viewport"
|
||||||
aria-hidden={shouldShowCover}
|
aria-hidden={shouldShowCover}
|
||||||
>
|
>
|
||||||
{runtimeContent}
|
{runtimeContent}
|
||||||
|
<RecommendRuntimeReadyProbe
|
||||||
|
key={activeEntryKey}
|
||||||
|
rootRef={runtimeViewportRef}
|
||||||
|
onReady={handleRuntimeReady}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<RecommendRuntimeMountedProbe
|
|
||||||
key={activeEntryKey}
|
|
||||||
onMounted={handleRuntimeMounted}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
</Suspense>
|
||||||
) : null}
|
) : null}
|
||||||
<RecommendRuntimeCover
|
<RecommendRuntimeCover
|
||||||
entry={entry}
|
entry={entry}
|
||||||
|
resolvedCoverUrls={resolvedCoverUrls}
|
||||||
className={
|
className={
|
||||||
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
|
shouldShowCover ? '' : 'platform-recommend-runtime-cover--hidden'
|
||||||
}
|
}
|
||||||
@@ -5424,6 +5781,8 @@ export function RpgEntryHomeView({
|
|||||||
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
|
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
|
||||||
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
|
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
|
||||||
const desktopLibraryPreview = myEntries.slice(0, 2);
|
const desktopLibraryPreview = myEntries.slice(0, 2);
|
||||||
|
const resolvedRecommendCoverUrls =
|
||||||
|
useResolvedRecommendCoverImages(recommendedFeedEntries);
|
||||||
const discoverFeedEntries = useMemo(() => {
|
const discoverFeedEntries = useMemo(() => {
|
||||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||||
const sourceEntries =
|
const sourceEntries =
|
||||||
@@ -5571,6 +5930,8 @@ export function RpgEntryHomeView({
|
|||||||
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
|
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
|
||||||
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
|
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
|
||||||
useState<1 | -1 | null>(null);
|
useState<1 | -1 | null>(null);
|
||||||
|
const [isRecommendDragResetting, setIsRecommendDragResetting] =
|
||||||
|
useState(false);
|
||||||
const activeRecommendEntryKeyForSelection = activeRecommendEntry
|
const activeRecommendEntryKeyForSelection = activeRecommendEntry
|
||||||
? buildPublicGalleryCardKey(activeRecommendEntry)
|
? buildPublicGalleryCardKey(activeRecommendEntry)
|
||||||
: null;
|
: null;
|
||||||
@@ -5587,6 +5948,7 @@ export function RpgEntryHomeView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRecommendDragCommitDirection(direction);
|
setRecommendDragCommitDirection(direction);
|
||||||
|
setIsRecommendDragResetting(false);
|
||||||
const panelHeight =
|
const panelHeight =
|
||||||
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
|
recommendCardStageRef.current?.getBoundingClientRect().height ?? 0;
|
||||||
const commitDistance = panelHeight > 0 ? panelHeight : window.innerHeight;
|
const commitDistance = panelHeight > 0 ? panelHeight : window.innerHeight;
|
||||||
@@ -5599,8 +5961,12 @@ export function RpgEntryHomeView({
|
|||||||
} else {
|
} else {
|
||||||
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
onSelectPreviousRecommendEntry?.(activeRecommendEntryKeyForSelection);
|
||||||
}
|
}
|
||||||
|
setIsRecommendDragResetting(true);
|
||||||
setRecommendDragOffsetY(0);
|
setRecommendDragOffsetY(0);
|
||||||
setRecommendDragCommitDirection(null);
|
setRecommendDragCommitDirection(null);
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
setIsRecommendDragResetting(false);
|
||||||
|
});
|
||||||
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
|
}, RECOMMEND_ENTRY_COMMIT_ANIMATION_MS);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -5689,7 +6055,9 @@ export function RpgEntryHomeView({
|
|||||||
} satisfies CSSProperties;
|
} satisfies CSSProperties;
|
||||||
const recommendRailClassName = recommendDragCommitDirection
|
const recommendRailClassName = recommendDragCommitDirection
|
||||||
? 'platform-recommend-swipe-rail--committing'
|
? 'platform-recommend-swipe-rail--committing'
|
||||||
: recommendDragOffsetY === 0
|
: isRecommendDragResetting
|
||||||
|
? 'platform-recommend-swipe-rail--resetting'
|
||||||
|
: recommendDragOffsetY === 0
|
||||||
? 'platform-recommend-swipe-rail--settled'
|
? 'platform-recommend-swipe-rail--settled'
|
||||||
: 'platform-recommend-swipe-rail--dragging';
|
: 'platform-recommend-swipe-rail--dragging';
|
||||||
const selectNextRecommendEntry = useCallback(() => {
|
const selectNextRecommendEntry = useCallback(() => {
|
||||||
@@ -5826,6 +6194,7 @@ export function RpgEntryHomeView({
|
|||||||
<RecommendRuntimePreviewCard
|
<RecommendRuntimePreviewCard
|
||||||
entry={previousRecommendEntry}
|
entry={previousRecommendEntry}
|
||||||
position="previous"
|
position="previous"
|
||||||
|
resolvedCoverUrls={resolvedRecommendCoverUrls}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -5848,6 +6217,7 @@ export function RpgEntryHomeView({
|
|||||||
runtimeContent={recommendRuntimeContent}
|
runtimeContent={recommendRuntimeContent}
|
||||||
isStarting={isStartingRecommendEntry}
|
isStarting={isStartingRecommendEntry}
|
||||||
isRuntimeReady={isRecommendRuntimeReady}
|
isRuntimeReady={isRecommendRuntimeReady}
|
||||||
|
resolvedCoverUrls={resolvedRecommendCoverUrls}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onDragPointerDown={beginRecommendDrag}
|
onDragPointerDown={beginRecommendDrag}
|
||||||
@@ -5875,6 +6245,7 @@ export function RpgEntryHomeView({
|
|||||||
<RecommendRuntimePreviewCard
|
<RecommendRuntimePreviewCard
|
||||||
entry={nextRecommendEntry}
|
entry={nextRecommendEntry}
|
||||||
position="next"
|
position="next"
|
||||||
|
resolvedCoverUrls={resolvedRecommendCoverUrls}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5303,13 +5303,13 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
background: var(--platform-recommend-runtime-fill);
|
background: var(--platform-recommend-runtime-fill);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
transition: opacity 420ms ease;
|
|
||||||
will-change: opacity;
|
will-change: opacity;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-recommend-runtime-cover--hidden {
|
.platform-recommend-runtime-cover--hidden {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
transition: opacity 420ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.platform-recommend-swipe-stage {
|
.platform-recommend-swipe-stage {
|
||||||
@@ -5335,6 +5335,10 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
|||||||
transition: none;
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.platform-recommend-swipe-rail--resetting {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.platform-recommend-swipe-page {
|
.platform-recommend-swipe-page {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|||||||
@@ -129,3 +129,18 @@ describe('index stylesheet creation agent hero contrast', () => {
|
|||||||
expect(hintBlock).toContain('rgba(255, 255, 255, 0.72) !important');
|
expect(hintBlock).toContain('rgba(255, 255, 255, 0.72) !important');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('index stylesheet recommend runtime cover', () => {
|
||||||
|
it('only fades the card cover out after runtime is ready', () => {
|
||||||
|
const css = readIndexCss();
|
||||||
|
|
||||||
|
const coverBlock = getCssBlock(css, '.platform-recommend-runtime-cover');
|
||||||
|
expect(coverBlock).not.toContain('transition: opacity');
|
||||||
|
|
||||||
|
const hiddenCoverBlock = getCssBlock(
|
||||||
|
css,
|
||||||
|
'.platform-recommend-runtime-cover--hidden',
|
||||||
|
);
|
||||||
|
expect(hiddenCoverBlock).toContain('transition: opacity 420ms ease;');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user