diff --git a/src/platform/SizedTextarea.css b/src/platform/SizedTextarea.css new file mode 100644 index 0000000..14184f6 --- /dev/null +++ b/src/platform/SizedTextarea.css @@ -0,0 +1,5 @@ +.SizedTextarea { + overflow-y: hidden; + width: 100%; + resize: vertical; +} diff --git a/src/platform/SizedTextarea.tsx b/src/platform/SizedTextarea.tsx new file mode 100644 index 0000000..4f97ff1 --- /dev/null +++ b/src/platform/SizedTextarea.tsx @@ -0,0 +1,63 @@ +import { splitProps, type Component, type JSX } from "solid-js"; +import "./SizedTextarea.css"; + +function isBoundEventHandler( + handler: JSX.EventHandlerUnion, +): handler is JSX.BoundEventHandler { + return Array.isArray(handler); +} + +function callEventHandlerUnion( + handler: JSX.EventHandlerUnion, + event: E & { currentTarget: T; target: Element }, +) { + if (isBoundEventHandler(handler)) { + const fn = handler[0], + value = handler[1]; + fn(value, event); + } else { + (handler as (e: typeof event) => void).bind(event.target)(event); + } +} + +function onTextareaRefreshHeight< + E extends Event & { + currentTarget: HTMLTextAreaElement; + target: HTMLTextAreaElement; + }, +>( + ocallback: JSX.EventHandlerUnion | undefined, + event: E, +) { + const element = event.currentTarget; + element.style.removeProperty("height"); + element.style.height = `${element.scrollHeight + 2}px`; + + if (ocallback) { + callEventHandlerUnion(ocallback, event); + } +} + +/** + * The + ); +}; + +export default SizedTextarea; diff --git a/src/timelines/TimelinePanel.tsx b/src/timelines/TimelinePanel.tsx index edd1806..150ca0e 100644 --- a/src/timelines/TimelinePanel.tsx +++ b/src/timelines/TimelinePanel.tsx @@ -33,7 +33,6 @@ const TimelinePanel: Component<{ () => props.client.v1.timelines[props.name], () => ({ limit: 20 }), ); - const [typing, setTyping] = createSignal(false); const tlEndObserver = new IntersectionObserver(() => { if (untrack(() => props.prefetch) && !snapshot.loading) @@ -65,8 +64,6 @@ const TimelinePanel: Component<{ style={{ "--scaffold-topbar-height": "0px", }} - isTyping={typing()} - onTypingChange={setTyping} client={props.client} onSent={() => refetchTimeline("prev")} /> diff --git a/src/timelines/TootBottomSheet.tsx b/src/timelines/TootBottomSheet.tsx index 40d04ad..92a44c3 100644 --- a/src/timelines/TootBottomSheet.tsx +++ b/src/timelines/TootBottomSheet.tsx @@ -46,7 +46,6 @@ const TootBottomSheet: Component = (props) => { }>(); const navigate = useNavigate(); const time = createTimeSource(); - const [isInTyping, setInTyping] = createSignal(false); const acctText = () => decodeURIComponent(params.acct); const session = useSessionForAcctStr(acctText); @@ -70,12 +69,6 @@ const TootBottomSheet: Component = (props) => { return tootId; }); - createEffect(() => { - if (location.state?.tootReply) { - setInTyping(true); - } - }); - const [tootContextErrorUncaught, { refetch: refetchContext }] = createResource( () => [session().client, params.id] as const, @@ -282,8 +275,6 @@ const TootBottomSheet: Component = (props) => { .MuiToolbar-root { + justify-content: space-between; + + > :first-child { + margin-left: -0.5em; + } + + > :last-child { + margin-right: -0.5em; + } + } .reply-input { display: flex; align-items: flex-start; gap: 8px; } + + .options { + display: flex; + justify-content: flex-end; + gap: 16px; + flex-flow: row wrap; + padding-top: 16px; + padding-bottom: 8px; + margin-left: -0.5em; + margin-right: -0.5em; + + animation: TootComposerFadeIn 110ms var(--tutu-anim-curve-sharp) both; + } } +@keyframes TootComposerFadeIn { + 0% { + opacity: 0.5; + } + 100% { + opacity: 1; + } +} diff --git a/src/timelines/TootComposer.tsx b/src/timelines/TootComposer.tsx index 1671e19..60e6bc5 100644 --- a/src/timelines/TootComposer.tsx +++ b/src/timelines/TootComposer.tsx @@ -1,6 +1,7 @@ import { createEffect, createMemo, + createRenderEffect, createSignal, createUniqueId, onMount, @@ -24,6 +25,9 @@ import { Switch, Divider, CircularProgress, + Toolbar, + MenuItem, + ListItemAvatar, } from "@suid/material"; import { ArrowDropDown, @@ -34,6 +38,8 @@ import { ListAlt as ListAltIcon, Visibility, Translate, + Close, + MoreVert, } from "@suid/icons-material"; import type { Account } from "../accounts/stores"; import "./TootComposer.css"; @@ -44,6 +50,11 @@ import iso639_1 from "iso-639-1"; import ChooseTootLang from "./ChooseTootLang"; import type { mastodon } from "masto"; import cardStyles from "../material/cards.module.css"; +import { Title } from "../material/typography"; +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"; @@ -211,23 +222,22 @@ const TootComposer: Component<{ profile?: Account; replyToDisplayName?: string; mentions?: readonly string[]; - isTyping?: boolean; - onTypingChange: (value: boolean) => void; client?: mastodon.rest.Client; inReplyToId?: string; onSent?: (status: mastodon.v1.Status) => void; }> = (props) => { let inputRef: HTMLTextAreaElement; - let sendKey: string | undefined; - const typing = () => props.isTyping; - const setTyping = (v: boolean) => props.onTypingChange(v); + const session = useDefaultSession(); + + const [active, setActive] = createSignal(false); const [sending, setSending] = createSignal(false); const [visibility, setVisibility] = createSignal("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?", @@ -240,15 +250,11 @@ const TootComposer: Component<{ }); createEffect(() => { - if (typing()) { + if (active()) { setTimeout(() => inputRef.focus(), 0); } }); - onMount(() => { - makeEventListener(inputRef, "focus", () => setTyping(true)); - }); - createEffect(() => { if (inputRef.value !== "") return; if (props.mentions) { @@ -258,7 +264,7 @@ const TootComposer: Component<{ }); const containerStyle = () => - typing() || permPicker() + active() || permPicker() ? { position: "sticky" as const, top: "var(--scaffold-topbar-height, 0)", @@ -281,17 +287,15 @@ const TootComposer: Component<{ } }; - const getOrGenSendKey = () => { - if (sendKey === undefined) { - sendKey = window.crypto.randomUUID(); - } - return sendKey; - }; + const idempotencyKey = createMemo(() => window.crypto.randomUUID()); const send = async () => { + const client = session()?.client; + if (!client) return; + setSending(true); try { - const status = await props.client!.v1.statuses.create( + const status = await client.v1.statuses.create( { status: inputRef.value, language: language(), @@ -301,7 +305,7 @@ const TootComposer: Component<{ { requestInit: { headers: { - ["Idempotency-Key"]: getOrGenSendKey(), + ["Idempotency-Key"]: idempotencyKey(), }, }, }, @@ -319,9 +323,6 @@ const TootComposer: Component<{ ref={props.ref} class={/* @once */ `TootComposer ${cardStyles.card}`} style={containerStyle()} - onClick={() => { - inputRef.focus(); - }} on:touchend={ cancelEvent /* on: is required to register the event handler on the exact element */ @@ -329,6 +330,44 @@ const TootComposer: Component<{ on:touchmove={cancelEvent} on:wheel={cancelEvent} > + + + + + + openMenu(e.currentTarget.getBoundingClientRect())} + > + + + +
+ + + + + + + { + createRenderEffect(() => { + const inf = session()?.account.inf; + return (e.innerHTML = resolveCustomEmoji( + inf?.displayName || "", + inf?.emojis ?? [], + )); + }); + }} + > + + + +
+
+
- + autocomplete="off" + > } > - +
- -
- -