diff --git a/.vscode/settings.json b/.vscode/settings.json index d499920ea..47eabf2db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,11 @@ "editor.insertSpaces": true, "editor.tabSize": 2, // Nix environment settings - "nixEnvSelector.nixFile": "${workspaceFolder}/default.nix" + "nixEnvSelector.nixFile": "${workspaceFolder}/default.nix", + "search.exclude": { + "**/coverage": true, + "**/dist": true, + "**/i18n": true, + "**/styled-system": true + } } diff --git a/packages/client/components/app/interface/channels/text/Message.tsx b/packages/client/components/app/interface/channels/text/Message.tsx index 67997ed34..0226330d9 100644 --- a/packages/client/components/app/interface/channels/text/Message.tsx +++ b/packages/client/components/app/interface/channels/text/Message.tsx @@ -13,6 +13,7 @@ import { useState } from "@revolt/state"; import { Attachment, Avatar, + CompositionMediaPicker, Embed, MessageContainer, MessageReply, @@ -30,6 +31,8 @@ import { floatingUserMenusFromMessage, } from "../../../menus/UserContextMenu"; +import { startsWithPackPUA } from "@revolt/markdown/emoji/UnicodeEmoji"; +import { MediaPickerProps } from "@revolt/ui/components/features/messaging/composition/picker/CompositionMediaPicker"; import { EditMessage } from "./EditMessage"; /** @@ -75,6 +78,8 @@ export function Message(props: Props) { const client = useClient(); const [isHovering, setIsHovering] = createSignal(false); + const [reactPicker, setReactPicker] = createSignal(); + let msgRef; /** * Determine whether this message only contains a GIF @@ -104,6 +109,8 @@ export function Message(props: Props) { return ( } - contextMenu={() => } + contextMenu={ + props.editing + ? undefined + : () => ( + + ) + } timestamp={props.message.createdAt} edited={props.message.editedAt} mentioned={props.message.mentioned} @@ -257,6 +273,29 @@ export function Message(props: Props) { } > + + props.message?.channel?.sendMessage({ + content, + replies: [{ id: props.message.id, mention: true }], + }) + } + onTextReplacement={(emoji) => + react( + emoji.startsWith(":") + ? emoji.slice(1, emoji.length - 1) + : startsWithPackPUA(emoji) + ? emoji.slice(1) + : emoji, + ) + } + > + {(trigProps) => { + trigProps.ref(msgRef); + setReactPicker(trigProps); + return <>; + }} + - - - {(attachment) => ( - - )} - - - - - {(embed) => } - - + + {(attachment) => ( + + )} + + + {(embed) => } + >} interactions={props.message.interactions} userId={client().user!.id} addReaction={react} removeReaction={unreact} - sendGIF={(content) => - props.message?.channel?.sendMessage({ - content, - replies: [{ id: props.message.id, mention: true }], - }) - } + reactPicker={reactPicker} /> ); diff --git a/packages/client/components/app/interface/channels/text/Messages.tsx b/packages/client/components/app/interface/channels/text/Messages.tsx index eba9e2ba9..87ea1b10d 100644 --- a/packages/client/components/app/interface/channels/text/Messages.tsx +++ b/packages/client/components/app/interface/channels/text/Messages.tsx @@ -281,7 +281,7 @@ export function Messages(props: Props) { // If we're not at the end, restore scroll position if (existingState && !existingState.atEnd) { setTimeout(() => - listRef!.scrollTo({ + listRef?.scrollTo({ top: existingState.scrollTop!, behavior: "instant", }), @@ -290,7 +290,7 @@ export function Messages(props: Props) { // Or... reset scroll to the end else if (atEnd()) { setTimeout(() => - listRef!.scrollTo({ + listRef?.scrollTo({ top: 9999999, behavior: "instant", }), diff --git a/packages/client/components/app/interface/settings/ChannelSettings.tsx b/packages/client/components/app/interface/settings/ChannelSettings.tsx index d00fa6937..9b39b37e7 100644 --- a/packages/client/components/app/interface/settings/ChannelSettings.tsx +++ b/packages/client/components/app/interface/settings/ChannelSettings.tsx @@ -19,6 +19,7 @@ import { ChannelPermissionsEditor } from "./channel/permissions/ChannelPermissio import { ChannelPermissionsOverview } from "./channel/permissions/ChannelPermissionsOverview"; import { ViewWebhook } from "./channel/webhooks/ViewWebhook"; import { WebhooksList } from "./channel/webhooks/WebhooksList"; +import { BackCard } from "./user/_AccountCard"; const Config: SettingsConfiguration = { /** @@ -98,11 +99,12 @@ const Config: SettingsConfiguration = { * Generate list of categories / entries for channel settings * @returns List */ - list(channel) { + list(channel, onClose) { const { openModal } = useModals(); return { context: channel, + prepend: , entries: [ { title: , diff --git a/packages/client/components/app/interface/settings/ServerSettings.tsx b/packages/client/components/app/interface/settings/ServerSettings.tsx index c7b48002e..28d880332 100644 --- a/packages/client/components/app/interface/settings/ServerSettings.tsx +++ b/packages/client/components/app/interface/settings/ServerSettings.tsx @@ -24,6 +24,7 @@ import { EmojiList } from "./server/emojis/EmojiList"; import { ListServerInvites } from "./server/invites/ListServerInvites"; import { ServerRoleEditor } from "./server/roles/ServerRoleEditor"; import { ServerRoleOverview } from "./server/roles/ServerRoleOverview"; +import { BackCard } from "./user/_AccountCard"; const Config: SettingsConfiguration = { /** @@ -89,12 +90,13 @@ const Config: SettingsConfiguration = { * Generate list of categories / entries for server settings * @returns List */ - list(server) { + list(server, onClose) { const user = useUser(); const { openModal } = useModals(); return { context: server, + prepend: , entries: [ { title: , diff --git a/packages/client/components/app/interface/settings/Settings.tsx b/packages/client/components/app/interface/settings/Settings.tsx index bd60cadc8..d7504d46c 100644 --- a/packages/client/components/app/interface/settings/Settings.tsx +++ b/packages/client/components/app/interface/settings/Settings.tsx @@ -4,6 +4,7 @@ import { createContext, createMemo, createSignal, + Setter, untrack, useContext, } from "solid-js"; @@ -25,6 +26,8 @@ export interface SettingsProps { * Settings context */ context: never; + + contentRef: Setter; } /** @@ -89,11 +92,16 @@ export function Settings(props: SettingsProps & SettingsConfiguration) { navigate, }} > - + {(list) => ( <> ) { */ function MemoisedList(props: { context: never; - list: (context: never) => SettingsList; + onClose?: () => void; + list: (context: never, onClose?: () => void) => SettingsList; children: (list: Accessor>) => JSX.Element; }) { /** * Generate list of categories / links */ - const list = createMemo(() => props.list(props.context)); - + const list = createMemo(() => props.list(props.context, props.onClose)); return <>{props.children(list)}; } diff --git a/packages/client/components/app/interface/settings/UserSettings.tsx b/packages/client/components/app/interface/settings/UserSettings.tsx index 2216c5819..9dab2f49c 100644 --- a/packages/client/components/app/interface/settings/UserSettings.tsx +++ b/packages/client/components/app/interface/settings/UserSettings.tsx @@ -33,7 +33,7 @@ import { Feedback } from "./user/Feedback"; import { LanguageSettings } from "./user/Language"; import Native from "./user/Native"; import { Sessions } from "./user/Sessions"; -import { AccountCard } from "./user/_AccountCard"; +import { AccountCard, BackCard } from "./user/_AccountCard"; import { AppearanceMenu } from "./user/appearance"; import { MyBots, ViewBot } from "./user/bots"; import { EditProfile } from "./user/profile"; @@ -105,7 +105,7 @@ const Config: SettingsConfiguration<{ server: Server }> = { * Generate list of categories / entries for client settings * @returns List */ - list() { + list(_, onClose) { const { pop } = useModals(); const { logout } = useClientLifecycle(); @@ -113,6 +113,7 @@ const Config: SettingsConfiguration<{ server: Server }> = { context: null!, prepend: ( +
diff --git a/packages/client/components/app/interface/settings/_layout/Content.tsx b/packages/client/components/app/interface/settings/_layout/Content.tsx index 75d0fc1de..5760c6759 100644 --- a/packages/client/components/app/interface/settings/_layout/Content.tsx +++ b/packages/client/components/app/interface/settings/_layout/Content.tsx @@ -1,4 +1,4 @@ -import { Accessor, JSX, Show } from "solid-js"; +import { Accessor, JSX, Setter, Show } from "solid-js"; import { css, cva } from "styled-system/css"; import { styled } from "styled-system/jsx"; @@ -19,34 +19,33 @@ export function SettingsContent(props: { list: Accessor>; title: (ctx: SettingsList, key: string) => string; page: Accessor; + ref: Setter; }) { const { navigate } = useSettingsNavigation(); return ( -
+
- + - - - props.title(props.list() as SettingsList, key) - } - navigate={(keys) => navigate(keys.join("/"))} - /> - + + + + props.title(props.list() as SettingsList, key) + } + navigate={(keys) => navigate(keys.join("/"))} + /> + + {props.children}
- + @@ -121,7 +120,7 @@ const CloseAction = styled("div", { marginTop: "4px", display: "flex", justifyContent: "center", - width: "36px", + width: "40px", fontWeight: 600, color: "var(--md-sys-color-on-surface)", fontSize: "0.75rem", diff --git a/packages/client/components/app/interface/settings/_layout/Sidebar.tsx b/packages/client/components/app/interface/settings/_layout/Sidebar.tsx index c39509023..e300cff81 100644 --- a/packages/client/components/app/interface/settings/_layout/Sidebar.tsx +++ b/packages/client/components/app/interface/settings/_layout/Sidebar.tsx @@ -20,28 +20,28 @@ import { */ export function SettingsSidebar(props: { list: Accessor>; - setPage: Setter; page: Accessor; }) { const { navigate } = useSettingsNavigation(); + const list = props.list(); /** * Select first page on load */ onMount(() => { if (!props.page()) { - props.setPage(props.list().entries[0].entries[0].id); + props.setPage(list.entries[0].entries[0].id); } }); return ( - +
- + - {props.list().prepend} - + {list.prepend} + {(category) => ( @@ -87,7 +87,7 @@ export function SettingsSidebar(props: { )} - {props.list().append} + {list.append}
@@ -104,6 +104,7 @@ const Base = styled("div", { flex: "1 0 218px", paddingLeft: "8px", justifyContent: "flex-end", + height: "100%", }, }); diff --git a/packages/client/components/app/interface/settings/_layout/SidebarButton.tsx b/packages/client/components/app/interface/settings/_layout/SidebarButton.tsx index a24c27149..7e57d72f3 100644 --- a/packages/client/components/app/interface/settings/_layout/SidebarButton.tsx +++ b/packages/client/components/app/interface/settings/_layout/SidebarButton.tsx @@ -1,15 +1,39 @@ +import { useState } from "@revolt/state"; +import { JSX, splitProps } from "solid-js"; import { styled } from "styled-system/jsx"; /** * Sidebar button */ -export const SidebarButton = styled("a", { +export function SidebarButton( + props: JSX.HTMLAttributes & { + "aria-selected"?: boolean; + noDrawer?: boolean; + }, +) { + const { diagDrawer } = useState(); + const [local, other] = splitProps(props, ["onClick", "noDrawer", "class"]); + + function onClick(e: Event) { + if (!local.noDrawer) diagDrawer()?.setShown(true); + // @ts-expect-error callable listener + if (local.onClick) local.onClick(e); + } + + return ( + + ); +} + +const SidebarButtonBase = styled("a", { base: { // for : position: "relative", - minWidth: 0, - display: "flex", alignItems: "center", padding: "6px 8px", diff --git a/packages/client/components/app/interface/settings/index.tsx b/packages/client/components/app/interface/settings/index.tsx index 78f574a63..d41573f79 100644 --- a/packages/client/components/app/interface/settings/index.tsx +++ b/packages/client/components/app/interface/settings/index.tsx @@ -9,9 +9,10 @@ export { Settings } from "./Settings"; export type SettingsConfiguration = { /** * Generate list of categories and entries + * @param props State information * @returns List */ - list: (context: T) => SettingsList; + list: (context: T, onClose?: () => void) => SettingsList; /** * Render the title of the current breadcrumb key diff --git a/packages/client/components/app/interface/settings/server/roles/ServerRoleEditor.tsx b/packages/client/components/app/interface/settings/server/roles/ServerRoleEditor.tsx index 6bc77c252..1546bbdea 100644 --- a/packages/client/components/app/interface/settings/server/roles/ServerRoleEditor.tsx +++ b/packages/client/components/app/interface/settings/server/roles/ServerRoleEditor.tsx @@ -90,12 +90,13 @@ export function ServerRoleEditor(props: { context: Server; roleId: string }) { /> - + pickerRef()?.click()} > @@ -119,7 +120,7 @@ export function ServerRoleEditor(props: { context: Server; roleId: string }) { }} /> - + - + ); } + +export function BackCard(props: { onClose?: () => void }) { + return ( + + + + + + Back + + + + ); +} diff --git a/packages/client/components/app/interface/settings/user/appearance/AppearanceMenu.tsx b/packages/client/components/app/interface/settings/user/appearance/AppearanceMenu.tsx index 07f7d6520..8513b43f3 100644 --- a/packages/client/components/app/interface/settings/user/appearance/AppearanceMenu.tsx +++ b/packages/client/components/app/interface/settings/user/appearance/AppearanceMenu.tsx @@ -98,7 +98,7 @@ export function AppearanceMenu() { */} - + ; +}) { const user = useUser(); const state = useState(); const client = useClient(); @@ -162,7 +168,18 @@ export function MessageContextMenu(props: { message?: Message; file?: File }) { Copy text + + + + props.reactPicker?.()?.onClickEmoji(e)} + > + React + + + [ ...modals, { - // just need something unique id, show: true, props, diff --git a/packages/client/components/modal/modals/Settings.tsx b/packages/client/components/modal/modals/Settings.tsx index eb9343d4b..aac8b8457 100644 --- a/packages/client/components/modal/modals/Settings.tsx +++ b/packages/client/components/modal/modals/Settings.tsx @@ -1,10 +1,12 @@ -import { Show } from "solid-js"; +import { createEffect, createSignal, on, onCleanup, Show } from "solid-js"; import { Portal } from "solid-js/web"; import { Motion, Presence } from "solid-motionone"; import { Settings, SettingsConfigurations } from "@revolt/app"; import { DialogProps } from "@revolt/ui"; +import { useState } from "@revolt/state"; +import { SlideDrawer } from "@revolt/ui/components/navigation/SlideDrawer"; import { Modals } from "../types"; /** @@ -13,9 +15,25 @@ import { Modals } from "../types"; export function SettingsModal( props: DialogProps & Modals & { type: "settings" }, ) { + const { setDiagDrawer } = useState(); // eslint-disable-next-line solid/reactivity const config = SettingsConfigurations[props.config]; + //Drawer slider for mobile + let rootRef, sDrawer: SlideDrawer | undefined; + const [contRef, setContRef] = createSignal(); + createEffect( + on(contRef, (cont) => { + if (!cont || sDrawer) return; + sDrawer = new SlideDrawer(cont, rootRef!); + setDiagDrawer(sDrawer); + }), + ); + onCleanup(() => { + sDrawer?.delete(); + setDiagDrawer((sDrawer = undefined)); + }); + return (
diff --git a/packages/client/components/modal/types.ts b/packages/client/components/modal/types.ts index de7c6a38e..60220ac8b 100644 --- a/packages/client/components/modal/types.ts +++ b/packages/client/components/modal/types.ts @@ -150,15 +150,6 @@ export type Modals = type: "emoji_preview"; emoji: Emoji; } - | { - /** - * @deprecated build proper error handling! - */ - type: "error"; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: any; - } | { type: "error2"; diff --git a/packages/client/components/state/index.tsx b/packages/client/components/state/index.tsx index bfdbb55db..f84762a93 100644 --- a/packages/client/components/state/index.tsx +++ b/packages/client/components/state/index.tsx @@ -11,6 +11,8 @@ import { SetStoreFunction, createStore } from "solid-js/store"; import equal from "fast-deep-equal"; import localforage from "localforage"; +import { isMobileBrowser } from "@livekit/components-core"; +import { SlideDrawer } from "@revolt/ui/components/navigation/SlideDrawer"; import { AbstractStore, Store } from "./stores"; import { Auth } from "./stores/Auth"; import { Draft } from "./stores/Draft"; @@ -47,6 +49,12 @@ export class State { private setStore: SetStoreFunction; private writeQueue: Record; + isMobile: boolean; + appDrawer; + setAppDrawer; + diagDrawer; + setDiagDrawer; + // define all stores auth = new Auth(this); draft = new Draft(this); @@ -99,6 +107,15 @@ export class State { this.store = store as never; this.setStore = setStore; this.writeQueue = {}; + this.isMobile = isMobileBrowser(); + + const [ad, setAd] = createSignal(); + this.appDrawer = ad; + this.setAppDrawer = setAd; + + const [dd, setDd] = createSignal(); + this.diagDrawer = dd; + this.setDiagDrawer = setDd; } /** diff --git a/packages/client/components/ui/components/design/Avatar.tsx b/packages/client/components/ui/components/design/Avatar.tsx index b83cb2482..9fe5cd35a 100644 --- a/packages/client/components/ui/components/design/Avatar.tsx +++ b/packages/client/components/ui/components/design/Avatar.tsx @@ -16,7 +16,7 @@ export type Props = { /** * Avatar shape */ - shape?: "circle" | "rounded-square"; + shape?: "circle" | "square" | "rounded-square"; /** * Image source @@ -205,6 +205,7 @@ const Shape = styled("div", { circle: { borderRadius: "var(--borderRadius-circle)", }, + square: {}, "rounded-square": { borderRadius: "var(--borderRadius-md)", }, diff --git a/packages/client/components/ui/components/design/Button.tsx b/packages/client/components/ui/components/design/Button.tsx index c142304a0..b335174bc 100644 --- a/packages/client/components/ui/components/design/Button.tsx +++ b/packages/client/components/ui/components/design/Button.tsx @@ -280,23 +280,23 @@ const button = cva({ */ size: { xs: { - height: "32px", + minHeight: "32px", "--padding-inline": "12px", }, sm: { - height: "40px", + minHeight: "40px", "--padding-inline": "16px", }, md: { - height: "56px", + minHeight: "56px", "--padding-inline": "24px", }, lg: { - height: "96px", + minHeight: "96px", "--padding-inline": "48px", }, xl: { - height: "136px", + minHeight: "136px", "--padding-inline": "64px", }, diff --git a/packages/client/components/ui/components/design/Dialog.tsx b/packages/client/components/ui/components/design/Dialog.tsx index 20f5a1926..abab4ceae 100644 --- a/packages/client/components/ui/components/design/Dialog.tsx +++ b/packages/client/components/ui/components/design/Dialog.tsx @@ -43,6 +43,7 @@ export function Dialog(props: Props) { , - "role" | "tabIndex" | "aria-selected" + "role" | "tabIndex" | "aria-selected" | "style" >, "onClick" | "disabled" >; @@ -28,6 +28,7 @@ export function IconButton(props: Props) { "aria-selected", "tabIndex", "role", + "style", ]); const [style, rest] = splitProps(propsRest, [ diff --git a/packages/client/components/ui/components/design/LoadingProgress.tsx b/packages/client/components/ui/components/design/LoadingProgress.tsx index 43374cab4..193078df5 100644 --- a/packages/client/components/ui/components/design/LoadingProgress.tsx +++ b/packages/client/components/ui/components/design/LoadingProgress.tsx @@ -1,4 +1,6 @@ import "mdui/components/circular-progress.js"; +import { cva } from "styled-system/css"; +import { styled } from "styled-system/jsx"; /** * Progress indicators express an unspecified wait time or display the duration of a process @@ -7,5 +9,28 @@ import "mdui/components/circular-progress.js"; * @specification https://m3.material.io/components/progress-indicators */ export function CircularProgress() { - return ; + return ( + + + + ); } + +const Base = styled("div", { + base: { + position: "relative", + width: "100%", + height: "100%", + minWidth: "40px", + minHeight: "40px", + }, +}); + +const loader = cva({ + base: { + position: "absolute", + left: "50%", + top: "50%", + transform: "translate(-50%, -50%)", + }, +}); diff --git a/packages/client/components/ui/components/design/MenuButton.tsx b/packages/client/components/ui/components/design/MenuButton.tsx index 29d18a87e..ff6a337ef 100644 --- a/packages/client/components/ui/components/design/MenuButton.tsx +++ b/packages/client/components/ui/components/design/MenuButton.tsx @@ -3,6 +3,7 @@ import { JSX, Show, splitProps } from "solid-js"; import { cva } from "styled-system/css"; import { styled } from "styled-system/jsx"; +import { useState } from "@revolt/state"; import { Ripple } from "./Ripple"; import { Unreads } from "./Unreads"; @@ -38,13 +39,22 @@ export type Props = { * Hover actions */ readonly actions?: JSX.Element; + + readonly noDrawer?: boolean; }; /** * Button intended for sidebar contexts */ -export function MenuButton(props: Props & JSX.HTMLAttributes) { +export function MenuButton( + props: Props & + JSX.HTMLAttributes & + JSX.HTMLAttributes & { href?: string }, +) { + const { appDrawer } = useState(); const [local, other] = splitProps(props, [ + "onClick", + "noDrawer", "attention", "size", "icon", @@ -53,20 +63,15 @@ export function MenuButton(props: Props & JSX.HTMLAttributes) { "actions", ]); - return ( - // TODO: port to panda-css to merge down components -
+ function onClick(e: Event) { + if (!local.noDrawer) appDrawer()?.setShown(true); + // @ts-expect-error callable listener + if (local.onClick) local.onClick(e); + } + + const cont = ( + <> - {/* */} {local.icon} {local.children} @@ -83,8 +88,43 @@ export function MenuButton(props: Props & JSX.HTMLAttributes) { {local.actions} )} - {/* */} -
+ + ); + + return ( + // TODO: port to panda-css to merge down components + + {cont} +
+ } + > + + {cont} + +
); } diff --git a/packages/client/components/ui/components/design/Text.tsx b/packages/client/components/ui/components/design/Text.tsx index 74472f7fd..3134da836 100644 --- a/packages/client/components/ui/components/design/Text.tsx +++ b/packages/client/components/ui/components/design/Text.tsx @@ -143,6 +143,7 @@ export const typography = cva({ class: "body", size: "large", css: { + overflowWrap: "anywhere", lineHeight: "1.5rem", fontSize: "1rem", letterSpacing: "0.009375rem", @@ -153,6 +154,7 @@ export const typography = cva({ class: "body", size: "medium", css: { + overflowWrap: "anywhere", lineHeight: "1.25rem", fontSize: "0.875rem", letterSpacing: "0.015625rem", @@ -163,6 +165,7 @@ export const typography = cva({ class: "body", size: "small", css: { + overflowWrap: "anywhere", lineHeight: "1rem", fontSize: "0.75rem", letterSpacing: "0.025rem", diff --git a/packages/client/components/ui/components/design/TextField.tsx b/packages/client/components/ui/components/design/TextField.tsx index b01bdb60c..fc37ba3f9 100644 --- a/packages/client/components/ui/components/design/TextField.tsx +++ b/packages/client/components/ui/components/design/TextField.tsx @@ -2,6 +2,7 @@ import type { JSX } from "solid-js"; import "mdui/components/select.js"; import "mdui/components/text-field.js"; +import { cva } from "styled-system/css"; type Props = JSX.HTMLAttributes & { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -78,6 +79,10 @@ type Props = JSX.HTMLAttributes & { tabindex?: number; }; +const field = cva({ + base: { cursor: "text" }, +}); + /** * Text fields let users enter text into a UI * @@ -88,6 +93,7 @@ export function TextField(props: Props) { return ( ); diff --git a/packages/client/components/ui/components/features/messaging/composition/MessageBox.tsx b/packages/client/components/ui/components/features/messaging/composition/MessageBox.tsx index 8702e13e9..faa6df19b 100644 --- a/packages/client/components/ui/components/features/messaging/composition/MessageBox.tsx +++ b/packages/client/components/ui/components/features/messaging/composition/MessageBox.tsx @@ -95,9 +95,9 @@ interface Props { const Base = styled("div", { base: { flexGrow: 1, + minWidth: 0, - paddingInlineEnd: "var(--gap-md)", - paddingBlock: "var(--gap-sm)", + padding: "var(--gap-sm) var(--gap-md)", borderStartRadius: "var(--borderRadius-xl)", display: "flex", @@ -141,6 +141,9 @@ const Blocked = styled(Row, { userSelect: "none", padding: "var(--gap-md)", }, + variants: { + noPad: { true: { padding: 0 } }, + }, }); /** @@ -155,17 +158,13 @@ export const InlineIcon = styled("div", { }, variants: { size: { - short: { - width: "14px", - }, - normal: { - width: "42px", - }, - wide: { - width: "62px", - }, + short: { width: "14px" }, + normal: { width: "42px" }, }, }, + defaultVariants: { + size: "normal", + }, }); const FloatingAction = styled("div", { @@ -232,7 +231,7 @@ export function MessageBox(props: Props) { - + @@ -257,7 +256,7 @@ export function MessageBox(props: Props) { } > - + You don't have permission to send messages in this channel. diff --git a/packages/client/components/ui/components/features/messaging/composition/picker/CompositionMediaPicker.tsx b/packages/client/components/ui/components/features/messaging/composition/picker/CompositionMediaPicker.tsx index f409d857d..59d73a458 100644 --- a/packages/client/components/ui/components/features/messaging/composition/picker/CompositionMediaPicker.tsx +++ b/packages/client/components/ui/components/features/messaging/composition/picker/CompositionMediaPicker.tsx @@ -25,17 +25,21 @@ import { Row } from "@revolt/ui/components/layout"; import { EmojiPicker } from "./EmojiPicker"; import { GifPicker } from "./GifPicker"; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyRef = Ref; + +export type MediaPickerProps = { + ref: AnyRef; + onClickGif: (_: AnyRef, ref?: AnyRef) => void; + onClickEmoji: (_: AnyRef, ref?: AnyRef) => void; +}; + interface Props { /** * User card trigger area - * @param triggerProps Props that need to be applied to the trigger area + * @param trigProps Props that need to be applied to the trigger area */ - children: (triggerProps: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ref: Ref; - onClickGif: () => void; - onClickEmoji: () => void; - }) => JSX.Element; + children: (trigProps: MediaPickerProps) => JSX.Element; /** * Send a message @@ -55,19 +59,24 @@ export const CompositionMediaPickerContext = createContext( export function CompositionMediaPicker(props: Props) { const [anchor, setAnchor] = createSignal(); const [show, setShow] = createSignal<"gif" | "emoji">(); + let altRef: AnyRef | undefined; return ( {props.children({ ref: setAnchor, - onClickGif: () => - setShow((current) => (current === "gif" ? undefined : "gif")), - onClickEmoji: () => - setShow((current) => (current === "emoji" ? undefined : "emoji")), + onClickGif: (_, ref) => { + altRef = ref; + setShow((current) => (current === "gif" ? undefined : "gif")); + }, + onClickEmoji: (_, ref) => { + altRef = ref; + setShow((current) => (current === "emoji" ? undefined : "emoji")); + }, })} - - - + + + altRef || anchor()} show={show} setShow={setShow} onMessage={props.onMessage} onTextReplacement={props.onTextReplacement} /> - - - + +
+ ); } @@ -97,27 +106,46 @@ function Picker( }, ) { const [floating, setFloating] = createSignal(); + const [fixed, setFixed] = createSignal(false); const position = useFloating(() => props.anchor(), floating, { placement: "top-end", middleware: [offset(5), flip(), shift()], }); - function onMouseDown() { - props.setShow(); + function onMouseDown(ev: MouseEvent) { + if (!floating()?.contains(ev.target as never)) props.setShow(); } + function onResize() { + const el = floating(); + if (!el) return; + const rect = el.getBoundingClientRect(); - onMount(() => document.addEventListener("mousedown", onMouseDown)); - onCleanup(() => document.removeEventListener("mousedown", onMouseDown)); + //Prevent overflow off-screen + if (rect.right > innerWidth || rect.bottom > innerHeight) setFixed(true); + } + onMount(() => { + addEventListener("mousedown", onMouseDown); + addEventListener("resize", onResize); + setTimeout(onResize, 1); + }); + onCleanup(() => { + removeEventListener("mousedown", onMouseDown); + removeEventListener("resize", onResize); + }); return ( @@ -157,7 +185,8 @@ const Base = styled("div", { base: { width: "400px", height: "400px", - // paddingInlineEnd: "5px", + maxWidth: "100%", + maxHeight: "calc(100% - 72px)", }, }); diff --git a/packages/client/components/ui/components/features/messaging/composition/picker/EmojiPicker.tsx b/packages/client/components/ui/components/features/messaging/composition/picker/EmojiPicker.tsx index 1e6cf40cc..79d05ac01 100644 --- a/packages/client/components/ui/components/features/messaging/composition/picker/EmojiPicker.tsx +++ b/packages/client/components/ui/components/features/messaging/composition/picker/EmojiPicker.tsx @@ -4,6 +4,7 @@ import { Switch, createMemo, createSignal, + onMount, useContext, } from "solid-js"; @@ -63,17 +64,20 @@ type Item = text: string; }; -const COLUMNS = 10; - export function EmojiPicker() { const client = useClient(); const state = useState(); const [filter, setFilter] = createSignal(""); + const [colCount, setColCount] = createSignal(0); let serverScrollTargetElement!: HTMLDivElement; let emojiScrollTargetElement!: HTMLDivElement; + onMount(() => + setColCount(Math.floor(emojiScrollTargetElement.offsetWidth / 40)), + ); + const items = createMemo(() => { const filterText = filter().toLowerCase(); @@ -92,7 +96,8 @@ export function EmojiPicker() { ] as Item[]; } - const items: Item[] = []; + const items: Item[] = [], + cols = colCount(); for (const server of state.ordering.orderedServers(client())) { const emojis = server.emojis; @@ -104,7 +109,7 @@ export function EmojiPicker() { server, }); - while (items.length % COLUMNS) { + while (items.length % cols) { items.push({ t: 1 }); } @@ -112,7 +117,7 @@ export function EmojiPicker() { items.push({ t: 2, emoji }); } - while (items.length % COLUMNS) { + while (items.length % cols) { items.push({ t: 1 }); } } @@ -122,7 +127,7 @@ export function EmojiPicker() { title: "Default", }); - while (items.length % COLUMNS) { + while (items.length % cols) { items.push({ t: 1 }); } @@ -140,15 +145,10 @@ export function EmojiPicker() { return ( { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }} onInput={(e) => setFilter(e.currentTarget.value)} /> @@ -176,11 +176,7 @@ export function EmojiPicker() { items={items()} scrollTarget={emojiScrollTargetElement} itemSize={{ height: 40, width: 40 }} - crossAxisCount={(measurements) => - Math.floor( - measurements.container.cross / measurements.itemSize.cross, - ) - } + crossAxisCount={colCount} > {EmojiItem} @@ -301,7 +297,7 @@ const EmojiOption = styled("div", { display: "flex", alignItems: "center", paddingInline: "var(--gap-md)", - width: `calc(40px * ${COLUMNS}) !important`, + width: "100% !important", }, }, { diff --git a/packages/client/components/ui/components/features/messaging/composition/picker/GifPicker.tsx b/packages/client/components/ui/components/features/messaging/composition/picker/GifPicker.tsx index 5c79cc1a7..1a5f8b165 100644 --- a/packages/client/components/ui/components/features/messaging/composition/picker/GifPicker.tsx +++ b/packages/client/components/ui/components/features/messaging/composition/picker/GifPicker.tsx @@ -21,6 +21,7 @@ import { typography, } from "@revolt/ui/components/design"; +import { useState } from "@revolt/state"; import { CompositionMediaPickerContext } from "./CompositionMediaPicker"; type GifCategory = { title: string; image: string }; @@ -33,22 +34,17 @@ type GifResult = { const FilterContext = createContext<(value: string) => void>(); export function GifPicker() { + const { isMobile } = useState(); const [filter, setFilter] = createSignal(""); - const fliterLowercase = () => filter().toLowerCase(); return ( { - e.preventDefault(); - e.stopPropagation(); - e.stopImmediatePropagation(); - }} onChange={(e) => setFilter(e.currentTarget.value)} /> }> @@ -146,10 +142,11 @@ function Categories() { - Math.floor(measurements.container.cross / measurements.itemSize.cross) - } + itemSize={(contWidth) => ({ + width: contWidth / 2, + height: Math.floor((contWidth * 3) / 10), + })} + crossAxisCount={() => 2} > {CategoryItem} @@ -243,10 +240,11 @@ function GifSearch(props: { query: string }) { - Math.floor(measurements.container.cross / measurements.itemSize.cross) - } + itemSize={(contWidth) => ({ + width: contWidth / 2, + height: Math.floor((contWidth * 3) / 10), + })} + crossAxisCount={() => 2} > {GifItem} diff --git a/packages/client/components/ui/components/features/messaging/elements/Attachment.tsx b/packages/client/components/ui/components/features/messaging/elements/Attachment.tsx index bc0273d7b..c46b4091e 100644 --- a/packages/client/components/ui/components/features/messaging/elements/Attachment.tsx +++ b/packages/client/components/ui/components/features/messaging/elements/Attachment.tsx @@ -1,4 +1,4 @@ -import { Match, Show, Switch } from "solid-js"; +import { Accessor, Match, Show, Switch } from "solid-js"; import { File, ImageEmbed, Message, VideoEmbed } from "stoat.js"; import { css } from "styled-system/css"; @@ -9,6 +9,7 @@ import { useModals } from "@revolt/modal"; import { Column } from "@revolt/ui/components/layout"; import { SizedContent, Spoiler } from "@revolt/ui/components/utils"; +import { MediaPickerProps } from "../composition/picker/CompositionMediaPicker"; import { FileInfo } from "./FileInfo"; import { TextFile } from "./TextFile"; @@ -27,7 +28,11 @@ export const AttachmentContainer = styled(Column, { /** * Render a given list of files */ -export function Attachment(props: { file: File; message?: Message }) { +export function Attachment(props: { + file: File; + message?: Message; + reactPicker?: Accessor; +}) { const { openModal } = useModals(); return ( @@ -52,7 +57,11 @@ export function Attachment(props: { file: File; message?: Message }) { src={props.file.createFileURL()} use:floating={{ contextMenu: () => ( - + ), }} /> @@ -72,7 +81,11 @@ export function Attachment(props: { file: File; message?: Message }) { src={props.file.originalUrl} use:floating={{ contextMenu: () => ( - + ), }} /> @@ -90,6 +103,7 @@ export function Attachment(props: { file: File; message?: Message }) { ), }} diff --git a/packages/client/components/ui/components/features/messaging/elements/Container.tsx b/packages/client/components/ui/components/features/messaging/elements/Container.tsx index 46f1764d4..d522a8593 100644 --- a/packages/client/components/ui/components/features/messaging/elements/Container.tsx +++ b/packages/client/components/ui/components/features/messaging/elements/Container.tsx @@ -1,4 +1,4 @@ -import { JSX, Match, Show, Switch } from "solid-js"; +import { Accessor, JSX, Match, Ref, Show, Switch } from "solid-js"; import { useLingui } from "@lingui-solid/solid/macro"; import { Message } from "stoat.js"; @@ -13,6 +13,8 @@ import { Time, } from "@revolt/ui/components/utils"; +import { useState } from "@revolt/state"; +import { MediaPickerProps } from "../composition/picker/CompositionMediaPicker"; import { MessageToolbar } from "./MessageToolbar"; interface CommonProps { @@ -102,6 +104,10 @@ type Props = CommonProps & { */ contextMenu?: () => JSX.Element; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ref?: Ref; + reactPicker?: Accessor; + /** * Additional match cases for the inline-start information element */ @@ -236,6 +242,7 @@ const Body = styled("div", { editing: { true: { flexGrow: 1, + overflow: "visible", }, }, }, @@ -306,10 +313,12 @@ const CompactInfo = styled(Row, { */ export function MessageContainer(props: Props) { const { t } = useLingui(); + const { isMobile } = useState(); return (
props.onHover && props.onHover(true)} onMouseLeave={() => props.onHover && props.onHover(false)} class={ @@ -325,9 +334,17 @@ export function MessageContainer(props: Props) { use:floating={{ contextMenu: props.contextMenu }} > - + @@ -346,7 +363,7 @@ export function MessageContainer(props: Props) { use:floating={{ tooltip: { placement: "top", - content: ( + content: () => ( <> {t`Sent`}{" "} - ) as string, // ignore aria requirement + ), + aria: "", }, }} > @@ -419,39 +438,41 @@ export function MessageContainer(props: Props) {
{props.info} - - - - {t`Sent`}{" "} - - - + + ( + <> + {t`Sent`}{" "} + + ( <> {t`Edited`}{" "}
- - props.message?.channel?.sendMessage({ - content, - replies: [{ id: props.message.id, mention: true }], - }) - } - onTextReplacement={(emoji) => - props.message!.react( - emoji.startsWith(":") - ? emoji.slice(1, emoji.length - 1) - : startsWithPackPUA(emoji) - ? emoji.slice(1) - : emoji, - ) - } +
props.reactPicker?.()?.onClickEmoji(e, reactRef)} > - {(triggerProps) => ( -
- - -
- )} - + + +
, + contextMenu: () => ( + + ), contextMenuHandler: "click", }} > diff --git a/packages/client/components/ui/components/features/messaging/elements/Reactions.tsx b/packages/client/components/ui/components/features/messaging/elements/Reactions.tsx index 37cb595e0..ce0891572 100644 --- a/packages/client/components/ui/components/features/messaging/elements/Reactions.tsx +++ b/packages/client/components/ui/components/features/messaging/elements/Reactions.tsx @@ -1,4 +1,4 @@ -import { For, Show, createMemo } from "solid-js"; +import { Accessor, For, Show, createMemo } from "solid-js"; import { useLingui } from "@lingui-solid/solid/macro"; import { API } from "stoat.js"; @@ -12,8 +12,7 @@ import { Row } from "@revolt/ui/components/layout"; import MdAdd from "@material-design-icons/svg/outlined/add.svg?component-solid"; -import { startsWithPackPUA } from "@revolt/markdown/emoji/UnicodeEmoji"; -import { CompositionMediaPicker } from "../composition"; +import { MediaPickerProps } from "../composition/picker/CompositionMediaPicker"; interface Props { /** @@ -37,17 +36,13 @@ interface Props { */ addReaction(reaction: string): void; - /** - * Send a GIF reaction - * @param text Message - */ - sendGIF(text: string): void; - /** * Remove a reaction * @param reaction ID */ removeReaction(reaction: string): void; + + reactPicker: Accessor; } /** @@ -93,6 +88,8 @@ export function Reactions(props: Props) { : required.length || optional.length; }; + let reactRef; + return ( @@ -121,27 +118,15 @@ export function Reactions(props: Props) { /> )} - - props.addReaction( - emoji.startsWith(":") - ? emoji.slice(1, emoji.length - 1) - : startsWithPackPUA(emoji) - ? emoji.slice(1) - : emoji, - ) - } +
props.reactPicker()?.onClickEmoji(e, reactRef)} > - {(triggerProps) => ( -
- - - - -
- )} - + + + + +
); diff --git a/packages/client/components/ui/components/features/messaging/elements/TextEmbed.tsx b/packages/client/components/ui/components/features/messaging/elements/TextEmbed.tsx index 902ad5708..929bee736 100644 --- a/packages/client/components/ui/components/features/messaging/elements/TextEmbed.tsx +++ b/packages/client/components/ui/components/features/messaging/elements/TextEmbed.tsx @@ -34,6 +34,7 @@ const SiteInformation = styled("div", { base: { display: "flex", flexDirection: "row", + alignItems: "center", gap: "var(--gap-md)", }, }); diff --git a/packages/client/components/ui/components/features/texteditor/TextEditor2.tsx b/packages/client/components/ui/components/features/texteditor/TextEditor2.tsx index 0702ca21f..6841b2528 100644 --- a/packages/client/components/ui/components/features/texteditor/TextEditor2.tsx +++ b/packages/client/components/ui/components/features/texteditor/TextEditor2.tsx @@ -9,6 +9,7 @@ import { css } from "styled-system/css"; import { scrollableStyles } from "../../../directives/scrollable"; import { AutoCompleteSearchSpace } from "../../utils/autoComplete"; +import { useState } from "@revolt/state"; import { codeMirrorAutoComplete } from "./codeMirrorAutoComplete"; import { isInFencedCodeBlock } from "./codeMirrorCommon"; import { smartLineWrapping } from "./codeMirrorLineWrap"; @@ -73,16 +74,21 @@ const placeholderCompartment = new Compartment(); export function TextEditor2(props: Props) { const editorScrollbarClasses = scrollableStyles(); + const { isMobile } = useState(); const codeMirror = document.createElement("div"); codeMirror.className = editor; + //Custom CSS + codeMirror.style.minWidth = "0"; + /** * Handle 'Enter' key presses * Submit only if not currently in a code block */ const enterKeymap = keymap.of([ { - key: "Enter", + key: isMobile ? "Ctrl-Enter" : "Enter", + //TODO Ctrl-Enter is only detected on Firefox mobile, not Chrome mobile run: (view) => { if (!props.onComplete) return false; @@ -120,7 +126,11 @@ export function TextEditor2(props: Props) { doc: props.initialValue?.[0], extensions: [ /* Enable browser spellchecking */ - EditorView.contentAttributes.of({ spellcheck: "true" }), + EditorView.contentAttributes.of({ + spellcheck: "true", + autocorrect: "true", + autocapitalize: "true", + }), /* Mount keymaps */ enterKeymap, diff --git a/packages/client/components/ui/components/features/texteditor/codeMirrorLineWrap.ts b/packages/client/components/ui/components/features/texteditor/codeMirrorLineWrap.ts index 428e376fc..a6b86b894 100644 --- a/packages/client/components/ui/components/features/texteditor/codeMirrorLineWrap.ts +++ b/packages/client/components/ui/components/features/texteditor/codeMirrorLineWrap.ts @@ -155,17 +155,26 @@ const lineWrapStyles = EditorView.theme({ ".cm-line.linewrap-indent": { // The tiny offset appears to make the indent more reliable, // for unknown reasons. - "text-indent": "calc(-1 * var(--indented) - 0.1px)", - "padding-left": "calc(var(--indented) + var(--cm-left-padding, 4px))", + textIndent: "calc(-1 * var(--indented) - 0.1px)", + paddingLeft: "calc(var(--indented) + var(--cm-left-padding, 4px))", }, ".linewrap-whitespace": { - "font-family": "monospace, monospace", + fontFamily: "monospace, monospace", // Prevent slightly-oversided monospace fonts from changing line heights // when indented, but also changes the height of lines with only whitespace... - // "font-size": "0.9em", + // fontSize: ".9em", }, ".cm-line > *": { - "text-indent": "0", + textIndent: 0, + }, + ".cm-content": { + minWidth: 0, + }, + ".cm-placeholder": { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + width: "100%", }, }); diff --git a/packages/client/components/ui/components/features/voice/callCard/VoiceCallCard.tsx b/packages/client/components/ui/components/features/voice/callCard/VoiceCallCard.tsx index 3036add02..46f465e62 100644 --- a/packages/client/components/ui/components/features/voice/callCard/VoiceCallCard.tsx +++ b/packages/client/components/ui/components/features/voice/callCard/VoiceCallCard.tsx @@ -3,274 +3,258 @@ import { Match, Show, Switch, - batch, createContext, createEffect, + createMemo, createSignal, - on, onCleanup, onMount, useContext, } from "solid-js"; import { Portal } from "solid-js/web"; -import { AutoSizer } from "@dschz/solid-auto-sizer"; +import { makeResizeObserver } from "@solid-primitives/resize-observer"; import { Channel } from "stoat.js"; -import { css } from "styled-system/css"; import { styled } from "styled-system/jsx"; -import { InRoom, useVoice } from "@revolt/rtc"; +import { useVoice } from "@revolt/rtc"; +import { useState } from "@revolt/state"; +import { SlideState } from "@revolt/ui/components/navigation/SlideDrawer"; import { VoiceCallCardActiveRoom } from "./VoiceCallCardActiveRoom"; import { VoiceCallCardPiP } from "./VoiceCallCardPiP"; import { VoiceCallCardPreview } from "./VoiceCallCardPreview"; -type State = - | { - type: "floating"; - corner: "top-left" | "top-right" | "bottom-left" | "bottom-right"; - } - | { - type: "fixed"; - x: number; - y: number; - width: number; - channel: Channel; - }; +type Mode = "floating" | "moving"; +type FloatType = "tl" | "tr" | "bl" | "br"; -type NewState = { channel: Channel; x: number; y: number; width: number }; +type Info = { + channel: Channel; + pos: DOMRect; + drawer?: SlideState; +}; -const callCardContext = createContext<(state?: NewState) => void>(null!); +type PtrEvent = { + type: string; + tid: number; + clientX: number; + clientY: number; +}; -/** - * Voice call card context - */ +const PAD = 16, + PAD_X = `${PAD}px`, + PAD_Y = `${PAD + 56}px`; + +const callCardContext = createContext<(info?: Info) => void>(); + +/** Voice call card context */ export function VoiceCallCardContext(props: { children: JSX.Element }) { - const [state, setState] = createSignal({ - type: "floating", - corner: "bottom-right", - }); + const voice = useVoice(); - const [moving, setMoving] = createSignal(); - const [offset, setOffset] = createSignal({ x: 0, y: 0 }); - - function position() { - const position = state(); - - switch (position.type) { - case "fixed": - return { - transform: `translate(${position.x}px, ${position.y}px)`, - // top: position.y + "px", - // left: position.x + "px", - width: position.width + "px", - height: "40vh", - }; - case "floating": - return { - "--width": "280px", - "--height": "158px", - "--padding-x": "32px", - "--padding-y": "96px", - transform: `translate(${ - position.corner === "top-left" || position.corner === "bottom-left" - ? "calc(var(--padding-x) + var(--offset-x))" - : "calc(100vw - var(--padding-x) - var(--width) + var(--offset-x))" - }, ${ - position.corner === "top-left" || position.corner === "top-right" - ? "calc(var(--padding-y) + var(--offset-y))" - : "calc(100vh - var(--padding-y) - var(--height) + var(--offset-y))" - })`, - width: "var(--width)", - height: "var(--height)", - }; - } + const [mode, setMode] = createSignal(); + const [info, setInfo] = createSignal(); + + let ref: HTMLDivElement | undefined, + events: AbortController | null, + tid = 0, + ofsX = 0, + ofsY = 0; + + function getTouch(id: number, tl: TouchList) { + for (const t of tl) if (t.identifier === id) return t; } - createEffect( - on(moving, (moving) => { - if (moving) { - const controller = new AbortController(); - - document.addEventListener( - "mousemove", - (event) => { - const position = state(); - if (position.type !== "floating") return controller.abort(); - - setOffset((pos) => ({ - x: pos.x + event.movementX, - y: pos.y + event.movementY, - })); - }, - { signal: controller.signal }, - ); - - document.addEventListener( - "mouseup", - (event) => { - batch(() => { - setMoving(false); - - const left = event.clientX < window.outerWidth / 2; - const top = event.clientY < window.outerHeight / 2; - - setState({ - type: "floating", - corner: left - ? top - ? "top-left" - : "bottom-left" - : top - ? "top-right" - : "bottom-right", - }); - }); - }, - { signal: controller.signal }, - ); - - onCleanup(() => controller.abort()); - } - }), - ); + /** + * @param tid Touch ID, or null to track new touch + */ + function getPointer( + e: MouseEvent | TouchEvent, + tid: number | null, + ): PtrEvent | undefined { + let t: MouseEvent | Touch | undefined; + if (e instanceof TouchEvent) { + t = tid != null ? getTouch(tid, e.changedTouches) : e.touches[0]; + if (!t) return; + } else t = e; + return { + type: e.type, + tid: (t as Touch).identifier || 0, + clientX: t!.clientX, + clientY: t!.clientY, + }; + } - function updateState(state?: NewState) { - if (state) { - setState({ - type: "fixed", - width: state.width, - x: state.x, - y: state.y, - channel: state.channel, - }); - } else { - setState({ - type: "floating", - corner: "bottom-right", - }); + function mouseDown(e: MouseEvent | TouchEvent) { + const ptr = getPointer(e, null)!; + tid = ptr.tid; + if (mode() === "floating") { + const pos = ref!.getBoundingClientRect(); + ofsX = ptr.clientX - pos.x; + ofsY = ptr.clientY - pos.y; + setMode("moving"); + addEvents(); } } - function updateStateWithTransition(state?: NewState) { - // no clue if this works + function mouseMove(e: MouseEvent | TouchEvent) { + const ptr = getPointer(e, tid); + if (!ptr) return; + e.preventDefault(); + const x = ptr.clientX - ofsX, + y = ptr.clientY - ofsY; + ref!.style.transform = `translate(${x}px, ${y}px)`; + } - if (!document.startViewTransition) { - updateState(state); - return; - } + function mouseUp(e: MouseEvent | TouchEvent) { + const ptr = getPointer(e, tid); + if (!ptr) return; + const sty = ref!.style, + pos = ref!.getBoundingClientRect(), + left = ptr.clientX - ofsX + pos.width / 2 < outerWidth / 2, + top = ptr.clientY - ofsY + pos.height / 2 < outerHeight / 2; + + sty.transition = "all .2s cubic-bezier(0, 1.67, 0.85, 0.8)"; + setFloat(left ? (top ? "tl" : "bl") : top ? "tr" : "br"); + //Reset CSS transition on next render pass + setTimeout(() => (sty.transition = ""), 1); + resetEvents(); + } + + function addEvents() { + if (events) return; + events = new AbortController(); + const opt = { passive: false, signal: events.signal }; + document.addEventListener("mousemove", mouseMove, opt); + document.addEventListener("mouseup", mouseUp, opt); + document.addEventListener("touchmove", mouseMove, opt); + document.addEventListener("touchend", mouseUp, opt); + } + + function resetEvents() { + events?.abort(); + events = null; + } + + const channel = createMemo(() => { + const inf = info(); + + if (!ref) return; + const sty = ref.style; + + //Set mode based on state + if (inf?.pos && (!inf.drawer || inf.drawer === SlideState.SHOWN)) { + sty.transform = `translate(${inf.pos.x}px, ${inf.pos.y}px)`; + sty.width = `${inf.pos.width}px`; + setMode(); + } else if (!voice.channel()) { + const y = inf?.pos.y ?? ref.getBoundingClientRect().y; + sty.transform = `translate(${innerWidth + 50}px, ${y}px)`; + setMode(); + } else if (!mode()) setFloat("tr"); + + resetEvents(); + return inf?.channel; + }); - document.startViewTransition(() => updateState(state)); + function setFloat(float: FloatType) { + const sty = ref!.style, + x = float[1] === "l" ? PAD_X : `calc(100vw - var(--flt-w) - ${PAD_X})`, + y = float[0] === "t" ? PAD_Y : `calc(100vh - var(--flt-h) - ${PAD_Y})`; + sty.transform = `translate(${x}, ${y})`; + sty.width = ""; + setMode("floating"); } + onCleanup(resetEvents); + return ( - + {props.children} - -
{ - if (state().type === "floating") { - batch(() => { - setMoving(true); - setOffset({ x: 0, y: 0 }); - }); - } - }} - // dragging logic for touch input - // todo + - - + + - - - - + + -
+
); } -/** - * 'Marker' to send position information for mounting the floating call card - */ +const Float = styled("div", { + base: { + position: "fixed", + zIndex: 10, + pointerEvents: "none", + transition: "all .3s cubic-bezier(1, 0, 0, 1)", + height: "40vh", + }, + variants: { + mode: { + floating: { cursor: "grab" }, + moving: { + cursor: "grabbing", + transition: "none", + }, + }, + }, + compoundVariants: [ + { + mode: ["floating", "moving"], + css: { + "--flt-w": "300px", + "--flt-h": "170px", + width: "var(--flt-w)", + height: "var(--flt-h)", + }, + }, + ], +}); + +/** 'Marker' to send position information for mounting the floating call card */ export function VoiceChannelCallCardMount(props: { channel: Channel }) { const voice = useVoice(); - const [width, setWidth] = createSignal(0); - - const [ref, setRef] = createSignal(); - const updateSize = useContext(callCardContext)!; + const state = useState(); + const setInfo = useContext(callCardContext)!; + let ref: HTMLDivElement | undefined; + + function updateInfo() { + const vc = voice.channel(); + setInfo( + !vc || vc.id === props.channel.id + ? { + channel: props.channel, + pos: ref!.getBoundingClientRect(), + drawer: state.appDrawer()?.state, + } + : undefined, + ); + } - const ongoingCallElsewhere = () => - voice.channel() && voice.channel()?.id !== props.channel.id; + createEffect(updateInfo); - createEffect(() => { - const rect = ref()?.getBoundingClientRect(); - const w = width(); - - const activeChannel = voice.channel(); - const canUpdate = !activeChannel || activeChannel.id === props.channel.id; - - if (rect?.left && w) { - if (canUpdate) { - updateSize({ - x: rect.left, - y: rect.top, - width: w, - channel: props.channel, - }); - } else { - updateSize(); - } - } + //Observe resize of parent + let obs: ReturnType; + onMount(() => { + obs = makeResizeObserver(updateInfo); + obs.observe(ref!.parentElement!); + }); + onCleanup(() => { + obs.unobserve(ref!.parentElement!); + setInfo(); }); - onCleanup(() => updateSize()); - - return ( -
-
- - {({ width }) => { - setWidth(width); - return null; - }} - -
- - - - -
- ); + return
; } /** @@ -278,7 +262,7 @@ export function VoiceChannelCallCardMount(props: { channel: Channel }) { */ function VoiceCallCard(props: { channel: Channel }) { const voice = useVoice(); - const inCall = () => voice.channel()?.id === props.channel.id; + const inCall = () => !!voice.channel(); let viewRef: HTMLDivElement | undefined; @@ -319,7 +303,7 @@ function VoiceCallCard(props: { channel: Channel }) { const Base = styled("div", { base: { - // todo: temp for Mount + left: 0, top: "var(--gap-md)", padding: "var(--gap-md)", diff --git a/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardActions.tsx b/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardActions.tsx index 3484c7d81..fcf6e75dc 100644 --- a/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardActions.tsx +++ b/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardActions.tsx @@ -1,3 +1,4 @@ +import { useNavigate } from "@solidjs/router"; import { Show } from "solid-js"; import { useLingui } from "@lingui-solid/solid/macro"; @@ -5,37 +6,51 @@ import { styled } from "styled-system/jsx"; import { CONFIGURATION } from "@revolt/common"; import { useVoice } from "@revolt/rtc"; +import { useState } from "@revolt/state"; import { Button, IconButton } from "@revolt/ui/components/design"; import { Symbol } from "@revolt/ui/components/utils/Symbol"; export function VoiceCallCardActions(props: { size: "xs" | "sm" }) { const voice = useVoice(); + const state = useState(); + const navigate = useNavigate(); const { t } = useLingui(); - function isVideoEnabled() { - return CONFIGURATION.ENABLE_VIDEO; - } + const enableVideo = CONFIGURATION.ENABLE_VIDEO; return ( - - - arrow_top_left - - + { + navigate(voice.channel()?.path ?? ""); + state.appDrawer()?.setShown(true); + }} + use:floating={{ + tooltip: { + placement: "top", + content: t`Return to voice channel`, + }, + }} + > + arrow_top_left + voice.toggleMute()} use:floating={{ - tooltip: voice.speakingPermission - ? undefined - : { - placement: "top", - content: t`Missing permission`, - }, + tooltip: { + placement: "top", + content: voice.speakingPermission + ? voice.microphone() + ? t`Mute` + : t`Unmute` + : t`Missing permission`, + }, }} isDisabled={!voice.speakingPermission} > @@ -48,12 +63,14 @@ export function VoiceCallCardActions(props: { size: "xs" | "sm" }) { variant={voice.deafen() || !voice.listenPermission ? "tonal" : "filled"} onPress={() => voice.toggleDeafen()} use:floating={{ - tooltip: voice.listenPermission - ? undefined - : { - placement: "top", - content: t`Missing permission`, - }, + tooltip: { + placement: "top", + content: voice.listenPermission + ? voice.deafen() + ? t`Listen` + : t`Defean` + : t`Missing permission`, + }, }} isDisabled={!voice.listenPermission} > @@ -66,44 +83,44 @@ export function VoiceCallCardActions(props: { size: "xs" | "sm" }) { { - if (isVideoEnabled()) voice.toggleCamera(); + if (enableVideo) voice.toggleCamera(); }} use:floating={{ tooltip: { placement: "top", - content: isVideoEnabled() + content: enableVideo ? voice.video() - ? "Stop Camera" - : "Start Camera" - : "Coming soon! 👀", + ? t`Stop camera` + : t`Start camera` + : t`Coming soon! 👀`, }, }} - isDisabled={!isVideoEnabled()} + isDisabled={!enableVideo} > camera_video { - if (isVideoEnabled()) voice.toggleScreenshare(); + if (enableVideo) voice.toggleScreenshare(); }} use:floating={{ tooltip: { placement: "top", - content: isVideoEnabled() + content: enableVideo ? voice.screenshare() - ? "Stop Sharing" - : "Share Screen" - : "Coming soon! 👀", + ? t`Stop sharing` + : t`Share screen` + : t`Coming soon! 👀`, }, }} - isDisabled={!isVideoEnabled()} + isDisabled={!enableVideo} > stop_screen_share} > screen_share @@ -113,6 +130,12 @@ export function VoiceCallCardActions(props: { size: "xs" | "sm" }) { size={props.size} variant="_error" onPress={() => voice.disconnect()} + use:floating={{ + tooltip: { + placement: "top", + content: t`End call`, + }, + }} > call_end @@ -125,6 +148,7 @@ const Actions = styled("div", { flexShrink: 0, gap: "var(--gap-md)", padding: "var(--gap-md)", + zIndex: 2, display: "flex", width: "fit-content", diff --git a/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardPiP.tsx b/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardPiP.tsx index e4f261c3f..94a73a3d4 100644 --- a/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardPiP.tsx +++ b/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardPiP.tsx @@ -1,16 +1,20 @@ import { Show } from "solid-js"; import { TrackLoop, + TrackReference, useEnsureParticipant, useIsMuted, useIsSpeaking, + useTrackRefContext, useTracks, + VideoTrack, } from "solid-livekit-components"; import { Track } from "livekit-client"; import { styled } from "styled-system/jsx"; import { useUser } from "@revolt/markdown/users"; +import { useVoice } from "@revolt/rtc"; import { Avatar } from "@revolt/ui/components/design"; import { Row } from "@revolt/ui/components/layout"; import { Symbol } from "@revolt/ui/components/utils/Symbol"; @@ -19,17 +23,33 @@ import { VoiceCallCardActions } from "./VoiceCallCardActions"; import { VoiceCallCardStatus } from "./VoiceCallCardStatus"; export function VoiceCallCardPiP() { - const tracks = useTracks( + const voice = useVoice(); + const audTracks = useTracks( [{ source: Track.Source.Microphone, withPlaceholder: true }], { onlySubscribed: false }, ); + const hasFocusVideo = () => { + const track = voice.focusTrack(); + if (!track) return false; + + return ( + track.source === Track.Source.ScreenShare || + !useIsMuted({ + participant: track.participant, + source: Track.Source.Camera, + })() + ); + }; + return ( - - {() => } - - + + }> + + {() => } + + ); @@ -48,19 +68,58 @@ function ConnectedUser() { return ( - + - mic_off + mic_off ); } +function MiniVideoTile() { + const voice = useVoice(); + + return ( + [voice.focusTrack()!]}> + {() => } + + ); +} + +function MiniVideo() { + const track = useTrackRefContext(); + + return ( + + ); +} + const UserIcon = styled("div", { base: { display: "grid", width: "24px", height: "24px", + color: "#fffb", + overflow: "hidden", + borderRadius: "var(--borderRadius-circle)", "& *": { gridArea: "1/1", @@ -90,7 +149,7 @@ const MiniCard = styled("div", { display: "flex", alignItems: "center", flexDirection: "column", - justifyContent: "center", + justifyContent: "end", gap: "var(--gap-md)", padding: "var(--gap-md)", diff --git a/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardStatus.tsx b/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardStatus.tsx index f604be2ec..5dc07e3d5 100644 --- a/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardStatus.tsx +++ b/packages/client/components/ui/components/features/voice/callCard/VoiceCallCardStatus.tsx @@ -4,7 +4,7 @@ import { styled } from "styled-system/jsx"; import { useVoice } from "@revolt/rtc"; import { Symbol } from "@revolt/ui/components/utils/Symbol"; -export function VoiceCallCardStatus() { +export function VoiceCallCardStatus(props: { pip?: boolean }) { const voice = useVoice(); const symbol = () => { @@ -38,7 +38,7 @@ export function VoiceCallCardStatus() { }; return ( - + {symbol()}{" "} {text()} @@ -68,6 +68,7 @@ const Status = styled("div", { display: "flex", justifyContent: "center", + zIndex: 1, _hover: { "& div": { @@ -94,5 +95,12 @@ const Status = styled("div", { color: "var(--md-sys-color-outline)", }, }, + pip: { + true: { + position: "absolute", + left: "var(--gap-md)", + top: "var(--gap-md)", + }, + }, }, }); diff --git a/packages/client/components/ui/components/floating/Tooltip.tsx b/packages/client/components/ui/components/floating/Tooltip.tsx index c8228e6e6..2b212d3dc 100644 --- a/packages/client/components/ui/components/floating/Tooltip.tsx +++ b/packages/client/components/ui/components/floating/Tooltip.tsx @@ -35,12 +35,6 @@ export function Tooltip(props: Props) { const [local, remote] = splitProps(props, ["children"]); return ( -
- {local.children} -
+
{local.children}
); } diff --git a/packages/client/components/ui/components/floating/UserCard.tsx b/packages/client/components/ui/components/floating/UserCard.tsx index 9a76c0ffa..3c15a2765 100644 --- a/packages/client/components/ui/components/floating/UserCard.tsx +++ b/packages/client/components/ui/components/floating/UserCard.tsx @@ -1,4 +1,4 @@ -import { JSX } from "solid-js"; +import { JSX, Show } from "solid-js"; import { useQuery } from "@tanstack/solid-query"; import { cva } from "styled-system/css"; @@ -6,6 +6,7 @@ import { styled } from "styled-system/jsx"; import { useModals } from "@revolt/modal"; +import { useState } from "@revolt/state"; import { Profile } from "../features"; /** @@ -33,42 +34,48 @@ export function UserCard( props: JSX.Directives["floating"]["userCard"] & object & { onClose: () => void }, ) { + const { isMobile } = useState(); const { openModal } = useModals(); const query = useQuery(() => ({ queryKey: ["profile", props.user.id], queryFn: () => props.user.fetchProfile(), })); - function openFull() { + function openProfile() { openModal({ type: "user_profile", user: props.user }); + } + function openFull() { + openProfile(); props.onClose(); } return ( -
{ - e.preventDefault(); - e.stopImmediatePropagation(); - }} - > - - + +
{ + e.preventDefault(); + e.stopImmediatePropagation(); + }} + > + + - - - - - - - -
+ + + + + + +
+
+
); } diff --git a/packages/client/components/ui/components/navigation/SlideDrawer.ts b/packages/client/components/ui/components/navigation/SlideDrawer.ts new file mode 100644 index 000000000..e6ae9cde4 --- /dev/null +++ b/packages/client/components/ui/components/navigation/SlideDrawer.ts @@ -0,0 +1,244 @@ +import { createSignal } from "solid-js"; + +const ANIM_MS = 150, + VEL_MS = 33, //30Hz velocity update + VEL_AVG = 5, //Moving avg smoothing + VEL_TRIG = 0.3, //Override drawer pos if over + TRIG_X = 10, + CANCEL_Y = 20; + +type TrackTouch = { + id: number; + x: number; + y: number; + newX?: number; + newT?: number; + prevX?: number; + prevT?: number; + trig?: boolean; + vAvg?: Array; + vOfs?: number; +}; + +export enum SlideState { + HIDDEN = 1, + SHOWN, + HIDING, + SHOWING, + MOVING, +} + +export class SlideDrawer { + private media; + private touch: TrackTouch | null = null; + private tTmr: NodeJS.Timeout | null = null; + private vTmr: NodeJS.Timeout | null = null; + private ofs = 0; + + private eGet; + private eSet; + private sGet; + private sSet; + + constructor( + private drawer: HTMLElement, + private root: HTMLElement, + ) { + this.start = this.start.bind(this); + this.move = this.move.bind(this); + root.addEventListener("touchstart", this.start); + root.addEventListener("touchmove", this.move); + root.addEventListener("touchend", this.move); + + //Signals + const [eg, es] = createSignal(false); + this.eGet = eg; + this.eSet = es; + const [sg, ss] = createSignal(SlideState.HIDDEN); + this.sGet = sg; + this.sSet = ss; + + //Auto-enable based on device width + const pwMax = getComputedStyle(document.body).getPropertyValue( + "--phone-max-width", + ); + this.media = matchMedia(`(max-width: ${pwMax})`); + this.media.onchange = (e) => (this.enabled = e.matches); + this.enabled = this.media.matches; + } + + private start(e: TouchEvent) { + //Cancel if more than one finger + if (e.touches.length > 1) return this.endTouch(); + if (this.touch || !this.eGet()) return; + + //Track this touch + const t = e.touches[0]; + this.touch = { + id: t.identifier, + x: t.screenX, + y: t.screenY, + }; + } + + private move(e: TouchEvent) { + if (!this.touch) return; + const isEnd = e.type === "touchend"; + let t, tNew; + for (t of e.changedTouches) + if (t.identifier === this.touch.id) { + tNew = t; + break; + } + if (!tNew) return; + + t = this.touch; + const dy = tNew.screenY - t.y, + ds = this.drawer.style, + max = -innerWidth; + let dx = tNew.screenX - t.x, + trig = t.trig; + + if (!trig && Math.abs(dy) > CANCEL_Y) { + this.endTouch(); + } else if (trig || Math.abs(dx) > TRIG_X) { + //Store new/prev X for vel calc + const type = trig ? "new" : "prev"; + t[`${type}X`] = dx; + t[`${type}T`] = performance.now(); + + if (!trig) { + t.trig = trig = true; + this.tfTimer(); + this.velTimer(); + if (!isEnd) this.sSet(SlideState.MOVING); + } + + dx = Math.max(Math.min(this.ofs + dx, 0), max); + if (!isEnd) ds.transform = `translateX(${dx}px)`; + e.preventDefault(); + e.stopPropagation(); + } + + if (isEnd) { + if (trig) { + this.addVel(); + //Calc avg vel + let vel = 0; + if (t.vAvg) { + let v; + for (v of t.vAvg) vel += v; + vel /= t.vAvg.length; + } + //Finalize show/hide state + let show = dx < max / 2; + if (vel > VEL_TRIG) show = false; + else if (vel < -VEL_TRIG) show = true; + this.tfTimer(true, show); + } + this.endTouch(); + } + } + + private velTimer() { + if (this.vTmr) return; + this.vTmr = setInterval(this.addVel.bind(this), VEL_MS); + } + + private addVel(skipStale = false) { + const t = this.touch; + if (!t || !t.newT) return; + + //Velocity since last update + const stale = t.prevT === t.newT, + vel = stale ? 0 : (t.newX! - t.prevX!) / (t.newT! - t.prevT!); + + if (stale && skipStale) return; + + t.prevX = t.newX; + t.prevT = t.newT; + + if (t.vAvg) { + //Insert at vOfs + t.vAvg[t.vOfs!] = vel; + if (++t.vOfs! === VEL_AVG) t.vOfs = 0; + } else { + //Fill with first data + t.vAvg = [vel]; + t.vOfs = 1; + } + } + + private endTouch() { + clearInterval(this.vTmr!); + this.touch = this.vTmr = null; + } + + private tfTimer(set = false, show = false) { + //Animate transition + const ds = this.drawer.style; + this.setElState(false); + if (set) { + this.ofs = show ? -innerWidth : 0; + ds.transition = `transform ${ANIM_MS}ms`; + ds.transform = `translateX(${this.ofs}px)`; + this.sSet(show ? SlideState.SHOWING : SlideState.HIDING); + } else { + ds.transition = ds.transform = ""; + } + + //Finalize after delay + clearTimeout(this.tTmr!); + this.tTmr = set + ? setTimeout(() => { + ds.transition = ds.transform = ""; + this.setElState(show); + this.tTmr = null; + this.sSet(show ? SlideState.SHOWN : SlideState.HIDDEN); + }, ANIM_MS + 50) + : null; + } + + private setElState(show: boolean) { + const ds = this.drawer.style; + this.root.style.width = show ? "" : "200vw"; + ds.marginLeft = show ? "" : "100vw"; + } + + delete() { + this.enabled = false; + this.root.removeEventListener("touchstart", this.start); + this.root.removeEventListener("touchmove", this.move); + this.root.removeEventListener("touchend", this.move); + this.media.onchange = null; + } + + get enabled() { + return this.eGet(); + } + set enabled(en: boolean) { + if (this.eGet() !== en) { + this.drawer.style.zIndex = en ? "1" : ""; + this.tfTimer(); + this.endTouch(); + if (!en) this.setElState(true); + this.ofs = 0; + this.eSet(en); + this.sSet(SlideState.HIDDEN); + } + } + + get state() { + return this.sGet(); + } + + setShown(show: boolean) { + if (!this.eGet() || this.touch?.trig || this.tTmr) return false; + if ((this.ofs !== 0) !== show) { + this.setElState(false); + this.drawer.style.transform = `translateX(${this.ofs}px)`; + setTimeout(() => this.tfTimer(true, show), 1); + } + return true; + } +} diff --git a/packages/client/components/ui/directives/floating.ts b/packages/client/components/ui/directives/floating.ts index d745be7eb..8b3cd42df 100644 --- a/packages/client/components/ui/directives/floating.ts +++ b/packages/client/components/ui/directives/floating.ts @@ -125,11 +125,14 @@ export function floating(element: HTMLElement, accessor: Accessor) { trigger("contextMenu"); } + let isTouch = false, + tTmr: NodeJS.Timeout | undefined; + /** * Handle mouse entering */ function onMouseEnter() { - trigger("tooltip", true); + if (!isTouch) trigger("tooltip", true); } /** @@ -139,6 +142,15 @@ export function floating(element: HTMLElement, accessor: Accessor) { trigger("tooltip", false); } + function onTouch() { + isTouch = true; + clearTimeout(tTmr); + tTmr = setTimeout(() => { + isTouch = false; + tTmr = undefined; + }, 100); + } + createEffect( on( () => accessor().userCard, @@ -166,10 +178,14 @@ export function floating(element: HTMLElement, accessor: Accessor) { element.addEventListener("mouseenter", onMouseEnter); element.addEventListener("mouseleave", onMouseLeave); + element.addEventListener("touchstart", onTouch); + element.addEventListener("touchend", onTouch); onCleanup(() => { element.removeEventListener("mouseenter", onMouseEnter); element.removeEventListener("mouseleave", onMouseLeave); + element.addEventListener("touchstart", onTouch); + element.addEventListener("touchend", onTouch); }); } }, diff --git a/packages/client/components/ui/emojiMapping.json b/packages/client/components/ui/emojiMapping.json index 32b1be562..5d7a23806 100644 --- a/packages/client/components/ui/emojiMapping.json +++ b/packages/client/components/ui/emojiMapping.json @@ -1252,8 +1252,10 @@ "no-under-eighteen": "🔞", "no-sound": "🔕", "mute": "🔇", + "a": "🅰️", "a-button": "🅰️", "ab-button": "🆎", + "b": "🅱️", "b-button": "🅱️", "o-button": "🅾️", "cl-button": "🆑", @@ -1364,6 +1366,17 @@ "numbers": "🔢", "number-sign": "#️⃣", "asterisk": "*️⃣", + "0": "0️⃣", + "1": "1️⃣", + "2": "2️⃣", + "3": "3️⃣", + "4": "4️⃣", + "5": "5️⃣", + "6": "6️⃣", + "7": "7️⃣", + "8": "8️⃣", + "9": "9️⃣", + "10": "🔟", "zero": "0️⃣", "one": "1️⃣", "two": "2️⃣", @@ -1426,8 +1439,11 @@ "curly-loop": "➰", "curly-loop-double": "➿", "wavy-dash": "〰️", + "c": "©️", "copyright": "©️", + "r": "®️", "registered": "®️", + "tm": "™️", "trade-mark": "™️", "radio-button": "🔘", "white-square-button": "🔳", diff --git a/packages/client/components/ui/styles.css b/packages/client/components/ui/styles.css index 5cdb943c4..4ec15d199 100644 --- a/packages/client/components/ui/styles.css +++ b/packages/client/components/ui/styles.css @@ -6,14 +6,13 @@ @import "@fontsource/inter/800"; @import "@fontsource/jetbrains-mono"; -* { - box-sizing: border-box; -} +* { box-sizing: border-box } body { margin: 0; background: #191919; font-family: var(--fonts-primary); + --phone-max-width: 600px; } #root { @@ -25,6 +24,65 @@ body { background: #191919; } +/* App: Desktop view */ +.app_root { + display: flex; + flex-direction: column; + height: 100%; +} +.main_bar { + display: flex; + flex-shrink: 0; +} + +/* Main: Phone view */ +@media (max-width: 600px) { + main { + margin: 0; + border-radius: 0; + } + .dialog_scrim { padding: 30px } + .channel_bar { flex-grow: 1 } + .main_bar { + --layout-width-channel-sidebar: auto; + position: absolute; + width: 100vw; + height: 100%; + } +} + +/* Settings: Desktop view */ +.button.back { display: none } +.settings_overlay { + display: flex; + height: 100%; + pointer-events: all; + color: var(--md-sys-color-on-surface); + background: var(--md-sys-color-surface-container-highest); +} + +/* Settings: Tablet view */ +@media (max-width: 900px) { + .settings_cont { padding: 12px } + .settings_sidebar .content { padding: 8px 0 } + .settings .close { display: none } + .button.back { display: flex } +} + +/* Settings: Phone view */ +@media (max-width: 600px) { + .settings_sidebar { + position: absolute; + width: 100vw; + height: 100%; + padding-left: 12px; + } + .settings_sidebar > * { width: 100% } + .settings_sidebar .content { max-width: unset } + .settings { border-radius: 0 } + .settings_cont { height: 100vh } +} + /* HighlightJs */ /*! Theme: GitHub Dark diff --git a/packages/client/index.html b/packages/client/index.html index 0b0342828..a98736439 100644 --- a/packages/client/index.html +++ b/packages/client/index.html @@ -1,13 +1,10 @@ - - - - + + + + You need to enable JavaScript to run this app.
- diff --git a/packages/client/src/Interface.tsx b/packages/client/src/Interface.tsx index 5efe073e0..7b81b4826 100644 --- a/packages/client/src/Interface.tsx +++ b/packages/client/src/Interface.tsx @@ -1,4 +1,11 @@ -import { JSX, Match, Switch, createEffect } from "solid-js"; +import { + createEffect, + createSignal, + JSX, + Match, + onCleanup, + Switch, +} from "solid-js"; import { Server } from "stoat.js"; import { styled } from "styled-system/jsx"; @@ -15,6 +22,7 @@ import { useState } from "@revolt/state"; import { LAYOUT_SECTIONS } from "@revolt/state/stores/Layout"; import { CircularProgress } from "@revolt/ui"; +import { SlideDrawer } from "../components/ui/components/navigation/SlideDrawer"; import { Sidebar } from "./interface/Sidebar"; /** @@ -31,10 +39,7 @@ const Interface = (props: { children: JSX.Element }) => { if (!e.defaultPrevented) { if (e.to === "/settings") { e.preventDefault(); - openModal({ - type: "settings", - config: "user", - }); + openModal({ type: "settings", config: "user" }); } else if (typeof e.to === "string") { state.layout.setLastActivePath(e.to); } @@ -57,15 +62,34 @@ const Interface = (props: { children: JSX.Element }) => { ].includes(lifecycle.state()); } + //Drawer slider for mobile + let rootRef, sDrawer: SlideDrawer | undefined; + const [contRef, setContRef] = createSignal(); + function rstLayout() { + state.layout.setSectionState(LAYOUT_SECTIONS.PRIMARY_SIDEBAR, false, false); + state.layout.setSectionState(LAYOUT_SECTIONS.MEMBER_SIDEBAR, false, true); + } + createEffect(() => { + //Create drawer + const cont = contRef(); + if (cont && !sDrawer) sDrawer = new SlideDrawer(cont, rootRef!); + //Update on layout change + if (sDrawer) { + const en = sDrawer.enabled; + setTimeout(() => { + state.setAppDrawer(en ? sDrawer : undefined); + if (en) rstLayout(); + }, 1); + } + }); + onCleanup(() => { + sDrawer?.delete(); + state.setAppDrawer((sDrawer = undefined)); + }); + return ( -
+
}> @@ -96,6 +120,8 @@ const Interface = (props: { children: JSX.Element }) => { })} /> +
- + { if (props.sidebarState!().state === "default") { diff --git a/packages/client/src/interface/channels/text/Composition.tsx b/packages/client/src/interface/channels/text/Composition.tsx index 3a22b583c..fe0a0b10a 100644 --- a/packages/client/src/interface/channels/text/Composition.tsx +++ b/packages/client/src/interface/channels/text/Composition.tsx @@ -1,8 +1,6 @@ import { For, - Match, Show, - Switch, createEffect, createMemo, createSignal, @@ -353,15 +351,16 @@ export function MessageComposition(props: Props) { content={draft()?.content ?? ""} setContent={setContent} actionsStart={ - }> - - - - add - - - - + } + > + + + add + + + } actionsEnd={ @@ -382,17 +381,18 @@ export function MessageComposition(props: Props) { > {(triggerProps) => ( <> - - - gif - - - + + + + gif + + + + emoticon -
)} diff --git a/packages/client/src/interface/channels/text/TextChannel.tsx b/packages/client/src/interface/channels/text/TextChannel.tsx index c4a539c19..e79591a0c 100644 --- a/packages/client/src/interface/channels/text/TextChannel.tsx +++ b/packages/client/src/interface/channels/text/TextChannel.tsx @@ -31,6 +31,7 @@ import { VoiceChannelCallCardMount } from "@revolt/ui/components/features/voice/ import { ChannelHeader } from "../ChannelHeader"; import { ChannelPageProps } from "../ChannelPage"; +import { Channel } from "stoat.js"; import { MessageComposition } from "./Composition"; import { MemberSidebar } from "./MemberSidebar"; import { TextSearchSidebar } from "./TextSearchSidebar"; @@ -50,6 +51,10 @@ export type SidebarState = state: "default"; }; +export function canIHasSidebar(ch: Channel) { + return !["SavedMessages", "DirectMessage"].includes(ch.type); +} + /** * Channel component */ @@ -217,7 +222,7 @@ export function TextChannel(props: ChannelPageProps) { LAYOUT_SECTIONS.MEMBER_SIDEBAR, true, ) && - props.channel.type !== "SavedMessages") || + canIHasSidebar(props.channel)) || sidebarState().state !== "default" } > diff --git a/packages/client/src/interface/common/CommonHeader.tsx b/packages/client/src/interface/common/CommonHeader.tsx index 3659db902..27235f3df 100644 --- a/packages/client/src/interface/common/CommonHeader.tsx +++ b/packages/client/src/interface/common/CommonHeader.tsx @@ -1,6 +1,9 @@ import { BiRegularChevronLeft, BiRegularChevronRight } from "solid-icons/bi"; + import { JSX, Match, Switch } from "solid-js"; +import MdArrowBack from "@material-design-icons/svg/outlined/arrow_back.svg?component-solid"; + import { useLingui } from "@lingui-solid/solid/macro"; import { css } from "styled-system/css"; @@ -19,9 +22,15 @@ export function HeaderIcon(props: { children: JSX.Element }) { return (
- state.layout.toggleSectionState(LAYOUT_SECTIONS.PRIMARY_SIDEBAR, true) - } + onClick={() => { + const ad = state.appDrawer(); + if (ad) ad.setShown(false); + else + state.layout.toggleSectionState( + LAYOUT_SECTIONS.PRIMARY_SIDEBAR, + true, + ); + }} use:floating={{ tooltip: { placement: "bottom", @@ -29,7 +38,17 @@ export function HeaderIcon(props: { children: JSX.Element }) { }, }} > - }> + + + {props.children} + + } + > + + + + {props.children} - {props.children}
); } diff --git a/packages/client/src/interface/navigation/channels/HomeSidebar.tsx b/packages/client/src/interface/navigation/channels/HomeSidebar.tsx index de8579e53..9b6d87c10 100644 --- a/packages/client/src/interface/navigation/channels/HomeSidebar.tsx +++ b/packages/client/src/interface/navigation/channels/HomeSidebar.tsx @@ -25,6 +25,7 @@ import { Symbol } from "@revolt/ui/components/utils/Symbol"; import MdClose from "@material-design-icons/svg/outlined/close.svg?component-solid"; +import { useState } from "@revolt/state"; import { SidebarBase } from "./common"; interface Props { @@ -55,6 +56,7 @@ export const HomeSidebar = (props: Props) => { const navigate = useNavigate(); const location = useLocation(); const { openModal } = useModals(); + const { isMobile } = useState(); const savedNotesChannelId = createMemo(() => props.openSavedNotes()); @@ -66,44 +68,40 @@ export const HomeSidebar = (props: Props) => { }); return ( - +
Conversations - - home} - attention={location.pathname === "/app" ? "selected" : "normal"} - > - - Home - - - + home} + attention={location.pathname === "/app" ? "selected" : "normal"} + > + + Home + +
- - group} - attention={ - location.pathname === "/friends" ? "selected" : "normal" - } - > - - Friends -
- - {pendingRequests()} requests - - - - + group} + attention={location.pathname === "/friends" ? "selected" : "normal"} + > + + Friends +
+ + {pendingRequests()} requests + + + )} @@ -261,12 +256,12 @@ const NameStatusStack = styled("div", { * Single conversation entry */ function Entry( - props: { channel: Channel; active: boolean } /*& Omit< + props: { channel: Channel; active: boolean; isMobile: boolean } /*& Omit< ComponentProps, "href" >*/, ) { - const [local, remote] = splitProps(props, ["channel", "active"]); + const [local, remote] = splitProps(props, ["channel", "active", "isMobile"]); const { t } = useLingui(); const { openModal } = useModals(); @@ -288,49 +283,51 @@ function Entry( ); return ( - - - - - - - - } - /> - - - } - actions={ + + + + + + + } + /> + + + } + actions={ + { e.preventDefault(); @@ -342,55 +339,55 @@ function Entry( > - } - use:floating={{ - contextMenu: () => - local.channel.type === "DirectMessage" ? ( - - ) : ( - - ), - }} - > - - - - - - - - {/* + } + use:floating={{ + contextMenu: () => + local.channel.type === "DirectMessage" ? ( + + ) : ( + + ), + }} + > + + + + + + + + {/* */} - {local.channel.recipientIds.size}{" "} - {local.channel.recipientIds.size > 1 ? `Members` : "Member"} - - - - - {local.channel?.recipient?.displayName} - - - } - placement="top-start" - aria={status()!} - > - - - - - - - - - - + {local.channel.recipientIds.size}{" "} + {local.channel.recipientIds.size > 1 ? `Members` : "Member"} + + + + + {local.channel?.recipient?.displayName} + + + } + placement="top-start" + aria={status()!} + > + + + + + + + + + ); } diff --git a/packages/client/src/interface/navigation/channels/ServerSidebar.tsx b/packages/client/src/interface/navigation/channels/ServerSidebar.tsx index 2b0b857ee..f683bdb21 100644 --- a/packages/client/src/interface/navigation/channels/ServerSidebar.tsx +++ b/packages/client/src/interface/navigation/channels/ServerSidebar.tsx @@ -112,9 +112,7 @@ export const ServerSidebar = (props: Props) => { // TODO: we want it to feel smooth when navigating through channels, so we'll want to select channels immediately but not actually navigate until we're done moving through them /** Navigates to the channel offset from the current one, wrapping around if needed */ const _navigateChannel = (byOffset: number) => { - if (props.channelId == null) { - return; - } + if (props.channelId == null) return; const channels = visibleChannels(); @@ -192,7 +190,10 @@ export const ServerSidebar = (props: Props) => { } return ( - + @@ -224,7 +225,7 @@ export const ServerSidebar = (props: Props) => {
- - - grid_3x3}> - - - headset_mic - - - - - - - - } - actions={ - <> - - { - e.preventDefault(); - openModal({ - type: "create_invite", - channel: props.channel, - }); - }} + + + grid_3x3}> + + - - person_add - - - - - - { - e.preventDefault(); - openModal({ - type: "settings", - config: "channel", - context: props.channel, - }); - }} - > - - settings - - - - - } - > - - - - - - - - + headset_mic + + + + + + + + } + actions={ + + + { + e.preventDefault(); + openModal({ + type: "create_invite", + channel: props.channel, + }); + }} + > + + person_add + + + + + { + e.preventDefault(); + openModal({ + type: "settings", + config: "channel", + context: props.channel, + }); + }} + > + + settings + + + + + } + > + + + + + + + ); } diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index ff6df4a38..db7294868 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -33,13 +33,16 @@ export default defineConfig({ injectManifest: { maximumFileSizeToCacheInBytes: 4000000, }, + devOptions: { + enabled: true, + }, manifest: { name: "Stoat", short_name: "Stoat", description: "User-first open source chat platform.", categories: ["communication", "chat", "messaging"], start_url: base, - orientation: "portrait", + orientation: "any", display_override: ["window-controls-overlay"], display: "standalone", background_color: "#101823",