import { createEffect, createMemo, createRenderEffect, createSignal, Show, type Accessor, type Component, type JSX, type Ref, } from "solid-js"; import Scaffold from "../material/Scaffold"; import { Avatar, Button, IconButton, List, ListItemButton, ListItemIcon, ListItemSecondaryAction, ListItemText, Radio, Switch, Divider, CircularProgress, Toolbar, MenuItem, ListItemAvatar, } from "@suid/material"; import { ArrowDropDown, Public as PublicIcon, Send, People as PeopleIcon, ThreeP as ThreePIcon, ListAlt as ListAltIcon, Visibility, Translate, Close, MoreVert, } from "@suid/icons-material"; import type { Account } from "../accounts/stores"; import "./TootComposer.css"; import BottomSheet from "../material/BottomSheet"; import { useLanguage } from "../platform/i18n"; import iso639_1 from "iso-639-1"; import ChooseTootLang from "./ChooseTootLang"; import type { mastodon } from "masto"; import cardStyles from "../material/cards.module.css"; import Menu, { createManagedMenuState } from "../material/Menu"; import { useDefaultSession } from "../masto/clients"; import { resolveCustomEmoji } from "../masto/toot"; import SizedTextarea from "../platform/SizedTextarea"; type TootVisibility = "public" | "unlisted" | "private" | "direct"; const TootVisibilityPickerDialog: Component<{ open?: boolean; class?: string; onClose: () => void; visibility: TootVisibility; onVisibilityChange: (value: TootVisibility) => void; }> = (props) => { type Kind = "public" | "private" | "direct"; const kind = () => props.visibility === "public" || props.visibility === "unlisted" ? "public" : props.visibility; const setKind = (nv: Kind) => { if (nv == "public") { props.onVisibilityChange(discoverable() ? "public" : "unlisted"); } else { props.onVisibilityChange(nv); } }; const discoverable = () => { return props.visibility === "public"; }; const setDiscoverable = (setter: (v: boolean) => boolean) => { const nval = setter(discoverable()); props.onVisibilityChange(nval ? "public" : "unlisted"); // trigger change }; return ( <BottomSheet open={props.open} onClose={props.onClose} bottomUp class={props.class} > <Scaffold bottom={ <div style={{ "border-top": "1px solid #ddd", background: "var(--tutu-color-surface)", padding: "8px 16px", width: "100%", "text-align": "end", }} > <Button onClick={props.onClose}>Confirm</Button> </div> } > <List dense> <ListItemButton onClick={[setKind, "public"]}> <ListItemIcon> <PublicIcon /> </ListItemIcon> <ListItemText primary="Public" secondary="Everyone can see this toot" ></ListItemText> <ListItemSecondaryAction> <Radio checked={kind() == "public"}></Radio> </ListItemSecondaryAction> </ListItemButton> <ListItemButton sx={{ paddingLeft: "40px" }} disabled={kind() !== "public"} onClick={() => setDiscoverable((x) => !x)} > <ListItemIcon> <ListAltIcon /> </ListItemIcon> <ListItemText primary="Discoverable" secondary="The others can discover it on the exploration." ></ListItemText> <ListItemSecondaryAction> <Switch checked={discoverable()} disabled={kind() !== "public"} ></Switch> </ListItemSecondaryAction> </ListItemButton> <Divider /> <ListItemButton onClick={[setKind, "private"]}> <ListItemIcon> <PeopleIcon /> </ListItemIcon> <ListItemText primary="Only Followers" secondary="Visibile for followers only" ></ListItemText> <ListItemSecondaryAction> <Radio checked={kind() == "private"}></Radio> </ListItemSecondaryAction> </ListItemButton> <Divider /> <ListItemButton onClick={[setKind, "direct"]}> <ListItemIcon> <ThreePIcon /> </ListItemIcon> <ListItemText primary="Only Mentions" secondary="Visible for mentioned users only" ></ListItemText> <ListItemSecondaryAction> <Radio checked={kind() == "direct"}></Radio> </ListItemSecondaryAction> </ListItemButton> </List> </Scaffold> </BottomSheet> ); }; const TootLanguagePickerDialog: Component<{ open?: boolean; class?: string; onClose: () => void; code: string; onCodeChange: (nval: string) => void; }> = (props) => { return ( <BottomSheet open={props.open} onClose={props.onClose} class={props.class}> <Show when={props.open}> <ChooseTootLang code={props.code} onCodeChange={props.onCodeChange} onClose={props.onClose} /> </Show> </BottomSheet> ); }; function randomChoose<T extends any[]>( rn: number, K: T, ): T extends Array<infer E> ? E : never { const idx = Math.floor(rn * K.length); return K[idx]; } function useRandomChoice<T>(choices: () => T[]): Accessor<T> { return createMemo(() => randomChoose(Math.random(), choices())); } function cancelEvent(event: Event) { event.stopPropagation(); } const TootComposer: Component<{ ref?: Ref<HTMLDivElement>; style?: JSX.CSSProperties; profile?: Account; replyToDisplayName?: string; mentions?: readonly string[]; client?: mastodon.rest.Client; inReplyToId?: string; onSent?: (status: mastodon.v1.Status) => void; }> = (props) => { let inputRef: HTMLTextAreaElement; const session = useDefaultSession(); const [active, setActive] = createSignal(false); const [sending, setSending] = createSignal(false); const [visibility, setVisibility] = createSignal<TootVisibility>("public"); const [permPicker, setPermPicker] = createSignal(false); const [language, setLanguage] = createSignal("en"); const [langPickerOpen, setLangPickerOpen] = createSignal(false); const appLanguage = useLanguage(); const [openMenu, menuState] = createManagedMenuState(); const randomPlaceholder = useRandomChoice(() => [ "What's happening?", "What do you think?", ]); createEffect(() => { const lang = appLanguage().split("-")[0]; setLanguage(lang); }); createEffect(() => { if (active()) { setTimeout(() => inputRef.focus(), 0); } }); createEffect(() => { if (inputRef.value !== "") return; if (props.mentions) { const prepText = props.mentions.join(" ") + " "; inputRef.value = prepText; } }); const containerStyle = () => active() || permPicker() ? { position: "sticky" as const, top: "var(--scaffold-topbar-height, 0)", bottom: "var(--safe-area-inset-bottom, 0)", "z-index": 2, ...props.style, } : undefined; const visibilityText = () => { switch (visibility()) { case "public": return "Discoverable"; case "unlisted": return "Public"; case "private": return "Only Followers"; case "direct": return "Only Mentions"; } }; const idempotencyKey = createMemo(() => window.crypto.randomUUID()); const send = async () => { const client = session()?.client; if (!client) return; setSending(true); try { const status = await client.v1.statuses.create( { status: inputRef.value, language: language(), visibility: visibility(), inReplyToId: props.inReplyToId, }, { requestInit: { headers: { ["Idempotency-Key"]: idempotencyKey(), }, }, }, ); props.onSent?.(status); inputRef.value = ""; } finally { setSending(false); } }; return ( <div ref={props.ref} class={/* @once */ `TootComposer ${cardStyles.card}`} style={containerStyle()} on:touchend={ cancelEvent /* on: is required to register the event handler on the exact element */ } on:touchmove={cancelEvent} on:wheel={cancelEvent} > <Show when={active()}> <Toolbar class={cardStyles.cardNoPad}> <IconButton onClick={[setActive, false]} aria-label="Close the composer" > <Close /> </IconButton> <IconButton onClick={(e) => openMenu(e.currentTarget.getBoundingClientRect())} > <MoreVert /> </IconButton> </Toolbar> <div class={cardStyles.cardNoPad}> <Menu {...menuState}> <MenuItem> <ListItemAvatar> <Avatar src={session()?.account.inf?.avatar}></Avatar> </ListItemAvatar> <ListItemText secondary={"Default account"}> <span ref={(e) => { createRenderEffect(() => { const inf = session()?.account.inf; return (e.innerHTML = resolveCustomEmoji( inf?.displayName || "", inf?.emojis ?? [], )); }); }} ></span> </ListItemText> </MenuItem> </Menu> </div> </Show> <div class="reply-input"> <Show when={props.profile}> <Avatar src={props.profile!.inf?.avatar} sx={{ marginLeft: "-0.25em" }} /> </Show> <SizedTextarea ref={inputRef!} placeholder={ props.replyToDisplayName ? `Reply to ${props.replyToDisplayName}...` : randomPlaceholder() } onFocus={[setActive, true]} style={{ width: "100%", border: "none" }} disabled={sending()} autocomplete="off" ></SizedTextarea> <Show when={props.client}> <Show when={!sending()} fallback={ <div style={{ padding: "8px" }}> <CircularProgress sx={{ marginRight: "-0.5em", width: "1.5rem", height: "1.5rem", }} /> </div> } > <IconButton sx={{ marginRight: "-0.5em" }} onClick={send} aria-label="Send" > <Send /> </IconButton> </Show> </Show> </div> <Show when={active()}> <div class="options"> <Button startIcon={<Translate />} endIcon={<ArrowDropDown />} onClick={[setLangPickerOpen, true]} disabled={sending()} > <span style={{ "vertical-align": "bottom" }}> {iso639_1.getNativeName(language())} </span> </Button> <Button startIcon={<Visibility />} endIcon={<ArrowDropDown />} onClick={[setPermPicker, true]} disabled={sending()} > <span style={{ "vertical-align": "bottom" }}> {visibilityText()} </span> </Button> </div> <TootVisibilityPickerDialog class={cardStyles.cardNoPad} open={permPicker()} onClose={() => setPermPicker(false)} visibility={visibility()} onVisibilityChange={setVisibility} /> <TootLanguagePickerDialog class={cardStyles.cardNoPad} open={langPickerOpen()} onClose={() => setLangPickerOpen(false)} code={language()} onCodeChange={setLanguage} /> </Show> </div> ); }; export default TootComposer;