TryGhost/Ghost

View on GitHub
apps/admin-x-activitypub/src/components/global/APReplyBox.tsx

Summary

Maintainability
A
1 hr
Test Coverage
import React, {HTMLProps, useEffect, useId, useRef, useState} from 'react';

import * as FormPrimitive from '@radix-ui/react-form';
import APAvatar from './APAvatar';
import clsx from 'clsx';
import getUsername from '../../utils/get-username';
import {Activity} from '../activities/ActivityItem';
import {ActorProperties, ObjectProperties} from '@tryghost/admin-x-framework/api/activitypub';
import {Button, showToast} from '@tryghost/admin-x-design-system';
import {useReplyMutationForUser, useUserDataForUser} from '../../hooks/useActivityPubQueries';

export interface APTextAreaProps extends HTMLProps<HTMLTextAreaElement> {
    title?: string;
    value?: string;
    rows?: number;
    error?: boolean;
    placeholder?: string;
    hint?: React.ReactNode;
    className?: string;
    onChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
    onNewReply?: (activity: Activity) => void;
    object: ObjectProperties;
    focused: number;
}

const APReplyBox: React.FC<APTextAreaProps> = ({
    title,
    value,
    rows = 1,
    maxLength,
    error,
    hint,
    className,
    object,
    focused,
    onNewReply,
    ...props
}) => {
    const id = useId();
    const [textValue, setTextValue] = useState(value); // Manage the textarea value with state
    const replyMutation = useReplyMutationForUser('index');

    const {data: user} = useUserDataForUser('index');

    const textareaRef = useRef<HTMLTextAreaElement>(null);

    useEffect(() => {
        if (textareaRef.current && focused) {
            textareaRef.current.focus();
        }
    }, [focused]);

    async function handleClick() {
        if (!textValue) {
            return;
        }
        await replyMutation.mutate({id: object.id, content: textValue}, {
            onSuccess(activity: Activity) {
                setTextValue('');
                showToast({
                    message: 'Reply sent',
                    type: 'success'
                });
                if (onNewReply) {
                    onNewReply(activity);
                }
            }
        });
    }

    function handleChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
        setTextValue(event.target.value); // Update the state on every change
    }

    const [isFocused, setFocused] = useState(false);

    function handleBlur() {
        setFocused(false);
    }

    function handleFocus() {
        setFocused(true);
    }

    const styles = clsx(
        `ap-textarea order-2 w-full resize-none rounded-lg border py-2 pr-3 text-[1.5rem] transition-all dark:text-white ${isFocused && 'pb-12'}`,
        error ? 'border-red' : 'border-transparent placeholder:text-grey-500 dark:placeholder:text-grey-800',
        title && 'mt-1.5',
        className
    );

    // We disable the button if either the textbox isn't focused, or the reply is currently being sent.
    const buttonDisabled = !isFocused || replyMutation.isLoading;

    let placeholder = 'Reply...';
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    const attributedTo = (object.attributedTo || {}) as any;
    if (typeof attributedTo.preferredUsername === 'string' && typeof attributedTo.id === 'string') {
        placeholder = `Reply to ${getUsername(attributedTo)}...`;
    }

    return (
        <div className='flex w-full gap-x-3 py-6'>
            <APAvatar author={user as ActorProperties} />
            <div className='relative w-full'>
                <FormPrimitive.Root asChild>
                    <div className='flex w-full flex-col'>
                        <FormPrimitive.Field name={id} asChild>
                            <FormPrimitive.Control asChild>
                                <textarea
                                    ref={textareaRef}
                                    className={styles}
                                    disabled={replyMutation.isLoading}
                                    id={id}
                                    maxLength={maxLength}
                                    placeholder={placeholder}
                                    rows={isFocused ? 3 : rows}
                                    value={textValue}
                                    onBlur={handleBlur}
                                    onChange={handleChange}
                                    onFocus={handleFocus}
                                    {...props}>
                                </textarea>
                            </FormPrimitive.Control>
                        </FormPrimitive.Field>
                        {title}
                        {hint}
                    </div>
                </FormPrimitive.Root>
                <div className='absolute bottom-[3px] right-[9px] flex space-x-4 transition-[opacity] duration-150'>
                    <Button color='black' disabled={buttonDisabled} id='post' label='Post' loading={replyMutation.isLoading} size='md' onMouseDown={handleClick} />
                </div>
            </div>
        </div>
    );
};

export default APReplyBox;