From bb7f4978e5f4b3e3f72610ba6c5ffb985a19e311 Mon Sep 17 00:00:00 2001 From: memoclaw Date: Tue, 24 Mar 2026 08:40:49 +0800 Subject: [PATCH] refactor(web): consolidate SharedMemo into MemoDetail (#5773) Co-authored-by: memoclaw <265580040+memoclaw@users.noreply.github.com> --- web/src/components/MemoView/MemoView.tsx | 2 +- .../components/MemoView/MemoViewContext.tsx | 2 +- web/src/hooks/useMemoDetailError.ts | 18 +--- web/src/hooks/useMemoShareQueries.ts | 9 ++ web/src/pages/MemoDetail.tsx | 49 ++++++++--- web/src/pages/SharedMemo.tsx | 85 ------------------- web/src/pages/SignIn.tsx | 8 +- web/src/pages/SignUp.tsx | 8 +- web/src/router/index.tsx | 4 +- 9 files changed, 55 insertions(+), 130 deletions(-) delete mode 100644 web/src/pages/SharedMemo.tsx diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index a44fdad84..f177657ea 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -38,7 +38,7 @@ const MemoView: React.FC = (props: MemoViewProps) => { const closeEditor = useCallback(() => setShowEditor(false), []); const location = useLocation(); - const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`); + const isInMemoDetailPage = location.pathname.startsWith(`/${memoData.name}`) || location.pathname.startsWith("/memos/shares/"); const showCommentPreview = !isInMemoDetailPage && computeCommentAmount(memoData) > 0; const contextValue = useMemo( diff --git a/web/src/components/MemoView/MemoViewContext.tsx b/web/src/components/MemoView/MemoViewContext.tsx index c9df07c96..d9c29ca2f 100644 --- a/web/src/components/MemoView/MemoViewContext.tsx +++ b/web/src/components/MemoView/MemoViewContext.tsx @@ -37,7 +37,7 @@ export const useMemoViewDerived = () => { const { memo, isArchived, readonly } = useMemoViewContext(); const location = useLocation(); - const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`); + const isInMemoDetailPage = location.pathname.startsWith(`/${memo.name}`) || location.pathname.startsWith("/memos/shares/"); const commentAmount = computeCommentAmount(memo); const displayTime = memo.displayTime ? timestampDate(memo.displayTime) : undefined; diff --git a/web/src/hooks/useMemoDetailError.ts b/web/src/hooks/useMemoDetailError.ts index f1f6b7366..15a4ef291 100644 --- a/web/src/hooks/useMemoDetailError.ts +++ b/web/src/hooks/useMemoDetailError.ts @@ -2,16 +2,12 @@ import { Code, ConnectError } from "@connectrpc/connect"; import { useEffect } from "react"; import { toast } from "react-hot-toast"; import useNavigateTo from "@/hooks/useNavigateTo"; -import { AUTH_REASON_PROTECTED_MEMO, redirectOnAuthFailure } from "@/utils/auth-redirect"; interface UseMemoDetailErrorOptions { error: Error | null; - pathname: string; - search: string; - hash: string; } -const useMemoDetailError = ({ error, pathname, search, hash }: UseMemoDetailErrorOptions) => { +const useMemoDetailError = ({ error }: UseMemoDetailErrorOptions) => { const navigateTo = useNavigateTo(); useEffect(() => { @@ -20,15 +16,7 @@ const useMemoDetailError = ({ error, pathname, search, hash }: UseMemoDetailErro } if (error instanceof ConnectError) { - if (error.code === Code.Unauthenticated) { - redirectOnAuthFailure(true, { - redirect: `${pathname}${search}${hash}`, - reason: AUTH_REASON_PROTECTED_MEMO, - }); - return; - } - - if (error.code === Code.PermissionDenied || error.code === Code.NotFound) { + if (error.code === Code.Unauthenticated || error.code === Code.PermissionDenied || error.code === Code.NotFound) { navigateTo("/404", { replace: true }); return; } @@ -38,7 +26,7 @@ const useMemoDetailError = ({ error, pathname, search, hash }: UseMemoDetailErro } toast.error(error.message); - }, [error, hash, pathname, search, navigateTo]); + }, [error, navigateTo]); }; export default useMemoDetailError; diff --git a/web/src/hooks/useMemoShareQueries.ts b/web/src/hooks/useMemoShareQueries.ts index 05720652d..24d4229bd 100644 --- a/web/src/hooks/useMemoShareQueries.ts +++ b/web/src/hooks/useMemoShareQueries.ts @@ -2,6 +2,7 @@ import { create } from "@bufbuild/protobuf"; import { timestampFromDate } from "@bufbuild/protobuf/wkt"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { memoServiceClient } from "@/connect"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { MemoShare } from "@/types/proto/api/v1/memo_service_pb"; import { CreateMemoShareRequestSchema, @@ -90,3 +91,11 @@ export function getShareUrl(share: MemoShare): string { export function getShareToken(share: MemoShare): string { return share.name.split("/").pop() ?? ""; } + +/** Rewrites attachment URLs to include a share token for unauthenticated access. */ +export function withShareAttachmentLinks(attachments: Attachment[], token: string): Attachment[] { + return attachments.map((a) => { + if (a.externalLink) return a; + return { ...a, externalLink: `${window.location.origin}/file/${a.name}/${a.filename}?share_token=${encodeURIComponent(token)}` }; + }); +} diff --git a/web/src/pages/MemoDetail.tsx b/web/src/pages/MemoDetail.tsx index d346afcdd..9f1776beb 100644 --- a/web/src/pages/MemoDetail.tsx +++ b/web/src/pages/MemoDetail.tsx @@ -1,6 +1,7 @@ +import { Code, ConnectError } from "@connectrpc/connect"; import { ArrowUpLeftFromCircleIcon } from "lucide-react"; import { useEffect } from "react"; -import { Link, useLocation, useParams } from "react-router-dom"; +import { Link, Navigate, useLocation, useParams } from "react-router-dom"; import MemoCommentSection from "@/components/MemoCommentSection"; import { MemoDetailSidebar, MemoDetailSidebarDrawer } from "@/components/MemoDetailSidebar"; import MemoView from "@/components/MemoView"; @@ -9,22 +10,36 @@ import { memoNamePrefix } from "@/helpers/resource-names"; import useMediaQuery from "@/hooks/useMediaQuery"; import useMemoDetailError from "@/hooks/useMemoDetailError"; import { useMemo, useMemoComments } from "@/hooks/useMemoQueries"; +import { useSharedMemo, withShareAttachmentLinks } from "@/hooks/useMemoShareQueries"; import { cn } from "@/lib/utils"; +import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; const MemoDetail = () => { const md = useMediaQuery("md"); const params = useParams(); const location = useLocation(); const { state: locationState, hash } = location; - const memoName = `${memoNamePrefix}${params.uid}`; - const { data: memo, error, isLoading } = useMemo(memoName, { enabled: !!memoName }); + // Detect share mode from the route parameter. + const shareToken = params.token; + const isShareMode = !!shareToken; + + // Primary memo fetch — share token or direct name. + const memoNameFromParams = params.uid ? `${memoNamePrefix}${params.uid}` : ""; + const { + data: memoFromDirect, + error: directError, + isLoading: directLoading, + } = useMemo(memoNameFromParams, { enabled: !isShareMode && !!memoNameFromParams }); + const { data: memoFromShare, error: shareError, isLoading: shareLoading } = useSharedMemo(shareToken ?? "", { enabled: isShareMode }); + + const memo = isShareMode ? memoFromShare : memoFromDirect; + const error = isShareMode ? shareError : directError; + const isLoading = isShareMode ? shareLoading : directLoading; + const memoName = memo?.name ?? memoNameFromParams; useMemoDetailError({ error: error as Error | null, - pathname: location.pathname, - search: location.search, - hash: location.hash, }); const { data: parentMemo } = useMemo(memo?.parent || "", { @@ -42,15 +57,27 @@ const MemoDetail = () => { el?.scrollIntoView({ behavior: "smooth", block: "center" }); }, [hash, comments]); + if (isShareMode) { + const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated); + if (isNotFound || (!isLoading && !memo)) { + return ; + } + } + if (isLoading || !memo) { return null; } + // In share mode, rewrite attachment URLs to include the share token for unauthenticated access. + const displayMemo = isShareMode + ? { ...memo, attachments: withShareAttachmentLinks(memo.attachments as Attachment[], shareToken!) } + : memo; + return (
{!md && ( - + )}
@@ -69,19 +96,19 @@ const MemoDetail = () => {
)} - + {md && (
- +
)} diff --git a/web/src/pages/SharedMemo.tsx b/web/src/pages/SharedMemo.tsx deleted file mode 100644 index 1cdb92ea6..000000000 --- a/web/src/pages/SharedMemo.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import type { Timestamp } from "@bufbuild/protobuf/wkt"; -import { timestampDate } from "@bufbuild/protobuf/wkt"; -import { Code, ConnectError } from "@connectrpc/connect"; -import { AlertCircleIcon } from "lucide-react"; -import { useParams } from "react-router-dom"; -import MemoContent from "@/components/MemoContent"; -import { AttachmentListView } from "@/components/MemoMetadata"; -import { useImagePreview } from "@/components/MemoView/hooks"; -import PreviewImageDialog from "@/components/PreviewImageDialog"; -import UserAvatar from "@/components/UserAvatar"; -import { useSharedMemo } from "@/hooks/useMemoShareQueries"; -import { useUser } from "@/hooks/useUserQueries"; -import i18n from "@/i18n"; -import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; -import { useTranslate } from "@/utils/i18n"; - -function withShareAttachmentLinks(attachments: Attachment[], token: string): Attachment[] { - return attachments.map((a) => { - if (a.externalLink) return a; - return { ...a, externalLink: `${window.location.origin}/file/${a.name}/${a.filename}?share_token=${encodeURIComponent(token)}` }; - }); -} - -const SharedMemo = () => { - const t = useTranslate(); - const { token = "" } = useParams<{ token: string }>(); - const { previewState, openPreview, setPreviewOpen } = useImagePreview(); - - const { data: memo, error, isLoading } = useSharedMemo(token, { enabled: !!token }); - const { data: creator } = useUser(memo?.creator ?? "", { enabled: !!memo?.creator }); - - const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated); - - if (isLoading) { - return ( -
-
-
- ); - } - - if (isNotFound || (!isLoading && !memo)) { - return ( -
- -

{t("memo.share.invalid-link")}

-
- ); - } - - if (error || !memo) return null; - - const displayDate = (memo.displayTime as Timestamp | undefined) - ? timestampDate(memo.displayTime as Timestamp)?.toLocaleString(i18n.language) - : null; - - return ( -
- {/* Creator + date above the card */} -
-
- - {creator?.displayName || creator?.username || memo.creator} -
- {displayDate && {displayDate}} -
- -
- - {memo.attachments.length > 0 && ( - - )} -
- - -
- ); -}; - -export default SharedMemo; diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/SignIn.tsx index 8a38d71bd..de7e9f28f 100644 --- a/web/src/pages/SignIn.tsx +++ b/web/src/pages/SignIn.tsx @@ -12,7 +12,7 @@ import useCurrentUser from "@/hooks/useCurrentUser"; import { handleError } from "@/lib/error"; import { ROUTES } from "@/router/routes"; import { IdentityProvider, IdentityProvider_Type } from "@/types/proto/api/v1/idp_service_pb"; -import { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; +import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; import { storeOAuthState } from "@/utils/oauth"; @@ -23,7 +23,6 @@ const SignIn = () => { const { generalSetting: instanceGeneralSetting } = useInstance(); const [searchParams] = useSearchParams(); const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); - const authReason = searchParams.get(AUTH_REASON_PARAM); const signUpPath = searchParams.toString() ? `${ROUTES.AUTH}/signup?${searchParams.toString()}` : `${ROUTES.AUTH}/signup`; // Redirect to root page if already signed in. @@ -87,11 +86,6 @@ const SignIn = () => {

{instanceGeneralSetting.customProfile?.title || "Memos"}

- {authReason === AUTH_REASON_PROTECTED_MEMO && ( -
- {t("auth.protected-memo-notice")} -
- )} {!instanceGeneralSetting.disallowPasswordAuth ? ( ) : ( diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/SignUp.tsx index cf02471c9..9fd6b771f 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/SignUp.tsx @@ -16,7 +16,7 @@ import useNavigateTo from "@/hooks/useNavigateTo"; import { handleError } from "@/lib/error"; import { ROUTES } from "@/router/routes"; import { User_Role, UserSchema } from "@/types/proto/api/v1/user_service_pb"; -import { AUTH_REASON_PARAM, AUTH_REASON_PROTECTED_MEMO, AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; +import { AUTH_REDIRECT_PARAM, getSafeRedirectPath } from "@/utils/auth-redirect"; import { useTranslate } from "@/utils/i18n"; const SignUp = () => { @@ -29,7 +29,6 @@ const SignUp = () => { const { generalSetting: instanceGeneralSetting, profile, initialize: initInstance } = useInstance(); const [searchParams] = useSearchParams(); const redirectTarget = getSafeRedirectPath(searchParams.get(AUTH_REDIRECT_PARAM)); - const authReason = searchParams.get(AUTH_REASON_PARAM); const signInPath = searchParams.toString() ? `${ROUTES.AUTH}?${searchParams.toString()}` : ROUTES.AUTH; const handleUsernameInputChanged = (e: React.ChangeEvent) => { @@ -94,11 +93,6 @@ const SignUp = () => {

{instanceGeneralSetting.customProfile?.title || "Memos"}

- {authReason === AUTH_REASON_PROTECTED_MEMO && ( -
- {t("auth.protected-memo-notice")} -
- )} {!instanceGeneralSetting.disallowUserRegistration ? ( <>

{t("auth.create-your-account")}

diff --git a/web/src/router/index.tsx b/web/src/router/index.tsx index 7bdffc6fe..7375e1e68 100644 --- a/web/src/router/index.tsx +++ b/web/src/router/index.tsx @@ -33,7 +33,6 @@ const NotFound = lazyWithReload(() => import("@/pages/NotFound")); const PermissionDenied = lazyWithReload(() => import("@/pages/PermissionDenied")); const Attachments = lazyWithReload(() => import("@/pages/Attachments")); const Setting = lazyWithReload(() => import("@/pages/Setting")); -const SharedMemo = lazyWithReload(() => import("@/pages/SharedMemo")); const SignIn = lazyWithReload(() => import("@/pages/SignIn")); const SignUp = lazyWithReload(() => import("@/pages/SignUp")); const UserProfile = lazyWithReload(() => import("@/pages/UserProfile")); @@ -76,13 +75,12 @@ const router = createBrowserRouter([ { path: Routes.INBOX, element: }, { path: Routes.SETTING, element: }, { path: "memos/:uid", element: }, + { path: "memos/shares/:token", element: }, { path: "403", element: }, { path: "404", element: }, { path: "*", element: }, ], }, - // Public share-link viewer — outside RootLayout to bypass auth-gating - { path: "memos/shares/:token", element: }, ], }, ]);