diff --git a/proto/api/v1/instance_service.proto b/proto/api/v1/instance_service.proto index baeea0bd9..05ed9d97c 100644 --- a/proto/api/v1/instance_service.proto +++ b/proto/api/v1/instance_service.proto @@ -156,6 +156,8 @@ message InstanceSetting { int32 content_length_limit = 3; // enable_double_click_edit enables editing on double click. bool enable_double_click_edit = 4; + // disable_reactions disables memo reactions across the UI and API. + bool disable_reactions = 8; // reactions is the list of reactions. repeated string reactions = 7; } diff --git a/proto/gen/api/v1/instance_service.pb.go b/proto/gen/api/v1/instance_service.pb.go index edef32e91..342a4a37d 100644 --- a/proto/gen/api/v1/instance_service.pb.go +++ b/proto/gen/api/v1/instance_service.pb.go @@ -654,6 +654,8 @@ type InstanceSetting_MemoRelatedSetting struct { ContentLengthLimit int32 `protobuf:"varint,3,opt,name=content_length_limit,json=contentLengthLimit,proto3" json:"content_length_limit,omitempty"` // enable_double_click_edit enables editing on double click. EnableDoubleClickEdit bool `protobuf:"varint,4,opt,name=enable_double_click_edit,json=enableDoubleClickEdit,proto3" json:"enable_double_click_edit,omitempty"` + // disable_reactions disables memo reactions across the UI and API. + DisableReactions bool `protobuf:"varint,8,opt,name=disable_reactions,json=disableReactions,proto3" json:"disable_reactions,omitempty"` // reactions is the list of reactions. Reactions []string `protobuf:"bytes,7,rep,name=reactions,proto3" json:"reactions,omitempty"` unknownFields protoimpl.UnknownFields @@ -718,6 +720,13 @@ func (x *InstanceSetting_MemoRelatedSetting) GetEnableDoubleClickEdit() bool { return false } +func (x *InstanceSetting_MemoRelatedSetting) GetDisableReactions() bool { + if x != nil { + return x.DisableReactions + } + return false +} + func (x *InstanceSetting_MemoRelatedSetting) GetReactions() []string { if x != nil { return x.Reactions @@ -882,7 +891,7 @@ const file_api_v1_instance_service_proto_rawDesc = "" + "\x04demo\x18\x03 \x01(\bR\x04demo\x12!\n" + "\finstance_url\x18\x06 \x01(\tR\vinstanceUrl\x12 \n" + "\vinitialized\x18\a \x01(\bR\vinitialized\"\x1b\n" + - "\x19GetInstanceProfileRequest\"\x99\x0f\n" + + "\x19GetInstanceProfileRequest\"\xc6\x0f\n" + "\x0fInstanceSetting\x12\x17\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12W\n" + "\x0fgeneral_setting\x18\x02 \x01(\v2,.memos.api.v1.InstanceSetting.GeneralSettingH\x00R\x0egeneralSetting\x12W\n" + @@ -917,12 +926,13 @@ const file_api_v1_instance_service_proto_rawDesc = "" + "\x18STORAGE_TYPE_UNSPECIFIED\x10\x00\x12\f\n" + "\bDATABASE\x10\x01\x12\t\n" + "\x05LOCAL\x10\x02\x12\x06\n" + - "\x02S3\x10\x03\x1a\x94\x02\n" + + "\x02S3\x10\x03\x1a\xc1\x02\n" + "\x12MemoRelatedSetting\x12<\n" + "\x1adisallow_public_visibility\x18\x01 \x01(\bR\x18disallowPublicVisibility\x127\n" + "\x18display_with_update_time\x18\x02 \x01(\bR\x15displayWithUpdateTime\x120\n" + "\x14content_length_limit\x18\x03 \x01(\x05R\x12contentLengthLimit\x127\n" + - "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12\x1c\n" + + "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12+\n" + + "\x11disable_reactions\x18\b \x01(\bR\x10disableReactions\x12\x1c\n" + "\treactions\x18\a \x03(\tR\treactions\"F\n" + "\x03Key\x12\x13\n" + "\x0fKEY_UNSPECIFIED\x10\x00\x12\v\n" + diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index 3cbc36ebf..ed135f2cc 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -2212,6 +2212,9 @@ components: enableDoubleClickEdit: type: boolean description: enable_double_click_edit enables editing on double click. + disableReactions: + type: boolean + description: disable_reactions disables memo reactions across the UI and API. reactions: type: array items: diff --git a/proto/gen/store/instance_setting.pb.go b/proto/gen/store/instance_setting.pb.go index d368e626d..6a931060e 100644 --- a/proto/gen/store/instance_setting.pb.go +++ b/proto/gen/store/instance_setting.pb.go @@ -649,6 +649,8 @@ type InstanceMemoRelatedSetting struct { ContentLengthLimit int32 `protobuf:"varint,3,opt,name=content_length_limit,json=contentLengthLimit,proto3" json:"content_length_limit,omitempty"` // enable_double_click_edit enables editing on double click. EnableDoubleClickEdit bool `protobuf:"varint,4,opt,name=enable_double_click_edit,json=enableDoubleClickEdit,proto3" json:"enable_double_click_edit,omitempty"` + // disable_reactions disables memo reactions across the UI and API. + DisableReactions bool `protobuf:"varint,8,opt,name=disable_reactions,json=disableReactions,proto3" json:"disable_reactions,omitempty"` // reactions is the list of reactions. Reactions []string `protobuf:"bytes,7,rep,name=reactions,proto3" json:"reactions,omitempty"` unknownFields protoimpl.UnknownFields @@ -713,6 +715,13 @@ func (x *InstanceMemoRelatedSetting) GetEnableDoubleClickEdit() bool { return false } +func (x *InstanceMemoRelatedSetting) GetDisableReactions() bool { + if x != nil { + return x.DisableReactions + } + return false +} + func (x *InstanceMemoRelatedSetting) GetReactions() []string { if x != nil { return x.Reactions @@ -765,12 +774,13 @@ const file_store_instance_setting_proto_rawDesc = "" + "\bendpoint\x18\x03 \x01(\tR\bendpoint\x12\x16\n" + "\x06region\x18\x04 \x01(\tR\x06region\x12\x16\n" + "\x06bucket\x18\x05 \x01(\tR\x06bucket\x12$\n" + - "\x0euse_path_style\x18\x06 \x01(\bR\fusePathStyle\"\x9c\x02\n" + + "\x0euse_path_style\x18\x06 \x01(\bR\fusePathStyle\"\xc9\x02\n" + "\x1aInstanceMemoRelatedSetting\x12<\n" + "\x1adisallow_public_visibility\x18\x01 \x01(\bR\x18disallowPublicVisibility\x127\n" + "\x18display_with_update_time\x18\x02 \x01(\bR\x15displayWithUpdateTime\x120\n" + "\x14content_length_limit\x18\x03 \x01(\x05R\x12contentLengthLimit\x127\n" + - "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12\x1c\n" + + "\x18enable_double_click_edit\x18\x04 \x01(\bR\x15enableDoubleClickEdit\x12+\n" + + "\x11disable_reactions\x18\b \x01(\bR\x10disableReactions\x12\x1c\n" + "\treactions\x18\a \x03(\tR\treactions*q\n" + "\x12InstanceSettingKey\x12$\n" + " INSTANCE_SETTING_KEY_UNSPECIFIED\x10\x00\x12\t\n" + diff --git a/proto/store/instance_setting.proto b/proto/store/instance_setting.proto index fcfcbdd60..f57b71393 100644 --- a/proto/store/instance_setting.proto +++ b/proto/store/instance_setting.proto @@ -100,6 +100,8 @@ message InstanceMemoRelatedSetting { int32 content_length_limit = 3; // enable_double_click_edit enables editing on double click. bool enable_double_click_edit = 4; + // disable_reactions disables memo reactions across the UI and API. + bool disable_reactions = 8; // reactions is the list of reactions. repeated string reactions = 7; } diff --git a/server/router/api/v1/instance_service.go b/server/router/api/v1/instance_service.go index 0765706e5..ec740e2ed 100644 --- a/server/router/api/v1/instance_service.go +++ b/server/router/api/v1/instance_service.go @@ -253,6 +253,7 @@ func convertInstanceMemoRelatedSettingFromStore(setting *storepb.InstanceMemoRel DisplayWithUpdateTime: setting.DisplayWithUpdateTime, ContentLengthLimit: setting.ContentLengthLimit, EnableDoubleClickEdit: setting.EnableDoubleClickEdit, + DisableReactions: setting.DisableReactions, Reactions: setting.Reactions, } } @@ -266,6 +267,7 @@ func convertInstanceMemoRelatedSettingToStore(setting *v1pb.InstanceSetting_Memo DisplayWithUpdateTime: setting.DisplayWithUpdateTime, ContentLengthLimit: setting.ContentLengthLimit, EnableDoubleClickEdit: setting.EnableDoubleClickEdit, + DisableReactions: setting.DisableReactions, Reactions: setting.Reactions, } } diff --git a/server/router/api/v1/memo_service.go b/server/router/api/v1/memo_service.go index b73bd5403..fa0cbbd17 100644 --- a/server/router/api/v1/memo_service.go +++ b/server/router/api/v1/memo_service.go @@ -249,12 +249,14 @@ func (s *APIV1Service) ListMemos(ctx context.Context, request *v1pb.ListMemosReq } // REACTIONS - reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs}) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list reactions") - } - for _, reaction := range reactions { - reactionMap[reaction.ContentID] = append(reactionMap[reaction.ContentID], reaction) + if !instanceMemoRelatedSetting.DisableReactions { + reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list reactions") + } + for _, reaction := range reactions { + reactionMap[reaction.ContentID] = append(reactionMap[reaction.ContentID], reaction) + } } // ATTACHMENTS @@ -313,11 +315,18 @@ func (s *APIV1Service) GetMemo(ctx context.Context, request *v1pb.GetMemoRequest } } - reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ - ContentID: &request.Name, - }) + var reactions []*store.Reaction + reactionsEnabled, err := s.reactionsEnabled(ctx) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list reactions") + return nil, err + } + if reactionsEnabled { + reactions, err = s.Store.ListReactions(ctx, &store.FindReaction{ + ContentID: &request.Name, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list reactions") + } } attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{ @@ -449,11 +458,18 @@ func (s *APIV1Service) UpdateMemo(ctx context.Context, request *v1pb.UpdateMemoR if err != nil { return nil, errors.Wrap(err, "failed to get memo") } - reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ - ContentID: &request.Memo.Name, - }) + var reactions []*store.Reaction + reactionsEnabled, err := s.reactionsEnabled(ctx) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list reactions") + return nil, err + } + if reactionsEnabled { + reactions, err = s.Store.ListReactions(ctx, &store.FindReaction{ + ContentID: &request.Memo.Name, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list reactions") + } } attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{ MemoID: &memo.ID, @@ -501,11 +517,18 @@ func (s *APIV1Service) DeleteMemo(ctx context.Context, request *v1pb.DeleteMemoR return nil, status.Errorf(codes.PermissionDenied, "permission denied") } - reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ - ContentID: &request.Name, - }) + var reactions []*store.Reaction + reactionsEnabled, err := s.reactionsEnabled(ctx) if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list reactions") + return nil, err + } + if reactionsEnabled { + reactions, err = s.Store.ListReactions(ctx, &store.FindReaction{ + ContentID: &request.Name, + }) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list reactions") + } } attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{ @@ -669,14 +692,19 @@ func (s *APIV1Service) ListMemoComments(ctx context.Context, request *v1pb.ListM contentIDs = append(contentIDs, memoName) memoIDsForAttachments = append(memoIDsForAttachments, memo.ID) } - reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs}) - if err != nil { - return nil, status.Errorf(codes.Internal, "failed to list reactions") - } - memoReactionsMap := make(map[string][]*store.Reaction) - for _, reaction := range reactions { - memoReactionsMap[reaction.ContentID] = append(memoReactionsMap[reaction.ContentID], reaction) + reactionsEnabled, err := s.reactionsEnabled(ctx) + if err != nil { + return nil, err + } + if reactionsEnabled { + reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ContentIDList: contentIDs}) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to list reactions") + } + for _, reaction := range reactions { + memoReactionsMap[reaction.ContentID] = append(memoReactionsMap[reaction.ContentID], reaction) + } } attachments, err := s.Store.ListAttachments(ctx, &store.FindAttachment{MemoIDList: memoIDsForAttachments}) @@ -715,6 +743,14 @@ func (s *APIV1Service) getContentLengthLimit(ctx context.Context) (int, error) { return int(instanceMemoRelatedSetting.ContentLengthLimit), nil } +func (s *APIV1Service) reactionsEnabled(ctx context.Context) (bool, error) { + instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx) + if err != nil { + return false, status.Errorf(codes.Internal, "failed to get instance memo related setting") + } + return !instanceMemoRelatedSetting.DisableReactions, nil +} + // DispatchMemoCreatedWebhook dispatches webhook when memo is created. func (s *APIV1Service) DispatchMemoCreatedWebhook(ctx context.Context, memo *v1pb.Memo) error { return s.dispatchMemoRelatedWebhook(ctx, memo, "memos.memo.created") diff --git a/server/router/api/v1/reaction_service.go b/server/router/api/v1/reaction_service.go index 872ececde..76331cd56 100644 --- a/server/router/api/v1/reaction_service.go +++ b/server/router/api/v1/reaction_service.go @@ -15,6 +15,14 @@ import ( ) func (s *APIV1Service) ListMemoReactions(ctx context.Context, request *v1pb.ListMemoReactionsRequest) (*v1pb.ListMemoReactionsResponse, error) { + instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get instance memo related setting") + } + if instanceMemoRelatedSetting.DisableReactions { + return nil, status.Errorf(codes.PermissionDenied, "reactions are disabled") + } + reactions, err := s.Store.ListReactions(ctx, &store.FindReaction{ ContentID: &request.Name, }) @@ -33,6 +41,14 @@ func (s *APIV1Service) ListMemoReactions(ctx context.Context, request *v1pb.List } func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.UpsertMemoReactionRequest) (*v1pb.Reaction, error) { + instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get instance memo related setting") + } + if instanceMemoRelatedSetting.DisableReactions { + return nil, status.Errorf(codes.PermissionDenied, "reactions are disabled") + } + user, err := s.fetchCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user") @@ -55,6 +71,14 @@ func (s *APIV1Service) UpsertMemoReaction(ctx context.Context, request *v1pb.Ups } func (s *APIV1Service) DeleteMemoReaction(ctx context.Context, request *v1pb.DeleteMemoReactionRequest) (*emptypb.Empty, error) { + instanceMemoRelatedSetting, err := s.Store.GetInstanceMemoRelatedSetting(ctx) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to get instance memo related setting") + } + if instanceMemoRelatedSetting.DisableReactions { + return nil, status.Errorf(codes.PermissionDenied, "reactions are disabled") + } + user, err := s.fetchCurrentUser(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "failed to get current user: %v", err) diff --git a/store/instance_setting.go b/store/instance_setting.go index d64508159..1f313c0a0 100644 --- a/store/instance_setting.go +++ b/store/instance_setting.go @@ -158,7 +158,7 @@ func (s *Store) GetInstanceMemoRelatedSetting(ctx context.Context) (*storepb.Ins if instanceMemoRelatedSetting.ContentLengthLimit < DefaultContentLengthLimit { instanceMemoRelatedSetting.ContentLengthLimit = DefaultContentLengthLimit } - if len(instanceMemoRelatedSetting.Reactions) == 0 { + if len(instanceMemoRelatedSetting.Reactions) == 0 && !instanceMemoRelatedSetting.DisableReactions { instanceMemoRelatedSetting.Reactions = append(instanceMemoRelatedSetting.Reactions, DefaultReactions...) } s.instanceSettingCache.Set(ctx, storepb.InstanceSettingKey_MEMO_RELATED.String(), &storepb.InstanceSetting{ diff --git a/web/src/components/DesktopHeader.tsx b/web/src/components/DesktopHeader.tsx new file mode 100644 index 000000000..2b6ddfe90 --- /dev/null +++ b/web/src/components/DesktopHeader.tsx @@ -0,0 +1,25 @@ +import { Link } from "react-router-dom"; +import { useInstance } from "@/contexts/InstanceContext"; +import UserAvatar from "@/components/UserAvatar"; + +const DesktopHeader = () => { + const { generalSetting } = useInstance(); + const title = generalSetting.customProfile?.title || "Memos"; + const avatarUrl = generalSetting.customProfile?.logoUrl || "/full-logo.webp"; + + return ( +
+
+ + + {title} + +
+
+
+
+
+ ); +}; + +export default DesktopHeader; diff --git a/web/src/components/MemoActionMenu/MemoActionMenu.tsx b/web/src/components/MemoActionMenu/MemoActionMenu.tsx index afe2c0ce2..a19382a91 100644 --- a/web/src/components/MemoActionMenu/MemoActionMenu.tsx +++ b/web/src/components/MemoActionMenu/MemoActionMenu.tsx @@ -81,6 +81,12 @@ const MemoActionMenu = (props: MemoActionMenuProps) => { {t("common.edit")} + {props.onSelect && ( + + + {t("common.select")} + + )} )} diff --git a/web/src/components/MemoActionMenu/types.ts b/web/src/components/MemoActionMenu/types.ts index 9133f95ea..b5723a392 100644 --- a/web/src/components/MemoActionMenu/types.ts +++ b/web/src/components/MemoActionMenu/types.ts @@ -5,6 +5,7 @@ export interface MemoActionMenuProps { readonly?: boolean; className?: string; onEdit?: () => void; + onSelect?: () => void; } export interface UseMemoActionHandlersReturn { diff --git a/web/src/components/MemoReactionListView/MemoReactionListView.tsx b/web/src/components/MemoReactionListView/MemoReactionListView.tsx index afe362a25..f7c15d8ec 100644 --- a/web/src/components/MemoReactionListView/MemoReactionListView.tsx +++ b/web/src/components/MemoReactionListView/MemoReactionListView.tsx @@ -1,4 +1,5 @@ import { memo } from "react"; +import { useInstance } from "@/contexts/InstanceContext"; import useCurrentUser from "@/hooks/useCurrentUser"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo, Reaction } from "@/types/proto/api/v1/memo_service_pb"; @@ -13,10 +14,15 @@ interface Props { const MemoReactionListView = (props: Props) => { const { memo: memoData, reactions } = props; + const { memoRelatedSetting } = useInstance(); const currentUser = useCurrentUser(); const reactionGroup = useReactionGroups(reactions); const readonly = memoData.state === State.ARCHIVED; + if (memoRelatedSetting.disableReactions) { + return null; + } + if (reactions.length === 0) { return null; } diff --git a/web/src/components/MemoReactionListView/ReactionSelector.tsx b/web/src/components/MemoReactionListView/ReactionSelector.tsx index c981bd499..9fc7b8e92 100644 --- a/web/src/components/MemoReactionListView/ReactionSelector.tsx +++ b/web/src/components/MemoReactionListView/ReactionSelector.tsx @@ -17,6 +17,10 @@ const ReactionSelector = (props: Props) => { const [open, setOpen] = useState(false); const { memoRelatedSetting } = useInstance(); + if (memoRelatedSetting.disableReactions) { + return null; + } + const handleOpenChange = (newOpen: boolean) => { setOpen(newOpen); onOpenChange?.(newOpen); diff --git a/web/src/components/MemoView/MemoView.tsx b/web/src/components/MemoView/MemoView.tsx index 9d617b7f8..d8e459754 100644 --- a/web/src/components/MemoView/MemoView.tsx +++ b/web/src/components/MemoView/MemoView.tsx @@ -4,6 +4,7 @@ import { useUser } from "@/hooks/useUserQueries"; import { cn } from "@/lib/utils"; import { State } from "@/types/proto/api/v1/common_pb"; import { isSuperUser } from "@/utils/user"; +import { useMemoSelection } from "@/contexts/MemoSelectionContext"; import MemoEditor from "../MemoEditor"; import PreviewImageDialog from "../PreviewImageDialog"; import { MemoBody, MemoHeader } from "./components"; @@ -26,6 +27,8 @@ const MemoView: React.FC = (props: MemoViewProps) => { const { nsfw, showNSFWContent, toggleNsfwVisibility } = useNsfwContent(memoData, props.showNsfwContent); const { previewState, openPreview, setPreviewOpen } = useImagePreview(); const { unpinMemo } = useMemoActions(memoData, isArchived); + const selection = useMemoSelection(); + const isSelected = selection?.isSelected(memoData.name) ?? false; const handleEditorConfirm = () => setShowEditor(false); const handleEditorCancel = () => setShowEditor(false); @@ -68,7 +71,16 @@ const MemoView: React.FC = (props: MemoViewProps) => { return ( -
+
= ({ const { memo, creator, currentUser, parentPage, isArchived, readonly, showNSFWContent, nsfw } = useMemoViewContext(); const { isInMemoDetailPage, commentAmount, relativeTimeFormat } = useMemoViewDerived(); + const selection = useMemoSelection(); + const isSelected = selection?.isSelected(memo.name) ?? false; const displayTime = isArchived ? ( (memo.displayTime ? timestampDate(memo.displayTime) : undefined)?.toLocaleString(i18n.language) @@ -52,6 +56,14 @@ const MemoHeader: React.FC = ({
+ {selection && selection.isSelectionMode && !readonly && ( + selection.toggleMemoSelection(memo.name)} + onClick={(event) => event.stopPropagation()} + aria-label={t("common.select")} + /> + )} {currentUser && !isArchived && ( = ({ )} - + selection.enterSelectionMode(memo.name) : undefined} />
); diff --git a/web/src/components/MobileHeader.tsx b/web/src/components/MobileHeader.tsx index 8b5923a2e..dc7c42d60 100644 --- a/web/src/components/MobileHeader.tsx +++ b/web/src/components/MobileHeader.tsx @@ -19,13 +19,16 @@ const MobileHeader = (props: Props) => { return (
0 && "shadow-md", className, )} > - {!sm && } -
{children}
+
+ {!sm && } +
{children}
+
+
); }; diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index 518635094..88f6d4590 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -1,13 +1,18 @@ import { useQueryClient } from "@tanstack/react-query"; -import { ArrowUpIcon } from "lucide-react"; +import toast from "react-hot-toast"; +import { ArchiveIcon, ArrowUpIcon, BookmarkPlusIcon, TrashIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { matchPath } from "react-router-dom"; import { Button } from "@/components/ui/button"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { userServiceClient } from "@/connect"; +import { MemoSelectionContext, useMemoSelection } from "@/contexts/MemoSelectionContext"; import { useView } from "@/contexts/ViewContext"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; -import { useInfiniteMemos } from "@/hooks/useMemoQueries"; +import { useDeleteMemo, useInfiniteMemos, useUpdateMemo } from "@/hooks/useMemoQueries"; import { userKeys } from "@/hooks/useUserQueries"; +import { handleError } from "@/lib/error"; import { Routes } from "@/router"; import { State } from "@/types/proto/api/v1/common_pb"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; @@ -85,6 +90,9 @@ const PagedMemoList = (props: Props) => { const t = useTranslate(); const { layout } = useView(); const queryClient = useQueryClient(); + const [isSelectionMode, setIsSelectionMode] = useState(false); + const [selectedMemoNames, setSelectedMemoNames] = useState>(() => new Set()); + const [selectionBarContainer, setSelectionBarContainer] = useState(null); // Show memo editor only on the root route const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname)); @@ -105,6 +113,42 @@ const PagedMemoList = (props: Props) => { // Apply custom sorting if provided, otherwise use memos directly const sortedMemoList = useMemo(() => (props.listSort ? props.listSort(memos) : memos), [memos, props.listSort]); + const selectionContextValue = useMemo(() => { + const selectedCount = selectedMemoNames.size; + return { + isSelectionMode, + selectedMemoNames, + selectedCount, + isSelected: (name: string) => selectedMemoNames.has(name), + toggleMemoSelection: (name: string) => { + setSelectedMemoNames((prev) => { + const next = new Set(prev); + if (next.has(name)) { + next.delete(name); + } else { + next.add(name); + } + return next; + }); + }, + enterSelectionMode: (name?: string) => { + setIsSelectionMode(true); + if (name) { + setSelectedMemoNames((prev) => { + if (prev.has(name)) return prev; + const next = new Set(prev); + next.add(name); + return next; + }); + } + }, + exitSelectionMode: () => { + setIsSelectionMode(false); + setSelectedMemoNames(new Set()); + }, + }; + }, [isSelectionMode, selectedMemoNames]); + // Prefetch creators when new data arrives to improve performance useEffect(() => { if (!data?.pages || !props.showCreator) return; @@ -133,6 +177,27 @@ const PagedMemoList = (props: Props) => { onFetchNext: fetchNextPage, }); + useEffect(() => { + setSelectionBarContainer(document.getElementById("memo-selection-actions")); + }, []); + + useEffect(() => { + if (!isSelectionMode || selectedMemoNames.size === 0) return; + const memoNameSet = new Set(sortedMemoList.map((memo) => memo.name)); + setSelectedMemoNames((prev) => { + let changed = false; + const next = new Set(); + for (const name of prev) { + if (memoNameSet.has(name)) { + next.add(name); + } else { + changed = true; + } + } + return changed ? next : prev; + }); + }, [isSelectionMode, selectedMemoNames, sortedMemoList]); + // Infinite scroll: fetch more when user scrolls near bottom useEffect(() => { if (!hasNextPage) return; @@ -150,6 +215,7 @@ const PagedMemoList = (props: Props) => { const children = (
+ {/* Show skeleton loader during initial load */} {isLoading ? ( @@ -192,7 +258,7 @@ const PagedMemoList = (props: Props) => {
); - return children; + return {children}; }; const BackToTop = () => { @@ -230,3 +296,116 @@ const BackToTop = () => { }; export default PagedMemoList; + +const MemoSelectionBar = ({ memoList, container }: { memoList: Memo[]; container: HTMLElement | null }) => { + const t = useTranslate(); + const selection = useMemoSelection(); + const { mutateAsync: updateMemo } = useUpdateMemo(); + const { mutateAsync: deleteMemo } = useDeleteMemo(); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + + if (!selection || !selection.isSelectionMode || !container) { + return null; + } + + const selectedMemos = memoList.filter((memo) => selection.selectedMemoNames.has(memo.name)); + const selectedCount = selection.selectedCount; + + const handleBulkPin = async () => { + if (selectedCount === 0) return; + const targets = selectedMemos.filter((memo) => !memo.pinned); + if (targets.length === 0) return; + try { + await Promise.all( + targets.map((memo) => updateMemo({ update: { name: memo.name, pinned: true }, updateMask: ["pinned"] })), + ); + toast.success(t("message.pinned-selected-memos")); + } catch (error: unknown) { + handleError(error, toast.error, { + context: "Bulk pin memos", + fallbackMessage: "Failed to pin selected memos", + }); + } + }; + + const handleBulkArchive = async () => { + if (selectedCount === 0) return; + const targets = selectedMemos.filter((memo) => memo.state !== State.ARCHIVED); + if (targets.length === 0) return; + try { + await Promise.all( + targets.map((memo) => updateMemo({ update: { name: memo.name, state: State.ARCHIVED }, updateMask: ["state"] })), + ); + toast.success(t("message.archived-selected-memos")); + } catch (error: unknown) { + handleError(error, toast.error, { + context: "Bulk archive memos", + fallbackMessage: "Failed to archive selected memos", + }); + } + }; + + const confirmBulkDelete = async () => { + if (selectedCount === 0) return; + try { + await Promise.all(selectedMemos.map((memo) => deleteMemo(memo.name))); + toast.success(t("message.deleted-selected-memos")); + selection.exitSelectionMode(); + } catch (error: unknown) { + handleError(error, toast.error, { + context: "Bulk delete memos", + fallbackMessage: "Failed to delete selected memos", + }); + } + }; + + return createPortal( +
+ {t("memo.selected-count", { count: selectedCount })} +
+ + + + +
+ + +
, + container, + ); +}; diff --git a/web/src/components/Settings/MemoRelatedSettings.tsx b/web/src/components/Settings/MemoRelatedSettings.tsx index d46ce652f..faceb7f39 100644 --- a/web/src/components/Settings/MemoRelatedSettings.tsx +++ b/web/src/components/Settings/MemoRelatedSettings.tsx @@ -44,7 +44,7 @@ const MemoRelatedSettings = () => { }; const handleUpdateSetting = async () => { - if (memoRelatedSetting.reactions.length === 0) { + if (!memoRelatedSetting.disableReactions && memoRelatedSetting.reactions.length === 0) { toast.error("Reactions must not be empty."); return; } @@ -103,31 +103,42 @@ const MemoRelatedSettings = () => { -
- {memoRelatedSetting.reactions.map((reactionType) => ( - - {reactionType} - updatePartialSetting({ reactions: memoRelatedSetting.reactions.filter((r) => r !== reactionType) })} - > - - - - ))} -
- setEditingReaction(event.target.value.trim())} - onKeyDown={(e) => e.key === "Enter" && upsertReaction()} - /> - + + updatePartialSetting({ disableReactions: checked })} + /> + + + {!memoRelatedSetting.disableReactions ? ( +
+ {memoRelatedSetting.reactions.map((reactionType) => ( + + {reactionType} + updatePartialSetting({ reactions: memoRelatedSetting.reactions.filter((r) => r !== reactionType) })} + > + + + + ))} +
+ setEditingReaction(event.target.value.trim())} + onKeyDown={(e) => e.key === "Enter" && upsertReaction()} + /> + +
-
+ ) : ( +

{t("setting.memo-related-settings.reactions-disabled")}

+ )}
diff --git a/web/src/contexts/MemoSelectionContext.tsx b/web/src/contexts/MemoSelectionContext.tsx new file mode 100644 index 000000000..fa0f620ea --- /dev/null +++ b/web/src/contexts/MemoSelectionContext.tsx @@ -0,0 +1,15 @@ +import { createContext, useContext } from "react"; + +export interface MemoSelectionContextValue { + isSelectionMode: boolean; + selectedMemoNames: Set; + selectedCount: number; + isSelected: (name: string) => boolean; + toggleMemoSelection: (name: string) => void; + enterSelectionMode: (name?: string) => void; + exitSelectionMode: () => void; +} + +export const MemoSelectionContext = createContext(null); + +export const useMemoSelection = () => useContext(MemoSelectionContext); diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx index 55570641e..ef413c146 100644 --- a/web/src/layouts/MainLayout.tsx +++ b/web/src/layouts/MainLayout.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { matchPath, Outlet, useLocation } from "react-router-dom"; import type { MemoExplorerContext } from "@/components/MemoExplorer"; import { MemoExplorer, MemoExplorerDrawer } from "@/components/MemoExplorer"; +import DesktopHeader from "@/components/DesktopHeader"; import MobileHeader from "@/components/MobileHeader"; import { userServiceClient } from "@/connect"; import useCurrentUser from "@/hooks/useCurrentUser"; @@ -80,7 +81,12 @@ const MainLayout = () => {
)}
-
+ {md && ( +
+ +
+ )} +
diff --git a/web/src/locales/en.json b/web/src/locales/en.json index d4598eea1..39aa558b0 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -158,6 +158,8 @@ "count-memos-in-date": "{{count}} {{memos}} in {{date}}", "delete-confirm": "Are you sure you want to delete this memo?", "delete-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.", + "delete-selected-confirm": "Are you sure you want to delete the selected memos?", + "delete-selected-confirm-description": "This action is irreversible. Attachments, links, and references will also be removed.", "direction": "Direction", "direction-asc": "Ascending", "direction-desc": "Descending", @@ -171,6 +173,7 @@ "remove-completed-task-list-items": "Remove done", "remove-completed-task-list-items-confirm": "Are you sure you want to remove all completed to-dos? THIS ACTION IS IRREVERSIBLE", "search-placeholder": "Search memos...", + "selected-count": "{{count}} selected", "show-less": "Show less", "show-more": "Show more", "to-do": "To-do", @@ -186,8 +189,10 @@ }, "message": { "archived-successfully": "Archived successfully", + "archived-selected-memos": "Archived selected memos", "change-memo-created-time": "Change memo created time", "copied": "Copied", + "deleted-selected-memos": "Deleted selected memos", "deleted-successfully": "Memo deleted successfully", "description-is-required": "Description is required", "failed-to-embed-memo": "Failed to embed memo", @@ -199,6 +204,7 @@ "no-data": "No data found.", "password-changed": "Password Changed", "password-not-match": "Passwords do not match.", + "pinned-selected-memos": "Pinned selected memos", "remove-completed-task-list-items-successfully": "The removal was successful", "restored-successfully": "Restored successfully", "succeed-copy-content": "Content copied successfully.", @@ -307,6 +313,8 @@ "memo-related": "Memo", "memo-related-settings": { "content-lenght-limit": "Content length limit (Byte)", + "disable-reactions": "Disable reactions", + "reactions-disabled": "Reactions are disabled for this instance.", "enable-blur-nsfw-content": "Enable sensitive content (NSFW) blurring", "enable-memo-comments": "Enable memo comments", "enable-memo-location": "Enable memo location", diff --git a/web/src/types/proto/api/v1/instance_service_pb.ts b/web/src/types/proto/api/v1/instance_service_pb.ts index 5d4163bf1..c0ef5122d 100644 --- a/web/src/types/proto/api/v1/instance_service_pb.ts +++ b/web/src/types/proto/api/v1/instance_service_pb.ts @@ -16,7 +16,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file api/v1/instance_service.proto. */ export const file_api_v1_instance_service: GenFile = /*@__PURE__*/ - fileDesc("Ch1hcGkvdjEvaW5zdGFuY2Vfc2VydmljZS5wcm90bxIMbWVtb3MuYXBpLnYxIlsKD0luc3RhbmNlUHJvZmlsZRIPCgd2ZXJzaW9uGAIgASgJEgwKBGRlbW8YAyABKAgSFAoMaW5zdGFuY2VfdXJsGAYgASgJEhMKC2luaXRpYWxpemVkGAcgASgIIhsKGUdldEluc3RhbmNlUHJvZmlsZVJlcXVlc3QiswsKD0luc3RhbmNlU2V0dGluZxIRCgRuYW1lGAEgASgJQgPgQQgSRwoPZ2VuZXJhbF9zZXR0aW5nGAIgASgLMiwubWVtb3MuYXBpLnYxLkluc3RhbmNlU2V0dGluZy5HZW5lcmFsU2V0dGluZ0gAEkcKD3N0b3JhZ2Vfc2V0dGluZxgDIAEoCzIsLm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmcuU3RvcmFnZVNldHRpbmdIABJQChRtZW1vX3JlbGF0ZWRfc2V0dGluZxgEIAEoCzIwLm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmcuTWVtb1JlbGF0ZWRTZXR0aW5nSAAahwMKDkdlbmVyYWxTZXR0aW5nEiIKGmRpc2FsbG93X3VzZXJfcmVnaXN0cmF0aW9uGAIgASgIEh4KFmRpc2FsbG93X3Bhc3N3b3JkX2F1dGgYAyABKAgSGQoRYWRkaXRpb25hbF9zY3JpcHQYBCABKAkSGAoQYWRkaXRpb25hbF9zdHlsZRgFIAEoCRJSCg5jdXN0b21fcHJvZmlsZRgGIAEoCzI6Lm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmcuR2VuZXJhbFNldHRpbmcuQ3VzdG9tUHJvZmlsZRIdChV3ZWVrX3N0YXJ0X2RheV9vZmZzZXQYByABKAUSIAoYZGlzYWxsb3dfY2hhbmdlX3VzZXJuYW1lGAggASgIEiAKGGRpc2FsbG93X2NoYW5nZV9uaWNrbmFtZRgJIAEoCBpFCg1DdXN0b21Qcm9maWxlEg0KBXRpdGxlGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEhAKCGxvZ29fdXJsGAMgASgJGroDCg5TdG9yYWdlU2V0dGluZxJOCgxzdG9yYWdlX3R5cGUYASABKA4yOC5tZW1vcy5hcGkudjEuSW5zdGFuY2VTZXR0aW5nLlN0b3JhZ2VTZXR0aW5nLlN0b3JhZ2VUeXBlEhkKEWZpbGVwYXRoX3RlbXBsYXRlGAIgASgJEhwKFHVwbG9hZF9zaXplX2xpbWl0X21iGAMgASgDEkgKCXMzX2NvbmZpZxgEIAEoCzI1Lm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmcuU3RvcmFnZVNldHRpbmcuUzNDb25maWcahgEKCFMzQ29uZmlnEhUKDWFjY2Vzc19rZXlfaWQYASABKAkSGQoRYWNjZXNzX2tleV9zZWNyZXQYAiABKAkSEAoIZW5kcG9pbnQYAyABKAkSDgoGcmVnaW9uGAQgASgJEg4KBmJ1Y2tldBgFIAEoCRIWCg51c2VfcGF0aF9zdHlsZRgGIAEoCCJMCgtTdG9yYWdlVHlwZRIcChhTVE9SQUdFX1RZUEVfVU5TUEVDSUZJRUQQABIMCghEQVRBQkFTRRABEgkKBUxPQ0FMEAISBgoCUzMQAxqtAQoSTWVtb1JlbGF0ZWRTZXR0aW5nEiIKGmRpc2FsbG93X3B1YmxpY192aXNpYmlsaXR5GAEgASgIEiAKGGRpc3BsYXlfd2l0aF91cGRhdGVfdGltZRgCIAEoCBIcChRjb250ZW50X2xlbmd0aF9saW1pdBgDIAEoBRIgChhlbmFibGVfZG91YmxlX2NsaWNrX2VkaXQYBCABKAgSEQoJcmVhY3Rpb25zGAcgAygJIkYKA0tleRITCg9LRVlfVU5TUEVDSUZJRUQQABILCgdHRU5FUkFMEAESCwoHU1RPUkFHRRACEhAKDE1FTU9fUkVMQVRFRBADOmHqQV4KHG1lbW9zLmFwaS52MS9JbnN0YW5jZVNldHRpbmcSG2luc3RhbmNlL3NldHRpbmdzL3tzZXR0aW5nfSoQaW5zdGFuY2VTZXR0aW5nczIPaW5zdGFuY2VTZXR0aW5nQgcKBXZhbHVlIk8KGUdldEluc3RhbmNlU2V0dGluZ1JlcXVlc3QSMgoEbmFtZRgBIAEoCUIk4EEC+kEeChxtZW1vcy5hcGkudjEvSW5zdGFuY2VTZXR0aW5nIokBChxVcGRhdGVJbnN0YW5jZVNldHRpbmdSZXF1ZXN0EjMKB3NldHRpbmcYASABKAsyHS5tZW1vcy5hcGkudjEuSW5zdGFuY2VTZXR0aW5nQgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQEy2wMKD0luc3RhbmNlU2VydmljZRJ+ChJHZXRJbnN0YW5jZVByb2ZpbGUSJy5tZW1vcy5hcGkudjEuR2V0SW5zdGFuY2VQcm9maWxlUmVxdWVzdBodLm1lbW9zLmFwaS52MS5JbnN0YW5jZVByb2ZpbGUiIILT5JMCGhIYL2FwaS92MS9pbnN0YW5jZS9wcm9maWxlEo8BChJHZXRJbnN0YW5jZVNldHRpbmcSJy5tZW1vcy5hcGkudjEuR2V0SW5zdGFuY2VTZXR0aW5nUmVxdWVzdBodLm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmciMdpBBG5hbWWC0+STAiQSIi9hcGkvdjEve25hbWU9aW5zdGFuY2Uvc2V0dGluZ3MvKn0StQEKFVVwZGF0ZUluc3RhbmNlU2V0dGluZxIqLm1lbW9zLmFwaS52MS5VcGRhdGVJbnN0YW5jZVNldHRpbmdSZXF1ZXN0Gh0ubWVtb3MuYXBpLnYxLkluc3RhbmNlU2V0dGluZyJR2kETc2V0dGluZyx1cGRhdGVfbWFza4LT5JMCNToHc2V0dGluZzIqL2FwaS92MS97c2V0dGluZy5uYW1lPWluc3RhbmNlL3NldHRpbmdzLyp9QqwBChBjb20ubWVtb3MuYXBpLnYxQhRJbnN0YW5jZVNlcnZpY2VQcm90b1ABWjBnaXRodWIuY29tL3VzZW1lbW9zL21lbW9zL3Byb3RvL2dlbi9hcGkvdjE7YXBpdjGiAgNNQViqAgxNZW1vcy5BcGkuVjHKAgxNZW1vc1xBcGlcVjHiAhhNZW1vc1xBcGlcVjFcR1BCTWV0YWRhdGHqAg5NZW1vczo6QXBpOjpWMWIGcHJvdG8z", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_field_mask]); + fileDesc("Ch1hcGkvdjEvaW5zdGFuY2Vfc2VydmljZS5wcm90bxIMbWVtb3MuYXBpLnYxIlsKD0luc3RhbmNlUHJvZmlsZRIPCgd2ZXJzaW9uGAIgASgJEgwKBGRlbW8YAyABKAgSFAoMaW5zdGFuY2VfdXJsGAYgASgJEhMKC2luaXRpYWxpemVkGAcgASgIIhsKGUdldEluc3RhbmNlUHJvZmlsZVJlcXVlc3QizgsKD0luc3RhbmNlU2V0dGluZxIRCgRuYW1lGAEgASgJQgPgQQgSRwoPZ2VuZXJhbF9zZXR0aW5nGAIgASgLMiwubWVtb3MuYXBpLnYxLkluc3RhbmNlU2V0dGluZy5HZW5lcmFsU2V0dGluZ0gAEkcKD3N0b3JhZ2Vfc2V0dGluZxgDIAEoCzIsLm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmcuU3RvcmFnZVNldHRpbmdIABJQChRtZW1vX3JlbGF0ZWRfc2V0dGluZxgEIAEoCzIwLm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmcuTWVtb1JlbGF0ZWRTZXR0aW5nSAAahwMKDkdlbmVyYWxTZXR0aW5nEiIKGmRpc2FsbG93X3VzZXJfcmVnaXN0cmF0aW9uGAIgASgIEh4KFmRpc2FsbG93X3Bhc3N3b3JkX2F1dGgYAyABKAgSGQoRYWRkaXRpb25hbF9zY3JpcHQYBCABKAkSGAoQYWRkaXRpb25hbF9zdHlsZRgFIAEoCRJSCg5jdXN0b21fcHJvZmlsZRgGIAEoCzI6Lm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmcuR2VuZXJhbFNldHRpbmcuQ3VzdG9tUHJvZmlsZRIdChV3ZWVrX3N0YXJ0X2RheV9vZmZzZXQYByABKAUSIAoYZGlzYWxsb3dfY2hhbmdlX3VzZXJuYW1lGAggASgIEiAKGGRpc2FsbG93X2NoYW5nZV9uaWNrbmFtZRgJIAEoCBpFCg1DdXN0b21Qcm9maWxlEg0KBXRpdGxlGAEgASgJEhMKC2Rlc2NyaXB0aW9uGAIgASgJEhAKCGxvZ29fdXJsGAMgASgJGroDCg5TdG9yYWdlU2V0dGluZxJOCgxzdG9yYWdlX3R5cGUYASABKA4yOC5tZW1vcy5hcGkudjEuSW5zdGFuY2VTZXR0aW5nLlN0b3JhZ2VTZXR0aW5nLlN0b3JhZ2VUeXBlEhkKEWZpbGVwYXRoX3RlbXBsYXRlGAIgASgJEhwKFHVwbG9hZF9zaXplX2xpbWl0X21iGAMgASgDEkgKCXMzX2NvbmZpZxgEIAEoCzI1Lm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmcuU3RvcmFnZVNldHRpbmcuUzNDb25maWcahgEKCFMzQ29uZmlnEhUKDWFjY2Vzc19rZXlfaWQYASABKAkSGQoRYWNjZXNzX2tleV9zZWNyZXQYAiABKAkSEAoIZW5kcG9pbnQYAyABKAkSDgoGcmVnaW9uGAQgASgJEg4KBmJ1Y2tldBgFIAEoCRIWCg51c2VfcGF0aF9zdHlsZRgGIAEoCCJMCgtTdG9yYWdlVHlwZRIcChhTVE9SQUdFX1RZUEVfVU5TUEVDSUZJRUQQABIMCghEQVRBQkFTRRABEgkKBUxPQ0FMEAISBgoCUzMQAxrIAQoSTWVtb1JlbGF0ZWRTZXR0aW5nEiIKGmRpc2FsbG93X3B1YmxpY192aXNpYmlsaXR5GAEgASgIEiAKGGRpc3BsYXlfd2l0aF91cGRhdGVfdGltZRgCIAEoCBIcChRjb250ZW50X2xlbmd0aF9saW1pdBgDIAEoBRIgChhlbmFibGVfZG91YmxlX2NsaWNrX2VkaXQYBCABKAgSGQoRZGlzYWJsZV9yZWFjdGlvbnMYCCABKAgSEQoJcmVhY3Rpb25zGAcgAygJIkYKA0tleRITCg9LRVlfVU5TUEVDSUZJRUQQABILCgdHRU5FUkFMEAESCwoHU1RPUkFHRRACEhAKDE1FTU9fUkVMQVRFRBADOmHqQV4KHG1lbW9zLmFwaS52MS9JbnN0YW5jZVNldHRpbmcSG2luc3RhbmNlL3NldHRpbmdzL3tzZXR0aW5nfSoQaW5zdGFuY2VTZXR0aW5nczIPaW5zdGFuY2VTZXR0aW5nQgcKBXZhbHVlIk8KGUdldEluc3RhbmNlU2V0dGluZ1JlcXVlc3QSMgoEbmFtZRgBIAEoCUIk4EEC+kEeChxtZW1vcy5hcGkudjEvSW5zdGFuY2VTZXR0aW5nIokBChxVcGRhdGVJbnN0YW5jZVNldHRpbmdSZXF1ZXN0EjMKB3NldHRpbmcYASABKAsyHS5tZW1vcy5hcGkudjEuSW5zdGFuY2VTZXR0aW5nQgPgQQISNAoLdXBkYXRlX21hc2sYAiABKAsyGi5nb29nbGUucHJvdG9idWYuRmllbGRNYXNrQgPgQQEy2wMKD0luc3RhbmNlU2VydmljZRJ+ChJHZXRJbnN0YW5jZVByb2ZpbGUSJy5tZW1vcy5hcGkudjEuR2V0SW5zdGFuY2VQcm9maWxlUmVxdWVzdBodLm1lbW9zLmFwaS52MS5JbnN0YW5jZVByb2ZpbGUiIILT5JMCGhIYL2FwaS92MS9pbnN0YW5jZS9wcm9maWxlEo8BChJHZXRJbnN0YW5jZVNldHRpbmcSJy5tZW1vcy5hcGkudjEuR2V0SW5zdGFuY2VTZXR0aW5nUmVxdWVzdBodLm1lbW9zLmFwaS52MS5JbnN0YW5jZVNldHRpbmciMdpBBG5hbWWC0+STAiQSIi9hcGkvdjEve25hbWU9aW5zdGFuY2Uvc2V0dGluZ3MvKn0StQEKFVVwZGF0ZUluc3RhbmNlU2V0dGluZxIqLm1lbW9zLmFwaS52MS5VcGRhdGVJbnN0YW5jZVNldHRpbmdSZXF1ZXN0Gh0ubWVtb3MuYXBpLnYxLkluc3RhbmNlU2V0dGluZyJR2kETc2V0dGluZyx1cGRhdGVfbWFza4LT5JMCNToHc2V0dGluZzIqL2FwaS92MS97c2V0dGluZy5uYW1lPWluc3RhbmNlL3NldHRpbmdzLyp9QqwBChBjb20ubWVtb3MuYXBpLnYxQhRJbnN0YW5jZVNlcnZpY2VQcm90b1ABWjBnaXRodWIuY29tL3VzZW1lbW9zL21lbW9zL3Byb3RvL2dlbi9hcGkvdjE7YXBpdjGiAgNNQViqAgxNZW1vcy5BcGkuVjHKAgxNZW1vc1xBcGlcVjHiAhhNZW1vc1xBcGlcVjFcR1BCTWV0YWRhdGHqAg5NZW1vczo6QXBpOjpWMWIGcHJvdG8z", [file_google_api_annotations, file_google_api_client, file_google_api_field_behavior, file_google_api_resource, file_google_protobuf_field_mask]); /** * Instance profile message containing basic instance information. @@ -384,6 +384,13 @@ export type InstanceSetting_MemoRelatedSetting = Message<"memos.api.v1.InstanceS */ enableDoubleClickEdit: boolean; + /** + * disable_reactions disables memo reactions across the UI and API. + * + * @generated from field: bool disable_reactions = 8; + */ + disableReactions: boolean; + /** * reactions is the list of reactions. *