diff --git a/.changeset/add-room-banner-support.md b/.changeset/add-room-banner-support.md new file mode 100644 index 000000000..57ffeb749 --- /dev/null +++ b/.changeset/add-room-banner-support.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add Space banner support per MSC4221. You can now set it from the space settings. diff --git a/src/app/components/page/Page.tsx b/src/app/components/page/Page.tsx index c6bb22d44..bcad116d3 100644 --- a/src/app/components/page/Page.tsx +++ b/src/app/components/page/Page.tsx @@ -49,7 +49,6 @@ export const PageNavHeader = as<'header', css.PageNavHeaderVariants>(
diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index e0db34b00..cb49900ff 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -4,15 +4,22 @@ import { Button, Chip, color, + config, + Dialog, + Header, Icon, + IconButton, Icons, Input, + Overlay, + OverlayBackdrop, + OverlayCenter, Spinner, Text, TextArea, } from 'folds'; import type { FormEventHandler } from 'react'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useAtomValue } from 'jotai'; import Linkify from 'linkify-react'; import classNames from 'classnames'; @@ -40,6 +47,12 @@ import { useAlive } from '$hooks/useAlive'; import type { RoomPermissionsAPI } from '$hooks/useRoomPermissions'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { useStateEvent } from '$hooks/useStateEvent'; +import type { RoomBannerContent } from '$types/matrix-sdk-events'; +import { CustomStateEvent } from '$types/matrix/room'; +import { SettingTile } from '$components/setting-tile'; +import { stopPropagation } from '$utils/keyboard'; +import FocusTrap from 'focus-trap-react'; type RoomProfileEditProps = { canEditAvatar: boolean; @@ -296,6 +309,175 @@ export function RoomProfileEdit({ ); } +export type ProfileProps = { + bannerURI?: string; +}; +function RoomBannerEdit({ bannerURI }: Readonly) { + const mx = useMatrixClient(); + const [alertRemove, setAlertRemove] = useState(false); + + const space = useRoom(); + const [stagedUrl, setStagedUrl] = useState(); + const [isRemoving, setIsRemoving] = useState(false); + + const bannerUrl = bannerURI; + + useEffect(() => { + if (bannerUrl) { + setStagedUrl(undefined); + } + }, [bannerUrl]); + + const [imageFile, setImageFile] = useState(); + const imageFileURL = useObjectURL(imageFile); + + const uploadAtom = useMemo(() => { + if (imageFile) return createUploadAtom(imageFile); + return undefined; + }, [imageFile]); + + const pickFile = useFilePicker(setImageFile, false); + + const handlePick = useCallback(() => { + setIsRemoving(false); + setStagedUrl(undefined); + pickFile('image/*'); + }, [pickFile]); + + const handleRemoveUpload = useCallback(() => { + setImageFile(undefined); + }, []); + + const handleUploaded = useCallback( + (upload: UploadSuccess) => { + const { mxc } = upload; + + if (imageFileURL) setStagedUrl(imageFileURL); + mx.sendStateEvent(space.roomId, CustomStateEvent.RoomBanner, { url: mxc }, ''); + setImageFile(undefined); + }, + [mx, imageFileURL, space] + ); + + const handleRemoveBanner = async () => { + setIsRemoving(true); + setStagedUrl(undefined); + setImageFile(undefined); + + mx.sendStateEvent(space.roomId, CustomStateEvent.RoomBanner, { url: '' }, ''); + + setAlertRemove(false); + }; + + const previewUrl = isRemoving ? undefined : imageFileURL || stagedUrl || bannerUrl; + + return ( + + + + {previewUrl ? ( + Banner Preview + ) : ( + + + No Banner Set + + + )} + + + {uploadAtom ? ( + + + + ) : ( + + + {bannerUrl && ( + + )} + + )} + + + }> + + setAlertRemove(false), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + +
+ + Remove Banner + + setAlertRemove(false)} radii="300"> + + +
+ + Are you sure you want to remove profile banner? + + +
+
+
+
+
+ ); +} + type RoomProfileProps = { permissions: RoomPermissionsAPI; }; @@ -325,6 +507,10 @@ export function RoomProfile({ permissions }: RoomProfileProps) { const handleCloseEdit = useCallback(() => setEdit(false), []); + const bannerState = useStateEvent(room, CustomStateEvent.RoomBanner); + const bannerMXC = bannerState?.getContent()?.url; + const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true); + return ( Profile @@ -393,6 +579,16 @@ export function RoomProfile({ permissions }: RoomProfileProps) { )} + {room.isSpaceRoom() && ( + + + + )} ); } diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index b67a30ae5..e00e5f425 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -237,6 +237,7 @@ function ThemeVisualPreferences() { const [linkPreviewMaxHeightInput, setLinkPreviewMaxHeightInput] = useState( linkPreviewImageMaxHeight.toString() ); + const [showRoomBanners, setShowRoomBanners] = useSetting(settingsAtom, 'showRoomBanners'); const handleIncomingDefaultHeightChange: ChangeEventHandler = (evt) => { const val = evt.target.value; @@ -345,6 +346,14 @@ function ThemeVisualPreferences() { /> + + } + /> + + { @@ -627,6 +637,7 @@ function SidebarWidth({ sidebarSelector }: { sidebarSelector: string }) { if (sidebarSelector === 'threadRootHeight') return threadRootHeight; if (sidebarSelector === 'vcmsgSidebarWidth') return vcmsgSidebarWidth; if (sidebarSelector === 'widgetSidebarWidth') return widgetSidebarWidth; + if (sidebarSelector === 'roomBannerHeight') return roomBannerHeight; return undefined; }, [ sidebarSelector, @@ -636,6 +647,7 @@ function SidebarWidth({ sidebarSelector }: { sidebarSelector: string }) { threadRootHeight, vcmsgSidebarWidth, widgetSidebarWidth, + roomBannerHeight, ]); const [curValue, setCurValue] = useState(getCurValue); const setValue = (value: number) => { @@ -645,6 +657,7 @@ function SidebarWidth({ sidebarSelector }: { sidebarSelector: string }) { if (sidebarSelector === 'threadRootHeight') setThreadRootHeight(value); if (sidebarSelector === 'vcmsgSidebarWidth') setvcmsgSidebarWidth(value); if (sidebarSelector === 'widgetSidebarWidth') setWidgetSidebarWidth(value); + if (sidebarSelector === 'roomBannerHeight') setRoomBannerHeight(value); }; useEffect(() => { diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 32a705c29..03cfac011 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -93,6 +93,7 @@ const settingsLinkFocusIdsBySection: Record layout: 'widgetSidebarWidth', name: 'Widget Panel Width', }, + { + layout: 'roomBannerHeight', + name: 'Room Banner Height', + }, ], [] ); diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 31861dd2e..63c5016de 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -29,7 +29,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; import { mDirectAtom } from '$state/mDirectList'; import { NavCategory, NavCategoryHeader, NavItem, NavItemContent, NavLink } from '$components/nav'; import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '$pages/pathUtils'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '$utils/matrix'; +import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { useSpaceLobbySelected, useSpaceSearchSelected } from '$hooks/router/useSelectedSpace'; import { useSpace } from '$hooks/useSpace'; @@ -86,6 +86,10 @@ import { RoomAvatar } from '$components/room-avatar'; import { getRoomAvatarUrl } from '$utils/room'; import { nameInitials } from '$utils/common'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { CustomStateEvent } from '$types/matrix/room'; +import type { RoomBannerContent } from '$types/matrix-sdk-events'; +import * as css from './styles.css'; +import { ClientSideHoverFreeze } from '$components/ClientSideHoverFreeze'; const debugLog = createDebugLogger('Space'); @@ -267,68 +271,114 @@ function SpaceHeader({ hideText, mx }: { hideText?: boolean; mx: MatrixClient }) return cords; }); }; + const [showBanners] = useSetting(settingsAtom, 'showRoomBanners'); + const [roomBannerHeight, setRoomBannerHeight] = useSetting(settingsAtom, 'roomBannerHeight'); + const [curHeight, setCurHeight] = useState(roomBannerHeight); + useEffect(() => { + setCurHeight(roomBannerHeight); + }, [roomBannerHeight]); + + const bannerState = useStateEvent(space, CustomStateEvent.RoomBanner); + const bannerMXC = bannerState?.getContent()?.url; + const bannerURI = mxcUrlToHttp(mx, bannerMXC ?? '', true); + const hasBanner = !!(bannerURI && !hideText && showBanners); return ( <> - - - {hideText ? ( - - ( - - {nameInitials(spaceName)} - - )} - /> - - ) : ( - <> - - - {spaceName} - - {joinRules?.join_rule !== JoinRule.Public && } - - - +
+ + + {hideText ? ( + + ( + + {nameInitials(spaceName)} + + )} + /> + + ) : ( + <> + + + {spaceName} + + {joinRules?.join_rule !== JoinRule.Public && ( + + )} + + + + + + + + )} + + + {menuAnchor && ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} > - - - - + setMenuAnchor(undefined)} /> + + } + /> )} - - - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> +
+ + {hasBanner && ( + <> +
+ + {`${spaceName}'s + +
+ + )} ); diff --git a/src/app/pages/client/space/styles.css.ts b/src/app/pages/client/space/styles.css.ts new file mode 100644 index 000000000..c569121bc --- /dev/null +++ b/src/app/pages/client/space/styles.css.ts @@ -0,0 +1,51 @@ +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { color, config } from 'folds'; + +export const RoomCoverHeaderContainer = style({ width: '100%', position: 'relative' }); +export const RoomCoverNavContainer = style({ + position: 'absolute', + width: '100%', + zIndex: '10000', + top: '0', + background: 'linear-gradient(0deg,#0000 0%, rgb(0, 0, 0) 120%)', +}); +export const RoomCoverlessNavContainer = recipe({ + base: { + flexShrink: 0, + borderBottom: `1px solid ${color.Background.ContainerLine}`, + minHeight: '100%', + paddingRight: 0, + }, + variants: { + hideText: { + true: { + padding: `${config.space.S100} ${config.space.S200} ${config.space.S200}`, + }, + false: { + padding: `${config.space.S100} ${config.space.S200} ${config.space.S200} ${config.space.S400}`, + }, + }, + }, +}); + +export const RoomCoverContainer = style({ + overflow: 'hidden', +}); + +export const RoomCover = style({ + height: '100%', + width: '100%', + objectFit: 'cover', + objectPosition: 'center', +}); + +export const RoomCoverFallback = style({ + filter: 'blur(16px) brightness(50%)', + transform: 'scale(2)', +}); + +export const RoomCoverImage = style({ + objectFit: 'cover', + width: '100%', +}); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index c492e6ff6..b1b744c1f 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -161,7 +161,9 @@ export interface Settings { showPersonaSetting: boolean; closeFoldersByDefault: boolean; showRoomIcon: ShowRoomIcon; + showRoomBanners: boolean; roomSidebarWidth: number; + roomBannerHeight: number; memberSidebarWidth: number; threadSidebarWidth: number; threadRootHeight: number; @@ -291,7 +293,9 @@ export const defaultSettings: Settings = { showPersonaSetting: false, closeFoldersByDefault: false, showRoomIcon: ShowRoomIcon.Smart, + showRoomBanners: true, roomSidebarWidth: 256, + roomBannerHeight: 190, memberSidebarWidth: 262, threadSidebarWidth: 440, threadRootHeight: 220, diff --git a/src/types/matrix-sdk-events.d.ts b/src/types/matrix-sdk-events.d.ts index 17ca0888c..2960e02d5 100644 --- a/src/types/matrix-sdk-events.d.ts +++ b/src/types/matrix-sdk-events.d.ts @@ -32,6 +32,9 @@ type RoomCosmeticsPronounsEventContent = { pronouns?: PronounSet[]; }; +type RoomBannerContent = { + url?: string; +}; declare module 'matrix-js-sdk/lib/@types/event' { interface StateEvents { [prefix.MATRIX_UNSTABLE_STATE_ROOM_EMOTES_PROPERTY_NAME]: PackContent; @@ -41,6 +44,7 @@ declare module 'matrix-js-sdk/lib/@types/event' { [prefix.MATRIX_SABLE_UNSTABLE_STATE_COSMETICS_MEMBER_FONT_PROPERTY_NAME]: RoomCosmeticsFontEventContent; [prefix.MATRIX_SABLE_UNSTABLE_STATE_COSMETICS_MEMBER_PRONOUNS_PROPERTY_NAME]: RoomCosmeticsPronounsEventContent; [prefix.MATRIX_SABLE_UNSTABLE_STATE_ROOM_ABBREVIATIONS_PROPERTY_NAME]: RoomAbbreviationsContent; + [prefix.MATRIX_UNSTABLE_STATE_ROOM_BANNER_PROPERTY_NAME]: RoomBannerContent; } interface AccountDataEvents { diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 4ca95c468..af32fe773 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -16,6 +16,7 @@ export const CustomStateEvent = { RoomCosmeticsFont: 'moe.sable.room.cosmetics.font', RoomCosmeticsPronouns: 'moe.sable.room.cosmetics.pronouns', RoomAbbreviations: 'moe.sable.room.abbreviations', + RoomBanner: 'page.codeberg.everypizza.room.banner', } as const; export type CustomStateEvent = (typeof CustomStateEvent)[keyof typeof CustomStateEvent]; diff --git a/src/unstable/prefixes/misc.ts b/src/unstable/prefixes/misc.ts index c35fea44a..a5450ec3a 100644 --- a/src/unstable/prefixes/misc.ts +++ b/src/unstable/prefixes/misc.ts @@ -15,3 +15,6 @@ export const MATRIX_CINNY_UNSTABLE_STATE_ROOM_POWER_LEVELS_LABEL_PROPERTY_NAME = export const MATRIX_ELEMENT_UNSTABLE_ACCOUNT_RECENT_EMOJIS_PROPERTY_NAME = 'io.element.recent_emoji'; export const MATRIX_ELEMENT_UNSTABLE_STATE_ROOM_WIDGET_PROPERTY_NAME = 'im.vector.modular.widgets'; + +export const MATRIX_UNSTABLE_STATE_ROOM_BANNER_PROPERTY_NAME = + 'page.codeberg.everypizza.room.banner';