diff --git a/package.json b/package.json index 9c76fc29b2..0930917e1b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@milkdown/react": "7.3.3", "@milkdown/transformer": "7.3.3", "@milkdown/utils": "7.3.3", - "@mx-space/api-client": "1.7.2", + "@mx-space/api-client": "1.8.0-alpha.4", "@prosemirror-adapter/react": "0.2.6", "@radix-ui/react-dialog": "1.0.5", "@radix-ui/react-label": "2.0.2", @@ -93,6 +93,7 @@ "marked": "12.0.0", "medium-zoom": "1.1.0", "mermaid": "10.8.0", + "nanoid": "*", "next": "14.1.0", "next-themes": "0.2.1", "openai": "4.27.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff0ec1bb3e..da947c209f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,8 +72,8 @@ dependencies: specifier: 7.3.3 version: 7.3.3(@milkdown/core@7.3.3)(@milkdown/ctx@7.3.3)(@milkdown/prose@7.3.3)(@milkdown/transformer@7.3.3) '@mx-space/api-client': - specifier: 1.7.2 - version: 1.7.2 + specifier: 1.8.0-alpha.4 + version: 1.8.0-alpha.4 '@prosemirror-adapter/react': specifier: 0.2.6 version: 0.2.6(react-dom@18.2.0)(react@18.2.0) @@ -188,6 +188,9 @@ dependencies: mermaid: specifier: 10.8.0 version: 10.8.0 + nanoid: + specifier: '*' + version: 5.0.4 next: specifier: 14.1.0 version: 14.1.0(@babel/core@7.23.5)(react-dom@18.2.0)(react@18.2.0) @@ -2527,8 +2530,8 @@ packages: tslib: 2.6.2 dev: false - /@mx-space/api-client@1.7.2: - resolution: {integrity: sha512-tTvAYPNeOmb3zJcDVc5RJh66gBOtyVZhz2Z9f72S6PmvDEjFQ5aE3vpF/zVpp2uQ+xiOfuTsNP1FCJsL7n5v5A==} + /@mx-space/api-client@1.8.0-alpha.4: + resolution: {integrity: sha512-cthItrLMlarCxVIl13b4994Ow3ey/q9IAR4f6r8S09XyeuMrspH7yW4SvKrUEBhnVCBhLuYFPYL1Nf1FaYD2IQ==} dev: false /@mx-space/webhook@0.2.2: diff --git a/src/app/(app)/(page-detail)/[slug]/layout.tsx b/src/app/(app)/(page-detail)/[slug]/layout.tsx index 9e5e2d1702..aafd4a1cf7 100644 --- a/src/app/(app)/(page-detail)/[slug]/layout.tsx +++ b/src/app/(app)/(page-detail)/[slug]/layout.tsx @@ -1,6 +1,11 @@ import React from 'react' import type { Metadata } from 'next' +import { + buildRoomName, + Presence, + RoomProvider, +} from '~/components/modules/activity' import { CommentAreaRootLazy } from '~/components/modules/comment' import { TocFAB } from '~/components/modules/toc/TocFAB' import { BottomToUpSoftScaleTransitionView } from '~/components/ui/transition/BottomToUpSoftScaleTransitionView' @@ -13,6 +18,7 @@ import { getQueryClient } from '~/lib/query-client.server' import { requestErrorHandler } from '~/lib/request.server' import { CurrentPageDataProvider } from '~/providers/page/CurrentPageDataProvider' import { LayoutRightSideProvider } from '~/providers/shared/LayoutRightSideProvider' +import { WrappedElementProvider } from '~/providers/shared/WrappedElementProvider' import { queries } from '~/queries/definition' import { @@ -81,20 +87,33 @@ export default async (props: NextPageParams) => {
-
-
- - - - - - -
- - {props.children} - -
+ + +
+
+ + + + + + + +
+ + {props.children} + + + +
+
+
diff --git a/src/app/(app)/notes/[id]/layout.tsx b/src/app/(app)/notes/[id]/layout.tsx index 435049e0e5..a1b6574ab0 100644 --- a/src/app/(app)/notes/[id]/layout.tsx +++ b/src/app/(app)/notes/[id]/layout.tsx @@ -1,6 +1,7 @@ import { headers } from 'next/dist/client/components/headers' import type { Metadata } from 'next' +import { buildRoomName, RoomProvider } from '~/components/modules/activity' import { CommentAreaRootLazy } from '~/components/modules/comment' import { NoteFontSettingFab } from '~/components/modules/note/NoteFontFab' import { NoteMainContainer } from '~/components/modules/note/NoteMainContainer' @@ -91,18 +92,19 @@ export default async ( - - - - - - - - - + + + + + + + + + + diff --git a/src/app/(app)/notes/[id]/pageExtra.tsx b/src/app/(app)/notes/[id]/pageExtra.tsx index b9a2bedf09..6ffdc9b8eb 100644 --- a/src/app/(app)/notes/[id]/pageExtra.tsx +++ b/src/app/(app)/notes/[id]/pageExtra.tsx @@ -59,6 +59,7 @@ export const NoteTitle = () => { export const NoteDateMeta = () => { const created = useCurrentNoteDataSelector((data) => data?.data.created) + if (!created) return null const dateFormat = dayjs(created) .locale('zh-cn') diff --git a/src/app/(app)/notes/[id]/pageImpl.tsx b/src/app/(app)/notes/[id]/pageImpl.tsx index 9369d1cd2e..245c2fd848 100644 --- a/src/app/(app)/notes/[id]/pageImpl.tsx +++ b/src/app/(app)/notes/[id]/pageImpl.tsx @@ -5,10 +5,13 @@ import type { NoteModel } from '@mx-space/api-client' import { AckRead } from '~/components/common/AckRead' import { ClientOnly } from '~/components/common/ClientOnly' +import { Presence } from '~/components/modules/activity' import { NoteActionAside, NoteBottomBarAction, NoteFooterNavigationBarForMobile, + NoteMetaBar, + NoteMetaReadingCount, NoteTopic, } from '~/components/modules/note' import { NoteRootBanner } from '~/components/modules/note/NoteBanner' @@ -22,7 +25,6 @@ import { WrappedElementProvider } from '~/providers/shared/WrappedElementProvide import { NoteHeadCover } from '../../../../components/modules/note/NoteHeadCover' import { NoteHideIfSecret } from '../../../../components/modules/note/NoteHideIfSecret' -import { NoteMetaBar } from '../../../../components/modules/note/NoteMetaBar' import { IndentArticleContainer, MarkdownSelection, @@ -48,6 +50,7 @@ const NotePage = function (props: NoteModel) { + @@ -56,6 +59,7 @@ const NotePage = function (props: NoteModel) { + diff --git a/src/app/(app)/posts/(post-detail)/[category]/[slug]/layout.tsx b/src/app/(app)/posts/(post-detail)/[category]/[slug]/layout.tsx index 7582ff8b68..0e0f729b9b 100644 --- a/src/app/(app)/posts/(post-detail)/[category]/[slug]/layout.tsx +++ b/src/app/(app)/posts/(post-detail)/[category]/[slug]/layout.tsx @@ -1,6 +1,7 @@ import React from 'react' import type { Metadata } from 'next' +import { buildRoomName, RoomProvider } from '~/components/modules/activity' import { CommentAreaRootLazy } from '~/components/modules/comment' import { TocFAB } from '~/components/modules/toc/TocFAB' import { BottomToUpSoftScaleTransitionView } from '~/components/ui/transition/BottomToUpSoftScaleTransitionView' @@ -79,7 +80,9 @@ export default async (props: NextPageParams) => {
- + + + { } }) if (!meta) return null - return + return ( + + + + ) } diff --git a/src/app/(app)/posts/(post-detail)/[category]/[slug]/pageImpl.tsx b/src/app/(app)/posts/(post-detail)/[category]/[slug]/pageImpl.tsx index 8804015bec..058e829e1a 100644 --- a/src/app/(app)/posts/(post-detail)/[category]/[slug]/pageImpl.tsx +++ b/src/app/(app)/posts/(post-detail)/[category]/[slug]/pageImpl.tsx @@ -2,6 +2,7 @@ import type { PostModel } from '@mx-space/api-client' import { AckRead } from '~/components/common/AckRead' import { ClientOnly } from '~/components/common/ClientOnly' +import { Presence } from '~/components/modules/activity' import { PostActionAside, PostBottomBarAction, @@ -51,6 +52,7 @@ const PostPage = (props: PostModel) => { + diff --git a/src/app/(app)/says/page.tsx b/src/app/(app)/says/page.tsx index 99f0edb30f..7da22fd581 100644 --- a/src/app/(app)/says/page.tsx +++ b/src/app/(app)/says/page.tsx @@ -7,7 +7,7 @@ import Markdown from 'markdown-to-jsx' import type { SayModel } from '@mx-space/api-client' import type { MarkdownToJSX } from 'markdown-to-jsx' -import { useIsMobile } from '~/atoms' +import { useIsMobile } from '~/atoms/hooks' import { LoadMoreIndicator } from '~/components/modules/shared/LoadMoreIndicator' import { NothingFound } from '~/components/modules/shared/NothingFound' import { Loading } from '~/components/ui/loading' @@ -89,7 +89,7 @@ const SaySkeleton = memo(() => {
-
+
diff --git a/src/app/(app)/thinking/page.tsx b/src/app/(app)/thinking/page.tsx index 733162ffa2..3e19deb838 100644 --- a/src/app/(app)/thinking/page.tsx +++ b/src/app/(app)/thinking/page.tsx @@ -14,7 +14,7 @@ import { RecentlyAttitudeResultEnum, } from '@mx-space/api-client' -import { useIsLogged } from '~/atoms' +import { useIsLogged } from '~/atoms/hooks' import { TiltedSendIcon } from '~/components/icons/TiltedSendIcon' import { CommentBoxRootLazy, CommentsLazy } from '~/components/modules/comment' import { PeekLink } from '~/components/modules/peek/PeekLink' diff --git a/src/app/(app)/web-dev/layout.tsx b/src/app/(app)/web-dev/layout.tsx new file mode 100644 index 0000000000..6c0fc3a003 --- /dev/null +++ b/src/app/(app)/web-dev/layout.tsx @@ -0,0 +1,15 @@ +export const metadata = { + title: 'dev', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + <>{children} + + ) +} diff --git a/src/app/(app)/web-dev/page.tsx b/src/app/(app)/web-dev/page.tsx new file mode 100644 index 0000000000..018621aeb8 --- /dev/null +++ b/src/app/(app)/web-dev/page.tsx @@ -0,0 +1,121 @@ +/* eslint-disable react/display-name */ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { useCallback, useEffect, useMemo } from 'react' +import { m } from 'framer-motion' + +import { + useActivityPresence, + useActivityPresenceBySessionId, + useSocketIsConnect, + useSocketSessionId, +} from '~/atoms/hooks' +import { StyledButton } from '~/components/ui/button' +import { FloatPopover } from '~/components/ui/float-popover' +import { debounce } from '~/lib/lodash' +import { apiClient } from '~/lib/request' +import { usePageScrollLocation } from '~/providers/root/page-scroll-info-provider' +import { queries } from '~/queries/definition' +import { socketClient } from '~/socket' +import { EventTypes, SocketEmitEnum } from '~/types/events' + +export default () => { + const roomName = useMemo(() => `article-${111112222}`, []) + const identity = useSocketSessionId() + const update = useCallback( + debounce((position) => { + apiClient.activity.proxy.presence.update.post({ + data: { + identity, + position, + ts: Date.now(), + roomName, + sid: socketClient.socket.id, + }, + }) + }, 1000), + [identity], + ) + + const socketIsConnected = useSocketIsConnect() + useEffect(() => { + socketClient.emit(SocketEmitEnum.Join, { + roomName, + }) + + const handler = (e: any) => { + console.log(e, 'EventTypes.ACTIVITY_UPDATE_PRESENCE') + } + window.addEventListener( + `event:${EventTypes.ACTIVITY_UPDATE_PRESENCE}`, + handler, + ) + + return () => { + socketClient.emit(SocketEmitEnum.Leave, { + roomName, + }) + window.removeEventListener( + `event:${EventTypes.ACTIVITY_UPDATE_PRESENCE}`, + handler, + ) + } + }, [roomName, identity, socketIsConnected]) + + const { refetch } = useQuery({ + ...queries.activity.presence(roomName), + enabled: false, + }) + + const scrollLocation = usePageScrollLocation() + + useEffect(() => { + update((scrollLocation / document.body.scrollHeight) * 100) + }, [scrollLocation, update]) + + return ( + <> +
+ update + refetch()}>get + +
+
+ + ) +} + +const ReadPresenceTimeline = () => { + const sessionId = useSocketSessionId() + const activityPresence = useActivityPresenceBySessionId(sessionId) + console.log( + activityPresence, + 'activityPresence', + sessionId, + useActivityPresence(), + ) + + return ( +
+ + } + > +

你在这里。

+

阅读进度 50%

+
+
+ ) +} diff --git a/src/app/(dashboard)/dashboard/comments/page.tsx b/src/app/(dashboard)/dashboard/comments/page.tsx index eb1f724f48..3f76c340c7 100644 --- a/src/app/(dashboard)/dashboard/comments/page.tsx +++ b/src/app/(dashboard)/dashboard/comments/page.tsx @@ -8,7 +8,7 @@ import type { FC } from 'react' import { CommentState } from '@mx-space/api-client' -import { useIsMobile } from '~/atoms' +import { useIsMobile } from '~/atoms/hooks' import { CommentBatchActionGroup, CommentDataContext, diff --git a/src/app/(dashboard)/dashboard/notes/edit/page.tsx b/src/app/(dashboard)/dashboard/notes/edit/page.tsx index b913a5e92f..86057f4b5b 100644 --- a/src/app/(dashboard)/dashboard/notes/edit/page.tsx +++ b/src/app/(dashboard)/dashboard/notes/edit/page.tsx @@ -9,7 +9,7 @@ import { useRouter, useSearchParams } from 'next/navigation' import type { NoteDto } from '~/models/writing' import type { FC } from 'react' -import { useIsMobile } from '~/atoms' +import { useIsMobile } from '~/atoms/hooks' import { PageLoading } from '~/components/layout/dashboard/PageLoading' import { NoteEditorSidebar, diff --git a/src/app/(dashboard)/dashboard/notes/topics/page.tsx b/src/app/(dashboard)/dashboard/notes/topics/page.tsx index 4c2434b466..df9216b3af 100644 --- a/src/app/(dashboard)/dashboard/notes/topics/page.tsx +++ b/src/app/(dashboard)/dashboard/notes/topics/page.tsx @@ -3,7 +3,7 @@ import { useLayoutEffect } from 'react' import { useRouter } from 'next/navigation' -import { useResolveAdminUrl } from '~/atoms' +import { useResolveAdminUrl } from '~/atoms/hooks' export default function Page() { const toAdminUrl = useResolveAdminUrl() diff --git a/src/app/(dashboard)/dashboard/pages/page.tsx b/src/app/(dashboard)/dashboard/pages/page.tsx index 2a1d00f238..6de922d313 100644 --- a/src/app/(dashboard)/dashboard/pages/page.tsx +++ b/src/app/(dashboard)/dashboard/pages/page.tsx @@ -3,7 +3,7 @@ import { useLayoutEffect } from 'react' import { useRouter } from 'next/navigation' -import { useResolveAdminUrl } from '~/atoms' +import { useResolveAdminUrl } from '~/atoms/hooks' export default function Page() { const toAdminUrl = useResolveAdminUrl() diff --git a/src/app/(dashboard)/dashboard/posts/category/page.tsx b/src/app/(dashboard)/dashboard/posts/category/page.tsx index 8f96a09ea6..966cd01258 100644 --- a/src/app/(dashboard)/dashboard/posts/category/page.tsx +++ b/src/app/(dashboard)/dashboard/posts/category/page.tsx @@ -3,7 +3,7 @@ import { useLayoutEffect } from 'react' import { useRouter } from 'next/navigation' -import { useResolveAdminUrl } from '~/atoms' +import { useResolveAdminUrl } from '~/atoms/hooks' export default function Page() { const toAdminUrl = useResolveAdminUrl() diff --git a/src/app/(dashboard)/dashboard/posts/edit/page.tsx b/src/app/(dashboard)/dashboard/posts/edit/page.tsx index ca4eb36ded..137d890a82 100644 --- a/src/app/(dashboard)/dashboard/posts/edit/page.tsx +++ b/src/app/(dashboard)/dashboard/posts/edit/page.tsx @@ -9,7 +9,7 @@ import { useRouter, useSearchParams } from 'next/navigation' import type { PostDto } from '~/models/writing' import type { FC } from 'react' -import { useIsMobile } from '~/atoms' +import { useIsMobile } from '~/atoms/hooks' import { PageLoading } from '~/components/layout/dashboard/PageLoading' import { PostEditorSidebar, diff --git a/src/app/(dashboard)/dashboard/vue/page.tsx b/src/app/(dashboard)/dashboard/vue/page.tsx index 03b0b04674..d244bc4260 100644 --- a/src/app/(dashboard)/dashboard/vue/page.tsx +++ b/src/app/(dashboard)/dashboard/vue/page.tsx @@ -3,7 +3,8 @@ import { useLayoutEffect } from 'react' import { useRouter } from 'next/navigation' -import { fetchAppUrl, useResolveAdminUrl } from '~/atoms' +import { fetchAppUrl } from '~/atoms' +import { useResolveAdminUrl } from '~/atoms/hooks' import { FullPageLoading } from '~/components/ui/loading' export default function Page() { diff --git a/src/atoms/activity.ts b/src/atoms/activity.ts index 016cb64038..0a26d27478 100644 --- a/src/atoms/activity.ts +++ b/src/atoms/activity.ts @@ -1,4 +1,6 @@ -import { atom, useAtomValue } from 'jotai' +import { produce } from 'immer' +import { atom } from 'jotai' +import type { ActivityPresence } from '~/models/activity' import { jotaiStore } from '~/lib/store' @@ -15,14 +17,36 @@ type Activity = { } | null } -const activityAtom = atom({ +export const activityAtom = atom({ process: null, media: null, } as Activity) -export const useActivity = () => useAtomValue(activityAtom) export const setActivityProcessInfo = (process: Activity['process'] | null) => jotaiStore.set(activityAtom, (prev) => ({ ...prev, process })) export const setActivityMediaInfo = (media: Activity['media']) => jotaiStore.set(activityAtom, (prev) => ({ ...prev, media })) + +///////// + +export const activityPresenceAtom = atom({} as Record) + +export const setActivityPresence = (presence: ActivityPresence) => { + jotaiStore.set(activityPresenceAtom, (prev) => ({ + ...prev, + [presence.identity]: presence, + })) +} + +export const deleteActivityPresence = (sessionId: string) => { + jotaiStore.set(activityPresenceAtom, (prev) => { + return produce(prev, (draft) => { + delete draft[sessionId] + }) + }) +} + +export const resetActivityPresence = ( + data?: Record, +) => jotaiStore.set(activityPresenceAtom, data || {}) diff --git a/src/atoms/hooks/activity.ts b/src/atoms/hooks/activity.ts new file mode 100644 index 0000000000..17f82113bf --- /dev/null +++ b/src/atoms/hooks/activity.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import { useAtomValue } from 'jotai' +import { selectAtom } from 'jotai/utils' +import type { ActivityPresence } from '~/models/activity' + +import { activityAtom, activityPresenceAtom } from '../activity' + +export const useActivity = () => useAtomValue(activityAtom) +export const useActivityPresence = () => useAtomValue(activityPresenceAtom) +export const useActivityPresenceBySessionId = ( + sessionId: string, +): ActivityPresence | null => + useAtomValue( + useMemo( + () => + selectAtom(activityPresenceAtom, (atomValue) => atomValue[sessionId]), + [sessionId], + ), + ) + +export const useActivityPresenceByRoomName = (roomName: string) => + useAtomValue( + useMemo( + () => + selectAtom(activityPresenceAtom, (atomValue) => + Object.values(atomValue) + .filter((presence) => presence.roomName === roomName) + .map((presence) => presence.identity), + ), + [roomName], + ), + ) + +export const useCurrentRoomCount = (roomName: string) => + useAtomValue( + useMemo( + () => + selectAtom( + activityPresenceAtom, + (atomValue) => + Object.values(atomValue).filter( + (presence) => presence.roomName === roomName, + ).length, + ), + [roomName], + ), + ) diff --git a/src/atoms/hooks/index.ts b/src/atoms/hooks/index.ts new file mode 100644 index 0000000000..072544baa1 --- /dev/null +++ b/src/atoms/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './activity' +export * from './owner' +export * from './socket' +export * from './url' +export * from './viewport' diff --git a/src/atoms/hooks/owner.ts b/src/atoms/hooks/owner.ts new file mode 100644 index 0000000000..5ff7d7259f --- /dev/null +++ b/src/atoms/hooks/owner.ts @@ -0,0 +1,31 @@ +import { useMutation } from '@tanstack/react-query' +import { useAtomValue } from 'jotai' + +import { getToken, setToken } from '~/lib/cookie' +import { apiClient } from '~/lib/request' +import { jotaiStore } from '~/lib/store' + +import { isLoggedAtom, ownerAtom } from '../owner' +import { fetchAppUrl } from '../url' + +export const useIsLogged = () => useAtomValue(isLoggedAtom) + +export const useOwner = () => useAtomValue(ownerAtom) +export const useRefreshToken = () => { + return useMutation({ + mutationKey: ['refreshToken'], + mutationFn: refreshToken, + }) +} + +export const refreshToken = async () => { + const token = getToken() + if (!token) return + await apiClient.user.proxy.login.put<{ token: string }>().then((res) => { + jotaiStore.set(isLoggedAtom, true) + + setToken(res.token) + }) + + await fetchAppUrl() +} diff --git a/src/atoms/hooks/socket.ts b/src/atoms/hooks/socket.ts new file mode 100644 index 0000000000..b687846def --- /dev/null +++ b/src/atoms/hooks/socket.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react' +import { useAtomValue } from 'jotai' +import { customAlphabet } from 'nanoid' + +import { useUser } from '@clerk/nextjs' + +import { isClientSide } from '~/lib/env' +import { buildNSKey } from '~/lib/ns' + +import { socketIsConnectAtom } from '../socket' +import { useIsLogged, useOwner } from './owner' + +const alphabet = `1234567890abcdefghijklmnopqrstuvwxyz` + +const nanoid = customAlphabet(alphabet) +const defaultSessionId = nanoid(8) +const storageKey = buildNSKey('web-session') + +export const getSocketWebSessionId = () => { + if (!isClientSide) { + return '' + } + const sessionId = localStorage.getItem(storageKey) + if (sessionId) return sessionId + localStorage.setItem(storageKey, defaultSessionId) + return defaultSessionId +} + +export const useSocketSessionId = () => { + const user = useUser() + const owner = useOwner() + const ownerIsLogin = useIsLogged() + + return useMemo((): string => { + const fallbackSid = getSocketWebSessionId() + if (ownerIsLogin) { + if (!owner) return fallbackSid + return `owner-${owner.id}` + } else if (user && user.isSignedIn) { + return user.user.id.toLowerCase() + } + return fallbackSid + }, [owner, ownerIsLogin, user]) +} + +export const useSocketIsConnect = () => { + return useAtomValue(socketIsConnectAtom) +} diff --git a/src/atoms/hooks/url.ts b/src/atoms/hooks/url.ts new file mode 100644 index 0000000000..0bf7d32acb --- /dev/null +++ b/src/atoms/hooks/url.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react' +import { useAtomValue } from 'jotai' + +import { getToken } from '~/lib/cookie' +import { useAggregationSelector } from '~/providers/root/aggregation-data-provider' + +import { adminUrlAtom } from '../url' + +export const useAppUrl = () => { + const url = useAggregationSelector((a) => a.url) + const adminUrl = useAtomValue(adminUrlAtom) + return { + adminUrl, + ...url, + } +} + +export const useResolveAdminUrl = () => { + const { adminUrl } = useAppUrl() + return useCallback( + (path?: string) => { + if (!adminUrl) { + return '' + } + const parsedUrl = new URL(adminUrl.replace(/\/$/, '')) + const token = getToken() + if (token) { + parsedUrl.searchParams.set('token', token) + } + + return `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}${ + path || '' + }${parsedUrl.search}` + }, + [adminUrl], + ) +} diff --git a/src/atoms/hooks/viewport.ts b/src/atoms/hooks/viewport.ts new file mode 100644 index 0000000000..9406dfe3d0 --- /dev/null +++ b/src/atoms/hooks/viewport.ts @@ -0,0 +1,25 @@ +import { useCallback } from 'react' +import { useAtomValue } from 'jotai' +import { selectAtom } from 'jotai/utils' +import type { ExtractAtomValue } from 'jotai' + +import { viewportAtom } from '../viewport' + +export const useViewport = ( + selector: (value: ExtractAtomValue) => T, +): T => + useAtomValue( + selectAtom( + viewportAtom, + useCallback((atomValue) => selector(atomValue), []), + ), + ) + +export const useIsMobile = () => + useViewport( + useCallback( + (v: ExtractAtomValue) => + (v.sm || v.md || !v.sm) && !v.lg, + [], + ), + ) diff --git a/src/atoms/index.ts b/src/atoms/index.ts index 1b99f95fa7..1a72e996f5 100644 --- a/src/atoms/index.ts +++ b/src/atoms/index.ts @@ -1,5 +1,5 @@ -export * from './online' export * from './viewport' export * from './css-media' export * from './owner' -export * from './url' \ No newline at end of file +export * from './url' +export * from './socket' diff --git a/src/atoms/online.ts b/src/atoms/online.ts deleted file mode 100644 index e440434179..0000000000 --- a/src/atoms/online.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createAtomHooks } from 'jojoo/react' -import { atom } from 'jotai' - -export const [, , useOnlineCount, , , setOnlineCount] = createAtomHooks(atom(0)) diff --git a/src/atoms/owner.ts b/src/atoms/owner.ts index d46a97e7a9..29c77d6a11 100644 --- a/src/atoms/owner.ts +++ b/src/atoms/owner.ts @@ -1,5 +1,4 @@ -import { useMutation } from '@tanstack/react-query' -import { atom, useAtomValue } from 'jotai' +import { atom } from 'jotai' import { getToken, removeToken, setToken } from '~/lib/cookie' import { apiClient } from '~/lib/request' @@ -7,12 +6,13 @@ import { jotaiStore } from '~/lib/store' import { toast } from '~/lib/toast' import { aggregationDataAtom } from '~/providers/root/aggregation-data-provider' +import { refreshToken } from './hooks/owner' import { fetchAppUrl } from './url' -const ownerAtom = atom((get) => { +export const ownerAtom = atom((get) => { return get(aggregationDataAtom)?.user }) -const isLoggedAtom = atom(false) +export const isLoggedAtom = atom(false) export const login = async (username?: string, password?: string) => { if (username && password) { @@ -60,27 +60,4 @@ export const login = async (username?: string, password?: string) => { return true } -export const useIsLogged = () => useAtomValue(isLoggedAtom) - -export const useOwner = () => useAtomValue(ownerAtom) - export const isLogged = () => jotaiStore.get(isLoggedAtom) - -export const useRefreshToken = () => { - return useMutation({ - mutationKey: ['refreshToken'], - mutationFn: refreshToken, - }) -} - -const refreshToken = async () => { - const token = getToken() - if (!token) return - await apiClient.user.proxy.login.put<{ token: string }>().then((res) => { - jotaiStore.set(isLoggedAtom, true) - - setToken(res.token) - }) - - await fetchAppUrl() -} diff --git a/src/atoms/socket.ts b/src/atoms/socket.ts new file mode 100644 index 0000000000..87e1786b18 --- /dev/null +++ b/src/atoms/socket.ts @@ -0,0 +1,12 @@ +import { createAtomHooks } from 'jojoo/react' +import { atom } from 'jotai' + +import { jotaiStore } from '~/lib/store' + +export const [, , useOnlineCount, , , setOnlineCount] = createAtomHooks(atom(0)) + +export const socketIsConnectAtom = atom(false) + +export const setSocketIsConnect = (value: boolean) => { + jotaiStore.set(socketIsConnectAtom, value) +} diff --git a/src/atoms/url.ts b/src/atoms/url.ts index 673b9d4f4b..e93fc0f841 100644 --- a/src/atoms/url.ts +++ b/src/atoms/url.ts @@ -1,11 +1,8 @@ import { queryClient } from '~/providers/root/react-query-provider' -import { useCallback } from 'react' -import { atom, useAtomValue } from 'jotai' +import { atom } from 'jotai' -import { getToken } from '~/lib/cookie' import { apiClient } from '~/lib/request' import { jotaiStore } from '~/lib/store' -import { useAggregationSelector } from '~/providers/root/aggregation-data-provider' export interface UrlConfig { adminUrl: string @@ -13,8 +10,8 @@ export interface UrlConfig { webUrl: string } -const adminUrlAtom = atom(null) -const webUrlAtom = atom(null) +export const adminUrlAtom = atom(null) +export const webUrlAtom = atom(null) export const fetchAppUrl = async () => { const { data } = await queryClient.fetchQuery({ @@ -33,32 +30,3 @@ export const fetchAppUrl = async () => { export const getWebUrl = () => jotaiStore.get(webUrlAtom) export const setWebUrl = (url: string) => jotaiStore.set(webUrlAtom, url) export const getAdminUrl = () => jotaiStore.get(adminUrlAtom) -export const useAppUrl = () => { - const url = useAggregationSelector((a) => a.url) - const adminUrl = useAtomValue(adminUrlAtom) - return { - adminUrl, - ...url, - } -} - -export const useResolveAdminUrl = () => { - const { adminUrl } = useAppUrl() - return useCallback( - (path?: string) => { - if (!adminUrl) { - return '' - } - const parsedUrl = new URL(adminUrl.replace(/\/$/, '')) - const token = getToken() - if (token) { - parsedUrl.searchParams.set('token', token) - } - - return `${parsedUrl.protocol}//${parsedUrl.host}${parsedUrl.pathname}${ - path || '' - }${parsedUrl.search}` - }, - [adminUrl], - ) -} diff --git a/src/atoms/viewport.ts b/src/atoms/viewport.ts index fa24bc2282..023a7530b7 100644 --- a/src/atoms/viewport.ts +++ b/src/atoms/viewport.ts @@ -1,7 +1,4 @@ -import { useCallback } from 'react' -import { atom, useAtomValue } from 'jotai' -import { selectAtom } from 'jotai/utils' -import type { ExtractAtomValue } from 'jotai' +import { atom } from 'jotai' export const viewportAtom = atom({ /** @@ -32,23 +29,3 @@ export const viewportAtom = atom({ h: 0, w: 0, }) - -export const useViewport = ( - selector: (value: ExtractAtomValue) => T, -): T => - useAtomValue( - // @ts-ignore - selectAtom( - viewportAtom, - useCallback((atomValue) => selector(atomValue), []), - ), - ) - -export const useIsMobile = () => - useViewport( - useCallback( - (v: ExtractAtomValue) => - (v.sm || v.md || !v.sm) && !v.lg, - [], - ), - ) diff --git a/src/components/common/ImpressionTracker.tsx b/src/components/common/ImpressionTracker.tsx index f38686ee00..03a17fde8a 100644 --- a/src/components/common/ImpressionTracker.tsx +++ b/src/components/common/ImpressionTracker.tsx @@ -1,7 +1,7 @@ import { memo, useState } from 'react' import { useInView } from 'react-intersection-observer' -import { useIsLogged } from '~/atoms' +import { useIsLogged } from '~/atoms/hooks' import { TrackerAction } from '~/constants/tracker' type ImpressionProps = { diff --git a/src/components/layout/dashboard/Header.tsx b/src/components/layout/dashboard/Header.tsx index 622644d806..d39bae6db4 100644 --- a/src/components/layout/dashboard/Header.tsx +++ b/src/components/layout/dashboard/Header.tsx @@ -9,7 +9,7 @@ import type { DashboardRoute } from '~/app/(dashboard)/routes' import type { MouseEventHandler, ReactNode } from 'react' import { dashboardRoutes, useParentRouteObject } from '~/app/(dashboard)/routes' -import { useIsMobile } from '~/atoms' +import { useIsMobile } from '~/atoms/hooks' import { Avatar } from '~/components/ui/avatar' import { MotionButtonBase } from '~/components/ui/button' import { BreadcrumbDivider } from '~/components/ui/divider' diff --git a/src/components/layout/footer/GatewayCount.tsx b/src/components/layout/footer/GatewayCount.tsx index 034cdf37d5..5a14793ad6 100644 --- a/src/components/layout/footer/GatewayCount.tsx +++ b/src/components/layout/footer/GatewayCount.tsx @@ -1,13 +1,13 @@ 'use client' import { useOnlineCount } from '~/atoms' +import { useSocketIsConnect } from '~/atoms/hooks' import { ImpressionView } from '~/components/common/ImpressionTracker' import { Divider } from '~/components/ui/divider' import { FloatPopover } from '~/components/ui/float-popover' import { NumberSmoothTransition } from '~/components/ui/number-transition/NumberSmoothTransition' import { TrackerAction } from '~/constants/tracker' import { usePageIsActive } from '~/hooks/common/use-is-active' -import { useSocketIsConnect } from '~/socket/hooks' export const GatewayCount = () => { return ( diff --git a/src/components/layout/header/internal/Activity.tsx b/src/components/layout/header/internal/Activity.tsx index 6e5460bfb0..a4d616906c 100644 --- a/src/components/layout/header/internal/Activity.tsx +++ b/src/components/layout/header/internal/Activity.tsx @@ -13,11 +13,8 @@ import clsx from 'clsx' import { AnimatePresence, m } from 'framer-motion' import Image from 'next/image' -import { - setActivityMediaInfo, - setActivityProcessInfo, - useActivity, -} from '~/atoms/activity' +import { setActivityMediaInfo, setActivityProcessInfo } from '~/atoms/activity' +import { useActivity } from '~/atoms/hooks' import { ImpressionView } from '~/components/common/ImpressionTracker' import { FloatPopover } from '~/components/ui/float-popover' import { softBouncePreset } from '~/constants/spring' diff --git a/src/components/layout/header/internal/AnimatedLogo.tsx b/src/components/layout/header/internal/AnimatedLogo.tsx index 8eb7346c58..e24ac4485c 100644 --- a/src/components/layout/header/internal/AnimatedLogo.tsx +++ b/src/components/layout/header/internal/AnimatedLogo.tsx @@ -5,7 +5,8 @@ import { useCallback } from 'react' import { AnimatePresence, m } from 'framer-motion' import { useRouter } from 'next/navigation' -import { isLogged, useResolveAdminUrl, useViewport } from '~/atoms' +import { isLogged } from '~/atoms' +import { useResolveAdminUrl, useViewport } from '~/atoms/hooks' import { useSingleAndDoubleClick } from '~/hooks/common/use-single-double-click' import { noopObj } from '~/lib/noop' import { Routes } from '~/lib/route-builder' diff --git a/src/components/layout/header/internal/UserAuth.tsx b/src/components/layout/header/internal/UserAuth.tsx index 25e428b3c3..9557b5a99b 100644 --- a/src/components/layout/header/internal/UserAuth.tsx +++ b/src/components/layout/header/internal/UserAuth.tsx @@ -6,7 +6,7 @@ import dynamic from 'next/dynamic' import Image from 'next/image' import { usePathname } from 'next/navigation' -import { useIsLogged } from '~/atoms' +import { useIsLogged } from '~/atoms/hooks' import { UserArrowLeftIcon } from '~/components/icons/user-arrow-left' import { MotionButtonBase } from '~/components/ui/button' import { FloatPopover } from '~/components/ui/float-popover' diff --git a/src/components/layout/header/internal/hooks.ts b/src/components/layout/header/internal/hooks.ts index 5a38cf67aa..6777872ffa 100644 --- a/src/components/layout/header/internal/hooks.ts +++ b/src/components/layout/header/internal/hooks.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { atom, useAtomValue, useSetAtom } from 'jotai' -import { useIsMobile } from '~/atoms' +import { useIsMobile } from '~/atoms/hooks' import { jotaiStore } from '~/lib/store' import { usePageScrollLocationSelector } from '~/providers/root/page-scroll-info-provider' diff --git a/src/components/modules/activity/Presence.tsx b/src/components/modules/activity/Presence.tsx new file mode 100644 index 0000000000..79b851ee7d --- /dev/null +++ b/src/components/modules/activity/Presence.tsx @@ -0,0 +1,290 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { + forwardRef, + memo, + useCallback, + useDeferredValue, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import clsx from 'clsx' +import type { FC } from 'react' + +import { useUser } from '@clerk/nextjs' + +import { + useActivityPresenceByRoomName, + useActivityPresenceBySessionId, + useIsLogged, + useIsMobile, + useOwner, + useSocketSessionId, +} from '~/atoms/hooks' +import { FloatPopover } from '~/components/ui/float-popover' +import { RootPortal } from '~/components/ui/portal' +import { EmitKeyMap } from '~/constants/keys' +import { useEventCallback } from '~/hooks/common/use-event-callback' +import { useIsClient } from '~/hooks/common/use-is-client' +import { useIsDark } from '~/hooks/common/use-is-dark' +import { useReadPercent } from '~/hooks/shared/use-read-percent' +import { getColorScheme, stringToHue } from '~/lib/color' +import { formatSeconds } from '~/lib/datetime' +import { debounce } from '~/lib/lodash' +import { apiClient } from '~/lib/request' +import { springScrollTo } from '~/lib/scroller' +import { + useWrappedElementPosition, + useWrappedElementSize, +} from '~/providers/shared/WrappedElementProvider' +import { queries } from '~/queries/definition' +import { socketClient } from '~/socket' + +import { useRoomContext } from './Room' + +export const Presence = () => { + const isMobile = useIsMobile() + + const isClient = useIsClient() + + return isMobile ? null : isClient ? : null +} + +const PresenceImpl = () => { + const { roomName } = useRoomContext() + + const { refetch } = useQuery({ + ...queries.activity.presence(roomName), + + refetchOnMount: true, + refetchInterval: 30_000, + }) + + const identity = useSocketSessionId() + + const clerkUser = useUser() + const owner = useOwner() + + const isOwnerLogged = useIsLogged() + const displayName = useMemo( + () => + isOwnerLogged + ? owner?.name + : clerkUser.isSignedIn + ? clerkUser.user.fullName + : '', + [ + clerkUser.isSignedIn, + clerkUser.user?.fullName, + isOwnerLogged, + owner?.name, + ], + ) + + const update = useCallback( + debounce((position: number) => { + const sid = socketClient.socket.id + if (!sid) return + apiClient.activity.updatePresence({ + identity, + position, + sid, + roomName, + displayName: displayName || void 0, + }) + }, 1000), + [identity, displayName], + ) + + const percent = useReadPercent() + + const updateWithPercent = useEventCallback(() => update(percent)) + + useEffect(() => { + const handler = () => { + refetch() + updateWithPercent() + } + window.addEventListener(EmitKeyMap.SocketConnected, handler) + + return () => { + window.removeEventListener(EmitKeyMap.SocketConnected, handler) + } + }, [refetch, updateWithPercent]) + + useEffect(() => { + update(percent) + }, [percent, update]) + + return +} + +const ReadPresenceTimeline = () => { + const sessionId = useSocketSessionId() + + const { roomName } = useRoomContext() + const activityPresenceIdsCurrentRoom = useActivityPresenceByRoomName(roomName) + + // console.log(activityPresenceIdsCurrentRoom, 'activityPresenceIdsCurrentRoom') + // console.log( + + // activityPresence, + // 'activityPresence', + // sessionId, + // useActivityPresence(), + // ) + + return ( + +
+ {activityPresenceIdsCurrentRoom.map((identity) => { + return ( + + ) + })} +
+
+ ) +} + +interface TimelineItemProps { + identity: string + type: 'current' | 'other' +} +const TimelineItem: FC = memo(({ type, identity }) => { + const presence = useActivityPresenceBySessionId(identity) + + const readPercent = useReadPercent() + const isCurrent = type === 'current' + + const position = useDeferredValue( + isCurrent ? readPercent : presence?.position, + ) + const isDark = useIsDark() + const bgColor = useMemo(() => { + if (type === 'current') return '' + if (!presence) return '' + return getColorScheme(stringToHue(presence.identity))[ + isDark ? 'dark' : 'light' + ].accent + }, [isDark, presence, type]) + if (!presence && isCurrent) return null + + if (typeof position !== 'number') return null + const readingDuration = presence + ? formatSeconds((presence.operationTime - presence.connectedAt) / 1000) + : '' + + return ( + + } + > + {isCurrent ? ( +

你在这里。

+ ) : ( +

+ 读者{' '} + {presence?.displayName || + presence?.identity.slice(0, 2).toUpperCase()}{' '} + 在这里。 +

+ )} +

阅读进度 {position}%

+ + {readingDuration &&

阅读了 {readingDuration}

} +
+ ) +}) + +TimelineItem.displayName = 'TimelineItem' + +const MoitonBar = forwardRef< + HTMLButtonElement, + { + position: number + bgColor: string + isCurrent: boolean + } +>(({ bgColor, isCurrent, position, ...rest }, ref) => { + const elRef = useRef(null) + + const [memoedPosition] = useState(position) + useLayoutEffect(() => { + const el = elRef.current + if (!el) return + el.style.top = `${memoedPosition}%` + }, [memoedPosition]) + + const animateRef = useRef(null) + useEffect(() => { + if (isCurrent) { + return + } + const el = elRef.current + if (!el) return + + if (animateRef.current) animateRef.current.finish() + animateRef.current = el.animate( + [ + { + filter: 'blur(5px)', + }, + { + top: `${position}%`, + filter: 'blur(0px)', + }, + ], + { + duration: 200, + fill: 'forwards', + easing: 'ease-in-out', + }, + ) + }, [isCurrent, position]) + const { y } = useWrappedElementPosition() + const { h } = useWrappedElementSize() + + useImperativeHandle(ref, () => elRef.current!) + return ( +
-
- +
+
diff --git a/src/components/modules/comment/CommentBox/CommentBoxLegacyForm.tsx b/src/components/modules/comment/CommentBox/CommentBoxLegacyForm.tsx index 488fe6068f..3bf40a7eb6 100644 --- a/src/components/modules/comment/CommentBox/CommentBoxLegacyForm.tsx +++ b/src/components/modules/comment/CommentBox/CommentBoxLegacyForm.tsx @@ -2,9 +2,8 @@ import clsx from 'clsx' import { useAtom } from 'jotai' import Image from 'next/image' -import { useIsLogged } from '~/atoms' +import { useIsLogged } from '~/atoms/hooks' import { FormInput as FInput, Form } from '~/components/ui/form' -import { clsxm } from '~/lib/helper' import { useAggregationSelector } from '~/providers/root/aggregation-data-provider' import { CommentBoxActionBar } from './ActionBar' @@ -18,7 +17,7 @@ export const CommentBoxLegacyForm = () => { } const taClassName = - 'relative h-[150px] w-full rounded-lg bg-gray-200/50 pb-5 dark:bg-zinc-800/50' + 'relative h-[150px] w-full rounded-lg bg-gray-200/50 dark:bg-zinc-800/50' type FormKey = 'author' | 'mail' | 'url' const placeholderMap = { author: '昵称', @@ -50,8 +49,8 @@ const FormWithUserInfo = () => {
-
- +
+
@@ -80,7 +79,7 @@ const LoggedForm = () => { />
- +
diff --git a/src/components/modules/comment/CommentBox/Root.tsx b/src/components/modules/comment/CommentBox/Root.tsx index e0e73f247a..0c6e812a45 100644 --- a/src/components/modules/comment/CommentBox/Root.tsx +++ b/src/components/modules/comment/CommentBox/Root.tsx @@ -5,7 +5,7 @@ import type { CommentBaseProps } from '../types' import { SignedIn, SignedOut } from '@clerk/nextjs' -import { useIsLogged } from '~/atoms' +import { useIsLogged } from '~/atoms/hooks' import { ErrorBoundary } from '~/components/common/ErrorBoundary' import { AutoResizeHeight } from '~/components/modules/shared/AutoResizeHeight' import { clsxm } from '~/lib/helper' diff --git a/src/components/modules/comment/CommentBox/SwitchCommentMode.tsx b/src/components/modules/comment/CommentBox/SwitchCommentMode.tsx index b823c41429..f9809d104f 100644 --- a/src/components/modules/comment/CommentBox/SwitchCommentMode.tsx +++ b/src/components/modules/comment/CommentBox/SwitchCommentMode.tsx @@ -6,7 +6,7 @@ import type { FC } from 'react' import { useUser } from '@clerk/nextjs' -import { useIsLogged } from '~/atoms' +import { useIsLogged } from '~/atoms/hooks' import { MotionButtonBase } from '~/components/ui/button' import { FloatPopover } from '~/components/ui/float-popover' diff --git a/src/components/modules/comment/CommentBox/UniversalTextArea.tsx b/src/components/modules/comment/CommentBox/UniversalTextArea.tsx index 52a56e3bf2..7a3c795b01 100644 --- a/src/components/modules/comment/CommentBox/UniversalTextArea.tsx +++ b/src/components/modules/comment/CommentBox/UniversalTextArea.tsx @@ -15,7 +15,7 @@ import { CommentBoxSlotPortal } from './providers' const EmojiPicker = dynamic(() => import('../../shared/EmojiPicker').then((mod) => mod.EmojiPicker), ) -export const UniversalTextArea = () => { +export const UniversalTextArea: Component = ({ className }) => { const placeholder = useRefValue(() => getRandomPlaceholder()) const setter = useSetCommentBoxValues() const value = useCommentBoxTextValue() @@ -63,6 +63,7 @@ export const UniversalTextArea = () => { return (