Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

347 lines
12 KiB
TypeScript

/**
* X view 层 — base dataset 订阅 + 视图 hook。
*
* 设计与 RedBook (`apps/RedBook/data/view.ts`) 对齐:
* - base 数据(users/posts/replies)只在 loader 模块缓存里, 不进 zustand store
* - 组件通过 useSyncExternalStore 订阅 loader, 通过 useXStore 订阅 runtime overlay
* - 视图合并由本文件内 hook 用 useMemo + utils/hydrate.ts 纯函数完成
*
* 每个 hook 内部:
* 1. 订阅 base dataset (loader snapshot)
* 2. 订阅 store runtime slice (useShallow 保证引用稳定)
* 3. useMemo 合并 (依赖只在 base / runtime slice 任一变更时重算)
*/
import { useMemo, useSyncExternalStore } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { getXBaseDataset, subscribeBaseDataset, type XBaseDataset } from './loader';
import { conversations as baseConversations, notifications as baseNotifications, searchHistory as baseSearchHistory, currentUser } from './';
import { useXStore, type XStore } from '../state';
import {
buildPostIndex,
hydratePost,
hydrateReplyTree,
mergeLocalPosts,
resolveReplyById,
type XRuntimeMeSlice,
} from '../utils/hydrate';
import type { XMessage, XPost, XUser } from '../types';
// ---- Base dataset subscription ----
/**
* 订阅 X base dataset (users/posts/replies)。Loader 加载完成时返回的引用会换,
* useSyncExternalStore 自动触发组件重渲染。
*/
export function useXBaseDataset(): XBaseDataset {
return useSyncExternalStore(subscribeBaseDataset, getXBaseDataset, getXBaseDataset);
}
/** "Replies 已加载" 信号: replies map 非空即视为已加载。 */
export function useXRepliesLoaded(): boolean {
const base = useXBaseDataset();
return Object.keys(base.replies).length > 0;
}
// ---- Internal slice selectors ----
const selectMeSlice = (s: XStore): XRuntimeMeSlice => ({
id: s.user.id,
likedPostIds: s.user.likedPostIds,
retweetedPostIds: s.user.retweetedPostIds,
followedUserIds: s.user.followedUserIds,
followerUserIds: s.user.followerUserIds,
});
const selectRuntimePosts = (s: XStore) => s.posts;
const selectConversationsSlice = (s: XStore) => s.conversations;
// ---- Users ----
/**
* 把 me user 投影成 user 字典里的一条 entry。like/bookmark/post 等不影响展示形态的
* runtime 变更不会改变这个 slice (useShallow + 字段级浅比较), 避免下游字典重算。
*/
const selectMeUserEntry = (s: XStore): XUser => ({
id: s.user.id,
name: s.user.name,
avatar: s.user.avatar,
banner: s.user.banner,
verified: s.user.verified,
bio: s.user.bio,
location: s.user.location,
website: s.user.website,
birthDate: s.user.birthDate,
joinDate: s.user.joinDate,
restId: s.user.restId,
following: s.user.followedUserIds.length,
followers: s.user.followerUserIds.length,
});
/** Base users + 当前 me user (合并后字典)。 */
export function useXAllUsers(): Record<string, XUser> {
const base = useXBaseDataset();
const meEntry = useXStore(useShallow(selectMeUserEntry));
return useMemo(
() => ({ ...base.users, [meEntry.id]: meEntry }),
[base.users, meEntry],
);
}
// ---- Local posts (runtime overlay + base, with relationship-derived stats + retweet shells) ----
export function useXLocalPosts(): XPost[] {
const base = useXBaseDataset();
const runtimePosts = useXStore(selectRuntimePosts);
const me = useXStore(useShallow(selectMeSlice));
return useMemo(
() => mergeLocalPosts(runtimePosts, me, base.posts),
[runtimePosts, me, base.posts],
);
}
function useXPostIndex(): Map<string, XPost> {
const base = useXBaseDataset();
const runtimePosts = useXStore(selectRuntimePosts);
return useMemo(() => buildPostIndex(base.posts, runtimePosts), [base.posts, runtimePosts]);
}
// ---- Hydrated timelines ----
const HYDRATED_FOR_YOU_LIMIT = 200;
const HYDRATED_FOLLOWING_LIMIT = 100;
export function useXHydratedPosts(): any[] {
const localPosts = useXLocalPosts();
const allUsers = useXAllUsers();
const postIndex = useXPostIndex();
return useMemo(
() => localPosts.slice(0, HYDRATED_FOR_YOU_LIMIT).map((p) => hydratePost(p, allUsers, postIndex)),
[localPosts, allUsers, postIndex],
);
}
export function useXHydratedFollowingPosts(): any[] {
const localPosts = useXLocalPosts();
const allUsers = useXAllUsers();
const postIndex = useXPostIndex();
const followedUserIds = useXStore((s) => s.user.followedUserIds);
return useMemo(() => {
const followSet = new Set(followedUserIds);
followSet.add(currentUser.id);
return localPosts
.filter((p) => followSet.has(p.authorId))
.slice(0, HYDRATED_FOLLOWING_LIMIT)
.map((p) => hydratePost(p, allUsers, postIndex));
}, [localPosts, allUsers, postIndex, followedUserIds]);
}
/**
* Home 页一次性产出 "For You" + "Following" 两条 timeline。比分别调用
* useXHydratedPosts + useXHydratedFollowingPosts 共享一次 localPosts/allUsers/postIndex,
* 避免重复 mergeLocalPosts / buildPostIndex。
*/
export function useXHomeTimelines(): { forYou: any[]; following: any[] } {
const localPosts = useXLocalPosts();
const allUsers = useXAllUsers();
const postIndex = useXPostIndex();
const followedUserIds = useXStore((s) => s.user.followedUserIds);
return useMemo(() => {
const forYou = localPosts
.slice(0, HYDRATED_FOR_YOU_LIMIT)
.map((p) => hydratePost(p, allUsers, postIndex));
const followSet = new Set(followedUserIds);
followSet.add(currentUser.id);
const following = localPosts
.filter((p) => followSet.has(p.authorId))
.slice(0, HYDRATED_FOLLOWING_LIMIT)
.map((p) => hydratePost(p, allUsers, postIndex));
return { forYou, following };
}, [localPosts, allUsers, postIndex, followedUserIds]);
}
// ---- Single-post resolvers ----
/** 按 id 解析单个 post (本地 timeline -> postIndex -> replies)。 */
export function useXResolvedPost(postId: string): any | null {
const localPosts = useXLocalPosts();
const postIndex = useXPostIndex();
const allUsers = useXAllUsers();
const base = useXBaseDataset();
return useMemo(() => {
if (!postId) return null;
const localPost = localPosts.find((p) => p.id === postId);
if (localPost) return hydratePost(localPost, allUsers, postIndex);
const indexedPost = postIndex.get(postId);
if (indexedPost) return hydratePost(indexedPost, allUsers, postIndex);
return resolveReplyById(base.replies, postId, allUsers);
}, [postId, localPosts, postIndex, allUsers, base.replies]);
}
export function useXUserProfilePosts(userId: string, limit = 80): any[] {
const localPosts = useXLocalPosts();
const allUsers = useXAllUsers();
const postIndex = useXPostIndex();
return useMemo(() => {
if (!userId) return [];
const filtered: XPost[] = [];
for (const p of localPosts) {
if (p.authorId === userId) {
filtered.push(p);
if (filtered.length >= limit) break;
}
}
return filtered.map((p) => hydratePost(p, allUsers, postIndex));
}, [userId, limit, localPosts, allUsers, postIndex]);
}
/**
* 用户输入搜索 — case-insensitive fuzzy match 是合法例外,
* id 也走 lowercase 让 "openai" 匹中 id=OpenAI。
*/
export function useXSearchPosts(query: string, limit = 1000): any[] {
const localPosts = useXLocalPosts();
const allUsers = useXAllUsers();
const postIndex = useXPostIndex();
return useMemo(() => {
const q = query.trim().toLowerCase().replace(/\s+/g, ' ').replace(/^@/, '');
if (!q) return [];
const hits: XPost[] = [];
for (const p of localPosts) {
const content = (p.content || '').toLowerCase().replace(/\s+/g, ' ');
const author = allUsers[p.authorId];
const name = (author?.name || '').toLowerCase();
const handle = (author?.id || '').toLowerCase();
if (content.includes(q) || name.includes(q) || handle.includes(q)) {
hits.push(p);
if (hits.length >= limit) break;
}
}
return hits.map((p) => hydratePost(p, allUsers, postIndex));
}, [query, limit, localPosts, allUsers, postIndex]);
}
// ---- Notifications / Conversations / Search history ----
export function useXNotifications(): any[] {
const allUsers = useXAllUsers();
return useMemo(
() =>
baseNotifications.map((n: any) => {
const actor = allUsers[n.actorId];
return {
...n,
actor: {
id: actor?.id ?? n.actorId,
name: actor?.name || 'Unknown',
avatar: actor?.avatar,
},
};
}),
[allUsers],
);
}
export function useXMentionNotifications(): any[] {
const notifications = useXNotifications();
return useMemo(() => notifications.filter((n) => n.type === 'mention'), [notifications]);
}
export function useXConversations(): any[] {
const conversations = useXStore(selectConversationsSlice);
const allUsers = useXAllUsers();
return useMemo(() => {
const list = conversations.length > 0 ? conversations : baseConversations;
return list.map((c) => {
const participant = allUsers[c.participantId];
const lastMsg = c.messages[c.messages.length - 1];
return {
...c,
participant: {
id: participant?.id ?? c.participantId,
name: participant?.name || 'Unknown',
avatar: participant?.avatar,
verified: participant?.verified,
},
lastMessage: {
...lastMsg,
senderUserId: lastMsg.senderId,
isMe: lastMsg.senderId === currentUser.id,
},
messages: c.messages.map((m: XMessage & { isMe?: boolean }) => ({
...m,
senderUserId: m.senderId,
isMe: m.senderId === currentUser.id,
})),
};
});
}, [conversations, allUsers]);
}
export function useXRecentSearches(): any[] {
const allUsers = useXAllUsers();
return useMemo(
() =>
baseSearchHistory.map((h: any) => {
if (h.type === 'user' && h.userId) {
return { ...h, user: allUsers[String(h.userId)] };
}
return h;
}),
[allUsers],
);
}
// ---- Replies ----
export function useXRepliesForPost(postId: string): XPost[] {
const base = useXBaseDataset();
return useMemo(() => {
if (!postId) return [];
const raw = base.replies[postId];
if (!Array.isArray(raw)) return [];
return raw.map((r) => hydrateReplyTree(r, base.users));
}, [postId, base.replies, base.users]);
}
/**
* 用户 profile -> Replies tab: 收集该 user 在所有 replies 树里的回复,
* 同时附上 hydrated parent post (从 timeline / 关注流里查).
*/
export function useXUserReplies(userId: string, limit = 80): Array<{ reply: XPost; parent?: any }> {
const base = useXBaseDataset();
const localPosts = useXLocalPosts();
const allUsers = useXAllUsers();
const postIndex = useXPostIndex();
const followedUserIds = useXStore((s) => s.user.followedUserIds);
return useMemo(() => {
if (!userId) return [];
if (Object.keys(base.replies).length === 0) return [];
const hydratedPosts = localPosts.slice(0, HYDRATED_FOR_YOU_LIMIT).map((p) => hydratePost(p, allUsers, postIndex));
const followSet = new Set(followedUserIds);
const hydratedFollowing = localPosts
.filter((p) => followSet.has(p.authorId))
.slice(0, HYDRATED_FOLLOWING_LIMIT)
.map((p) => hydratePost(p, allUsers, postIndex));
const hydratedPostById = new Map<string, any>();
for (const p of hydratedPosts) hydratedPostById.set(p.id, p);
for (const p of hydratedFollowing) if (!hydratedPostById.has(p.id)) hydratedPostById.set(p.id, p);
const items: Array<{ reply: XPost; parent?: any }> = [];
outer: for (const [parentId, replies] of Object.entries(base.replies)) {
if (!Array.isArray(replies)) continue;
for (const r of replies as any[]) {
if (!r || r.authorId !== userId) continue;
items.push({
reply: hydrateReplyTree(r as XPost, allUsers),
parent: hydratedPostById.get(parentId),
});
if (items.length >= limit) break outer;
}
}
return items;
}, [userId, limit, base.replies, localPosts, allUsers, postIndex, followedUserIds]);
}