ProfileCollectionsAPI: Add & Migrate

This commit is contained in:
thororen1234 2026-03-20 15:45:06 -04:00
parent 9e12799d1d
commit f00ddd41e5
No known key found for this signature in database
13 changed files with 227 additions and 155 deletions

View file

@ -42,6 +42,7 @@ export { Plugins as plugins };
import { traceFunction } from "../debug/Tracer";
import { addAudioProcessor, removeAudioProcessor } from "./AudioPlayer";
import { addChannelToolbarButton, addHeaderBarButton, removeChannelToolbarButton, removeHeaderBarButton } from "./HeaderBar";
import { addProfileCollection, removeProfileCollection } from "./ProfileCollections";
import { addUserAreaButton, removeUserAreaButton } from "./UserArea";
const logger = new Logger("PluginManager", "#a6d189");
@ -196,7 +197,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollections
} = p;
if (p.start) {
@ -267,6 +268,7 @@ export const startPlugin = traceFunction("startPlugin", function startPlugin(p:
}
if (audioProcessor) addAudioProcessor(name, audioProcessor);
if (userAreaButton) addUserAreaButton(name, userAreaButton.render, userAreaButton.priority);
if (renderProfileCollections) addProfileCollection(name, renderProfileCollections, userAreaButton?.priority);
return true;
}, p => `startPlugin ${p.name}`);
@ -277,7 +279,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
onBeforeMessageEdit, onBeforeMessageSend, onMessageClick,
renderChatBarButton, chatBarButton, renderMemberListDecorator, renderMessageAccessory, renderMessageDecoration, renderMessagePopoverButton, messagePopoverButton,
// Custom
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton
renderNicknameIcon, headerBarButton, audioProcessor, userAreaButton, renderProfileCollections
} = p;
if (p.stop) {
@ -342,6 +344,7 @@ export const stopPlugin = traceFunction("stopPlugin", function stopPlugin(p: Plu
}
if (audioProcessor) removeAudioProcessor(name);
if (userAreaButton) removeUserAreaButton(name);
if (renderProfileCollections) removeProfileCollection(name);
return true;
}, p => `stopPlugin ${p.name}`);
@ -354,7 +357,7 @@ export const initPluginManager = onlyOnce(function init() {
"onBeforeMessageEdit", "onBeforeMessageSend", "onMessageClick",
"renderChatBarButton", "renderMemberListDecorator", "renderMessageAccessory", "renderMessageDecoration", "renderMessagePopoverButton",
// Custom
"renderNicknameIcon"
"renderNicknameIcon", "renderProfileCollections"
];
const neededApiPlugins = new Set<string>();
@ -396,6 +399,7 @@ export const initPluginManager = onlyOnce(function init() {
if (p.headerBarButton) neededApiPlugins.add("HeaderBarAPI");
if (p.audioProcessor) neededApiPlugins.add("AudioPlayerAPI");
if (p.userAreaButton) neededApiPlugins.add("UserAreaAPI");
if (p.renderProfileCollections) neededApiPlugins.add("ProfileCollectionsAPI");
for (const key of pluginKeysToBind) {
p[key] &&= p[key].bind(p) as any;

View file

@ -0,0 +1,79 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import ErrorBoundary from "@components/ErrorBoundary";
import { Logger } from "@utils/Logger";
import { useEffect, useState } from "@webpack/common";
import type { JSX } from "react";
const logger = new Logger("ProfileCollectionAPI");
export type ProfileCollectionFactory = Function;
export interface ProfileCollectionData {
/** Function that renders the collection component, receives the full profile props */
render: ProfileCollectionFactory;
/** Higher priority collections appear first in the list. Default: 0 */
priority?: number;
}
interface CollectionEntry {
render: ProfileCollectionFactory;
priority: number;
}
const profileCollections = new Map<string, CollectionEntry>();
const profileCollectionListeners = new Set<() => void>();
/**
* Adds a collection to the user profile panel.
*
* @param id - Unique identifier for the collection (e.g., "my-plugin-profile-collection")
* @param render - Function that returns the collection JSX. Receives the full profile props object.
* @param priority - Higher values appear first. Default: 0
*
* @example
* addProfileCollection("my-collection", (props) => (
* <div>Custom content for {props.user.id}</div>
* ));
*/
export function addProfileCollection(id: string, render: ProfileCollectionFactory, priority = 0) {
profileCollections.set(id, { render, priority });
profileCollectionListeners.forEach(listener => listener());
}
/**
* Removes a collection from the user profile panel.
*
* @param id - The identifier used when adding the collection
*/
export function removeProfileCollection(id: string) {
profileCollections.delete(id);
profileCollectionListeners.forEach(listener => listener());
}
function ProfileCollections({ props }: { props: any; }) {
const [, forceUpdate] = useState(0);
useEffect(() => {
const listener = () => forceUpdate(n => n + 1);
profileCollectionListeners.add(listener);
return () => { profileCollectionListeners.delete(listener); };
}, []);
return Array.from(profileCollections)
.sort(([, a], [, b]) => b.priority - a.priority)
.map(([id, { render: Collection }]) => (
<ErrorBoundary noop key={id} onError={e => logger.error(`Failed to render profile collection: ${id}`, e.error)}>
{Collection({ ...props, user: props.user ?? props.currentUser })}
</ErrorBoundary>
));
}
/** @internal Injected by ProfileCollectionAPI patch (do NOT call directly) */
export function renderProfileCollections(props: any) {
return <ProfileCollections props={props} />;
}

View file

@ -34,6 +34,7 @@ import * as $Notices from "./Notices";
import * as $Notifications from "./Notifications";
import * as $UserArea from "./UserArea";
export * as PluginManager from "./PluginManager";
import * as $ProfileCollections from "./ProfileCollections";
import * as $ServerList from "./ServerList";
import * as $Settings from "./Settings";
import * as $Styles from "./Styles";
@ -170,3 +171,8 @@ export const UserArea = $UserArea;
* Just used to identify if user is on Equicord as Vencord doesn't have this.
*/
export const isEquicord = true;
/**
* An API allowing you to add other collections where discords game collection is.
*/
export const ProfileCollections = $ProfileCollections;

View file

@ -0,0 +1,37 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Devs } from "@utils/constants";
import definePlugin from "@utils/types";
export default definePlugin({
name: "ProfileCollectionsAPI",
description: "API to add collections to the user profile panel like discords game collection.",
authors: [Devs.thororen],
patches: [
{
find: /\.POPOUT,onClose:\i}\),nicknameIcons:.+?\.isProvisional/,
replacement: {
match: /user:\i,widgets:.{0,100}?\}\),/,
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),",
}
},
{
find: '"UserProfileAccountPopout"',
replacement: {
match: /user:\i,widgets:.{0,100}}\),/,
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections(arguments[0]),",
},
},
{
find: ".SIDEBAR,disableToolbar:",
replacement: {
match: /user:(\i),widgets:.{0,100}?\}\),/,
replace: "$&Vencord.Api.ProfileCollections.renderProfileCollections({...arguments[0],isSideBar:true}),"
}
}
]
});

View file

@ -15,6 +15,7 @@ import { Clickable, React, useEffect, UserProfileStore, useState } from "@webpac
import { ReposModal } from "./ReposModal";
const DMSideBarClasses = findCssClassesLazy("widgetPreviews");
const ProfileCardClasses = findCssClassesLazy("cardsList", "firstCardContainer", "card", "container");
const ProfileCardContainerClasses = findCssClassesLazy("innerContainer", "icons", "icon", "displayCount", "displayCountText", "displayCountTextColor", "breadcrumb");
const ProfileCardOverlayClasses = findCssClassesLazy("overlay", "isPrivate", "outer");
@ -41,7 +42,7 @@ function getLanguageIconUrl(language: string | null): string {
return `https://cdn.jsdelivr.net/gh/devicons/devicon@develop/icons/${normalized}/${normalized}-original.svg`;
}
export function ProfilePopoutComponent({ id, theme }: { id: string, theme: string; }) {
export function ProfilePopoutComponent({ id, isSideBar = false }: { id: string, isSideBar?: boolean; }) {
const [repos, setRepos] = useState<GitHubRepo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -92,13 +93,13 @@ export function ProfilePopoutComponent({ id, theme }: { id: string, theme: strin
const topRepos = repos.slice(0, 4);
return (
const reposSection = (
<section className={ProfileCardClasses.container}>
<ul className={ProfileCardClasses.cardsList} tabIndex={-1}>
<li className={ProfileCardClasses.firstCardContainer}>
<Clickable className={ProfileCardContainerClasses.breadcrumb} onClick={openReposModal}>
<div className={classes(ProfileCardOverlayClasses.overlay, ProfileCardContainerClasses.innerContainer, ProfileCardClasses.card)}>
<Paragraph size="xs" weight="medium">
<Paragraph size={isSideBar ? "sm" : "xs"} weight="medium">
GitHub Repositories
</Paragraph>
{!!repos.length && (
@ -115,8 +116,8 @@ export function ProfilePopoutComponent({ id, theme }: { id: string, theme: strin
/>
{showCount && (
<div className={ProfileCardContainerClasses.displayCountText}>
<Span className={ProfileCardContainerClasses.displayCountTextColor} size="xs" weight="medium">
+{repos.length - 3}
<Span className={ProfileCardContainerClasses.displayCountTextColor} size={isSideBar ? "sm" : "xs"} weight="medium">
+{repos.length - 4}
</Span>
</div>
)}
@ -131,4 +132,8 @@ export function ProfilePopoutComponent({ id, theme }: { id: string, theme: strin
</ul>
</section>
);
return isSideBar
? <div className={DMSideBarClasses.widgetPreviews}>{reposSection}</div>
: reposSection;
}

View file

@ -33,11 +33,6 @@ export const settings = definePluginSettings({
description: "Show repository language",
default: true
},
showRepositoryTab: {
type: OptionType.BOOLEAN,
description: "Show repositories tab in profile modal (hides button in connections when enabled)",
default: true
},
});
export default definePlugin({
@ -47,30 +42,12 @@ export default definePlugin({
settings,
patches: [
// User Popout
{
find: /onOpenUserProfileModal:\i\}\),\i/,
replacement: {
match: /user:\i,widgets:.{0,100}?\}\),/,
replace: "$&$self.ProfileRepositoriesPopout(arguments[0]),"
}
},
// User Profile Modal v2
{
find: ".MODAL_V2,onClose:",
replacement: {
match: /displayProfile:(\i).*?connections:\i.{0,25}\i.\i\}\)\}\)/,
replace: "$&,$self.ProfileRepositoriesPopout({ user: arguments[0].user, displayProfile: $1 }),",
predicate: () => !settings.store.showRepositoryTab,
}
},
// User Profile Modal v2 tab bar
{
find: "#{intl::USER_PROFILE_ACTIVITY}",
replacement: {
match: /\.MUTUAL_GUILDS\}\)\)(?=,(\i))/,
replace: '$&,$1.push({text:"GitHub",section:"GITHUB"})',
predicate: () => settings.store.showRepositoryTab,
}
},
// User Profile Modal v2 tab content
@ -78,24 +55,19 @@ export default definePlugin({
find: ".WIDGETS?",
replacement: {
match: /(\i)===\i\.\i\.WISHLIST/,
replace: '$1==="GITHUB"?$self.ProfileRepositoriesTab(arguments[0]):$&'
replace: '$1==="GITHUB"?$self.renderProfileRepositoriesTab(arguments[0]):$&',
}
}
],
ProfileRepositoriesPopout: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => {
renderProfileCollections: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => {
return (
<ProfilePopoutComponent
{...props}
id={props.user.id}
theme={getProfileThemeProps(props).theme}
/>
);
},
{
noop: true
}
),
ProfileRepositoriesTab: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => {
}, { noop: true }),
renderProfileRepositoriesTab: ErrorBoundary.wrap((props: { user: User; displayProfile?: any; }) => {
return (
<ProfileTabComponent
{...props}
@ -103,9 +75,5 @@ export default definePlugin({
theme={getProfileThemeProps(props).theme}
/>
);
},
{
noop: true
}
)
}, { noop: true }),
});

View file

@ -23,30 +23,6 @@ export default definePlugin({
authors: [EquicordDevs.nexpid],
settings,
patches: [
// Personal profile popout
{
find: '"UserProfileAccountPopout"',
replacement: {
match: /user:(\i),bio:.{0,60}}\)/,
replace: "$&,$self.renderProfileSongs({userId:$1.id})",
},
},
// Message user popout
{
find: ".isProvisional?(",
replacement: {
match: /user:(\i),bio:.{0,60}}\)/,
replace: "$&,$self.renderProfileSongs({userId:$1.id})",
},
},
// DM sidebar profile
{
find: ".SIDEBAR}),nicknameIcons:",
replacement: {
match: /{userId:(\i)\.id}\)}\).{0,100}]}\)(?=\]\}\))/,
replace: "$&,$self.renderProfileSongs({userId:$1.id,isSidebar:true})",
},
},
// Full profile modal sections (lazy loaded)
{
find: ".MUTUAL_GUILDS})),",
@ -80,6 +56,6 @@ export default definePlugin({
useAuthorizationStore.persist.rehydrate();
},
renderProfileSongs: ErrorBoundary.wrap(ProfileSongs, { noop: true }),
renderProfileCollections: ErrorBoundary.wrap(ProfileSongs, { noop: true }),
renderWidgetSongs: ErrorBoundary.wrap(WidgetSongs, { noop: true }),
});

View file

@ -20,7 +20,7 @@ export default definePluginSettings({
type: OptionType.SLIDER,
description: "How many songs are shown when initially clicking on a user",
default: apiConstants.songLimit,
markers: makeRange(1, apiConstants.songLimit),
markers: makeRange(1, 3),
},
manager: {
type: OptionType.COMPONENT,

View file

@ -22,6 +22,8 @@ export const ContainerClasses = findCssClassesLazy(
);
export const CardClasses = findCssClassesLazy("card", "headerText", "headerContextMenu");
export const WidgetClasses = findCssClassesLazy("tabPanelScroller", "tabListScroller");
export const DMSideBarClasses = findCssClassesLazy("widgetPreviews");
export const ProfileCardClasses = findCssClassesLazy("cardsList", "firstCardContainer", "card", "container");
export enum SpinnerTypes {
CHASING_DOTS = "chasingDots",

View file

@ -7,9 +7,10 @@
import { BaseText } from "@components/index";
import { Native } from "@equicordplugins/songSpotlight.desktop/service";
import {
CardClasses,
ContainerClasses,
DMSideBarClasses,
OverlayClasses,
ProfileCardClasses,
Spinner,
} from "@equicordplugins/songSpotlight.desktop/ui/common";
import { RenderSongInfo } from "@song-spotlight/api/handlers";
@ -34,9 +35,10 @@ interface CollapsedProfileSongsProps extends ProfileSongsProps {
data?: UserData;
}
export default function CollapsedProfileSongs({ data, userId, isSidebar }: CollapsedProfileSongsProps) {
export default function CollapsedProfileSongs({ data, user, isSideBar }: CollapsedProfileSongsProps) {
const [renders, setRenders] = useState(new Map<string, RenderSongInfo>());
const previews = useMemo(() => data?.slice(0, shownSongs), [data]);
const userId = user?.id;
useEffect(() => {
setRenders(new Map());
@ -52,57 +54,67 @@ export default function CollapsedProfileSongs({ data, userId, isSidebar }: Colla
}
}, [previews]);
return (
<div
className={ContainerClasses.breadcrumb}
aria-label="Song Spotlight"
role="button"
tabIndex={0}
onClick={() => {
const user = UserStore.getUser(userId);
if (!user) return;
const songsSection = (
<section className={ProfileCardClasses.container}>
<ul className={ProfileCardClasses.cardsList} tabIndex={-1}>
<li className={ProfileCardClasses.firstCardContainer}>
<div
className={ContainerClasses.breadcrumb}
aria-label="Song Spotlight"
role="button"
tabIndex={0}
onClick={() => {
const user = UserStore.getUser(userId);
if (!user) return;
const guildId = SelectedGuildStore.getGuildId();
UserProfileActions.openUserProfileModal({
userId,
guildId,
channelId: SelectedChannelStore.getChannelId(),
analyticsLocation: {
page: guildId ? "Guild Channel" : "DM Channel",
section: "Profile Popout",
},
tabSection: "SONG_SPOTLIGHT",
});
}}
>
<div className={classes(OverlayClasses.overlay, ContainerClasses.innerContainer, CardClasses.card)}>
<BaseText size={isSidebar ? "sm" : "xs"} weight="medium">Song Spotlight</BaseText>
<div className={ContainerClasses.icons}>
{previews && data
? previews.map((song, i) => {
const render = renders.get(sid(song));
const extra = i === shownSongs - 1 && data.length > shownSongs;
const guildId = SelectedGuildStore.getGuildId();
UserProfileActions.openUserProfileModal({
userId,
guildId,
channelId: SelectedChannelStore.getChannelId(),
analyticsLocation: {
page: guildId ? "Guild Channel" : "DM Channel",
section: "Profile Popout",
},
tabSection: "SONG_SPOTLIGHT",
});
}}
>
<div className={classes(OverlayClasses.overlay, ContainerClasses.innerContainer, ProfileCardClasses.card)}>
<BaseText size={isSideBar ? "sm" : "xs"} weight="medium">Song Spotlight</BaseText>
<div className={ContainerClasses.icons}>
{previews && data
? previews.map((song, i) => {
const render = renders.get(sid(song));
const extra = i === shownSongs - 1 && data.length > shownSongs;
return (
<div className={ContainerClasses.icon} key={i}>
{render?.thumbnailUrl && (
<img
src={render.thumbnailUrl}
alt={render.label}
className={classes(extra && ContainerClasses.displayCount)}
/>
)}
{extra && (
<div className={ContainerClasses.displayCountText}>
<BaseText size="xs" weight="medium">+{data.length - shownSongs}</BaseText>
</div>
)}
</div>
);
})
: <Spinner type={Spinner.Type.SPINNING_CIRCLE} />}
</div>
</div>
</div>
return (
<div className={ContainerClasses.icon} key={i}>
{render?.thumbnailUrl && (
<img
src={render.thumbnailUrl}
alt={render.label}
className={classes(extra && ContainerClasses.displayCount)}
/>
)}
{extra && (
<div className={ContainerClasses.displayCountText}>
<BaseText size={isSideBar ? "sm" : "xs"} weight="medium">+{data.length - shownSongs}</BaseText>
</div>
)}
</div>
);
})
: <Spinner type={Spinner.Type.SPINNING_CIRCLE} />}
</div>
</div>
</div>
</li>
</ul>
</section>
);
return isSideBar
? <div className={DMSideBarClasses.widgetPreviews}>{songsSection}</div>
: songsSection;
}

View file

@ -23,6 +23,7 @@ import { openSettingsModal } from "@equicordplugins/songSpotlight.desktop/ui/set
import { sid } from "@song-spotlight/api/util";
import { copyWithToast } from "@utils/discord";
import { classes } from "@utils/index";
import { User } from "@vencord/discord-types";
import {
ContextMenuApi,
FluxDispatcher,
@ -39,16 +40,16 @@ import Song from ".";
import CollapsedProfileSongs from "./CollapsedProfileSongs";
export interface ProfileSongsProps {
userId: string;
isSidebar: boolean;
user: User;
isSideBar: boolean;
}
export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) {
export default function ProfileSongs({ user, isSideBar }: ProfileSongsProps) {
const [failed, setFailed] = useState(false);
const { isAuthorized } = useAuthorizationStore();
const { users } = useSongStore();
const { profileSongsLimit, collapseSongList } = settings.use();
const userId = user?.id;
const data = users[userId]?.data;
useEffect(() => {
if (isAuthorized() && !data) listData(userId).catch(() => setFailed(true));
@ -70,8 +71,8 @@ export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) {
return (
<CollapsedProfileSongs
data={data}
userId={userId}
isSidebar={isSidebar}
user={user}
isSideBar={isSideBar}
/>
);
} else if (pending) {
@ -81,14 +82,14 @@ export default function ProfileSongs({ userId, isSidebar }: ProfileSongsProps) {
return (
<div
className={classes(
isSidebar && OverlayClasses.overlay,
isSidebar && CardClasses.card,
cl("songs-container", isSidebar && "songs-container-sidebar"),
isSideBar && OverlayClasses.overlay,
isSideBar && CardClasses.card,
cl("songs-container", isSideBar && "songs-container-sidebar"),
)}
key="song-spotlight-profile-songs"
>
<Flex justifyContent="space-between">
<BaseText size="xs" weight={isSidebar ? "semibold" : "medium"} className={cl("header")}>
<BaseText size="xs" weight={isSideBar ? "semibold" : "medium"} className={cl("header")}>
Song Spotlight
</BaseText>
<Tooltip text="More">

View file

@ -82,26 +82,6 @@ export default definePlugin({
"user-profile-overflow-menu": userContextPatch
},
patches: [
// In the user popout. eg: when clicking the name in chat
{
// DM profile sidebar
find: ".SIDEBAR,disableToolbar:",
replacement: {
match: /user:(\i),widgets:.{0,100}?\}\),/,
replace: "$&$self.renderProfileComponent({user:$1,isSideBar:true}),"
}
},
{
// User popout
find: /\.POPOUT,onClose:\i}\),nicknameIcons:.+?\.isProvisional/,
replacement: {
match: /user:(\i),widgets:.{0,100}?\}\),/,
replace: "$&$self.renderProfileComponent({user:$1}),"
}
}
],
flux: {
CONNECTION_OPEN: initAuth,
},
@ -156,7 +136,7 @@ export default definePlugin({
}, 4000);
},
renderProfileComponent: ErrorBoundary.wrap(({ user, isSideBar = false }: { user: User; isSideBar?: boolean; }) => {
renderProfileCollections: ErrorBoundary.wrap(({ user, isSideBar = false }: { user: User; isSideBar?: boolean; }) => {
const [reviewData] = useAwaiter(() => getReviews(user.id, { limit: 4 }), { deps: [user.id], fallbackValue: null });
// Discord are masters at using a crap ton of html elements and css classes to create a simple ui that could have
@ -192,7 +172,7 @@ export default definePlugin({
{showCount && (
<div className={ProfileCardContainerClasses.displayCountText}>
<Span className={ProfileCardContainerClasses.displayCountTextColor} size="xs" weight="medium" defaultColor={false}>
+{reviewData.reviewCount - 3}
+{reviewData.reviewCount - 4}
</Span>
</div>
)}

View file

@ -27,6 +27,7 @@ import type { MessageDecorationFactory } from "@api/MessageDecorations";
import type { MessageClickListener, MessageEditListener, MessageSendListener } from "@api/MessageEvents";
import type { MessagePopoverButtonData, MessagePopoverButtonFactory } from "@api/MessagePopover";
import type { NicknameIconFactory } from "@api/NicknameIcons";
import { ProfileCollectionFactory } from "@api/ProfileCollections";
import type { UserAreaButtonData } from "@api/UserArea";
import type { Command, FluxEvents } from "@vencord/discord-types";
import type { ReactNode } from "react";
@ -208,6 +209,7 @@ export interface PluginDef {
headerBarButton?: HeaderBarButtonData;
audioProcessor?: AudioProcessor;
userAreaButton?: UserAreaButtonData;
renderProfileCollections?: ProfileCollectionFactory;
// TODO: Remove eventually
/**