This commit is contained in:
Brent Bilis 2026-01-26 19:39:15 -05:00 committed by GitHub
commit d1db0c5c13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 450 additions and 66 deletions

View File

@ -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;
}

View File

@ -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" +

View File

@ -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:

View File

@ -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" +

View File

@ -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;
}

View File

@ -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,
}
}

View File

@ -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")

View File

@ -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)

View File

@ -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{

View File

@ -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 (
<div className="w-full flex flex-col gap-2">
<div className="w-full flex flex-row justify-between items-center">
<Link to="/" className="flex items-center gap-2 hover:opacity-80 transition-opacity">
<UserAvatar className="shrink-0 w-6 h-6 rounded-md" avatarUrl={avatarUrl} />
<span className="font-bold text-lg leading-6 text-foreground truncate">{title}</span>
</Link>
<div className="flex flex-row justify-end items-center">
<div id="memo-selection-actions" className="flex flex-row justify-end items-center" />
</div>
</div>
</div>
);
};
export default DesktopHeader;

View File

@ -81,6 +81,12 @@ const MemoActionMenu = (props: MemoActionMenuProps) => {
<Edit3Icon className="w-4 h-auto" />
{t("common.edit")}
</DropdownMenuItem>
{props.onSelect && (
<DropdownMenuItem onClick={props.onSelect}>
<SquareCheckIcon className="w-4 h-auto" />
{t("common.select")}
</DropdownMenuItem>
)}
</>
)}

View File

@ -5,6 +5,7 @@ export interface MemoActionMenuProps {
readonly?: boolean;
className?: string;
onEdit?: () => void;
onSelect?: () => void;
}
export interface UseMemoActionHandlersReturn {

View File

@ -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;
}

View File

@ -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);

View File

@ -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<MemoViewProps> = (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<MemoViewProps> = (props: MemoViewProps) => {
return (
<MemoViewContext.Provider value={contextValue}>
<article className={cn(MEMO_CARD_BASE_CLASSES, className)} ref={cardRef} tabIndex={readonly ? -1 : 0}>
<article
className={cn(
MEMO_CARD_BASE_CLASSES,
className,
selection?.isSelectionMode && "ring-1 ring-border/60",
isSelected && "ring-2 ring-primary/50 bg-accent/20",
)}
ref={cardRef}
tabIndex={readonly ? -1 : 0}
>
<MemoHeader
showCreator={props.showCreator}
showVisibility={props.showVisibility}

View File

@ -2,7 +2,9 @@ import { timestampDate } from "@bufbuild/protobuf/wkt";
import { BookmarkIcon, EyeOffIcon, MessageCircleMoreIcon } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { useMemoSelection } from "@/contexts/MemoSelectionContext";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Checkbox } from "@/components/ui/checkbox";
import i18n from "@/i18n";
import { cn } from "@/lib/utils";
import { Visibility } from "@/types/proto/api/v1/memo_service_pb";
@ -30,6 +32,8 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({
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<MemoHeaderProps> = ({
</div>
<div className="flex flex-row justify-end items-center select-none shrink-0 gap-2">
{selection && selection.isSelectionMode && !readonly && (
<Checkbox
checked={isSelected}
onCheckedChange={() => selection.toggleMemoSelection(memo.name)}
onClick={(event) => event.stopPropagation()}
aria-label={t("common.select")}
/>
)}
{currentUser && !isArchived && (
<ReactionSelector
className={cn("border-none w-auto h-auto", reactionSelectorOpen && "block!", "block sm:hidden sm:group-hover:block")}
@ -106,7 +118,7 @@ const MemoHeader: React.FC<MemoHeaderProps> = ({
</span>
)}
<MemoActionMenu memo={memo} readonly={readonly} onEdit={onEdit} />
<MemoActionMenu memo={memo} readonly={readonly} onEdit={onEdit} onSelect={selection ? () => selection.enterSelectionMode(memo.name) : undefined} />
</div>
</div>
);

View File

@ -19,13 +19,16 @@ const MobileHeader = (props: Props) => {
return (
<div
className={cn(
"sticky top-0 pt-3 pb-2 sm:pt-2 px-4 sm:px-6 sm:mb-1 bg-background bg-opacity-80 backdrop-blur-lg flex flex-row justify-between items-center w-full h-auto flex-nowrap shrink-0 z-1",
"sticky top-0 pt-3 pb-2 sm:pt-2 px-4 sm:px-6 sm:mb-1 bg-background bg-opacity-80 backdrop-blur-lg flex flex-col w-full h-auto shrink-0 z-1",
offsetTop > 0 && "shadow-md",
className,
)}
>
{!sm && <NavigationDrawer />}
<div className="w-full flex flex-row justify-end items-center">{children}</div>
<div className="flex flex-row justify-between items-center w-full flex-nowrap">
{!sm && <NavigationDrawer />}
<div className="w-full flex flex-row justify-end items-center gap-2">{children}</div>
</div>
<div id="memo-selection-actions" className="mt-2 flex flex-row justify-end items-center" />
</div>
);
};

View File

@ -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<Set<string>>(() => new Set());
const [selectionBarContainer, setSelectionBarContainer] = useState<HTMLElement | null>(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<string>();
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 = (
<div className="flex flex-col justify-start items-start w-full max-w-full">
<MemoSelectionBar memoList={sortedMemoList} container={selectionBarContainer} />
{/* Show skeleton loader during initial load */}
{isLoading ? (
<Skeleton showCreator={props.showCreator} count={4} />
@ -192,7 +258,7 @@ const PagedMemoList = (props: Props) => {
</div>
);
return children;
return <MemoSelectionContext.Provider value={selectionContextValue}>{children}</MemoSelectionContext.Provider>;
};
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(
<div className="flex flex-row justify-end items-center gap-2 rounded-md border border-border/60 bg-accent/40 px-2 py-1">
<span className="text-xs text-muted-foreground">{t("memo.selected-count", { count: selectedCount })}</span>
<div className="flex flex-row justify-end items-center gap-1">
<Button
variant="ghost"
size="icon"
disabled={selectedCount === 0}
onClick={handleBulkPin}
aria-label={t("common.pin")}
>
<BookmarkPlusIcon className="w-4 h-auto" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={selectedCount === 0}
onClick={handleBulkArchive}
aria-label={t("common.archive")}
>
<ArchiveIcon className="w-4 h-auto" />
</Button>
<Button
variant="ghost"
size="icon"
disabled={selectedCount === 0}
onClick={() => setDeleteDialogOpen(true)}
aria-label={t("common.delete")}
>
<TrashIcon className="w-4 h-auto" />
</Button>
<Button variant="ghost" size="icon" onClick={selection.exitSelectionMode} aria-label={t("common.cancel")}>
<XIcon className="w-4 h-auto" />
</Button>
</div>
<ConfirmDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
title={t("memo.delete-selected-confirm")}
confirmLabel={t("common.delete")}
description={t("memo.delete-selected-confirm-description")}
cancelLabel={t("common.cancel")}
onConfirm={confirmBulkDelete}
confirmVariant="destructive"
/>
</div>,
container,
);
};

View File

@ -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 = () => {
</SettingGroup>
<SettingGroup title={t("setting.memo-related-settings.reactions")} showSeparator>
<div className="w-full flex flex-row flex-wrap gap-2">
{memoRelatedSetting.reactions.map((reactionType) => (
<Badge key={reactionType} variant="outline" className="flex items-center gap-1.5 h-8 px-3">
{reactionType}
<span
className="cursor-pointer text-muted-foreground hover:text-destructive"
onClick={() => updatePartialSetting({ reactions: memoRelatedSetting.reactions.filter((r) => r !== reactionType) })}
>
<X className="w-3.5 h-3.5" />
</span>
</Badge>
))}
<div className="flex items-center gap-1.5">
<Input
className="w-32 h-8"
placeholder={t("common.input")}
value={editingReaction}
onChange={(event) => setEditingReaction(event.target.value.trim())}
onKeyDown={(e) => e.key === "Enter" && upsertReaction()}
/>
<Button variant="ghost" size="sm" onClick={upsertReaction} className="h-8 w-8 p-0">
<CheckIcon className="w-4 h-4" />
</Button>
<SettingRow label={t("setting.memo-related-settings.disable-reactions")}>
<Switch
checked={memoRelatedSetting.disableReactions}
onCheckedChange={(checked) => updatePartialSetting({ disableReactions: checked })}
/>
</SettingRow>
{!memoRelatedSetting.disableReactions ? (
<div className="w-full flex flex-row flex-wrap gap-2">
{memoRelatedSetting.reactions.map((reactionType) => (
<Badge key={reactionType} variant="outline" className="flex items-center gap-1.5 h-8 px-3">
{reactionType}
<span
className="cursor-pointer text-muted-foreground hover:text-destructive"
onClick={() => updatePartialSetting({ reactions: memoRelatedSetting.reactions.filter((r) => r !== reactionType) })}
>
<X className="w-3.5 h-3.5" />
</span>
</Badge>
))}
<div className="flex items-center gap-1.5">
<Input
className="w-32 h-8"
placeholder={t("common.input")}
value={editingReaction}
onChange={(event) => setEditingReaction(event.target.value.trim())}
onKeyDown={(e) => e.key === "Enter" && upsertReaction()}
/>
<Button variant="ghost" size="sm" onClick={upsertReaction} className="h-8 w-8 p-0">
<CheckIcon className="w-4 h-4" />
</Button>
</div>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{t("setting.memo-related-settings.reactions-disabled")}</p>
)}
</SettingGroup>
<div className="w-full flex justify-end">

View File

@ -0,0 +1,15 @@
import { createContext, useContext } from "react";
export interface MemoSelectionContextValue {
isSelectionMode: boolean;
selectedMemoNames: Set<string>;
selectedCount: number;
isSelected: (name: string) => boolean;
toggleMemoSelection: (name: string) => void;
enterSelectionMode: (name?: string) => void;
exitSelectionMode: () => void;
}
export const MemoSelectionContext = createContext<MemoSelectionContextValue | null>(null);
export const useMemoSelection = () => useContext(MemoSelectionContext);

View File

@ -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 = () => {
</div>
)}
<div className={cn("w-full min-h-full", lg ? "pl-72" : md ? "pl-56" : "")}>
<div className={cn("w-full mx-auto px-4 sm:px-6 md:pt-6 pb-8")}>
{md && (
<div className="sticky top-0 z-1 bg-background/80 backdrop-blur-lg border-b border-border/60 px-4 sm:px-6 py-3">
<DesktopHeader />
</div>
)}
<div className={cn("w-full mx-auto px-4 sm:px-6 md:pt-4 pb-8")}>
<Outlet />
</div>
</div>

View File

@ -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",

View File

@ -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.
*