diff --git a/src/features/admin/hooks/useManageDittoServer.ts b/src/features/admin/hooks/useManageDittoServer.ts new file mode 100644 index 000000000..2202cb6dc --- /dev/null +++ b/src/features/admin/hooks/useManageDittoServer.ts @@ -0,0 +1,22 @@ +import { useMutation } from '@tanstack/react-query'; + +import { DittoInstanceCredentials } from 'soapbox/features/admin/manage-ditto-server.tsx'; +import { useApi } from 'soapbox/hooks/useApi.ts'; +import { queryClient } from 'soapbox/queries/client.ts'; + +function useManageDittoServer() { + const api = useApi(); + + const { mutate: updateDittoInstance } = useMutation({ + mutationFn: (data: DittoInstanceCredentials) => api.put('/api/v1/admin/ditto/instance', data), + onSuccess: () => { + queryClient.refetchQueries({ queryKey: ['instance', api.baseUrl, 'v2'] }); + }, + }); + + return { + updateDittoInstance, + }; +} + +export { useManageDittoServer }; \ No newline at end of file diff --git a/src/features/admin/manage-ditto-server.tsx b/src/features/admin/manage-ditto-server.tsx new file mode 100644 index 000000000..1888abf5d --- /dev/null +++ b/src/features/admin/manage-ditto-server.tsx @@ -0,0 +1,327 @@ +import { AxiosError } from 'axios'; +import React, { useState } from 'react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; + +import { uploadMedia } from 'soapbox/actions/media.ts'; +import { HTTPError } from 'soapbox/api/HTTPError.ts'; +import { useInstanceV2 } from 'soapbox/api/hooks/instance/useInstanceV2.ts'; +import StillImage from 'soapbox/components/still-image.tsx'; +import { Button } from 'soapbox/components/ui/button.tsx'; +import { Column } from 'soapbox/components/ui/column.tsx'; +import FileInput from 'soapbox/components/ui/file-input.tsx'; +import FormActions from 'soapbox/components/ui/form-actions.tsx'; +import FormGroup from 'soapbox/components/ui/form-group.tsx'; +import Form from 'soapbox/components/ui/form.tsx'; +import Input from 'soapbox/components/ui/input.tsx'; +import Spinner from 'soapbox/components/ui/spinner.tsx'; +import Stack from 'soapbox/components/ui/stack.tsx'; +import Streamfield from 'soapbox/components/ui/streamfield.tsx'; +import { useManageDittoServer } from 'soapbox/features/admin/hooks/useManageDittoServer.ts'; +import { useAppDispatch } from 'soapbox/hooks/useAppDispatch.ts'; +import { normalizeAttachment } from 'soapbox/normalizers/index.ts'; +import { thumbnailSchema } from 'soapbox/schemas/instance.ts'; +import { Screenshots } from 'soapbox/schemas/manifest.ts'; +import toast from 'soapbox/toast.tsx'; + +import type { StreamfieldComponent } from 'soapbox/components/ui/streamfield.tsx'; + +const messages = defineMessages({ + heading: { id: 'column.admin.ditto_server.manage', defaultMessage: 'Manage Ditto Server' }, + title: { id: 'column.admin.ditto_server.title', defaultMessage: 'Title' }, + description: { id: 'column.admin.ditto_server.description', defaultMessage: 'Description' }, + short_description: { id: 'column.admin.ditto_server.short_description', defaultMessage: 'Short Description' }, + thumbnail: { id: 'column.admin.ditto_server.thumbnail', defaultMessage: 'Thumbnail' }, + screenshots_label: { id: 'column.admin.ditto_server.screenshots.label', defaultMessage: 'Web Manifest screenshots' }, + screeenshot_label: { id: 'column.admin.ditto_server.screenshot.label', defaultMessage: 'Alternative text describing the image.' }, + upload_screenshot_success: { id: 'column.admin.ditto_server.upload.screenshot.success', defaultMessage: 'Screenshot uploaded!' }, + upload_thumbnail_success: { id: 'column.admin.ditto_server.upload.thumbnail.success', defaultMessage: 'Thumbnail uploaded!' }, + submit_success: { id: 'column.admin.ditto_server.submit.success', defaultMessage: 'Submitted successfully!' }, +}); + +/** + * Params to submit when updating a Ditto instance. + * @see PUT /api/v1/admin/ditto/instance + */ +export interface DittoInstanceCredentials { + /** Title of the instance. */ + title: string; + /** Description of the instance. */ + description: string; + /** Short description of the instance. */ + short_description: string; + /** Manifest screenshots. */ + screenshots: Screenshots; + /** https://docs.joinmastodon.org/entities/Instance/#thumbnail-url */ + thumbnail: Zod.infer; +} + +/** + * Main component that handles the logic and UI for managing a Ditto instance. + * Allows the admin to view and edit title, description, screenshots (Manifest field), etc... + * + * @returns A component that renders the Ditto Server management interface. + */ +const ManageDittoServer: React.FC = () => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + const { updateDittoInstance } = useManageDittoServer(); + const { instance } = useInstanceV2(); + + const [data, setData] = useState({ + title: instance?.title ?? '', + description: instance?.description ?? '', + short_description: instance?.short_description ?? '', + screenshots: instance?.screenshots ?? [], + thumbnail: instance?.thumbnail ?? { url: '', versions: {} }, + }); + + const [isThumbnailLoading, setThumbnailLoading] = useState(false); + + const handleSubmit: React.FormEventHandler = async (event) => { + event.preventDefault(); + updateDittoInstance(data, { + onSuccess: async () => { + toast.success(messages.submit_success); + }, + onError: async (err) => { + if (err instanceof HTTPError) { + try { + const { error } = await err.response.json(); + if (typeof error === 'string') { + toast.error(error); + return; + } + } catch { /* empty */ } + } + toast.error(err.message); + }, + }); + }; + + /** Set a single key in the request data. */ + const updateData = (key: string, value: any) => { + setData(prevData => { + return { ...prevData, [key]: value }; + }); + }; + + const handleTextChange = (key: keyof DittoInstanceCredentials): React.ChangeEventHandler => { + return e => { + updateData(key, e.target.value); + }; + }; + + const handleStreamItemChange = (key: Extract) => { + return (values: any[]) => { + updateData(key, values); + }; + }; + + const deleteStreamItem = (key: Extract) => { + return (i: number) => { + setData(prevData => { + const newData = { ...prevData }[key].toSpliced(i, 1); + + return { ...prevData, [key]: newData }; + }); + }; + }; + + const handleAddScreenshot = (): void => { + setData(prevData => { + const newData = { ...prevData }; + newData.screenshots.push({ + src: '', + }); + + return newData; + }); + }; + + const handleThumbnailChange = (key: Extract): React.ChangeEventHandler => { + return async(e) => { + setThumbnailLoading(true); + + const file = e.target.files ? e.target.files[0] : null; + if (!file) return; + + const data = new FormData(); + data.append('file', file); + + try { + const response = await dispatch(uploadMedia(data)); + const attachment = normalizeAttachment(response.data); + + if (attachment.type !== 'image') { + throw new Error('Only images supported.'); + } + + setData(prevData => { + return { ...prevData, [key]: { url: attachment.url, versions: { '@1x': attachment.url, '@2x': attachment.url } } }; + }); + + toast.success(messages.upload_thumbnail_success); + setThumbnailLoading(false); + } catch (err) { + setThumbnailLoading(false); + e.target.value = ''; + + if (err instanceof AxiosError) { + toast.error(err.response?.data?.error || 'An error occurred'); + return; + } + toast.error((err as Error)?.message || 'An error occurred'); + } + }; + }; + + return ( + +
+ + + + + + + + + + + + + + + + + {!isThumbnailLoading && data.thumbnail.url && } + + {isThumbnailLoading && } + + + + + + + + + + + + +
+ ); +}; + +type Screenshot = Screenshots[number] + +const ScreenshotInput: StreamfieldComponent = ({ value, onChange }) => { + const intl = useIntl(); + const dispatch = useAppDispatch(); + + const [isLoading, setLoading] = useState(false); + + const handleChange = (key: keyof Screenshot): React.ChangeEventHandler => { + return e => { + onChange({ ...value, [key]: e.currentTarget.value }); + }; + }; + + const handleFileChange = (key: Extract): React.ChangeEventHandler => { + return async(e) => { + setLoading(true); + + const file = e.target.files ? e.target.files[0] : null; + if (!file) return; + + const data = new FormData(); + data.append('file', file); + + try { + const response = await dispatch(uploadMedia(data)); + const attachment = normalizeAttachment(response.data); + + if (attachment.type !== 'image') { + throw new Error('Only images supported.'); + } + + const width = attachment?.meta?.getIn(['original', 'width']); + const height = attachment?.meta?.getIn(['original', 'height']); + + if (typeof width === 'number' && typeof height === 'number') { + onChange({ ...value, [key]: attachment.get('url'), ['sizes']: `${width}x${height}`, 'label': value.label }); + } else { + onChange({ ...value, [key]: attachment.get('url'), 'label': value.label }); + } + + toast.success(messages.upload_screenshot_success); + setLoading(false); + } catch (err) { + setLoading(false); + e.target.value = ''; + + if (err instanceof AxiosError) { + toast.error(err.response?.data?.error || 'An error occurred'); + return; + } + toast.error((err as Error)?.message || 'An error occurred'); + } + }; + }; + + return ( + + + {!isLoading && value.src && } + + {isLoading && } + + + + + + + + + ); +}; + +export default ManageDittoServer; \ No newline at end of file diff --git a/src/features/admin/tabs/dashboard.tsx b/src/features/admin/tabs/dashboard.tsx index 74f1731f6..c8ce60678 100644 --- a/src/features/admin/tabs/dashboard.tsx +++ b/src/features/admin/tabs/dashboard.tsx @@ -88,6 +88,13 @@ const Dashboard: React.FC = () => { + {account.admin && features.nostr && ( + } + /> + )} + {account.admin && ( = ({ children }) => + {features.nostr && } {features.nostr && } diff --git a/src/features/ui/util/async-components.ts b/src/features/ui/util/async-components.ts index d73689438..b73fd011d 100644 --- a/src/features/ui/util/async-components.ts +++ b/src/features/ui/util/async-components.ts @@ -173,6 +173,7 @@ export const EditDomainModal = lazy(() => import('soapbox/features/ui/components export const NostrRelays = lazy(() => import('soapbox/features/nostr-relays/index.tsx')); export const Bech32Redirect = lazy(() => import('soapbox/features/nostr/Bech32Redirect.tsx')); export const ManageZapSplit = lazy(() => import('soapbox/features/admin/manage-zap-split.tsx')); +export const ManageDittoServer = lazy(() => import('soapbox/features/admin/manage-ditto-server.tsx')); export const Relays = lazy(() => import('soapbox/features/admin/relays.tsx')); export const Rules = lazy(() => import('soapbox/features/admin/rules.tsx')); export const EditRuleModal = lazy(() => import('soapbox/features/ui/components/modals/edit-rule-modal.tsx')); diff --git a/src/locales/en.json b/src/locales/en.json index dfd811e3a..f87238b79 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -333,6 +333,16 @@ "column.admin.create_domain": "Create domaian", "column.admin.create_rule": "Create rule", "column.admin.dashboard": "Dashboard", + "column.admin.ditto_server.description": "Description", + "column.admin.ditto_server.manage": "Manage Ditto Server", + "column.admin.ditto_server.screenshot.label": "Alternative text describing the image.", + "column.admin.ditto_server.screenshots.label": "Web Manifest screenshots", + "column.admin.ditto_server.short_description": "Short Description", + "column.admin.ditto_server.submit.success": "Submitted successfully!", + "column.admin.ditto_server.thumbnail": "Thumbnail", + "column.admin.ditto_server.title": "Title", + "column.admin.ditto_server.upload.screenshot.success": "Screenshot uploaded!", + "column.admin.ditto_server.upload.thumbnail.success": "Thumbnail uploaded!", "column.admin.domains": "Domains", "column.admin.edit_announcement": "Edit announcement", "column.admin.edit_domain": "Edit domain", diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index 682d6b1c4..b50ac889f 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -2,6 +2,7 @@ import z from 'zod'; import { accountSchema } from './account.ts'; +import { screenshotsSchema } from './manifest.ts'; import { mrfSimpleSchema } from './pleroma.ts'; import { ruleSchema } from './rule.ts'; import { coerceObject, filteredArray, mimeSchema } from './utils.ts'; @@ -212,6 +213,8 @@ const instanceV2Schema = coerceObject({ pleroma: pleromaSchema, registrations: registrationsSchema, rules: filteredArray(ruleSchema), + screenshots: screenshotsSchema.catch([]), + short_description: z.string().catch(''), source_url: z.string().url().optional().catch(undefined), thumbnail: thumbnailSchema, title: z.string().catch(''), @@ -227,7 +230,7 @@ function upgradeInstance(v1: InstanceV1): InstanceV2 { account: v1.contact_account, email: v1.email, }, - description: v1.short_description, + description: v1.short_description, // Shouldn't it be "v1.description" ? domain: v1.uri, icon: [], languages: v1.languages, @@ -238,6 +241,8 @@ function upgradeInstance(v1: InstanceV1): InstanceV2 { enabled: v1.registrations, }, rules: v1.rules, + screenshots: [], + short_description: v1.short_description, thumbnail: { url: v1.thumbnail, versions: { @@ -255,4 +260,4 @@ function upgradeInstance(v1: InstanceV1): InstanceV2 { type InstanceV1 = z.infer; type InstanceV2 = z.infer; -export { instanceV1Schema, type InstanceV1, instanceV2Schema, type InstanceV2, upgradeInstance }; +export { instanceV1Schema, type InstanceV1, instanceV2Schema, type InstanceV2, upgradeInstance, thumbnailSchema }; diff --git a/src/schemas/manifest.ts b/src/schemas/manifest.ts new file mode 100644 index 000000000..32956ec7f --- /dev/null +++ b/src/schemas/manifest.ts @@ -0,0 +1,33 @@ +import z from 'zod'; + +const screenshotsSchema = z.array(z.object({ + form_factor: z.enum(['narrow', 'wide']).optional(), + label: z.string().optional(), + platform: z.enum([ + 'android', + 'chromeos', + 'ipados', + 'ios', + 'kaios', + 'macos', + 'windows', + 'xbox', + 'chrome_web_store', + 'itunes', + 'microsoft-inbox', + 'microsoft-store', + 'play', + ]).optional(), + /** https://developer.mozilla.org/en-US/docs/Web/Manifest/screenshots#sizes */ + sizes: z.string().refine((value) => + value.split(' ').every((v) => /^[1-9]\d{0,3}[xX][1-9]\d{0,3}$/.test(v)), + ).optional(), + /** Absolute URL. */ + src: z.string().url(), + /** MIME type of the image. */ + type: z.string().optional(), +})); + +type Screenshots = z.infer; + +export { screenshotsSchema, type Screenshots }; \ No newline at end of file