feat(memos): support moving memos between spaces (#6285)

This commit is contained in:
Johnny 2026-09-07 20:45:59 +08:00 committed by GitHub
parent 607dc28413
commit 2d75439ecc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 946 additions and 117 deletions

View file

@ -93,8 +93,8 @@ Distribution is derived rather than separately configured:
- A Space feed first requires active membership, then returns readable memos assigned to that Space that do not have a `COMMENT` relation. Membership does not make another author's assigned `PRIVATE` memo readable.
- Direct memo reads evaluate each memo independently, including comments.
- A comment or conversation query requires its context memo to be readable, then filters every replying memo by that memo's own audience.
- A `COMMENT` or `REFERENCE` relation and its snippet are returned only when both endpoints are readable.
- Reactions are readable whenever their memo is readable.
- A `COMMENT` or `REFERENCE` relation and its snippet are returned only when both endpoints are readable. A readable comment's `parent` field still identifies its context memo; this identity does not grant access. Clients fetch it independently and render an unavailable context on permission denial or not-found. Bearer-share responses omit this field.
- Reactions are readable whenever their memo is readable. An active reaction creator can withdraw their own reaction even after losing memo access or Space membership.
- Public profiles and other public surfaces continue to use `PUBLIC`, not Space placement.
### Participation and governance

View file

@ -258,8 +258,9 @@ message Memo {
// Output only. The computed properties of the memo.
Property property = 15 [(google.api.field_behavior) = OUTPUT_ONLY];
// Output only. The readable context memo of this COMMENT relation, if any.
// This is omitted unless the caller may independently read both memos.
// Output only. The context memo of this COMMENT relation, if any.
// Its identity is returned even when the caller cannot read the parent.
// Fetch the parent independently; this field does not grant read access.
// Format: memos/{memo}
optional string parent = 16 [
(google.api.field_behavior) = OUTPUT_ONLY,

View file

@ -246,8 +246,9 @@ type Memo struct {
Reactions []*Reaction `protobuf:"bytes,14,rep,name=reactions,proto3" json:"reactions,omitempty"`
// Output only. The computed properties of the memo.
Property *Memo_Property `protobuf:"bytes,15,opt,name=property,proto3" json:"property,omitempty"`
// Output only. The readable context memo of this COMMENT relation, if any.
// This is omitted unless the caller may independently read both memos.
// Output only. The context memo of this COMMENT relation, if any.
// Its identity is returned even when the caller cannot read the parent.
// Fetch the parent independently; this field does not grant read access.
// Format: memos/{memo}
Parent *string `protobuf:"bytes,16,opt,name=parent,proto3,oneof" json:"parent,omitempty"`
// Output only. The snippet of the memo content. Plain text only.

View file

@ -3997,8 +3997,9 @@ components:
readOnly: true
type: string
description: |-
Output only. The readable context memo of this COMMENT relation, if any.
This is omitted unless the caller may independently read both memos.
Output only. The context memo of this COMMENT relation, if any.
Its identity is returned even when the caller cannot read the parent.
Fetch the parent independently; this field does not grant read access.
Format: memos/{memo}
snippet:
readOnly: true

View file

@ -66,15 +66,11 @@ func (s *APIV1Service) convertMemoFromStoreWithCreators(ctx context.Context, mem
memoMessage.Location = convertLocationFromStore(memo.Payload.Location)
}
// Parent identity is part of a readable comment's context. It grants no
// access to the parent; clients resolve it independently and handle denial.
if memo.ParentUID != nil {
contextMemo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: memo.ParentUID})
if err != nil {
return nil, errors.Wrap(err, "failed to resolve comment context")
}
if contextMemo != nil && s.checkMemoReadAccess(ctx, contextMemo) == nil {
parentName := buildMemoName(*memo.ParentUID)
memoMessage.Parent = &parentName
}
parentName := buildMemoName(*memo.ParentUID)
memoMessage.Parent = &parentName
}
// Reactions have no independent audience and are readable whenever this

View file

@ -0,0 +1,112 @@
package test
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/fieldmaskpb"
apiv1 "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/store"
)
func TestMoveMemoPreservesIndependentConversation(t *testing.T) {
ctx := context.Background()
ts := NewTestService(t)
defer ts.Cleanup()
author, err := ts.CreateRegularUser(ctx, "move-author")
require.NoError(t, err)
commenter, err := ts.CreateRegularUser(ctx, "move-commenter")
require.NoError(t, err)
reader, err := ts.CreateRegularUser(ctx, "move-reader")
require.NoError(t, err)
authorCtx := ts.CreateUserContext(ctx, author.ID)
commenterCtx := ts.CreateUserContext(ctx, commenter.ID)
readerCtx := ts.CreateUserContext(ctx, reader.ID)
source, err := ts.Store.CreateSpace(ctx, &store.Space{UID: "move-source", Title: "Source"}, author.ID)
require.NoError(t, err)
target, err := ts.Store.CreateSpace(ctx, &store.Space{UID: "move-target", Title: "Target"}, author.ID)
require.NoError(t, err)
for _, member := range []*store.SpaceMember{
{SpaceID: source.ID, UserID: commenter.ID, Role: store.SpaceMemberRoleUser},
{SpaceID: target.ID, UserID: reader.ID, Role: store.SpaceMemberRoleUser},
} {
_, err := ts.InviteAndAcceptSpaceMember(ctx, member, author.ID)
require.NoError(t, err)
}
sourceName, targetName := "spaces/"+source.UID, "spaces/"+target.UID
reference, err := ts.Service.CreateMemo(authorCtx, &apiv1.CreateMemoRequest{Memo: &apiv1.Memo{
Content: "source reference", Space: &sourceName, Visibility: apiv1.Visibility_SPACE,
}})
require.NoError(t, err)
attachment, err := ts.Service.CreateAttachment(authorCtx, &apiv1.CreateAttachmentRequest{
Attachment: &apiv1.Attachment{Filename: "proposal.txt", Type: "text/plain", Content: []byte("proposal attachment")},
})
require.NoError(t, err)
original, err := ts.Service.CreateMemo(authorCtx, &apiv1.CreateMemoRequest{Memo: &apiv1.Memo{
Content: "original memo", Space: &sourceName, Visibility: apiv1.Visibility_SPACE,
Attachments: []*apiv1.Attachment{attachment},
Relations: []*apiv1.MemoRelation{{Type: apiv1.MemoRelation_REFERENCE, RelatedMemo: &apiv1.MemoRelation_Memo{Name: reference.Name}}},
}})
require.NoError(t, err)
comment, err := ts.Service.CreateMemoComment(commenterCtx, &apiv1.CreateMemoCommentRequest{
Name: original.Name, Comment: &apiv1.Memo{Content: "source comment", Space: &sourceName, Visibility: apiv1.Visibility_SPACE},
})
require.NoError(t, err)
reaction, err := ts.Service.UpsertMemoReaction(commenterCtx, &apiv1.UpsertMemoReactionRequest{
Name: original.Name, Reaction: &apiv1.Reaction{ReactionType: "👍"},
})
require.NoError(t, err)
moved, err := ts.Service.UpdateMemo(authorCtx, &apiv1.UpdateMemoRequest{
Memo: &apiv1.Memo{Name: original.Name, Space: &targetName, Visibility: apiv1.Visibility_SPACE},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"space", "visibility"}},
})
require.NoError(t, err)
require.Equal(t, original.Name, moved.Name)
require.Equal(t, original.Creator, moved.Creator)
require.Equal(t, original.CreateTime, moved.CreateTime)
require.Equal(t, original.UpdateTime, moved.UpdateTime)
require.Equal(t, original.Content, moved.Content)
require.Len(t, moved.Attachments, 1)
require.Equal(t, original.Attachments, moved.Attachments)
require.Len(t, moved.Relations, 2, "the author in both Spaces retains references and comments")
require.Len(t, moved.Reactions, 1)
require.Equal(t, reaction.Name, moved.Reactions[0].Name)
_, err = ts.Service.GetMemo(commenterCtx, &apiv1.GetMemoRequest{Name: original.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err))
readableComment, err := ts.Service.GetMemo(commenterCtx, &apiv1.GetMemoRequest{Name: comment.Name})
require.NoError(t, err)
require.Equal(t, original.Name, readableComment.GetParent(), "parent identity survives lost parent access")
require.Equal(t, sourceName, readableComment.GetSpace())
require.Equal(t, comment.Visibility, readableComment.Visibility)
require.Equal(t, comment.Content, readableComment.Content)
require.Empty(t, readableComment.Relations, "the inaccessible parent must not appear in relation previews")
for _, viewer := range []context.Context{authorCtx, readerCtx} {
comments, err := ts.Service.ListMemoComments(viewer, &apiv1.ListMemoCommentsRequest{Name: original.Name})
require.NoError(t, err)
if viewer == authorCtx {
require.Len(t, comments.Memos, 1)
} else {
require.Empty(t, comments.Memos)
}
}
destinationMemo, err := ts.Service.GetMemo(readerCtx, &apiv1.GetMemoRequest{Name: original.Name})
require.NoError(t, err)
require.Empty(t, destinationMemo.Relations)
require.Len(t, destinationMemo.Reactions, 1, "existing reactions are visible to new readers")
_, err = ts.Service.GetMemo(readerCtx, &apiv1.GetMemoRequest{Name: comment.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err))
_, err = ts.Service.DeleteMemoReaction(readerCtx, &apiv1.DeleteMemoReactionRequest{Name: reaction.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err))
_, err = ts.Service.DeleteMemoReaction(commenterCtx, &apiv1.DeleteMemoReactionRequest{Name: reaction.Name})
require.NoError(t, err, "the creator can withdraw a reaction without parent access")
reactions, err := ts.Service.ListMemoReactions(readerCtx, &apiv1.ListMemoReactionsRequest{Name: original.Name})
require.NoError(t, err)
require.Empty(t, reactions.Reactions)
}

View file

@ -1051,11 +1051,11 @@ func TestGetMemoCommentUsesMemoLocalReadAccessAndConcealsContext(t *testing.T) {
commentName := createdComment.Name
publicComment, err := ts.Service.GetMemo(ctx, &apiv1.GetMemoRequest{Name: commentName})
require.NoError(t, err)
require.Empty(t, publicComment.GetParent(), "unreadable context must not be exposed")
require.Equal(t, parent.Name, publicComment.GetParent(), "parent identity remains available without granting access")
otherComment, err := ts.Service.GetMemo(otherCtx, &apiv1.GetMemoRequest{Name: commentName})
require.NoError(t, err)
require.Empty(t, otherComment.GetParent(), "relation context requires both endpoints to be readable")
require.Equal(t, parent.Name, otherComment.GetParent(), "parent identity is independent of parent readability")
comment, err := ts.Service.GetMemo(ownerCtx, &apiv1.GetMemoRequest{Name: commentName})
require.NoError(t, err)

View file

@ -149,7 +149,7 @@ func TestUpsertMemoReactionRevalidatesSpaceParticipation(t *testing.T) {
require.NoError(t, ts.Store.DeleteSpaceMember(ctx, &store.DeleteSpaceMember{SpaceID: space.ID, UserID: member.ID}, owner.ID))
_, err = ts.Service.DeleteMemoReaction(memberCtx, &apiv1.DeleteMemoReactionRequest{Name: memberReaction.Name})
require.Equal(t, codes.PermissionDenied, status.Code(err))
require.NoError(t, err, "the creator may withdraw a reaction after leaving")
_, err = ts.Service.UpsertMemoReaction(memberCtx, &apiv1.UpsertMemoReactionRequest{
Name: memoName,
Reaction: &apiv1.Reaction{ReactionType: "🔥"},
@ -162,8 +162,7 @@ func TestUpsertMemoReactionRevalidatesSpaceParticipation(t *testing.T) {
Role: store.SpaceMemberRoleUser,
}, owner.ID)
require.NoError(t, err)
_, err = ts.Service.DeleteMemoReaction(memberCtx, &apiv1.DeleteMemoReactionRequest{Name: memberReaction.Name})
require.NoError(t, err)
_, err = ts.Service.UpsertMemoReaction(memberCtx, &apiv1.UpsertMemoReactionRequest{
Name: memoName,
Reaction: &apiv1.Reaction{ReactionType: "🔥"},

View file

@ -157,7 +157,7 @@ func (d *DB) deleteReactionAsCreator(ctx context.Context, delete *store.DeleteRe
}
defer func() { _ = tx.Rollback() }()
if delete.Policy != nil {
if err := validateMySQLReactionWritePolicy(ctx, tx, &store.Reaction{
if err := validateMySQLReactionDeletePolicy(ctx, tx, &store.Reaction{
CreatorID: *delete.ActorUserID,
MemoID: *delete.MemoID,
Policy: delete.Policy,

View file

@ -21,6 +21,18 @@ func validateMySQLReactionWritePolicy(ctx context.Context, tx *sql.Tx, reaction
return store.ValidateReactionWriteParticipation(reaction, participation)
}
func validateMySQLReactionDeletePolicy(ctx context.Context, tx *sql.Tx, reaction *store.Reaction) error {
policy := reaction.Policy
participation, err := loadMySQLMemoParticipation(ctx, tx, reaction.MemoID, policy.ActorUserID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return store.ErrReactionMemoNotFound
}
return errors.Wrap(err, "failed to read reaction participation")
}
return store.ValidateReactionWithdrawal(reaction, participation)
}
func mysqlSpaceMemberActive(ctx context.Context, tx *sql.Tx, spaceID, userID int32) (bool, error) {
var role store.SpaceMemberRole
err := tx.QueryRowContext(ctx, `SELECT role FROM space_member

View file

@ -156,7 +156,7 @@ func (d *DB) deleteReactionAsCreator(ctx context.Context, delete *store.DeleteRe
}
defer func() { _ = tx.Rollback() }()
if delete.Policy != nil {
if err := validatePostgresReactionWritePolicy(ctx, tx, &store.Reaction{
if err := validatePostgresReactionDeletePolicy(ctx, tx, &store.Reaction{
CreatorID: *delete.ActorUserID,
MemoID: *delete.MemoID,
Policy: delete.Policy,

View file

@ -20,3 +20,15 @@ func validatePostgresReactionWritePolicy(ctx context.Context, tx *sql.Tx, reacti
}
return store.ValidateReactionWriteParticipation(reaction, participation)
}
func validatePostgresReactionDeletePolicy(ctx context.Context, tx *sql.Tx, reaction *store.Reaction) error {
policy := reaction.Policy
participation, err := readPostgresMemoParticipation(ctx, tx, reaction.MemoID, policy.ActorUserID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return store.ErrReactionMemoNotFound
}
return errors.Wrap(err, "failed to read reaction participation")
}
return store.ValidateReactionWithdrawal(reaction, participation)
}

View file

@ -154,7 +154,7 @@ func (d *DB) deleteReactionAsCreator(ctx context.Context, delete *store.DeleteRe
}
defer func() { _ = tx.Rollback() }()
if delete.Policy != nil {
if err := validateSQLiteReactionWritePolicy(ctx, tx, &store.Reaction{
if err := validateSQLiteReactionDeletePolicy(ctx, tx, &store.Reaction{
CreatorID: *delete.ActorUserID,
MemoID: *delete.MemoID,
Policy: delete.Policy,

View file

@ -21,6 +21,18 @@ func validateSQLiteReactionWritePolicy(ctx context.Context, tx dbExecutor, react
return store.ValidateReactionWriteParticipation(reaction, participation)
}
func validateSQLiteReactionDeletePolicy(ctx context.Context, tx dbExecutor, reaction *store.Reaction) error {
policy := reaction.Policy
participation, err := loadSQLiteMemoParticipation(ctx, tx, reaction.MemoID, policy.ActorUserID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return store.ErrReactionMemoNotFound
}
return errors.Wrap(err, "failed to read reaction participation")
}
return store.ValidateReactionWithdrawal(reaction, participation)
}
func sqliteSpaceMemberActive(ctx context.Context, tx dbExecutor, spaceID, userID int32) (bool, error) {
var role store.SpaceMemberRole
err := tx.QueryRowContext(ctx, `SELECT role FROM space_member

View file

@ -25,6 +25,16 @@ func validateReactionWritePolicy(reaction *Reaction) error {
// ValidateReactionWriteParticipation applies the reaction actor identity and
// memo-local participation rules to state loaded by the write transaction.
func ValidateReactionWriteParticipation(reaction *Reaction, snapshot *MemoCommentAuthorizationSnapshot) error {
if err := ValidateReactionWithdrawal(reaction, snapshot); err != nil {
return err
}
return ValidateMemoCommentAuthorization(snapshot)
}
// ValidateReactionWithdrawal allows an active actor to withdraw their own reaction
// without requiring continued access to or participation in the memo. The driver
// also verifies ownership of the stored reaction in the same transaction.
func ValidateReactionWithdrawal(reaction *Reaction, snapshot *MemoCommentAuthorizationSnapshot) error {
if err := validateReactionWritePolicy(reaction); err != nil {
return err
}
@ -40,5 +50,5 @@ func ValidateReactionWriteParticipation(reaction *Reaction, snapshot *MemoCommen
if snapshot.ContextID != reaction.MemoID {
return ErrMemoMutationConflict
}
return ValidateMemoCommentAuthorization(snapshot)
return nil
}

View file

@ -136,7 +136,7 @@ func TestReactionWritePolicyRejectsInactiveActorAndMemo(t *testing.T) {
require.ErrorIs(t, err, store.ErrMemoSpaceNotWritable)
}
func TestDeleteReactionAtomicallyEnforcesCreatorAndParticipation(t *testing.T) {
func TestDeleteReactionAtomicallyEnforcesCreatorWithoutParticipation(t *testing.T) {
ctx := context.Background()
ts := NewTestingStore(ctx, t)
defer ts.Close()
@ -168,14 +168,8 @@ func TestDeleteReactionAtomicallyEnforcesCreatorAndParticipation(t *testing.T) {
err = ts.DeleteReaction(ctx, &store.DeleteReaction{
ID: &reaction.ID, MemoID: &memo.ID, ActorUserID: &member.ID, Policy: reactionWritePolicy(member.ID),
})
require.ErrorIs(t, err, store.ErrMemoSpaceMembershipRequired)
requireReactionPresent(ctx, t, ts, reaction.ID)
require.NoError(t, err, "creators can withdraw reactions after leaving the Space")
_, err = createSpaceMemberForTest(ctx, ts, &store.SpaceMember{SpaceID: space.ID, UserID: member.ID, Role: store.SpaceMemberRoleUser}, owner.ID)
require.NoError(t, err)
require.NoError(t, ts.DeleteReaction(ctx, &store.DeleteReaction{
ID: &reaction.ID, MemoID: &memo.ID, ActorUserID: &member.ID, Policy: reactionWritePolicy(member.ID),
}))
requireReactionMissing(ctx, t, ts, reaction.ID)
}

View file

@ -269,6 +269,8 @@ const MemoDetailSidebarContent = () => {
<MemoDetailSidebar
memo={memoDetail.memo}
parentMemo={memoDetail.parentMemo}
parentStatus={memoDetail.parentStatus}
onParentRetry={memoDetail.onParentRetry}
parentPage={memoDetail.from}
hasExplicitOrigin={memoDetail.hasExplicitOrigin}
commentCount={memoDetail.commentCount}

View file

@ -7,9 +7,11 @@ import {
CopyIcon,
Edit3Icon,
FileTextIcon,
FolderInputIcon,
LinkIcon,
ListChecksIcon,
ListRestartIcon,
MoreHorizontalIcon,
MoreVerticalIcon,
TrashIcon,
} from "lucide-react";
@ -28,6 +30,7 @@ import {
import { State } from "@/types/proto/api/v1/common_pb";
import { useTranslate } from "@/utils/i18n";
import { useMemoActionHandlers } from "./hooks";
import MemoMoveDialog from "./MemoMoveDialog";
import type { MemoActionMenuProps } from "./types";
const MemoActionMenu = (props: MemoActionMenuProps) => {
@ -36,6 +39,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
// Dialog state
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
// Derived state
const isComment = Boolean(memo.parent);
@ -45,6 +49,7 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
// Action handlers
const {
canMove,
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,
@ -123,26 +128,39 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
</DropdownMenuSub>
)}
{/* Write actions (non-readonly) */}
{!readonly && (
<>
{/* Archive/Restore (non-comment) */}
{!isComment && (
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
{isArchived ? t("common.restore") : t("common.archive")}
</DropdownMenuItem>
)}
{!readonly && !isComment && (
<DropdownMenuItem onClick={handleToggleMemoStatusClick}>
{isArchived ? <ArchiveRestoreIcon className="w-4 h-auto" /> : <ArchiveIcon className="w-4 h-auto" />}
{isArchived ? t("common.restore") : t("common.archive")}
</DropdownMenuItem>
)}
{/* Delete */}
<DropdownMenuItem onClick={handleDeleteMemoClick}>
<TrashIcon className="w-4 h-auto" />
{t("common.delete")}
</DropdownMenuItem>
</>
{(canMove || !readonly) && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<MoreHorizontalIcon className="size-4" />
{t("common.more")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{canMove && (
<DropdownMenuItem onClick={() => setMoveDialogOpen(true)}>
<FolderInputIcon className="size-4" />
{t("memo.move.title")}
</DropdownMenuItem>
)}
{!readonly && (
<DropdownMenuItem onClick={handleDeleteMemoClick}>
<TrashIcon className="size-4" />
{t("common.delete")}
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
{moveDialogOpen && <MemoMoveDialog memo={memo} onOpenChange={setMoveDialogOpen} />}
{/* Delete confirmation dialog */}
<ConfirmDialog
open={deleteDialogOpen}

View file

@ -0,0 +1,141 @@
import { type FormEvent, useState } from "react";
import { toast } from "react-hot-toast";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import useCurrentUser from "@/hooks/useCurrentUser";
import { useUpdateMemo } from "@/hooks/useMemoQueries";
import { useSpaces } from "@/hooks/useSpaceQueries";
import { getErrorMessage } from "@/lib/error";
import { extractSpaceUidFromName, getDuplicateSpaceTitles } from "@/lib/space-display";
import { type Memo, Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { useTranslate } from "@/utils/i18n";
import { getAssignableVisibilityOptions, getVisibilityOption } from "@/utils/memo";
const UNASSIGNED = "unassigned";
export default function MemoMoveDialog({ memo, onOpenChange }: { memo: Memo; onOpenChange: (open: boolean) => void }) {
const t = useTranslate();
const currentUser = useCurrentUser();
const { data: spaces = [], isPending: loadingSpaces, error: spacesError, refetch } = useSpaces(currentUser?.name);
const { mutateAsync: updateMemo, isPending } = useUpdateMemo();
const [destination, setDestination] = useState(memo.space || UNASSIGNED);
const [visibility, setVisibility] = useState(memo.visibility);
const [error, setError] = useState("");
const nextSpace = destination === UNASSIGNED ? undefined : destination;
const duplicateTitles = getDuplicateSpaceTitles(spaces);
const spaceLabel = (space: (typeof spaces)[number]) =>
duplicateTitles.has(space.title) ? `${space.title} (${extractSpaceUidFromName(space.name)})` : space.title;
const selectedSpace = spaces.find((space) => space.name === destination);
const destinationLabel = nextSpace
? selectedSpace
? spaceLabel(selectedSpace)
: extractSpaceUidFromName(nextSpace)
: t("memo.move.unassigned");
const visibilityOption = getVisibilityOption(visibility);
const canSubmit =
currentUser?.name === memo.creator && (memo.space || undefined) !== nextSpace && (!nextSpace || !!selectedSpace) && !isPending;
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!canSubmit) return;
setError("");
try {
await updateMemo({
update: { name: memo.name, space: nextSpace ?? "", visibility },
updateMask: memo.visibility === Visibility.SPACE || visibility !== memo.visibility ? ["space", "visibility"] : ["space"],
});
toast.success(t("memo.move.success"));
onOpenChange(false);
} catch (error) {
setError(getErrorMessage(error));
void refetch();
}
};
return (
<Dialog
open
onOpenChange={(open) => {
if (!isPending) onOpenChange(open);
}}
>
<DialogContent size="sm">
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>{t("memo.move.title")}</DialogTitle>
<DialogDescription>{t("memo.move.description")}</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="memo-destination">{t("space.current")}</Label>
<Select
value={destination}
disabled={isPending}
onValueChange={(value) => {
setDestination(value);
setError("");
if (value === UNASSIGNED && visibility === Visibility.SPACE) setVisibility(Visibility.PRIVATE);
}}
>
<SelectTrigger id="memo-destination" className="w-full">
<SelectValue>{destinationLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={UNASSIGNED}>{t("memo.move.unassigned")}</SelectItem>
{spaces.map((space) => (
<SelectItem key={space.name} value={space.name}>
{spaceLabel(space)}
</SelectItem>
))}
</SelectContent>
</Select>
{loadingSpaces && <p className="text-xs text-muted-foreground">{t("space.loading")}</p>}
{spacesError && (
<div role="alert" className="text-xs text-destructive">
{t("space.load-error")}{" "}
<Button type="button" variant="ghost" size="sm" onClick={() => void refetch()}>
{t("search.retry")}
</Button>
</div>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="memo-move-audience">{t("common.visibility")}</Label>
<Select value={String(visibility)} disabled={isPending} onValueChange={(value) => setVisibility(Number(value))}>
<SelectTrigger id="memo-move-audience" className="w-full">
<SelectValue>{visibilityOption && t(visibilityOption.labelKey)}</SelectValue>
</SelectTrigger>
<SelectContent>
{getAssignableVisibilityOptions({ hasSpacePlacement: !!nextSpace }).map((option) => (
<SelectItem key={option.value} value={String(option.value)}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs leading-5 text-muted-foreground">
{visibility === Visibility.SPACE
? t("memo.move.space-audience", { space: destinationLabel })
: visibilityOption && t(visibilityOption.descriptionKey)}
</p>
</div>
<p className="text-xs leading-5 text-muted-foreground">{t("memo.move.history")}</p>
{error && (
<p role="alert" className="text-sm text-destructive">
{error}
</p>
)}
<DialogFooter>
<Button type="button" variant="ghost" disabled={isPending} onClick={() => onOpenChange(false)}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={!canSubmit}>
{t("memo.move.confirm")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View file

@ -4,6 +4,7 @@ import { useCallback } from "react";
import toast from "react-hot-toast";
import { useLocation } from "react-router-dom";
import { useInstance } from "@/contexts/InstanceContext";
import useCurrentUser from "@/hooks/useCurrentUser";
import { memoKeys, useDeleteMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import useNavigateTo from "@/hooks/useNavigateTo";
import { userKeys } from "@/hooks/useUserQueries";
@ -25,6 +26,8 @@ interface UseMemoActionHandlersOptions {
export const useMemoActionHandlers = ({ memo, parentPage, onEdit, setDeleteDialogOpen }: UseMemoActionHandlersOptions) => {
const t = useTranslate();
const location = useLocation();
const currentUser = useCurrentUser();
const canMove = memo.creator === currentUser?.name && !location.pathname.startsWith(ROUTES.SHARED_MEMO);
const navigateTo = useNavigateTo();
const queryClient = useQueryClient();
const { profile } = useInstance();
@ -153,6 +156,7 @@ export const useMemoActionHandlers = ({ memo, parentPage, onEdit, setDeleteDialo
}, [memo.name, memo.parent, t, isInMemoDetailPage, parentPage, navigateTo, memoUpdatedCallback, deleteMemo, queryClient]);
return {
canMove,
handleTogglePinMemoBtnClick,
handleEditMemoClick,
handleToggleMemoStatusClick,

View file

@ -19,6 +19,7 @@ import SidebarSection, { SIDEBAR_SECTION_STACK_CLASSES } from "@/components/AppS
import { extractHeadings } from "@/components/MemoContent/pipeline";
import { getRelationBuckets, getRelationMemo } from "@/components/MemoMetadata/Relation/relationHelpers";
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
import MemoParentPlaceholder, { type MemoParentStatus } from "@/components/MemoParentPlaceholder";
import { createMemoNavigationState } from "@/components/MemoView/navigation";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { useInstance } from "@/contexts/InstanceContext";
@ -36,6 +37,8 @@ import MemoSharePanel from "./MemoSharePanel";
interface Props {
memo: Memo;
parentMemo?: Memo;
parentStatus?: MemoParentStatus;
onParentRetry?: () => void;
parentPage?: string;
hasExplicitOrigin?: boolean;
commentCount?: number;
@ -86,6 +89,8 @@ const BacklinkRow = ({
const MemoDetailSidebar = ({
memo,
parentMemo,
parentStatus,
onParentRetry,
parentPage,
hasExplicitOrigin = false,
commentCount,
@ -120,7 +125,7 @@ const MemoDetailSidebar = ({
? []
: referenced.flatMap((relation) => {
const relatedMemo = getRelationMemo(relation, "referenced");
return relatedMemo?.name && !relatedMemo.snippet ? [relatedMemo.name] : [];
return relatedMemo?.name ? [relatedMemo.name] : [];
}),
[forceReadonly, referenced],
);
@ -159,7 +164,7 @@ const MemoDetailSidebar = ({
const parentSnippet = parentMemo ? normalizeSnippet(parentMemo.snippet || parentMemo.content || parentMemo.name) : "";
const showComments = !forceReadonly && commentCount !== undefined && commentCount > 0;
const showOnThisMemo = headings.length > 1 || showComments;
const showConnections = !forceReadonly && (!!parentMemo || referenced.length > 0);
const showConnections = !forceReadonly && (!!parentMemo || !!parentStatus || referenced.length > 0);
const handleCopyLink = () => {
const host = (profile.instanceUrl || window.location.origin).replace(/\/+$/, "");
@ -210,7 +215,8 @@ const MemoDetailSidebar = ({
{showConnections && (
<SidebarSection label={t("memo.connections")}>
{parentMemo && (
{parentStatus && <MemoParentPlaceholder status={parentStatus} onRetry={onParentRetry} />}
{!parentStatus && parentMemo && (
<Link
aria-label={`${t("memo.parent-memo")}: ${parentSnippet}`}
className={cn(SIDEBAR_ROW_CLASSES, "text-muted-foreground hover:bg-sidebar-accent/65 hover:text-foreground")}
@ -225,6 +231,7 @@ const MemoDetailSidebar = ({
)}
{referenced.map((relation) => {
const relatedMemo = getRelationMemo(relation, "referenced");
if (relatedMemo && resolvedMemos[relatedMemo.name] === null) return null;
return (
<BacklinkRow
key={`referenced-${relatedMemo?.name}`}

View file

@ -41,10 +41,7 @@ const RelationItemCard: FC<{
const RelationListEditor: FC<RelationListEditorProps> = ({ relations, onRelationsChange, parentPage, memoName }) => {
const referenceRelations = useMemo(() => getEditorReferenceRelations(relations, memoName), [relations, memoName]);
const relatedMemoNames = useMemo(
() =>
referenceRelations.flatMap((relation) =>
relation.relatedMemo?.name && !relation.relatedMemo.snippet ? [relation.relatedMemo.name] : [],
),
() => referenceRelations.flatMap((relation) => (relation.relatedMemo?.name ? [relation.relatedMemo.name] : [])),
[referenceRelations],
);
const resolvedMemos = useResolvedRelationMemos(relatedMemoNames);
@ -68,6 +65,7 @@ const RelationListEditor: FC<RelationListEditorProps> = ({ relations, onRelation
>
{referenceRelations.map((relation) => {
const relatedMemo = relation.relatedMemo!;
if (resolvedMemos[relatedMemo.name] === null) return null;
const memo = relatedMemo.snippet ? relatedMemo : resolvedMemos[relatedMemo.name] || relatedMemo;
return <RelationItemCard key={memo.name} memo={memo} onRemove={() => handleDeleteRelation(memo.name)} parentPage={parentPage} />;
})}

View file

@ -34,7 +34,7 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
() =>
activeRelations.flatMap((relation) => {
const memo = getRelationMemo(relation, direction);
return memo?.name && !memo.snippet ? [memo.name] : [];
return memo?.name ? [memo.name] : [];
}),
[activeRelations, direction],
);
@ -75,7 +75,7 @@ function RelationListView({ relations, currentMemoName, parentPage, className }:
>
{activeRelations.map((relation) => {
const memo = getRelationMemo(relation, direction);
if (!memo) {
if (!memo || resolvedMemos[memo.name] === null) {
return null;
}
return (

View file

@ -1,54 +1,24 @@
import { create } from "@bufbuild/protobuf";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { findMemoInCollectionQueries, memoDetailQueryOptions } from "@/hooks/useMemoQueries";
import { MemoRelation_Memo, MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
import { type MemoRelation_Memo, MemoRelation_MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
export const useResolvedRelationMemos = (memoNames: string[], options?: { enabled?: boolean }) => {
const queryClient = useQueryClient();
const [resolvedMemos, setResolvedMemos] = useState<Record<string, MemoRelation_Memo>>({});
const enabled = options?.enabled ?? true;
const missingMemoNames = useMemo(() => {
return Array.from(new Set(memoNames)).filter((name) => name && !resolvedMemos[name]);
}, [memoNames, resolvedMemos]);
useEffect(() => {
if (!enabled || missingMemoNames.length === 0) {
return;
const client = useQueryClient();
const names = Array.from(new Set(memoNames.filter(Boolean)));
const queries = useQueries({
queries: names.map((name) => ({
...memoDetailQueryOptions(name),
enabled: options?.enabled ?? true,
initialData: () => findMemoInCollectionQueries(client, name, true),
})),
});
const resolved: Record<string, MemoRelation_Memo | null> = {};
queries.forEach((query, index) => {
if (query.data === null) resolved[names[index]] = null;
else if (query.data) {
resolved[names[index]] = create(MemoRelation_MemoSchema, { name: query.data.name, snippet: query.data.snippet });
}
let cancelled = false;
void (async () => {
try {
const memos = await Promise.all(
missingMemoNames.map(async (name) => {
const memo = findMemoInCollectionQueries(queryClient, name) ?? (await queryClient.fetchQuery(memoDetailQueryOptions(name)));
return create(MemoRelation_MemoSchema, { name: memo.name, snippet: memo.snippet });
}),
);
if (cancelled) {
return;
}
setResolvedMemos((prev) => {
const next = { ...prev };
for (const memo of memos) {
next[memo.name] = memo;
}
return next;
});
} catch {
// Keep existing relation data when snippet hydration fails.
}
})();
return () => {
cancelled = true;
};
}, [enabled, missingMemoNames, queryClient]);
return resolvedMemos;
});
return resolved;
};

View file

@ -0,0 +1,19 @@
import { Button } from "@/components/ui/button";
import { useTranslate } from "@/utils/i18n";
export type MemoParentStatus = "loading" | "unavailable" | "error";
export default function MemoParentPlaceholder({ status, onRetry }: { status: MemoParentStatus; onRetry?: () => void }) {
const t = useTranslate();
return (
<div className="px-2 py-1.5 text-xs text-muted-foreground" role="status">
<p>{t(status === "loading" ? "memo.parent-loading" : status === "error" ? "memo.parent-load-error" : "memo.parent-unavailable")}</p>
{status === "unavailable" && <p className="mt-1 leading-5">{t("memo.parent-unavailable-description")}</p>}
{status === "error" && onRetry && (
<Button variant="ghost" size="sm" className="mt-1" onClick={onRetry}>
{t("search.retry")}
</Button>
)}
</div>
);
}

View file

@ -1,5 +1,6 @@
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { useLocation } from "react-router-dom";
import type { MemoParentStatus } from "@/components/MemoParentPlaceholder";
import type { PrimaryMemoScope } from "@/lib/memo-views";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
@ -9,6 +10,8 @@ export type InboxFilter = "all" | "unread" | "archived";
export interface MemoDetailSidebarDescriptor {
memo: Memo;
parentMemo?: Memo;
parentStatus?: MemoParentStatus;
onParentRetry?: () => void;
from?: string;
hasExplicitOrigin?: boolean;
commentCount?: number;

View file

@ -1,5 +1,6 @@
import { create } from "@bufbuild/protobuf";
import { FieldMaskSchema } from "@bufbuild/protobuf/wkt";
import { Code, ConnectError } from "@connectrpc/connect";
import {
type InfiniteData,
type QueryClient,
@ -13,6 +14,7 @@ import { memoServiceClient } from "@/connect";
import { attachmentKeys } from "@/hooks/useAttachmentQueries";
import { userKeys } from "@/hooks/useUserQueries";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/lib/constants";
import { shouldRetry } from "@/lib/query-client";
import type { ListMemosRequest, ListMemosResponse, Memo } from "@/types/proto/api/v1/memo_service_pb";
import { ListMemoCommentsRequestSchema, ListMemosRequestSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
@ -30,10 +32,47 @@ export const memoKeys = {
export const memoDetailQueryOptions = (name: string) =>
queryOptions({
queryKey: memoKeys.detail(name),
queryFn: () => memoServiceClient.getMemo({ name }),
queryFn: async ({ client, signal }) => {
try {
return await memoServiceClient.getMemo({ name }, { signal });
} catch (error) {
if (!isMemoUnavailableError(error)) throw error;
// Store a content-free result, replacing any formerly readable detail.
// Remove copies and snippets so collection fallbacks cannot revive them.
discardUnavailableMemo(client, name);
return null;
}
},
retry: shouldRetry,
staleTime: 1000 * 10,
// A previous denial cannot establish access on a new visit, even while fresh.
refetchOnMount: (query) => (query.state.data === null ? "always" : true),
});
export function isMemoUnavailableError(error: unknown): boolean {
return error instanceof ConnectError && (error.code === Code.PermissionDenied || error.code === Code.NotFound);
}
function discardUnavailableMemo(client: QueryClient, name: string) {
const stripRelations = (memo: Memo): Memo => {
const relations = memo.relations.filter((relation) => relation.memo?.name !== name && relation.relatedMemo?.name !== name);
return relations.length === memo.relations.length ? memo : { ...memo, relations };
};
const stripList = (data: ListMemosResponse): ListMemosResponse => {
const memos = data.memos.filter((memo) => memo.name !== name).map(stripRelations);
return memos.length === data.memos.length && memos.every((memo, index) => memo === data.memos[index]) ? data : { ...data, memos };
};
for (const [key, data] of client.getQueriesData<MemoCollectionQueryData | Memo | null>({ queryKey: memoKeys.all })) {
let next = data;
if (isMemoListResponse(data)) next = stripList(data);
else if (isInfiniteMemoListData(data)) {
const pages = data.pages.map(stripList);
if (pages.some((page, index) => page !== data.pages[index])) next = { ...data, pages };
} else if (key[1] === "detail" && data && "name" in data && data.name !== name) next = stripRelations(data);
if (next !== data) client.setQueryData(key, next);
}
}
type MemoPatch = Partial<Memo> & Pick<Memo, "name">;
type MemoCollectionQueryData = ListMemosResponse | InfiniteData<ListMemosResponse>;
@ -109,8 +148,11 @@ function findMemoInQueryData(data: unknown, name: string): Memo | undefined {
return undefined;
}
export function findMemoInCollectionQueries(queryClient: QueryClient, name: string): Memo | undefined {
for (const [, data] of queryClient.getQueriesData<unknown>({ queryKey: memoKeys.all })) {
export function findMemoInCollectionQueries(queryClient: QueryClient, name: string, freshOnly = false): Memo | undefined {
if (queryClient.getQueryData(memoKeys.detail(name)) === null) return undefined;
for (const [key, data] of queryClient.getQueriesData<unknown>({ queryKey: memoKeys.all })) {
const state = queryClient.getQueryState(key);
if (freshOnly && (!state || state.isInvalidated || state.dataUpdatedAt < Date.now() - 10_000)) continue;
const memo = findMemoInQueryData(data, name);
if (memo) {
return memo;
@ -155,10 +197,11 @@ export function useInfiniteMemos(request: Partial<ListMemosRequest> = {}, option
}
export function useMemo(name: string, options?: { enabled?: boolean }) {
return useQuery({
const query = useQuery({
...memoDetailQueryOptions(name),
enabled: options?.enabled ?? true,
});
return { ...query, data: query.data ?? undefined, isUnavailable: query.data === null };
}
function isHTTPURL(url: string): boolean {
@ -217,7 +260,8 @@ export function useUpdateMemo() {
});
return memo;
},
onMutate: async ({ update }) => {
onMutate: async ({ update, updateMask }) => {
if (updateMask.includes("space")) return { previousMemo: undefined };
if (!update.name) {
return { previousMemo: undefined };
}
@ -247,7 +291,10 @@ export function useUpdateMemo() {
queryClient.invalidateQueries({ queryKey: memoKeys.all });
}
},
onSuccess: (updatedMemo) => {
onSuccess: (updatedMemo, { updateMask }) => {
if (updateMask.includes("space")) {
queryClient.invalidateQueries({ queryKey: memoKeys.all });
}
// Update cache with server response
queryClient.setQueryData(memoKeys.detail(updatedMemo.name), updatedMemo);
patchMemoInCollectionQueries(queryClient, updatedMemo);

View file

@ -349,6 +349,19 @@
"content-syntax": "Məzmun sintaksisi"
},
"memo": {
"parent-loading": "İlkin memo yüklənir…",
"parent-load-error": "İlkin memonu yükləmək mümkün olmadı.",
"parent-unavailable": "İlkin memo əlçatan deyil",
"parent-unavailable-description": "Silinmiş ola bilər və ya artıq giriş icazəniz yoxdur.",
"move": {
"title": "Məkana köçür",
"description": "Bu memonun yerini və onu kimin oxuya biləcəyini seçin.",
"unassigned": "Təyin edilməyib",
"confirm": "Köçür",
"success": "Memo köçürüldü",
"space-audience": "{{space}} məkanının üzvləri bu memonu oxuya biləcəklər.",
"history": "Mövcud reaksiyalar bu memo ilə qalır. Şərhlər və əlaqəli memolar öz giriş qaydalarını saxlayır."
},
"archived-at": "Arxivlənib",
"back-to": "{{source}} bölməsinə qayıt",
"click-to-hide-sensitive-content": "Həssas məzmunu gizlətmək üçün klikləyin",

View file

@ -349,6 +349,19 @@
"content-syntax": "Content syntax"
},
"memo": {
"parent-loading": "Loading original memo…",
"parent-load-error": "Couldn’t load the original memo.",
"parent-unavailable": "Original memo unavailable",
"parent-unavailable-description": "It may have been deleted or you may no longer have access.",
"move": {
"title": "Move to Space",
"description": "Choose where this memo belongs and who can read it.",
"unassigned": "Unassigned",
"confirm": "Move",
"success": "Memo moved",
"space-audience": "Members of {{space}} will be able to read this memo.",
"history": "Existing reactions stay with this memo. Comments and linked memos keep their own access rules."
},
"archived-at": "Archived at",
"back-to": "Back to {{source}}",
"click-to-hide-sensitive-content": "Click to hide sensitive content",

View file

@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo as useReactMemo, useRef, useState } fro
import { Link, Navigate, useLocation, useParams } from "react-router-dom";
import MemoCommentSection, { type MemoCommentSectionHandle } from "@/components/MemoCommentSection";
import { MentionResolutionProvider } from "@/components/MemoContent/MentionResolutionContext";
import MemoParentPlaceholder, { type MemoParentStatus } from "@/components/MemoParentPlaceholder";
import MemoView, { type MemoViewHandle } from "@/components/MemoView";
import { computeCommentAmount } from "@/components/MemoView/MemoViewContext";
import { createMemoNavigationState, resolveMemoDetailOrigin } from "@/components/MemoView/navigation";
@ -24,6 +25,8 @@ import { findMemoAnchorTarget } from "@/utils/markdown-manipulation";
const MemoSidebarRegistration = ({
memo,
parentMemo,
parentStatus,
onParentRetry,
from,
hasExplicitOrigin,
commentCount,
@ -35,6 +38,8 @@ const MemoSidebarRegistration = ({
}: {
memo: Memo;
parentMemo?: Memo;
parentStatus?: MemoParentStatus;
onParentRetry?: () => void;
from: string;
hasExplicitOrigin: boolean;
commentCount?: number;
@ -50,6 +55,8 @@ const MemoSidebarRegistration = ({
setMemoDetail({
memo,
parentMemo,
parentStatus,
onParentRetry,
from,
hasExplicitOrigin,
commentCount,
@ -69,6 +76,8 @@ const MemoSidebarRegistration = ({
onEdit,
onShareImageOpen,
parentMemo,
parentStatus,
onParentRetry,
readonly,
setMemoDetail,
]);
@ -100,6 +109,8 @@ const MemoDetail = () => {
data: memoFromDirect,
error: directError,
isLoading: directLoading,
isUnavailable: directUnavailable,
fetchStatus: directFetchStatus,
} = useMemo(memoNameFromParams, { enabled: !isShareMode && !!memoNameFromParams });
const { data: memoFromShare, error: shareError, isLoading: shareLoading } = useSharedMemo(shareToken ?? "", { enabled: isShareMode });
@ -121,10 +132,34 @@ const MemoDetail = () => {
error: error as Error | null,
});
const { data: parentMemo } = useMemo(memo?.parent || "", {
const {
data: fetchedParent,
error: parentError,
isUnavailable: parentUnavailable,
refetch: refetchParent,
} = useMemo(memo?.parent || "", {
enabled: !isShareMode && !!memo?.parent,
});
// Private parents return 401 to guests. The transport still owns session
// recovery; only a settled guest view treats that result as unavailable.
const guestParentDenied =
authInitialized && !currentUser && parentError instanceof ConnectError && parentError.code === Code.Unauthenticated;
const parentStatus: MemoParentStatus | undefined =
!isShareMode && memo?.parent
? parentUnavailable || guestParentDenied
? "unavailable"
: parentError
? "error"
: !fetchedParent
? "loading"
: undefined
: undefined;
const parentMemo = parentStatus ? undefined : fetchedParent;
const handleParentRetry = useCallback(() => {
void refetchParent();
}, [refetchParent]);
const {
data: comments = [],
fetchNextPage: fetchNextComments,
@ -169,6 +204,10 @@ const MemoDetail = () => {
el.scrollIntoView({ behavior: "smooth", block: "center" });
}, [hash, memo, memoName, comments]);
// Keep the query mounted while revalidating a cached denial. Redirecting
// earlier would abort the request that could confirm restored access.
if (!isShareMode && directUnavailable && directFetchStatus === "idle" && !directError) return <Navigate to="/404" replace />;
if (isShareMode) {
const isNotFound = error instanceof ConnectError && (error.code === Code.NotFound || error.code === Code.Unauthenticated);
if (isNotFound || (!isLoading && !memo)) {
@ -191,6 +230,8 @@ const MemoDetail = () => {
<MemoSidebarRegistration
memo={displayMemo}
parentMemo={parentMemo}
parentStatus={parentStatus}
onParentRetry={handleParentRetry}
from={parentPage}
hasExplicitOrigin={hasExplicitOrigin}
commentCount={isShareMode ? undefined : commentCount}
@ -202,6 +243,11 @@ const MemoDetail = () => {
/>
<div className="w-full max-w-2xl px-4 sm:px-6">
<div className="w-full">
{!isShareMode && parentStatus && (
<div className="mb-2 md:hidden">
<MemoParentPlaceholder status={parentStatus} onRetry={handleParentRetry} />
</div>
)}
{!isShareMode && parentMemo && (
<div className="w-auto inline-block mb-2 md:hidden">
<Link

View file

@ -169,8 +169,9 @@ export type Memo = Message<"memos.api.v1.Memo"> & {
property?: Memo_Property | undefined;
/**
* Output only. The readable context memo of this COMMENT relation, if any.
* This is omitted unless the caller may independently read both memos.
* Output only. The context memo of this COMMENT relation, if any.
* Its identity is returned even when the caller cannot read the parent.
* Fetch the parent independently; this field does not grant read access.
* Format: memos/{memo}
*
* @generated from field: optional string parent = 16;

View file

@ -0,0 +1,105 @@
import { create } from "@bufbuild/protobuf";
import { Code, ConnectError } from "@connectrpc/connect";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos";
import { findMemoInCollectionQueries, memoKeys, useMemo, useUpdateMemo } from "@/hooks/useMemoQueries";
import { ListMemosResponseSchema, MemoRelation_Type, MemoRelationSchema, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const api = vi.hoisted(() => ({ getMemo: vi.fn(), updateMemo: vi.fn() }));
vi.mock("@/connect", () => ({ memoServiceClient: api }));
const setup = () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 }, mutations: { retry: false } } });
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
return { client, wrapper };
};
describe("memo access transitions", () => {
beforeEach(() => vi.resetAllMocks());
it.each([
Code.PermissionDenied,
Code.NotFound,
])("discards denied parent content and snippets while preserving the comment (%s)", async (code) => {
const { client, wrapper } = setup();
const parent = create(MemoSchema, { name: "memos/parent", content: "secret", snippet: "secret" });
const comment = create(MemoSchema, {
name: "memos/comment",
parent: parent.name,
content: "my comment",
relations: [
create(MemoRelationSchema, {
type: MemoRelation_Type.COMMENT,
memo: { name: "memos/comment" },
relatedMemo: { name: parent.name, snippet: "secret" },
}),
],
});
client.setQueryData(memoKeys.detail(parent.name), parent, { updatedAt: 1 });
client.setQueryData(memoKeys.detail(comment.name), comment);
client.setQueryData(memoKeys.list({}), create(ListMemosResponseSchema, { memos: [parent, comment] }));
api.getMemo.mockRejectedValue(new ConnectError("denied", code));
const { result } = renderHook(() => useMemo(parent.name), { wrapper });
await waitFor(() => expect(result.current.isUnavailable).toBe(true));
expect(result.current.data).toBeUndefined();
expect(client.getQueryData(memoKeys.detail(parent.name))).toBeNull();
expect(findMemoInCollectionQueries(client, parent.name)).toBeUndefined();
expect(client.getQueryData(memoKeys.detail(comment.name))).toMatchObject({ content: "my comment", parent: parent.name, relations: [] });
expect(api.getMemo).toHaveBeenCalledOnce();
api.getMemo.mockResolvedValue(parent);
await act(async () => {
await result.current.refetch();
});
await waitFor(() => expect(result.current.isUnavailable).toBe(false));
expect(result.current.data?.content).toBe("secret");
client.clear();
});
it("resolves readable relations even when another relation is denied", async () => {
const { client, wrapper } = setup();
api.getMemo.mockImplementation(({ name }) =>
name === "memos/denied"
? Promise.reject(new ConnectError("denied", Code.PermissionDenied))
: Promise.resolve(create(MemoSchema, { name, snippet: "Readable" })),
);
const { result } = renderHook(() => useResolvedRelationMemos(["memos/denied", "memos/readable"]), { wrapper });
await waitFor(() => expect(result.current["memos/readable"]?.snippet).toBe("Readable"));
expect(result.current["memos/denied"]).toBeNull();
client.clear();
});
it("retains transient errors as retryable errors instead of marking a memo unavailable", async () => {
const { client, wrapper } = setup();
api.getMemo.mockRejectedValue(new ConnectError("offline", Code.Unavailable));
const { result } = renderHook(() => useMemo("memos/parent"), { wrapper });
await waitFor(() => expect(result.current.isError).toBe(true));
expect(result.current.isUnavailable).toBe(false);
expect(api.getMemo).toHaveBeenCalledTimes(2);
client.clear();
});
it("does not optimistically move a memo before the server accepts it", async () => {
const { client, wrapper } = setup();
const memo = create(MemoSchema, { name: "memos/moving", space: "spaces/a" });
client.setQueryData(memoKeys.detail(memo.name), memo);
let reject!: (error: Error) => void;
api.updateMemo.mockImplementation(
() =>
new Promise((_, fail) => {
reject = fail;
}),
);
const { result } = renderHook(() => useUpdateMemo(), { wrapper });
act(() => result.current.mutate({ update: { name: memo.name, space: "spaces/b" }, updateMask: ["space"] }));
await waitFor(() => expect(api.updateMemo).toHaveBeenCalledOnce());
expect(client.getQueryData(memoKeys.detail(memo.name))).toEqual(memo);
await act(async () => {
reject(new ConnectError("denied", Code.PermissionDenied));
});
await waitFor(() => expect(result.current.isError).toBe(true));
expect(client.getQueryData(memoKeys.detail(memo.name))).toEqual(memo);
client.clear();
});
});

View file

@ -1,11 +1,12 @@
import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MemoActionMenu from "@/components/MemoActionMenu";
import { State } from "@/types/proto/api/v1/common_pb";
import { MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const handlers = vi.hoisted(() => ({
canMove: true,
handleTogglePinMemoBtnClick: vi.fn(),
handleEditMemoClick: vi.fn(),
handleToggleMemoStatusClick: vi.fn(),
@ -21,6 +22,10 @@ vi.mock("@/components/ConfirmDialog", () => ({
default: () => null,
}));
vi.mock("@/components/MemoActionMenu/MemoMoveDialog", () => ({
default: () => <div role="dialog" aria-label="Move to Space" />,
}));
vi.mock("@/components/MemoActionMenu/hooks", () => ({
useMemoActionHandlers: () => handlers,
}));
@ -30,6 +35,35 @@ vi.mock("@/utils/i18n", () => ({
}));
describe("MemoActionMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
handlers.canMove = true;
});
it.each(["move", "delete"])("places %s inside More while keeping frequent actions in the main menu", async (action) => {
render(<MemoActionMenu memo={create(MemoSchema, { name: "memos/1", state: State.NORMAL })} />);
fireEvent.click(screen.getByRole("button", { name: "common.more" }));
expect(await screen.findByRole("menuitem", { name: "common.edit" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.archive" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "memo.move.title" })).not.toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "common.delete" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: "common.more" }));
expect(await screen.findByRole("menuitem", { name: "memo.move.title" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "common.delete" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: action === "move" ? "memo.move.title" : "common.delete" }));
if (action === "move") expect(await screen.findByRole("dialog", { name: "Move to Space" })).toBeInTheDocument();
else expect(handlers.handleDeleteMemoClick).toHaveBeenCalledOnce();
});
it("omits More when neither action is available", async () => {
handlers.canMove = false;
render(<MemoActionMenu memo={create(MemoSchema, { name: "memos/1", state: State.NORMAL })} readonly />);
fireEvent.click(screen.getByRole("button", { name: "common.more" }));
expect(await screen.findByRole("menuitem", { name: "common.copy" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "common.more" })).not.toBeInTheDocument();
});
it("is a quiet compact control that takes the accent fill while open", async () => {
const memo = create(MemoSchema, { name: "memos/1", state: State.NORMAL, pinned: false });
render(<MemoActionMenu memo={memo} />);

View file

@ -25,6 +25,8 @@ vi.mock("@/hooks/useUserQueries", () => ({
userKeys: { stats: () => ["users", "stats"] },
}));
vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: "users/alice" }) }));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ profile: { instanceUrl: "" } }),
}));

View file

@ -0,0 +1,145 @@
import { create } from "@bufbuild/protobuf";
import { Code, ConnectError } from "@connectrpc/connect";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { memoKeys } from "@/hooks/useMemoQueries";
import MemoDetail from "@/pages/MemoDetail";
import { type Memo, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const state = vi.hoisted(() => ({
currentUser: undefined as { name: string } | undefined,
getMemo: vi.fn(),
listMemoComments: vi.fn(),
setMemoDetail: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("@/connect", () => ({ memoServiceClient: state }));
vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ currentUser: state.currentUser, isInitialized: true }) }));
vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ isInitialized: true }) }));
vi.mock("@/contexts/AppSidebarContext", () => ({ useAppSidebar: () => ({ setMemoDetail: state.setMemoDetail }) }));
vi.mock("@/components/MemoView", () => ({ default: ({ memo }: { memo: Memo }) => <article>{memo.content}</article> }));
vi.mock("@/components/MemoView/MemoViewContext", () => ({ computeCommentAmount: () => 0 }));
vi.mock("@/components/MemoCommentSection", () => ({ default: () => null }));
vi.mock("@/components/MemoContent/MentionResolutionContext", () => ({
MentionResolutionProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
vi.mock("react-hot-toast", () => ({ toast: { error: state.toastError } }));
function renderDetail(client: QueryClient, name = "memos/restored") {
return render(
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[`/${name}`]}>
<Routes>
<Route path="/memos/:uid" element={<MemoDetail />} />
<Route path="/404" element={<div>not found</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
const createClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } });
describe("memo detail access recovery", () => {
beforeEach(() => {
vi.clearAllMocks();
state.currentUser = undefined;
state.listMemoComments.mockResolvedValue({ memos: [], nextPageToken: "" });
});
it.each(["fresh", "stale"])("revalidates a %s cached denial before showing a restored memo", async (freshness) => {
const client = createClient();
const name = "memos/restored";
client.setQueryData(memoKeys.detail(name), null, { updatedAt: freshness === "fresh" ? Date.now() : 1 });
let resolve!: (memo: Memo) => void;
let signal!: AbortSignal;
state.getMemo.mockImplementation((_, options) => {
signal = options.signal;
return new Promise<Memo>((done) => {
resolve = done;
});
});
renderDetail(client);
await waitFor(() => expect(state.getMemo).toHaveBeenCalledOnce());
expect(screen.queryByText("not found")).not.toBeInTheDocument();
expect(signal.aborted).toBe(false);
await act(async () => resolve(create(MemoSchema, { name, content: "Restored memo" })));
expect(await screen.findByText("Restored memo")).toBeInTheDocument();
expect(screen.queryByText("not found")).not.toBeInTheDocument();
client.clear();
});
it.each([Code.PermissionDenied, Code.NotFound])("redirects only after revalidation confirms denial (%s)", async (code) => {
const client = createClient();
client.setQueryData(memoKeys.detail("memos/restored"), null, { updatedAt: 1 });
let reject!: (error: Error) => void;
state.getMemo.mockImplementation(
() =>
new Promise((_, fail) => {
reject = fail;
}),
);
renderDetail(client);
await waitFor(() => expect(state.getMemo).toHaveBeenCalledOnce());
expect(screen.queryByText("not found")).not.toBeInTheDocument();
await act(async () => reject(new ConnectError("denied", code)));
expect(await screen.findByText("not found")).toBeInTheDocument();
client.clear();
});
it("does not treat a failed revalidation as a renewed denial", async () => {
const client = createClient();
client.setQueryData(memoKeys.detail("memos/restored"), null, { updatedAt: 1 });
state.getMemo.mockRejectedValue(new ConnectError("offline", Code.Unavailable));
renderDetail(client);
await waitFor(() => expect(state.toastError).toHaveBeenCalled());
expect(screen.queryByText("not found")).not.toBeInTheDocument();
client.clear();
});
it.each([false, true])("classifies parent 401 according to the current viewer (signed in: %s)", async (signedIn) => {
if (signedIn) state.currentUser = { name: "users/alice" };
const client = createClient();
const comment = create(MemoSchema, { name: "memos/comment", content: "Public comment", parent: "memos/private-parent" });
state.getMemo.mockImplementation(({ name }) =>
name === comment.name ? Promise.resolve(comment) : Promise.reject(new ConnectError("authentication required", Code.Unauthenticated)),
);
renderDetail(client, comment.name);
expect(await screen.findByText(signedIn ? "memo.parent-load-error" : "memo.parent-unavailable")).toBeInTheDocument();
expect(screen.getByText("Public comment")).toBeInTheDocument();
expect(screen.queryByText("not found")).not.toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
if (signedIn) expect(screen.getByRole("button", { name: "search.retry" })).toBeInTheDocument();
else expect(screen.queryByRole("button", { name: "search.retry" })).not.toBeInTheDocument();
expect(state.setMemoDetail).toHaveBeenLastCalledWith(
expect.objectContaining({
parentStatus: signedIn ? "error" : "unavailable",
parentMemo: undefined,
}),
);
client.clear();
});
it("lets a guest retry a transient parent error", async () => {
const client = createClient();
const comment = create(MemoSchema, { name: "memos/comment", content: "Public comment", parent: "memos/parent" });
let parentAvailable = false;
state.getMemo.mockImplementation(({ name }) =>
name === comment.name
? Promise.resolve(comment)
: parentAvailable
? Promise.resolve(create(MemoSchema, { name, content: "Parent memo" }))
: Promise.reject(new ConnectError("offline", Code.Unavailable)),
);
renderDetail(client, comment.name);
const retry = await screen.findByRole("button", { name: "search.retry" });
parentAvailable = true;
fireEvent.click(retry);
expect(await screen.findByRole("link", { name: "Parent memo" })).toBeInTheDocument();
expect(screen.getByText("Public comment")).toBeInTheDocument();
client.clear();
});
});

View file

@ -0,0 +1,91 @@
import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MemoMoveDialog from "@/components/MemoActionMenu/MemoMoveDialog";
import { MemoSchema, Visibility } from "@/types/proto/api/v1/memo_service_pb";
import { SpaceSchema } from "@/types/proto/api/v1/space_service_pb";
const state = vi.hoisted(() => ({ update: vi.fn(), refetch: vi.fn(), user: "users/alice" }));
vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: state.user }) }));
vi.mock("@/hooks/useMemoQueries", () => ({ useUpdateMemo: () => ({ mutateAsync: state.update, isPending: false }) }));
vi.mock("@/hooks/useSpaceQueries", () => ({
useSpaces: () => ({
data: [create(SpaceSchema, { name: "spaces/a", title: "Design" }), create(SpaceSchema, { name: "spaces/b", title: "Product" })],
isPending: false,
refetch: state.refetch,
}),
}));
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn() } }));
const memo = create(MemoSchema, { name: "memos/original", creator: "users/alice", space: "spaces/a", visibility: Visibility.SPACE });
const selectDestination = async (name: string) => {
fireEvent.click(screen.getByRole("combobox", { name: "space.current" }));
const option = await screen.findByRole("option", { name });
fireEvent.pointerDown(option, { pointerType: "mouse" });
fireEvent.click(option);
};
describe("Move to Space", () => {
beforeEach(() => {
vi.clearAllMocks();
state.user = "users/alice";
state.update.mockResolvedValue(memo);
});
it("requires a new destination and moves with explicit Space audience", async () => {
const close = vi.fn();
render(<MemoMoveDialog memo={memo} onOpenChange={close} />);
expect(screen.getByRole("button", { name: "memo.move.confirm" })).toBeDisabled();
await selectDestination("Product");
fireEvent.click(screen.getByRole("button", { name: "memo.move.confirm" }));
await waitFor(() => expect(close).toHaveBeenCalledWith(false));
expect(state.update).toHaveBeenCalledWith({
update: { name: memo.name, space: "spaces/b", visibility: Visibility.SPACE },
updateMask: ["space", "visibility"],
});
});
it("defaults to Private when unassigning a Space-visible memo", async () => {
render(<MemoMoveDialog memo={memo} onOpenChange={vi.fn()} />);
await selectDestination("memo.move.unassigned");
fireEvent.click(screen.getByRole("button", { name: "memo.move.confirm" }));
await waitFor(() =>
expect(state.update).toHaveBeenCalledWith({
update: { name: memo.name, space: "", visibility: Visibility.PRIVATE },
updateMask: ["space", "visibility"],
}),
);
});
it("preserves non-Space audience while assigning a memo", async () => {
render(<MemoMoveDialog memo={{ ...memo, space: undefined, visibility: Visibility.PUBLIC }} onOpenChange={vi.fn()} />);
await selectDestination("Product");
fireEvent.click(screen.getByRole("button", { name: "memo.move.confirm" }));
await waitFor(() =>
expect(state.update).toHaveBeenCalledWith({
update: { name: memo.name, space: "spaces/b", visibility: Visibility.PUBLIC },
updateMask: ["space"],
}),
);
});
it("keeps the dialog open and refreshes destinations when a move is rejected", async () => {
state.update.mockRejectedValue(new Error("Membership changed"));
const close = vi.fn();
render(<MemoMoveDialog memo={memo} onOpenChange={close} />);
await selectDestination("Product");
fireEvent.click(screen.getByRole("button", { name: "memo.move.confirm" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Membership changed");
expect(close).not.toHaveBeenCalled();
expect(state.refetch).toHaveBeenCalledOnce();
});
it("does not allow another user to submit a move", async () => {
state.user = "users/bob";
render(<MemoMoveDialog memo={memo} onOpenChange={vi.fn()} />);
await selectDestination("Product");
expect(screen.getByRole("button", { name: "memo.move.confirm" })).toBeDisabled();
expect(state.update).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,20 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import MemoParentPlaceholder from "@/components/MemoParentPlaceholder";
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
describe("unavailable parent", () => {
it("has no navigation or retry for unavailable content", () => {
render(<MemoParentPlaceholder status="unavailable" onRetry={vi.fn()} />);
expect(screen.getByText("memo.parent-unavailable")).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("offers Retry for transient failures", () => {
const retry = vi.fn();
render(<MemoParentPlaceholder status="error" onRetry={retry} />);
fireEvent.click(screen.getByRole("button", { name: "search.retry" }));
expect(retry).toHaveBeenCalledOnce();
});
});

View file

@ -180,6 +180,6 @@ describe("query deduplication", () => {
});
expect(clients.getMemo).toHaveBeenCalledTimes(1);
expect(clients.getMemo).toHaveBeenCalledWith({ name: missingMemo.name });
expect(clients.getMemo).toHaveBeenCalledWith({ name: missingMemo.name }, { signal: expect.any(AbortSignal) });
});
});