chore: theme in user setting

pull/4833/head
Steven 3 months ago
parent f907619752
commit 533591af2b

@ -369,6 +369,11 @@ message UserSetting {
// The default visibility of the memo. // The default visibility of the memo.
string memo_visibility = 4 [(google.api.field_behavior) = OPTIONAL]; string memo_visibility = 4 [(google.api.field_behavior) = OPTIONAL];
// The preferred theme of the user.
// This references a CSS file in the web/public/themes/ directory.
// If not set, the default theme will be used.
string theme = 5 [(google.api.field_behavior) = OPTIONAL];
} }
message GetUserSettingRequest { message GetUserSettingRequest {

@ -943,6 +943,10 @@ type UserSetting struct {
Appearance string `protobuf:"bytes,3,opt,name=appearance,proto3" json:"appearance,omitempty"` Appearance string `protobuf:"bytes,3,opt,name=appearance,proto3" json:"appearance,omitempty"`
// The default visibility of the memo. // The default visibility of the memo.
MemoVisibility string `protobuf:"bytes,4,opt,name=memo_visibility,json=memoVisibility,proto3" json:"memo_visibility,omitempty"` MemoVisibility string `protobuf:"bytes,4,opt,name=memo_visibility,json=memoVisibility,proto3" json:"memo_visibility,omitempty"`
// The preferred theme of the user.
// This references a CSS file in the web/public/themes/ directory.
// If not set, the default theme will be used.
Theme string `protobuf:"bytes,5,opt,name=theme,proto3" json:"theme,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -1005,6 +1009,13 @@ func (x *UserSetting) GetMemoVisibility() string {
return "" return ""
} }
func (x *UserSetting) GetTheme() string {
if x != nil {
return x.Theme
}
return ""
}
type GetUserSettingRequest struct { type GetUserSettingRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
// Required. The resource name of the user. // Required. The resource name of the user.
@ -2005,14 +2016,15 @@ const file_api_v1_user_service_proto_rawDesc = "" +
"\x16memos.api.v1/UserStats\x12\fusers/{user}*\tuserStats2\tuserStats\"D\n" + "\x16memos.api.v1/UserStats\x12\fusers/{user}*\tuserStats2\tuserStats\"D\n" +
"\x13GetUserStatsRequest\x12-\n" + "\x13GetUserStatsRequest\x12-\n" +
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" + "\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +
"\x11memos.api.v1/UserR\x04name\"\xde\x01\n" + "\x11memos.api.v1/UserR\x04name\"\xf9\x01\n" +
"\vUserSetting\x12\x17\n" + "\vUserSetting\x12\x17\n" +
"\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x1b\n" + "\x04name\x18\x01 \x01(\tB\x03\xe0A\bR\x04name\x12\x1b\n" +
"\x06locale\x18\x02 \x01(\tB\x03\xe0A\x01R\x06locale\x12#\n" + "\x06locale\x18\x02 \x01(\tB\x03\xe0A\x01R\x06locale\x12#\n" +
"\n" + "\n" +
"appearance\x18\x03 \x01(\tB\x03\xe0A\x01R\n" + "appearance\x18\x03 \x01(\tB\x03\xe0A\x01R\n" +
"appearance\x12,\n" + "appearance\x12,\n" +
"\x0fmemo_visibility\x18\x04 \x01(\tB\x03\xe0A\x01R\x0ememoVisibility:F\xeaAC\n" + "\x0fmemo_visibility\x18\x04 \x01(\tB\x03\xe0A\x01R\x0ememoVisibility\x12\x19\n" +
"\x05theme\x18\x05 \x01(\tB\x03\xe0A\x01R\x05theme:F\xeaAC\n" +
"\x18memos.api.v1/UserSetting\x12\fusers/{user}*\fuserSettings2\vuserSetting\"F\n" + "\x18memos.api.v1/UserSetting\x12\fusers/{user}*\fuserSettings2\vuserSetting\"F\n" +
"\x15GetUserSettingRequest\x12-\n" + "\x15GetUserSettingRequest\x12-\n" +
"\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" + "\x04name\x18\x01 \x01(\tB\x19\xe0A\x02\xfaA\x13\n" +

@ -2218,6 +2218,12 @@ paths:
memoVisibility: memoVisibility:
type: string type: string
description: The default visibility of the memo. description: The default visibility of the memo.
theme:
type: string
description: |-
The preferred theme of the user.
This references a CSS file in the web/public/themes/ directory.
If not set, the default theme will be used.
title: Required. The user setting to update. title: Required. The user setting to update.
required: required:
- setting - setting
@ -2866,6 +2872,12 @@ definitions:
memoVisibility: memoVisibility:
type: string type: string
description: The default visibility of the memo. description: The default visibility of the memo.
theme:
type: string
description: |-
The preferred theme of the user.
This references a CSS file in the web/public/themes/ directory.
If not set, the default theme will be used.
title: User settings message title: User settings message
apiv1Webhook: apiv1Webhook:
type: object type: object

@ -239,6 +239,9 @@ type GeneralUserSetting struct {
Appearance string `protobuf:"bytes,2,opt,name=appearance,proto3" json:"appearance,omitempty"` Appearance string `protobuf:"bytes,2,opt,name=appearance,proto3" json:"appearance,omitempty"`
// The user's memo visibility setting. // The user's memo visibility setting.
MemoVisibility string `protobuf:"bytes,3,opt,name=memo_visibility,json=memoVisibility,proto3" json:"memo_visibility,omitempty"` MemoVisibility string `protobuf:"bytes,3,opt,name=memo_visibility,json=memoVisibility,proto3" json:"memo_visibility,omitempty"`
// The user's theme preference.
// This references a CSS file in the web/public/themes/ directory.
Theme string `protobuf:"bytes,4,opt,name=theme,proto3" json:"theme,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -294,6 +297,13 @@ func (x *GeneralUserSetting) GetMemoVisibility() string {
return "" return ""
} }
func (x *GeneralUserSetting) GetTheme() string {
if x != nil {
return x.Theme
}
return ""
}
type SessionsUserSetting struct { type SessionsUserSetting struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Sessions []*SessionsUserSetting_Session `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` Sessions []*SessionsUserSetting_Session `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"`
@ -822,13 +832,14 @@ const file_store_user_setting_proto_rawDesc = "" +
"\rACCESS_TOKENS\x10\x03\x12\r\n" + "\rACCESS_TOKENS\x10\x03\x12\r\n" +
"\tSHORTCUTS\x10\x04\x12\f\n" + "\tSHORTCUTS\x10\x04\x12\f\n" +
"\bWEBHOOKS\x10\x05B\a\n" + "\bWEBHOOKS\x10\x05B\a\n" +
"\x05value\"u\n" + "\x05value\"\x8b\x01\n" +
"\x12GeneralUserSetting\x12\x16\n" + "\x12GeneralUserSetting\x12\x16\n" +
"\x06locale\x18\x01 \x01(\tR\x06locale\x12\x1e\n" + "\x06locale\x18\x01 \x01(\tR\x06locale\x12\x1e\n" +
"\n" + "\n" +
"appearance\x18\x02 \x01(\tR\n" + "appearance\x18\x02 \x01(\tR\n" +
"appearance\x12'\n" + "appearance\x12'\n" +
"\x0fmemo_visibility\x18\x03 \x01(\tR\x0ememoVisibility\"\xf3\x03\n" + "\x0fmemo_visibility\x18\x03 \x01(\tR\x0ememoVisibility\x12\x14\n" +
"\x05theme\x18\x04 \x01(\tR\x05theme\"\xf3\x03\n" +
"\x13SessionsUserSetting\x12D\n" + "\x13SessionsUserSetting\x12D\n" +
"\bsessions\x18\x01 \x03(\v2(.memos.store.SessionsUserSetting.SessionR\bsessions\x1a\xfd\x01\n" + "\bsessions\x18\x01 \x03(\v2(.memos.store.SessionsUserSetting.SessionR\bsessions\x1a\xfd\x01\n" +
"\aSession\x12\x1d\n" + "\aSession\x12\x1d\n" +

@ -40,6 +40,9 @@ message GeneralUserSetting {
string appearance = 2; string appearance = 2;
// The user's memo visibility setting. // The user's memo visibility setting.
string memo_visibility = 3; string memo_visibility = 3;
// The user's theme preference.
// This references a CSS file in the web/public/themes/ directory.
string theme = 4;
} }
message SessionsUserSetting { message SessionsUserSetting {

@ -249,7 +249,8 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
return nil, status.Errorf(codes.Internal, "failed to get workspace general setting: %v", err) return nil, status.Errorf(codes.Internal, "failed to get workspace general setting: %v", err)
} }
for _, field := range request.UpdateMask.Paths { for _, field := range request.UpdateMask.Paths {
if field == "username" { switch field {
case "username":
if workspaceGeneralSetting.DisallowChangeUsername { if workspaceGeneralSetting.DisallowChangeUsername {
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change username") return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change username")
} }
@ -257,35 +258,35 @@ func (s *APIV1Service) UpdateUser(ctx context.Context, request *v1pb.UpdateUserR
return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username) return nil, status.Errorf(codes.InvalidArgument, "invalid username: %s", request.User.Username)
} }
update.Username = &request.User.Username update.Username = &request.User.Username
} else if field == "display_name" { case "display_name":
if workspaceGeneralSetting.DisallowChangeNickname { if workspaceGeneralSetting.DisallowChangeNickname {
return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change nickname") return nil, status.Errorf(codes.PermissionDenied, "permission denied: disallow change nickname")
} }
update.Nickname = &request.User.DisplayName update.Nickname = &request.User.DisplayName
} else if field == "email" { case "email":
update.Email = &request.User.Email update.Email = &request.User.Email
} else if field == "avatar_url" { case "avatar_url":
update.AvatarURL = &request.User.AvatarUrl update.AvatarURL = &request.User.AvatarUrl
} else if field == "description" { case "description":
update.Description = &request.User.Description update.Description = &request.User.Description
} else if field == "role" { case "role":
// Only allow admin to update role. // Only allow admin to update role.
if currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost { if currentUser.Role != store.RoleAdmin && currentUser.Role != store.RoleHost {
return nil, status.Errorf(codes.PermissionDenied, "permission denied") return nil, status.Errorf(codes.PermissionDenied, "permission denied")
} }
role := convertUserRoleToStore(request.User.Role) role := convertUserRoleToStore(request.User.Role)
update.Role = &role update.Role = &role
} else if field == "password" { case "password":
passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost) passwordHash, err := bcrypt.GenerateFromPassword([]byte(request.User.Password), bcrypt.DefaultCost)
if err != nil { if err != nil {
return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err) return nil, echo.NewHTTPError(http.StatusInternalServerError, "failed to generate password hash").SetInternal(err)
} }
passwordHashStr := string(passwordHash) passwordHashStr := string(passwordHash)
update.PasswordHash = &passwordHashStr update.PasswordHash = &passwordHashStr
} else if field == "state" { case "state":
rowStatus := convertStateToStore(request.User.State) rowStatus := convertStateToStore(request.User.State)
update.RowStatus = &rowStatus update.RowStatus = &rowStatus
} else { default:
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field) return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
} }
} }
@ -334,6 +335,7 @@ func getDefaultUserSetting() *v1pb.UserSetting {
Locale: "en", Locale: "en",
Appearance: "system", Appearance: "system",
MemoVisibility: "PRIVATE", MemoVisibility: "PRIVATE",
Theme: "",
} }
} }
@ -370,9 +372,24 @@ func (s *APIV1Service) GetUserSetting(ctx context.Context, request *v1pb.GetUser
userSettingMessage.Locale = general.Locale userSettingMessage.Locale = general.Locale
userSettingMessage.Appearance = general.Appearance userSettingMessage.Appearance = general.Appearance
userSettingMessage.MemoVisibility = general.MemoVisibility userSettingMessage.MemoVisibility = general.MemoVisibility
userSettingMessage.Theme = general.Theme
} }
} }
} }
// Backfill theme if empty: use workspace theme or default to "default"
if userSettingMessage.Theme == "" {
workspaceGeneralSetting, err := s.Store.GetWorkspaceGeneralSetting(ctx)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to get workspace general setting: %v", err)
}
workspaceTheme := workspaceGeneralSetting.Theme
if workspaceTheme == "" {
workspaceTheme = "default"
}
userSettingMessage.Theme = workspaceTheme
}
return userSettingMessage, nil return userSettingMessage, nil
} }
@ -411,6 +428,7 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
Locale: "en", Locale: "en",
Appearance: "system", Appearance: "system",
MemoVisibility: "PRIVATE", MemoVisibility: "PRIVATE",
Theme: "",
} }
// If there's an existing setting, use its values as defaults // If there's an existing setting, use its values as defaults
@ -419,6 +437,7 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
generalSetting.Locale = existing.Locale generalSetting.Locale = existing.Locale
generalSetting.Appearance = existing.Appearance generalSetting.Appearance = existing.Appearance
generalSetting.MemoVisibility = existing.MemoVisibility generalSetting.MemoVisibility = existing.MemoVisibility
generalSetting.Theme = existing.Theme
} }
// Apply updates based on the update mask // Apply updates based on the update mask
@ -430,6 +449,8 @@ func (s *APIV1Service) UpdateUserSetting(ctx context.Context, request *v1pb.Upda
generalSetting.Appearance = request.Setting.Appearance generalSetting.Appearance = request.Setting.Appearance
case "memo_visibility": case "memo_visibility":
generalSetting.MemoVisibility = request.Setting.MemoVisibility generalSetting.MemoVisibility = request.Setting.MemoVisibility
case "theme":
generalSetting.Theme = request.Setting.Theme
default: default:
return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field) return nil, status.Errorf(codes.InvalidArgument, "invalid update path: %s", field)
} }

@ -149,8 +149,14 @@ func convertWorkspaceGeneralSettingFromStore(setting *storepb.WorkspaceGeneralSe
if setting == nil { if setting == nil {
return nil return nil
} }
// Backfill theme if empty
theme := setting.Theme
if theme == "" {
theme = "default"
}
generalSetting := &v1pb.WorkspaceGeneralSetting{ generalSetting := &v1pb.WorkspaceGeneralSetting{
Theme: setting.Theme, Theme: theme,
DisallowUserRegistration: setting.DisallowUserRegistration, DisallowUserRegistration: setting.DisallowUserRegistration,
DisallowPasswordAuth: setting.DisallowPasswordAuth, DisallowPasswordAuth: setting.DisallowPasswordAuth,
AdditionalScript: setting.AdditionalScript, AdditionalScript: setting.AdditionalScript,

@ -104,10 +104,12 @@ const App = observer(() => {
}); });
}, [userSetting?.locale, userSetting?.appearance]); }, [userSetting?.locale, userSetting?.appearance]);
// Load theme when workspace setting changes, validate API response // Load theme when user setting changes (user theme is already backfilled with workspace theme)
useEffect(() => { useEffect(() => {
loadTheme(workspaceGeneralSetting.theme); if (userSetting?.theme) {
}, [workspaceGeneralSetting.theme]); loadTheme(userSetting.theme);
}
}, [userSetting?.theme]);
return <Outlet />; return <Outlet />;
}); });

@ -6,13 +6,12 @@ import { useTranslate } from "@/utils/i18n";
interface Props { interface Props {
value: Appearance; value: Appearance;
onChange: (appearance: Appearance) => void; onChange: (appearance: Appearance) => void;
className?: string;
} }
const appearanceList = ["system", "light", "dark"] as const; const appearanceList = ["system", "light", "dark"] as const;
const AppearanceSelect: FC<Props> = (props: Props) => { const AppearanceSelect: FC<Props> = (props: Props) => {
const { onChange, value, className } = props; const { onChange, value } = props;
const t = useTranslate(); const t = useTranslate();
const getPrefixIcon = (appearance: Appearance) => { const getPrefixIcon = (appearance: Appearance) => {
@ -32,7 +31,7 @@ const AppearanceSelect: FC<Props> = (props: Props) => {
return ( return (
<Select value={value} onValueChange={handleSelectChange}> <Select value={value} onValueChange={handleSelectChange}>
<SelectTrigger className={`min-w-40 w-auto whitespace-nowrap ${className ?? ""}`}> <SelectTrigger>
<SelectValue placeholder="Select appearance" /> <SelectValue placeholder="Select appearance" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

@ -5,12 +5,11 @@ import { locales } from "@/i18n";
interface Props { interface Props {
value: Locale; value: Locale;
className?: string;
onChange: (locale: Locale) => void; onChange: (locale: Locale) => void;
} }
const LocaleSelect: FC<Props> = (props: Props) => { const LocaleSelect: FC<Props> = (props: Props) => {
const { onChange, value, className } = props; const { onChange, value } = props;
const handleSelectChange = async (locale: Locale) => { const handleSelectChange = async (locale: Locale) => {
onChange(locale); onChange(locale);
@ -18,7 +17,7 @@ const LocaleSelect: FC<Props> = (props: Props) => {
return ( return (
<Select value={value} onValueChange={handleSelectChange}> <Select value={value} onValueChange={handleSelectChange}>
<SelectTrigger className={`min-w-40 w-auto whitespace-nowrap ${className ?? ""}`}> <SelectTrigger>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GlobeIcon className="w-4 h-auto" /> <GlobeIcon className="w-4 h-auto" />
<SelectValue placeholder="Select language" /> <SelectValue placeholder="Select language" />

@ -8,6 +8,7 @@ import { useTranslate } from "@/utils/i18n";
import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo"; import { convertVisibilityFromString, convertVisibilityToString } from "@/utils/memo";
import AppearanceSelect from "../AppearanceSelect"; import AppearanceSelect from "../AppearanceSelect";
import LocaleSelect from "../LocaleSelect"; import LocaleSelect from "../LocaleSelect";
import ThemeSelector from "../ThemeSelector";
import VisibilityIcon from "../VisibilityIcon"; import VisibilityIcon from "../VisibilityIcon";
import WebhookSection from "./WebhookSection"; import WebhookSection from "./WebhookSection";
@ -27,6 +28,10 @@ const PreferencesSection = observer(() => {
await userStore.updateUserSetting({ memoVisibility: value }, ["memo_visibility"]); await userStore.updateUserSetting({ memoVisibility: value }, ["memo_visibility"]);
}; };
const handleThemeChange = async (theme: string) => {
await userStore.updateUserSetting({ theme }, ["theme"]);
};
return ( return (
<div className="w-full flex flex-col gap-2 pt-2 pb-4"> <div className="w-full flex flex-col gap-2 pt-2 pb-4">
<p className="font-medium text-muted-foreground">{t("common.basic")}</p> <p className="font-medium text-muted-foreground">{t("common.basic")}</p>
@ -41,6 +46,11 @@ const PreferencesSection = observer(() => {
<AppearanceSelect value={setting.appearance as Appearance} onChange={handleAppearanceSelectChange} /> <AppearanceSelect value={setting.appearance as Appearance} onChange={handleAppearanceSelectChange} />
</div> </div>
<div className="w-full flex flex-row justify-between items-center">
<span>{t("setting.preference-section.theme")}</span>
<ThemeSelector value={setting.theme} onValueChange={handleThemeChange} />
</div>
<p className="font-medium text-muted-foreground">{t("setting.preference")}</p> <p className="font-medium text-muted-foreground">{t("setting.preference")}</p>
<div className="w-full flex flex-row justify-between items-center"> <div className="w-full flex flex-row justify-between items-center">

@ -137,12 +137,12 @@ export function UpdateCustomizedProfileDialog({ open, onOpenChange, onSuccess }:
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t("setting.system-section.customize-server.locale")}</Label> <Label>{t("setting.system-section.customize-server.locale")}</Label>
<LocaleSelect className="w-full" value={customProfile.locale} onChange={handleLocaleSelectChange} /> <LocaleSelect value={customProfile.locale} onChange={handleLocaleSelectChange} />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t("setting.system-section.customize-server.appearance")}</Label> <Label>{t("setting.system-section.customize-server.appearance")}</Label>
<AppearanceSelect className="w-full" value={customProfile.appearance as Appearance} onChange={handleAppearanceSelectChange} /> <AppearanceSelect value={customProfile.appearance as Appearance} onChange={handleAppearanceSelectChange} />
</div> </div>
</div> </div>

@ -272,6 +272,12 @@ export interface UserSetting {
appearance: string; appearance: string;
/** The default visibility of the memo. */ /** The default visibility of the memo. */
memoVisibility: string; memoVisibility: string;
/**
* The preferred theme of the user.
* This references a CSS file in the web/public/themes/ directory.
* If not set, the default theme will be used.
*/
theme: string;
} }
export interface GetUserSettingRequest { export interface GetUserSettingRequest {
@ -1532,7 +1538,7 @@ export const GetUserStatsRequest: MessageFns<GetUserStatsRequest> = {
}; };
function createBaseUserSetting(): UserSetting { function createBaseUserSetting(): UserSetting {
return { name: "", locale: "", appearance: "", memoVisibility: "" }; return { name: "", locale: "", appearance: "", memoVisibility: "", theme: "" };
} }
export const UserSetting: MessageFns<UserSetting> = { export const UserSetting: MessageFns<UserSetting> = {
@ -1549,6 +1555,9 @@ export const UserSetting: MessageFns<UserSetting> = {
if (message.memoVisibility !== "") { if (message.memoVisibility !== "") {
writer.uint32(34).string(message.memoVisibility); writer.uint32(34).string(message.memoVisibility);
} }
if (message.theme !== "") {
writer.uint32(42).string(message.theme);
}
return writer; return writer;
}, },
@ -1591,6 +1600,14 @@ export const UserSetting: MessageFns<UserSetting> = {
message.memoVisibility = reader.string(); message.memoVisibility = reader.string();
continue; continue;
} }
case 5: {
if (tag !== 42) {
break;
}
message.theme = reader.string();
continue;
}
} }
if ((tag & 7) === 4 || tag === 0) { if ((tag & 7) === 4 || tag === 0) {
break; break;
@ -1609,6 +1626,7 @@ export const UserSetting: MessageFns<UserSetting> = {
message.locale = object.locale ?? ""; message.locale = object.locale ?? "";
message.appearance = object.appearance ?? ""; message.appearance = object.appearance ?? "";
message.memoVisibility = object.memoVisibility ?? ""; message.memoVisibility = object.memoVisibility ?? "";
message.theme = object.theme ?? "";
return message; return message;
}, },
}; };

Loading…
Cancel
Save