TootComposer: add toolbar & adjust layout
	
		
			
	
		
	
	
		
	
		
			All checks were successful
		
		
	
	
		
			
				
	
				/ depoly (push) Successful in 1m17s
				
			
		
		
	
	
				
					
				
			
		
			All checks were successful
		
		
	
	/ depoly (push) Successful in 1m17s
				
			This commit is contained in:
		
							parent
							
								
									44860a5bb2
								
							
						
					
					
						commit
						44c9e55928
					
				
					 6 changed files with 192 additions and 56 deletions
				
			
		
							
								
								
									
										5
									
								
								src/platform/SizedTextarea.css
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								src/platform/SizedTextarea.css
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
.SizedTextarea {
 | 
			
		||||
  overflow-y: hidden;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  resize: vertical;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										63
									
								
								src/platform/SizedTextarea.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								src/platform/SizedTextarea.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,63 @@
 | 
			
		|||
import { splitProps, type Component, type JSX } from "solid-js";
 | 
			
		||||
import "./SizedTextarea.css";
 | 
			
		||||
 | 
			
		||||
function isBoundEventHandler<T, E extends Event>(
 | 
			
		||||
  handler: JSX.EventHandlerUnion<T, E>,
 | 
			
		||||
): handler is JSX.BoundEventHandler<T, E> {
 | 
			
		||||
  return Array.isArray(handler);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function callEventHandlerUnion<T extends EventTarget, E extends Event>(
 | 
			
		||||
  handler: JSX.EventHandlerUnion<T, E>,
 | 
			
		||||
  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<HTMLTextAreaElement, E> | undefined,
 | 
			
		||||
  event: E,
 | 
			
		||||
) {
 | 
			
		||||
  const element = event.currentTarget;
 | 
			
		||||
  element.style.removeProperty("height");
 | 
			
		||||
  element.style.height = `${element.scrollHeight + 2}px`;
 | 
			
		||||
 | 
			
		||||
  if (ocallback) {
 | 
			
		||||
    callEventHandlerUnion(ocallback, event);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * The <textarea /> automatically vertically sized as the content.
 | 
			
		||||
 *
 | 
			
		||||
 * Note: listens the "focus" and "input" event using `addEventListener()`
 | 
			
		||||
 * may not work - use the event listening syntax on the component instead.
 | 
			
		||||
 * If you find it work, tell Rubicon to remove this note.
 | 
			
		||||
 */
 | 
			
		||||
const SizedTextarea: Component<JSX.HTMLElementTags["textarea"]> = (oprops) => {
 | 
			
		||||
  const [props, rest] = splitProps(oprops, ["onInput", "onFocus", "class"]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <textarea
 | 
			
		||||
      onInput={(event) =>
 | 
			
		||||
        onTextareaRefreshHeight<typeof event>(props.onInput, event)
 | 
			
		||||
      }
 | 
			
		||||
      onFocus={[onTextareaRefreshHeight, props.onFocus]}
 | 
			
		||||
      class={`SizedTextarea ${props.class || ""}`}
 | 
			
		||||
      {...rest}
 | 
			
		||||
    ></textarea>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default SizedTextarea;
 | 
			
		||||
| 
						 | 
				
			
			@ -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")}
 | 
			
		||||
          />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) => {
 | 
			
		|||
 | 
			
		||||
        <Show when={session()!.account}>
 | 
			
		||||
          <TootComposer
 | 
			
		||||
            isTyping={isInTyping()}
 | 
			
		||||
            onTypingChange={setInTyping}
 | 
			
		||||
            mentions={defaultMentions()}
 | 
			
		||||
            profile={session().account!}
 | 
			
		||||
            replyToDisplayName={toot()?.account?.displayName || ""}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,12 +1,45 @@
 | 
			
		|||
 | 
			
		||||
.TootComposer {
 | 
			
		||||
  --card-gut: 8px;
 | 
			
		||||
  contain: content;
 | 
			
		||||
 | 
			
		||||
  > .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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<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?",
 | 
			
		||||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
    >
 | 
			
		||||
      <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
 | 
			
		||||
| 
						 | 
				
			
			@ -336,16 +375,18 @@ const TootComposer: Component<{
 | 
			
		|||
            sx={{ marginLeft: "-0.25em" }}
 | 
			
		||||
          />
 | 
			
		||||
        </Show>
 | 
			
		||||
        <textarea
 | 
			
		||||
        <SizedTextarea
 | 
			
		||||
          ref={inputRef!}
 | 
			
		||||
          placeholder={
 | 
			
		||||
            props.replyToDisplayName
 | 
			
		||||
              ? `Reply to ${props.replyToDisplayName}...`
 | 
			
		||||
              : randomPlaceholder()
 | 
			
		||||
          }
 | 
			
		||||
          onFocus={[setActive, true]}
 | 
			
		||||
          style={{ width: "100%", border: "none" }}
 | 
			
		||||
          disabled={sending()}
 | 
			
		||||
        ></textarea>
 | 
			
		||||
          autocomplete="off"
 | 
			
		||||
        ></SizedTextarea>
 | 
			
		||||
        <Show when={props.client}>
 | 
			
		||||
          <Show
 | 
			
		||||
            when={!sending()}
 | 
			
		||||
| 
						 | 
				
			
			@ -361,32 +402,38 @@ const TootComposer: Component<{
 | 
			
		|||
              </div>
 | 
			
		||||
            }
 | 
			
		||||
          >
 | 
			
		||||
            <IconButton sx={{ marginRight: "-0.5em" }} onClick={send}>
 | 
			
		||||
            <IconButton
 | 
			
		||||
              sx={{ marginRight: "-0.5em" }}
 | 
			
		||||
              onClick={send}
 | 
			
		||||
              aria-label="Send"
 | 
			
		||||
            >
 | 
			
		||||
              <Send />
 | 
			
		||||
            </IconButton>
 | 
			
		||||
          </Show>
 | 
			
		||||
        </Show>
 | 
			
		||||
      </div>
 | 
			
		||||
 | 
			
		||||
      <Show when={typing()}>
 | 
			
		||||
        <div
 | 
			
		||||
          style={{
 | 
			
		||||
            display: "flex",
 | 
			
		||||
            "justify-content": "flex-end",
 | 
			
		||||
            "margin-top": "8px",
 | 
			
		||||
            gap: "16px",
 | 
			
		||||
            "flex-flow": "row wrap",
 | 
			
		||||
          }}
 | 
			
		||||
        >
 | 
			
		||||
          <Button onClick={[setLangPickerOpen, true]} disabled={sending()}>
 | 
			
		||||
            <Translate sx={{ marginTop: "-0.25em", marginRight: "0.25em" }} />
 | 
			
		||||
            {iso639_1.getNativeName(language())}
 | 
			
		||||
            <ArrowDropDown sx={{ marginTop: "-0.25em" }} />
 | 
			
		||||
      <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 onClick={[setPermPicker, true]} disabled={sending()}>
 | 
			
		||||
            <Visibility sx={{ marginTop: "-0.15em", marginRight: "0.25em" }} />
 | 
			
		||||
            {visibilityText()}
 | 
			
		||||
            <ArrowDropDown sx={{ marginTop: "-0.25em" }} />
 | 
			
		||||
          <Button
 | 
			
		||||
            startIcon={<Visibility />}
 | 
			
		||||
            endIcon={<ArrowDropDown />}
 | 
			
		||||
            onClick={[setPermPicker, true]}
 | 
			
		||||
            disabled={sending()}
 | 
			
		||||
          >
 | 
			
		||||
            <span style={{ "vertical-align": "bottom" }}>
 | 
			
		||||
              {visibilityText()}
 | 
			
		||||
            </span>
 | 
			
		||||
          </Button>
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue