pl-fe: steal more dashboard code from mastodon

Signed-off-by: Nicole Mikołajczyk <git@mkljczk.pl>
This commit is contained in:
Nicole Mikołajczyk
2025-04-28 20:37:16 +02:00
parent 0f78abf9a9
commit c634684dbd
13 changed files with 319 additions and 30 deletions

View File

@ -4411,7 +4411,7 @@ class PlApiClient {
getRetention: async (start_at: string, end_at: string, frequency: 'day' | 'month') => { getRetention: async (start_at: string, end_at: string, frequency: 'day' | 'month') => {
const response = await this.request('/api/v1/admin/retention', { method: 'POST', params: { start_at, end_at, frequency } }); const response = await this.request('/api/v1/admin/retention', { method: 'POST', params: { start_at, end_at, frequency } });
return v.parse(adminCohortSchema, response.json); return v.parse(filteredArray(adminCohortSchema), response.json);
}, },
}, },

View File

@ -12,7 +12,7 @@ const adminCohortSchema = v.object({
data: v.array(v.object({ data: v.array(v.object({
date: datetimeSchema, date: datetimeSchema,
rate: v.number(), rate: v.number(),
value: v.pipe(v.number(), v.integer()), value: v.pipe(v.unknown(), v.transform(Number)),
})), })),
}); });

View File

@ -6,13 +6,13 @@ import * as v from 'valibot';
*/ */
const adminDimensionSchema = v.object({ const adminDimensionSchema = v.object({
key: v.string(), key: v.string(),
data: v.object({ data: v.array(v.object({
key: v.string(), key: v.string(),
human_key: v.string(), human_key: v.string(),
value: v.string(), value: v.string(),
unit: v.fallback(v.optional(v.string()), undefined), unit: v.fallback(v.optional(v.string()), undefined),
human_value: v.fallback(v.optional(v.string()), undefined), human_value: v.fallback(v.optional(v.string()), undefined),
}), })),
}); });
/** /**

View File

@ -1,6 +1,6 @@
{ {
"name": "pl-api", "name": "pl-api",
"version": "1.0.0-rc.57", "version": "1.0.0-rc.58",
"type": "module", "type": "module",
"homepage": "https://github.com/mkljczk/pl-fe/tree/develop/packages/pl-api", "homepage": "https://github.com/mkljczk/pl-fe/tree/develop/packages/pl-api",
"repository": { "repository": {

View File

@ -104,7 +104,7 @@
"multiselect-react-dropdown": "^2.0.25", "multiselect-react-dropdown": "^2.0.25",
"mutative": "^1.1.0", "mutative": "^1.1.0",
"path-browserify": "^1.0.1", "path-browserify": "^1.0.1",
"pl-api": "^1.0.0-rc.57", "pl-api": "^1.0.0-rc.58",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"process": "^0.11.10", "process": "^0.11.10",
"punycode": "^2.1.1", "punycode": "^2.1.1",

View File

@ -79,7 +79,7 @@ const Counter: React.FC<ICounter> = ({
{label} {label}
</Text> </Text>
<div> <div className='mt-auto'>
<Sparklines width={259} height={55} data={data?.[0].data.map(x => x.value * 1) || []}> <Sparklines width={259} height={55} data={data?.[0].data.map(x => x.value * 1) || []}>
<SparklinesCurve /> <SparklinesCurve />
</Sparklines> </Sparklines>
@ -87,15 +87,17 @@ const Counter: React.FC<ICounter> = ({
</> </>
); );
const className = 'relative flex flex-col rounded bg-gray-200 font-medium dark:bg-gray-800';
if (to) { if (to) {
return ( return (
<Link to={to} className='relative rounded bg-gray-200 font-medium transition-transform hover:-translate-y-1 dark:bg-gray-800' target={target}> <Link to={to} className={clsx(className, 'transition-transform hover:-translate-y-1')} target={target}>
{inner} {inner}
</Link> </Link>
); );
} else { } else {
return ( return (
<div className='relative rounded bg-gray-200 font-medium dark:bg-gray-800'> <div className={className}>
{inner} {inner}
</div> </div>
); );

View File

@ -45,7 +45,7 @@ interface IDashCounters {
/** Wrapper container for dash counters. */ /** Wrapper container for dash counters. */
const DashCounters: React.FC<IDashCounters> = ({ children }) => ( const DashCounters: React.FC<IDashCounters> = ({ children }) => (
<div className='grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3'> <div className='grid grid-cols-1 gap-2 sm:grid-cols-2'>
{children} {children}
</div> </div>
); );

View File

@ -0,0 +1,83 @@
import React from 'react';
import { FormattedNumber } from 'react-intl';
import Text from 'pl-fe/components/ui/text';
import { useDimensions } from 'pl-fe/queries/admin/use-metrics';
import type { AdminDimensionKey, AdminGetDimensionsParams } from 'pl-api';
interface IDimension {
dimension: AdminDimensionKey;
startAt: string;
endAt: string;
label: JSX.Element;
params: AdminGetDimensionsParams;
}
const Dimension: React.FC<IDimension> = ({
dimension,
startAt,
endAt,
label,
params,
}) => {
const { data } = useDimensions([dimension], { ...params, start_at: startAt, end_at: endAt });
let content;
if (!data) {
content = (
<table>
<tbody>
{Array.from(Array(params.limit)).map((_, i) => (
<tr key={i}>
<td>
{/* <Skeleton width={100} /> */}
</td>
<td>
{/* <Skeleton width={60} /> */}
</td>
</tr>
))}
</tbody>
</table>
);
} else {
const sum = data[0].data.reduce((sum, cur) => sum + (+cur.value * 1), 0);
content = (
<table className='w-full'>
<tbody>
{data[0].data.map(item => (
<tr className='border-b border-primary-200 last:border-none dark:border-gray-800' key={item.key}>
<td className='p-2.5'>
<span
className='mr-2 inline-block size-2 rounded-full bg-green-500 shadow-sm'
style={{ opacity: +item.value / sum }}
/>
<Text title={item.key} weight='medium' size='xs' tag='span'>{item.human_key}</Text>
</td>
<td className='p-2.5 text-end'>
<Text size='xs'>
{typeof item.human_value !== 'undefined' ? item.human_value : <FormattedNumber value={+item.value} />}
</Text>
</td>
</tr>
))}
</tbody>
</table>
);
}
return (
<div>
<Text className='border-b border-primary-200 pb-1 dark:border-gray-800' weight='medium' size='sm'>{label}</Text>
{content}
</div>
);
};
export { Dimension };

View File

@ -0,0 +1,132 @@
import React from 'react';
import { FormattedDate, FormattedMessage, FormattedNumber } from 'react-intl';
import Text from 'pl-fe/components/ui/text';
import { useRetention } from 'pl-fe/queries/admin/use-metrics';
import type { AdminCohort } from 'pl-api';
const dateForCohort = (cohort: AdminCohort) => {
const timeZone = 'UTC';
switch (cohort.frequency) {
case 'day':
return <FormattedDate value={cohort.period} month='long' day='2-digit' timeZone={timeZone} />;
default:
return <FormattedDate value={cohort.period} month='long' year='numeric' timeZone={timeZone} />;
}
};
interface IRetention {
startAt: string;
endAt: string;
frequency: 'day' | 'month';
}
const Retention: React.FC<IRetention> = ({ startAt, endAt, frequency }) => {
const { data } = useRetention(startAt, endAt, frequency);
let content;
if (!data) {
content = <FormattedMessage id='loading_indicator.label' defaultMessage='Loading…' />;
} else {
content = (
<table className='text-xs'>
<thead>
<tr>
<th>
<div className='p-2.5 pl-0 font-bold'>
<FormattedMessage id='admin.dashboard.retention.cohort' defaultMessage='Sign-up month' />
</div>
</th>
<th>
<div className='p-2.5 font-bold'>
<FormattedMessage id='admin.dashboard.retention.cohort_size' defaultMessage='New users' />
</div>
</th>
{data[0].data.slice(1).map((retention, i) => (
<th key={retention.date} className='w-14'>
<div className='p-2.5 font-bold'>
{i + 1}
</div>
</th>
))}
</tr>
<tr>
<td>
<div className='p-2.5 pl-0 font-bold'>
<FormattedMessage id='admin.dashboard.retention.average' defaultMessage='Average' />
</div>
</td>
<td>
<div className='p-2.5 text-center'>
<FormattedNumber value={data.reduce((sum, cohort, i) => sum + ((cohort.data[0].value * 1) - sum) / (i + 1), 0)} maximumFractionDigits={0} />
</div>
</td>
{data[0].data.slice(1).map((retention, i) => {
const average = data.reduce((sum, cohort, k) => cohort.data[i + 1] ? sum + (cohort.data[i + 1].rate - sum) / (k + 1) : sum, 0);
return (
<td key={retention.date}>
<div className='bg-primary-200 p-2.5 font-medium dark:bg-gray-800' style={{ ['--tw-bg-opacity' as any]: 0.5 + average / 2 }}>
<FormattedNumber value={average} style='percent' />
</div>
</td>
);
})}
</tr>
</thead>
<tbody>
{data.slice(0, -1).map(cohort => (
<tr key={cohort.period}>
<td>
<div className='p-2.5 pl-0'>
{dateForCohort(cohort)}
</div>
</td>
<td>
<div className='p-2.5 text-center'>
<FormattedNumber value={cohort.data[0].value} />
</div>
</td>
{cohort.data.slice(1).map(retention => (
<td key={retention.date}>
<div className='bg-primary-200 p-2.5 font-medium dark:bg-gray-800' style={{ ['--tw-bg-opacity' as any]: 0.5 + retention.rate / 2 }}>
<FormattedNumber value={retention.rate} style='percent' />
</div>
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
let title = null;
switch (frequency) {
case 'day':
title = <FormattedMessage id='admin.dashboard.daily_retention' defaultMessage='User retention rate by day after sign-up' />;
break;
default:
title = <FormattedMessage id='admin.dashboard.monthly_retention' defaultMessage='User retention rate by month after sign-up' />;
}
return (
<div className='col-span-2'>
<Text className='border-b border-primary-200 pb-1 dark:border-gray-800' weight='medium' size='sm'>{title}</Text>
{content}
</div>
);
};
export { Retention };

View File

@ -1,10 +1,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, FormattedNumber } from 'react-intl';
import List, { ListItem } from 'pl-fe/components/list'; import List, { ListItem } from 'pl-fe/components/list';
import { CardTitle } from 'pl-fe/components/ui/card'; import { CardTitle } from 'pl-fe/components/ui/card';
import Icon from 'pl-fe/components/ui/icon'; import Icon from 'pl-fe/components/ui/icon';
import Stack from 'pl-fe/components/ui/stack'; import Stack from 'pl-fe/components/ui/stack';
import { useAppSelector } from 'pl-fe/hooks/use-app-selector';
import { useFeatures } from 'pl-fe/hooks/use-features'; import { useFeatures } from 'pl-fe/hooks/use-features';
import { useInstance } from 'pl-fe/hooks/use-instance'; import { useInstance } from 'pl-fe/hooks/use-instance';
import { useOwnAccount } from 'pl-fe/hooks/use-own-account'; import { useOwnAccount } from 'pl-fe/hooks/use-own-account';
@ -12,13 +13,20 @@ import sourceCode from 'pl-fe/utils/code';
import { Counter } from '../components/counter'; import { Counter } from '../components/counter';
import { DashCounter, DashCounters } from '../components/dashcounter'; import { DashCounter, DashCounters } from '../components/dashcounter';
import { Dimension } from '../components/dimension';
import RegistrationModePicker from '../components/registration-mode-picker'; import RegistrationModePicker from '../components/registration-mode-picker';
import { Retention } from '../components/retention';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const instance = useInstance(); const instance = useInstance();
const features = useFeatures(); const features = useFeatures();
const { account } = useOwnAccount(); const { account } = useOwnAccount();
const { pendingReports, pendingUsers } = useAppSelector((state) => ({
pendingReports: state.admin.openReports.length,
pendingUsers: state.admin.awaitingApproval.length,
}));
const v = features.version; const v = features.version;
const { const {
@ -30,8 +38,9 @@ const Dashboard: React.FC = () => {
const mau = instance.usage.users.active_month ?? instance.pleroma.stats.mau; const mau = instance.usage.users.active_month ?? instance.pleroma.stats.mau;
const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined; const retention = (userCount && mau) ? Math.round(mau / userCount * 100) : undefined;
const [endDay] = useState<string>(new Date().toISOString().slice(0, 10)); const [today] = useState<string>(new Date().toISOString().slice(0, 10));
const [startDay] = useState<string>(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10)); const [monthAgo] = useState<string>(new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
const [sixMonthsAgo] = useState<string>(new Date(new Date().getTime() - 30 * 6 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10));
if (!account) return null; if (!account) return null;
@ -41,8 +50,8 @@ const Dashboard: React.FC = () => {
{features.mastodonAdminMetrics ? ( {features.mastodonAdminMetrics ? (
<Counter <Counter
measure='new_users' measure='new_users'
startAt={startDay} startAt={monthAgo}
endAt={endDay} endAt={today}
to='/pl-fe/admin/users' to='/pl-fe/admin/users'
label={<FormattedMessage id='admin.counters.new_users' defaultMessage='new users' />} label={<FormattedMessage id='admin.counters.new_users' defaultMessage='new users' />}
/> />
@ -56,8 +65,8 @@ const Dashboard: React.FC = () => {
{features.mastodonAdminMetrics ? ( {features.mastodonAdminMetrics ? (
<Counter <Counter
measure='active_users' measure='active_users'
startAt={startDay} startAt={monthAgo}
endAt={endDay} endAt={today}
label={<FormattedMessage id='admin.counters.active_users' defaultMessage='active users' />} label={<FormattedMessage id='admin.counters.active_users' defaultMessage='active users' />}
/> />
) : ( ) : (
@ -77,21 +86,21 @@ const Dashboard: React.FC = () => {
<> <>
<Counter <Counter
measure='interactions' measure='interactions'
startAt={startDay} startAt={monthAgo}
endAt={endDay} endAt={today}
label={<FormattedMessage id='admin.counters.interactions' defaultMessage='interactions' />} label={<FormattedMessage id='admin.counters.interactions' defaultMessage='interactions' />}
/> />
<Counter <Counter
measure='opened_reports' measure='opened_reports'
startAt={startDay} startAt={monthAgo}
endAt={endDay} endAt={today}
to='/pl-fe/admin/reports' to='/pl-fe/admin/reports'
label={<FormattedMessage id='admin.counters.opened_reports' defaultMessage='reports opened' />} label={<FormattedMessage id='admin.counters.opened_reports' defaultMessage='reports opened' />}
/> />
<Counter <Counter
measure='resolved_reports' measure='resolved_reports'
startAt={startDay} startAt={monthAgo}
endAt={endDay} endAt={today}
to='/pl-fe/admin/reports' to='/pl-fe/admin/reports'
label={<FormattedMessage id='admin.counters.resolved_reports' defaultMessage='reports resolved' />} label={<FormattedMessage id='admin.counters.resolved_reports' defaultMessage='reports resolved' />}
/> />
@ -106,6 +115,52 @@ const Dashboard: React.FC = () => {
count={domainCount} count={domainCount}
label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />} label={<FormattedMessage id='admin.dashcounters.domain_count_label' defaultMessage='peers' />}
/> />
<List>
<ListItem size='sm' to='/pl-fe/admin/reports' label={<FormattedMessage id='admin.links.pending_reports' defaultMessage='{count, plural, one {{formattedCount} pending report} other {{formattedCount} pending reports}}' values={{ count: pendingReports, formattedCount: <strong><FormattedNumber value={pendingReports} /></strong> }} />} />
<ListItem size='sm' to='/pl-fe/admin/users' label={<FormattedMessage id='admin.links.pending_users' defaultMessage='{count, plural, one {{formattedCount} pending user} other {{formattedCount} pending users}}' values={{ count: pendingUsers, formattedCount: <strong><FormattedNumber value={pendingUsers} /></strong> }} />} />
{/* <ListItem size='sm' to='/pl-fe/admin' label={<FormattedMessage id='admin.links.pending_tags' defaultMessage='{count} pending tags' values={{ count: <strong>0</strong> }} />} />
<ListItem size='sm' to='/pl-fe/admin' label={<FormattedMessage id='admin.links.pending_appeals' defaultMessage='{count} pending appeals' values={{ count: <strong>0</strong> }} />} /> */}
</List>
{features.mastodonAdminMetrics && (
<>
<Dimension
dimension='sources'
startAt={monthAgo}
endAt={today}
params={{ limit: 8 }}
label={<FormattedMessage id='admin.dimensions.sources' defaultMessage='Sign-up sources' />}
/>
<Dimension
dimension='languages'
startAt={monthAgo}
endAt={today}
params={{ limit: 8 }}
label={<FormattedMessage id='admin.dimensions.top_languages' defaultMessage='Top active languages' />}
/>
<Dimension
dimension='servers'
startAt={monthAgo}
endAt={today}
params={{ limit: 8 }}
label={<FormattedMessage id='admin.dimensions.top_servers' defaultMessage='Top active servers' />}
/>
<Retention startAt={sixMonthsAgo} endAt={today} frequency='month' />
<Dimension
dimension='software_versions'
startAt={monthAgo}
endAt={today}
params={{ limit: 4 }}
label={<FormattedMessage id='admin.dimensions.software' defaultMessage='Software' />}
/>
<Dimension
dimension='space_usage'
startAt={monthAgo}
endAt={today}
params={{ limit: 3 }}
label={<FormattedMessage id='admin.dimensions.media_storage' defaultMessage='Media storage' />}
/>
</>
)}
</DashCounters> </DashCounters>
<List> <List>
@ -175,9 +230,11 @@ const Dashboard: React.FC = () => {
</a> </a>
</ListItem> </ListItem>
{!features.mastodonAdminMetrics && (
<ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}> <ListItem label={<FormattedMessage id='admin.software.backend' defaultMessage='Backend' />}>
<span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span> <span>{v.software + (v.build ? `+${v.build}` : '')} {v.version}</span>
</ListItem> </ListItem>
)}
</List> </List>
</Stack> </Stack>
); );

View File

@ -107,6 +107,8 @@
"admin.counters.new_users": "new users", "admin.counters.new_users": "new users",
"admin.counters.opened_reports": "reports opened", "admin.counters.opened_reports": "reports opened",
"admin.counters.resolved_reports": "reports resolved", "admin.counters.resolved_reports": "reports resolved",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up",
"admin.dashboard.monthly_retention": "User retention rate by month after sign-up",
"admin.dashboard.registration_mode.approval_hint": "Users can sign up, but their account only gets activated when an admin approves it.", "admin.dashboard.registration_mode.approval_hint": "Users can sign up, but their account only gets activated when an admin approves it.",
"admin.dashboard.registration_mode.approval_label": "Approval Required", "admin.dashboard.registration_mode.approval_label": "Approval Required",
"admin.dashboard.registration_mode.closed_hint": "Nobody can sign up. You can still invite people.", "admin.dashboard.registration_mode.closed_hint": "Nobody can sign up. You can still invite people.",
@ -114,6 +116,9 @@
"admin.dashboard.registration_mode.open_hint": "Anyone can join.", "admin.dashboard.registration_mode.open_hint": "Anyone can join.",
"admin.dashboard.registration_mode.open_label": "Open", "admin.dashboard.registration_mode.open_label": "Open",
"admin.dashboard.registration_mode_label": "Registrations", "admin.dashboard.registration_mode_label": "Registrations",
"admin.dashboard.retention.average": "Average",
"admin.dashboard.retention.cohort": "Sign-up month",
"admin.dashboard.retention.cohort_size": "New users",
"admin.dashboard.settings_saved": "Settings saved!", "admin.dashboard.settings_saved": "Settings saved!",
"admin.dashcounters.domain_count_label": "peers", "admin.dashcounters.domain_count_label": "peers",
"admin.dashcounters.mau_label": "monthly active users", "admin.dashcounters.mau_label": "monthly active users",
@ -121,6 +126,11 @@
"admin.dashcounters.status_count_label": "posts", "admin.dashcounters.status_count_label": "posts",
"admin.dashcounters.user_count_label": "total users", "admin.dashcounters.user_count_label": "total users",
"admin.dashwidgets.software_header": "Software", "admin.dashwidgets.software_header": "Software",
"admin.dimensions.media_storage": "Media storage",
"admin.dimensions.software": "Software",
"admin.dimensions.sources": "Sign-up sources",
"admin.dimensions.top_languages": "Top active languages",
"admin.dimensions.top_servers": "Top active servers",
"admin.domains.action": "Create domain", "admin.domains.action": "Create domain",
"admin.domains.delete": "Delete", "admin.domains.delete": "Delete",
"admin.domains.edit": "Edit", "admin.domains.edit": "Edit",
@ -161,6 +171,8 @@
"admin.edit_rule.updated": "Rule edited", "admin.edit_rule.updated": "Rule edited",
"admin.latest_accounts_panel.more": "Click to see {count, plural, one {# account} other {# accounts}}", "admin.latest_accounts_panel.more": "Click to see {count, plural, one {# account} other {# accounts}}",
"admin.latest_accounts_panel.title": "Latest Accounts", "admin.latest_accounts_panel.title": "Latest Accounts",
"admin.links.pending_reports": "{count, plural, one {{formattedCount} pending report} other {{formattedCount} pending reports}}",
"admin.links.pending_users": "{count, plural, one {{formattedCount} pending user} other {{formattedCount} pending users}}",
"admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.", "admin.moderation_log.empty_message": "You have not performed any moderation actions yet. When you do, a history will be shown here.",
"admin.relays.add.fail": "Failed to follow the instance relay", "admin.relays.add.fail": "Failed to follow the instance relay",
"admin.relays.add.success": "Instance relay followed", "admin.relays.add.success": "Instance relay followed",

View File

@ -49,9 +49,12 @@ const shortNumberFormat = (number: any, max?: number): React.ReactNode => {
/** Check if an entity ID is an integer (eg not a FlakeId). */ /** Check if an entity ID is an integer (eg not a FlakeId). */
const isIntegerId = (id: string): boolean => new RegExp(/^-?[0-9]+$/g).test(id); const isIntegerId = (id: string): boolean => new RegExp(/^-?[0-9]+$/g).test(id);
const roundTo10 = (num: number) => Math.round(num / 10) * 10;
export { export {
isNumber, isNumber,
roundDown, roundDown,
shortNumberFormat, shortNumberFormat,
isIntegerId, isIntegerId,
roundTo10,
}; };

View File

@ -6858,10 +6858,10 @@ pkg-dir@^4.1.0:
dependencies: dependencies:
find-up "^4.0.0" find-up "^4.0.0"
pl-api@^1.0.0-rc.57: pl-api@^1.0.0-rc.58:
version "1.0.0-rc.57" version "1.0.0-rc.58"
resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-1.0.0-rc.57.tgz#848259e4a38e3c44dc3048a86578d877b36e50e7" resolved "https://registry.yarnpkg.com/pl-api/-/pl-api-1.0.0-rc.58.tgz#3605128a53029ef4abd8316fdabe3ab4e0c2552a"
integrity sha512-ndT9fnL0LJt5EMRMHWRovAd/YE8w6Ya/Ow1TUbiwM760IqQUAIyxBNt5gLB89OlkbJQiVC+RD/vVUxBdBMisLg== integrity sha512-GR/gXEQGwo1SueSwPzPsRUeuTpulRx5Wpd3y6/bOC7r8BGJyKK0FI34/y3PYLEzBveYo5W2JP+tCI4dUKk+akQ==
dependencies: dependencies:
blurhash "^2.0.5" blurhash "^2.0.5"
http-link-header "^1.1.3" http-link-header "^1.1.3"