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 ? (
+
+ ) : (
+
+
+ No Banner Set
+
+
+ )}
+
+
+ {uploadAtom ? (
+
+
+
+ ) : (
+
+
+ {bannerUrl && (
+
+ )}
+
+ )}
+
+
+ }>
+
+ setAlertRemove(false),
+ clickOutsideDeactivates: true,
+ escapeDeactivates: stopPropagation,
+ }}
+ >
+
+
+
+
+
+ );
+}
+
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 && (
+ <>
+
+
+
+
+
+
+ >
)}
>
);
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';