diff --git a/apps/web/res/css/views/rooms/_Stickers.pcss b/apps/web/res/css/views/rooms/_Stickers.pcss index 75d947f4994..7d8015a31cf 100644 --- a/apps/web/res/css/views/rooms/_Stickers.pcss +++ b/apps/web/res/css/views/rooms/_Stickers.pcss @@ -4,11 +4,11 @@ .mx_Stickers_content_container { overflow: hidden; - height: 300px; + height: 450px; } #mx_persistedElement_stickerPicker { - .mx_AppTileFullWidth { + .mx_Stickers_hostInner_popover .mx_AppTileFullWidth { height: unset; box-sizing: border-box; border-left: none; @@ -16,13 +16,34 @@ border-bottom: none; } - .mx_AppTileMenuBar { + .mx_Stickers_hostInner_popover .mx_AppTileMenuBar { padding: 0; } - iframe { - /* Sticker picker depends on the fixed height previously used for all tiles */ - height: 283px; /* height of the popout minus the AppTile menu bar */ + .mx_Stickers_hostInner_popover iframe { + /* Sticker picker depends on a fixed popover height */ + height: 433px; /* 450px popover minus the AppTile menu bar */ + } + + .mx_Stickers_hostInner_sidebar, + .mx_Stickers_hostInner_sidebar .mx_AppTileFullWidth, + .mx_Stickers_hostInner_sidebar .mx_AppTileBody--large, + .mx_Stickers_hostInner_sidebar .mx_AppTile_persistedWrapper { + width: 100%; + height: 100%; + min-height: 0; + } + + .mx_Stickers_hostInner_sidebar .mx_AppTileFullWidth, + .mx_Stickers_hostInner_sidebar .mx_AppTileBody--large, + .mx_Stickers_hostInner_sidebar .mx_AppTile_persistedWrapper { + display: flex; + flex: 1 1 0; + flex-direction: column; + } + + .mx_Stickers_hostInner_sidebar iframe { + height: 100%; } } @@ -44,3 +65,68 @@ cursor: pointer; color: $accent; } + +.mx_Stickers_sidebar { + display: flex; + flex: 1 1 0; + width: 100%; + min-height: 0; + overflow: hidden; + + > div { + display: flex; + flex: 1; + width: 100%; + min-height: 0; + } + + > div > div { + width: 100%; + height: 100%; + } +} + +.mx_Stickers_host { + display: flex; + width: 100%; + min-height: 0; +} + +.mx_Stickers_host_popover { + height: 100%; +} + +.mx_Stickers_host_sidebar { + flex: 1 1 0; + height: 100%; +} + +.mx_Stickers_hostInner { + display: flex; + min-height: 0; +} + +.mx_Stickers_hostInner_sidebar { + flex: 1 1 0; +} + +.mx_Stickers_sidebarPlaceholder { + display: flex; + flex: 1 1 0; + width: 100%; + min-height: 0; +} + +.mx_Stickers_loading { + display: flex; + flex: 1 1 0; + min-height: 0; + align-items: center; + justify-content: center; +} + +.mx_WidgetCard > .mx_Stickers_sidebar, +.mx_WidgetCard > .mx_Stickers_sidebarPlaceholder { + flex: 1 1 0; + min-height: 0; +} diff --git a/apps/web/src/components/structures/RightPanel.tsx b/apps/web/src/components/structures/RightPanel.tsx index 24c16554554..284b39d06b6 100644 --- a/apps/web/src/components/structures/RightPanel.tsx +++ b/apps/web/src/components/structures/RightPanel.tsx @@ -34,6 +34,7 @@ import { Action } from "../../dispatcher/actions"; import { type XOR } from "../../@types/common"; import ExtensionsCard from "../views/right_panel/ExtensionsCard"; import MemberListView from "../views/rooms/MemberList/MemberListView"; +import StickerpickerCard from "../views/right_panel/StickerpickerCard"; interface BaseProps { overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView) @@ -277,6 +278,11 @@ export default class RightPanel extends React.Component { card = ; } break; + case RightPanelPhases.StickerPicker: + if (!!this.props.room) { + card = ; + } + break; } return ( diff --git a/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx b/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx index d0a168f691c..061f26765fd 100644 --- a/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/apps/web/src/components/views/context_menus/WidgetContextMenu.tsx @@ -37,6 +37,8 @@ interface IProps extends Omit, "child showUnpin?: boolean; // override delete handler onDeleteClick?(this: void): void; + // override attach to sidebar handler + onAttachToSidebarClick?(this: void): void; // override edit handler onEditClick?(this: void): void; } @@ -89,6 +91,7 @@ export const WidgetContextMenu: React.FC = ({ app, userWidget, onDeleteClick, + onAttachToSidebarClick, onEditClick, showUnpin, ...props @@ -190,6 +193,18 @@ export const WidgetContextMenu: React.FC = ({ ); } + let attachToSidebarButton: JSX.Element | undefined; + if (onAttachToSidebarClick) { + const onClick = (): void => { + onAttachToSidebarClick(); + onFinished(); + }; + + attachToSidebarButton = ( + + ); + } + let revokeButton: JSX.Element | undefined; if (showRevokeButton(cli, roomId, app, userWidget)) { const opts: ApprovalOpts = { approved: undefined }; @@ -245,6 +260,7 @@ export const WidgetContextMenu: React.FC = ({ {streamAudioStreamButton} {editButton} {revokeButton} + {attachToSidebarButton} {deleteButton} {snapshotButton} {moveLeftButton} diff --git a/apps/web/src/components/views/elements/AppTile.tsx b/apps/web/src/components/views/elements/AppTile.tsx index c35a77489fc..109d2227cff 100644 --- a/apps/web/src/components/views/elements/AppTile.tsx +++ b/apps/web/src/components/views/elements/AppTile.tsx @@ -40,7 +40,7 @@ import Spinner from "./Spinner"; import dis from "../../../dispatcher/dispatcher"; import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; import SettingsStore from "../../../settings/SettingsStore"; -import { ContextMenuButton } from "../../structures/ContextMenu"; +import { ContextMenuButton, toLeftOrRightOf } from "../../structures/ContextMenu"; import PersistedElement, { getPersistKey } from "./PersistedElement"; import { WidgetType } from "../../../widgets/WidgetType"; import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging"; @@ -63,7 +63,7 @@ import { toWidgetDescriptor } from "../../../modules/WidgetLifecycleApi"; import { parseUrl } from "../../../utils/UrlUtils"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts"; -import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx"; +import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin // because that would allow the iframe to programmatically remove the sandbox attribute, but @@ -100,12 +100,16 @@ interface IProps { onEditClick?: () => void; // Optional onDeleteClickHandler (overrides default behaviour) onDeleteClick?: () => void; + // Optional onAttachToSidebarClickHandler + onAttachToSidebarClick?: () => void; // Optionally hide the tile title showTitle?: boolean; // Optionally handle minimise button pointer events (default false) handleMinimisePointerEvents?: boolean; // Optionally hide the popout widget icon showPopout?: boolean; + // Whether sending a sticker should close the sticker picker widget + closeOnStickerSend?: boolean; // Is this an instance of a user widget userWidget: boolean; // sets the pointer-events property on the iframe @@ -146,6 +150,7 @@ export default class AppTile extends React.Component { showMenubar: true, showTitle: true, showPopout: true, + closeOnStickerSend: true, handleMinimisePointerEvents: false, userWidget: false, miniMode: false, @@ -584,7 +589,9 @@ export default class AppTile extends React.Component { threadId: this.props.threadId, }, }); - dis.dispatch({ action: "stickerpicker_close" }); + if (this.props.closeOnStickerSend) { + dis.dispatch({ action: "stickerpicker_close" }); + } } else { logger.warn("Ignoring sticker message. Invalid capability"); } @@ -870,26 +877,27 @@ export default class AppTile extends React.Component { )} - - - - } - app={this.props.app} - onFinished={this.closeContextMenu} - showUnpin={!this.props.userWidget} - userWidget={this.props.userWidget} - onEditClick={this.props.onEditClick} - onDeleteClick={this.props.onDeleteClick} - menuDisplayed={this.state.menuDisplayed} - /> + + + + {this.state.menuDisplayed && this.contextMenuButton.current && ( + + )} diff --git a/apps/web/src/components/views/right_panel/ExtensionsCard.tsx b/apps/web/src/components/views/right_panel/ExtensionsCard.tsx index 25aabadd555..dc8241da897 100644 --- a/apps/web/src/components/views/right_panel/ExtensionsCard.tsx +++ b/apps/web/src/components/views/right_panel/ExtensionsCard.tsx @@ -20,7 +20,7 @@ import { import BaseCard from "./BaseCard"; import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils"; import { _t } from "../../../languageHandler"; -import { useContextMenu } from "../../structures/ContextMenu"; +import { toLeftOrRightOf, useContextMenu } from "../../structures/ContextMenu"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { type IApp } from "../../../stores/WidgetStore"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; @@ -31,7 +31,7 @@ import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import EmptyState from "./EmptyState"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts"; import { UIComponent } from "../../../settings/UIFeature.ts"; -import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx"; +import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; interface Props { room: Room; @@ -107,21 +107,23 @@ const AppRow: React.FC = ({ app, room }) => { {canModifyWidget && ( - - - - } - /> + <> + + + + {menuDisplayed && handle.current && ( + + )} + )} = ({ onClose }) => { + const roomContext = useContext(RoomContext); + const room = roomContext.room; + if (!room) return null; + + const onDetachClick = (): void => { + setStickerpickerAttachedToSidebar(false); + dis.dispatch({ + action: "stickerpicker_detach_from_sidebar", + roomId: room.roomId, + }); + onClose(); + }; + + const header = ( +
+ + {_t("common|sticker")} + + + + +
+ ); + + return ( + + onClose()} + displayMode="sidebar" + /> + + ); +}; + +export default StickerpickerCard; diff --git a/apps/web/src/components/views/right_panel/WidgetCard.tsx b/apps/web/src/components/views/right_panel/WidgetCard.tsx index b9c7c239570..e34839657ab 100644 --- a/apps/web/src/components/views/right_panel/WidgetCard.tsx +++ b/apps/web/src/components/views/right_panel/WidgetCard.tsx @@ -14,11 +14,11 @@ import BaseCard from "./BaseCard"; import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils"; import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; -import { ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; +import { ContextMenuButton, toLeftOrRightOf, useContextMenu } from "../../structures/ContextMenu"; import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import Heading from "../typography/Heading"; -import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel"; +import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; interface IProps { room: Room; @@ -46,20 +46,22 @@ const WidgetCard: React.FC = ({ room, widgetId, onClose }) => { if (!app || !isRight) return null; const contextMenu: JSX.Element = ( - + + {menuDisplayed && handle.current && ( + - } - onFinished={closeMenu} - app={app} - menuDisplayed={menuDisplayed} - /> + )} + ); const header = ( diff --git a/apps/web/src/components/views/rooms/MessageComposer.tsx b/apps/web/src/components/views/rooms/MessageComposer.tsx index 06c843f1907..5512b2626aa 100644 --- a/apps/web/src/components/views/rooms/MessageComposer.tsx +++ b/apps/web/src/components/views/rooms/MessageComposer.tsx @@ -54,6 +54,9 @@ import { type MatrixClientProps, withMatrixClientHOC } from "../../../contexts/M import { UIFeature } from "../../../settings/UIFeature"; import { formatTimeLeft } from "../../../DateUtils"; import RoomReplacedSvg from "../../../../res/img/room_replaced.svg"; +import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; +import { isStickerpickerAttachedToSidebar, setStickerpickerAttachedToSidebar } from "./StickerpickerSidebarStore"; // The prefix used when persisting editor drafts to localstorage. export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_"; @@ -300,7 +303,21 @@ export class MessageComposer extends React.Component { break; } } + break; } + case "stickerpicker_attach_to_sidebar": + if (payload.roomId === this.props.room.roomId) { + setStickerpickerAttachedToSidebar(true); + this.setStickerPickerOpen(false); + RightPanelStore.instance.setCard({ phase: RightPanelPhases.StickerPicker }); + } + break; + case "stickerpicker_detach_from_sidebar": + if (payload.roomId === this.props.room.roomId) { + setStickerpickerAttachedToSidebar(false); + this.setStickerPickerOpen(false); + } + break; } }; @@ -486,6 +503,11 @@ export class MessageComposer extends React.Component { }; private toggleStickerPickerOpen = (): void => { + if (isStickerpickerAttachedToSidebar()) { + this.setStickerPickerOpen(false); + RightPanelStore.instance.showOrHidePhase(RightPanelPhases.StickerPicker); + return; + } this.setStickerPickerOpen(!this.state.isStickerPickerOpen); }; @@ -695,6 +717,7 @@ export class MessageComposer extends React.Component { relation={this.props.relation} onRecordStartEndClick={this.onRecordStartEndClick} setStickerPickerOpen={this.setStickerPickerOpen} + toggleStickerPickerOpen={this.toggleStickerPickerOpen} showLocationButton={ !window.electron && SettingsStore.getValue(UIFeature.LocationSharing) } diff --git a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx index 2221bf7592d..191a4d7c2e1 100644 --- a/apps/web/src/components/views/rooms/MessageComposerButtons.tsx +++ b/apps/web/src/components/views/rooms/MessageComposerButtons.tsx @@ -53,6 +53,7 @@ interface IProps { onRecordStartEndClick: () => void; relation?: IEventRelation; setStickerPickerOpen: (isStickerPickerOpen: boolean) => void; + toggleStickerPickerOpen: () => void; showLocationButton: boolean; showPollsButton: boolean; showStickersButton: boolean; @@ -106,10 +107,10 @@ const MessageComposerButtons: React.FC = (props: IProps) => { ) : ( emojiButton(props) ), + showStickersButton(props), uploadButton(), // props passed via UploadButtonContext ]; moreButtons = [ - showStickersButton(props), voiceRecordingButton(props, narrow), props.showPollsButton ? pollButton(room, props.relation) : null, showLocationButton(props, room, matrixClient), @@ -257,7 +258,7 @@ function showStickersButton(props: IProps): ReactElement | null { id="stickersButton" key="controls_stickers" className="mx_MessageComposer_button" - onClick={() => props.setStickerPickerOpen(!props.isStickerPickerOpen)} + onClick={props.toggleStickerPickerOpen} title={props.isStickerPickerOpen ? _t("composer|close_sticker_picker") : _t("common|sticker")} > diff --git a/apps/web/src/components/views/rooms/Stickerpicker.tsx b/apps/web/src/components/views/rooms/Stickerpicker.tsx index 80d94223353..22347d74166 100644 --- a/apps/web/src/components/views/rooms/Stickerpicker.tsx +++ b/apps/web/src/components/views/rooms/Stickerpicker.tsx @@ -8,11 +8,9 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX } from "react"; import { type Room, ClientEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import { type IWidget } from "matrix-widget-api"; import { _t, _td } from "../../../languageHandler"; -import AppTile from "../elements/AppTile"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import Spinner from "../elements/Spinner"; import dis from "../../../dispatcher/dispatcher"; import AccessibleButton from "../elements/AccessibleButton"; import WidgetUtils, { type UserWidget } from "../../../utils/WidgetUtils"; @@ -26,19 +24,15 @@ import type ScalarAuthClient from "../../../ScalarAuthClient"; import GenericElementContextMenu from "../context_menus/GenericElementContextMenu"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; - -// This should be below the dialog level (4000), but above the rest of the UI (1000-2000). -// We sit in a context menu, so this should be given to the context menu. -const STICKERPICKER_Z_INDEX = 3500; - -// Key to store the widget's AppTile under in PersistedElement -const PERSISTED_ELEMENT_KEY = "stickerPicker"; +import { setStickerpickerAttachedToSidebar } from "./StickerpickerSidebarStore"; +import StickerpickerHost, { PERSISTED_ELEMENT_KEY, STICKERPICKER_Z_INDEX } from "./StickerpickerHost"; interface IProps { room: Room; threadId?: string | null; isStickerPickerOpen: boolean; menuPosition?: any; + displayMode?: "popover" | "sidebar"; setStickerPickerOpen: (isStickerPickerOpen: boolean) => void; } @@ -46,21 +40,22 @@ interface IState { imError: string | null; stickerpickerWidget: UserWidget | null; widgetId: string | null; + widgetStateLoaded: boolean; } export default class Stickerpicker extends React.PureComponent { public static defaultProps: Partial = { threadId: null, + displayMode: "popover", }; public static currentWidget?: UserWidget; private dispatcherRef?: string; - private prevSentVisibility?: boolean; - private popoverWidth = 300; - private popoverHeight = 300; + private popoverWidth = 340; + private popoverHeight = 450; // This is loaded by _acquireScalarClient on an as-needed basis. private scalarClient: ScalarAuthClient | null = null; @@ -70,6 +65,7 @@ export default class Stickerpicker extends React.PureComponent { imError: null, stickerpickerWidget: null, widgetId: null, + widgetStateLoaded: false, }; } @@ -129,17 +125,20 @@ export default class Stickerpicker extends React.PureComponent { this.dispatcherRef = dis.register(this.onAction); // Track updates to widget state in account data - MatrixClientPeg.safeGet().on(ClientEvent.AccountData, this.updateWidget); + this.props.room.client.on(ClientEvent.AccountData, this.updateWidget); - RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); + if (this.props.displayMode === "popover") { + RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); + } // Initialise widget state from current account data this.updateWidget(); } public componentWillUnmount(): void { - const client = MatrixClientPeg.get(); - if (client) client.removeListener(ClientEvent.AccountData, this.updateWidget); - RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); + this.props.room.client.removeListener(ClientEvent.AccountData, this.updateWidget); + if (this.props.displayMode === "popover") { + RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate); + } window.removeEventListener("resize", this.onResize); dis.unregister(this.dispatcherRef); } @@ -160,7 +159,7 @@ export default class Stickerpicker extends React.PureComponent { const stickerpickerWidget = WidgetUtils.getStickerpickerWidgets(this.props.room.client)[0]; if (!stickerpickerWidget) { Stickerpicker.currentWidget = undefined; - this.setState({ stickerpickerWidget: null, widgetId: null }); + this.setState({ stickerpickerWidget: null, widgetId: null, widgetStateLoaded: true }); return; } @@ -177,6 +176,7 @@ export default class Stickerpicker extends React.PureComponent { this.setState({ stickerpickerWidget, widgetId: stickerpickerWidget ? stickerpickerWidget.id : null, + widgetStateLoaded: true, }); }; @@ -199,6 +199,15 @@ export default class Stickerpicker extends React.PureComponent { this.props.setStickerPickerOpen(false); }; + private attachToSidebar = (): void => { + setStickerpickerAttachedToSidebar(true); + this.props.setStickerPickerOpen(false); + dis.dispatch({ + action: "stickerpicker_attach_to_sidebar", + roomId: this.props.room.roomId, + }); + }; + private defaultStickerpickerContent(): JSX.Element { // eslint-disable-next-line @typescript-eslint/no-require-imports const imgSrc = require("../../../../res/img/stickerpack-placeholder.png"); @@ -219,6 +228,14 @@ export default class Stickerpicker extends React.PureComponent { ); } + private loadingStickerpickerContent(): JSX.Element { + return ( +
+ +
+ ); + } + private sendVisibilityToWidget(visible: boolean): void { if (!this.state.stickerpickerWidget) return; const messaging = WidgetMessagingStore.instance.getMessagingForUid( @@ -232,85 +249,51 @@ export default class Stickerpicker extends React.PureComponent { } } - public getStickerpickerContent(): JSX.Element { - // Handle integration manager errors + private renderStickerpickerWidget(displayMode: "popover" | "sidebar"): JSX.Element { if (this.state.imError) { return this.errorStickerpickerContent(); } - // Stickers - // TODO - Add support for Stickerpickers from multiple app stores. - // Render content from multiple stickerpack sources, each within their - // own iframe, within the stickerpicker UI element. - const stickerpickerWidget = this.state.stickerpickerWidget; - let stickersContent: JSX.Element | undefined; - - // Use a separate ReactDOM tree to render the AppTile separately so that it persists and does - // not unmount when we (a) close the sticker picker (b) switch rooms. It's properties are still - // updated. - - // Load stickerpack content - if (!!stickerpickerWidget?.content?.url) { - // Set default name - stickerpickerWidget.content.name = stickerpickerWidget.content.name || _t("common|stickerpack"); - - // FIXME: could this use the same code as other apps? - const stickerApp: IWidget = { - id: stickerpickerWidget.id, - url: stickerpickerWidget.content.url, - name: stickerpickerWidget.content.name, - type: stickerpickerWidget.content.type, - data: stickerpickerWidget.content.data, - creatorUserId: stickerpickerWidget.content.creatorUserId || stickerpickerWidget.sender, - }; - - stickersContent = ( -
-
- - - -
-
+ if (this.state.stickerpickerWidget?.content?.url) { + return ( + ); - } else { - // Default content to show if stickerpicker widget not added - stickersContent = this.defaultStickerpickerContent(); } - return stickersContent; + + return this.defaultStickerpickerContent(); + } + + public getStickerpickerContent(): JSX.Element { + return this.renderStickerpickerWidget("popover"); + } + + public getSidebarStickerpickerContent(): JSX.Element { + if (!this.state.widgetStateLoaded) { + return
{this.loadingStickerpickerContent()}
; + } + + if (this.state.imError) { + return this.errorStickerpickerContent(); + } + + return
{this.renderStickerpickerWidget("sidebar")}
; } /** * Called when the window is resized */ private onResize = (): void => { - if (this.props.isStickerPickerOpen) { + if (this.props.displayMode === "popover" && this.props.isStickerPickerOpen) { this.props.setStickerPickerOpen(false); } }; @@ -337,6 +320,10 @@ export default class Stickerpicker extends React.PureComponent { public render(): React.ReactNode { if (!this.props.isStickerPickerOpen) return null; + if (this.props.displayMode === "sidebar") { + return this.getSidebarStickerpickerContent(); + } + return ( import("../elements/AppTile")); + +interface IProps { + room: Room; + threadId?: string | null; + stickerpickerWidget: UserWidget; + displayMode: "popover" | "sidebar"; + popoverWidth: number; + popoverHeight: number; + onEditClick: () => void; + onDeleteClick: () => void; + onAttachToSidebarClick?: () => void; +} + +const StickerpickerHost: React.FC = ({ + room, + threadId, + stickerpickerWidget, + displayMode, + popoverWidth, + popoverHeight, + onEditClick, + onDeleteClick, + onAttachToSidebarClick, +}) => { + const currentUserId = room.client.getSafeUserId(); + const stickerApp: IWidget = { + id: stickerpickerWidget.id, + url: stickerpickerWidget.content.url, + name: stickerpickerWidget.content.name || _t("common|stickerpack"), + type: stickerpickerWidget.content.type, + data: stickerpickerWidget.content.data, + creatorUserId: stickerpickerWidget.content.creatorUserId || stickerpickerWidget.sender, + }; + + const sidebarMode = displayMode === "sidebar"; + + return ( +
+ +
+ + +
+ } + > + + +
+ + + ); +}; + +export default StickerpickerHost; diff --git a/apps/web/src/components/views/rooms/StickerpickerSidebarStore.ts b/apps/web/src/components/views/rooms/StickerpickerSidebarStore.ts new file mode 100644 index 00000000000..1504c360dc2 --- /dev/null +++ b/apps/web/src/components/views/rooms/StickerpickerSidebarStore.ts @@ -0,0 +1,16 @@ +/* +Copyright 2026 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +const STICKERPICKER_SIDEBAR_KEY = "mx_stickerpicker_attached_to_sidebar"; + +export function isStickerpickerAttachedToSidebar(): boolean { + return localStorage.getItem(STICKERPICKER_SIDEBAR_KEY) === "true"; +} + +export function setStickerpickerAttachedToSidebar(attached: boolean): void { + localStorage.setItem(STICKERPICKER_SIDEBAR_KEY, attached ? "true" : "false"); +} diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 32d0d255a9b..e14af960980 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -3277,6 +3277,7 @@ "start_group_chat_button": "Start a group chat" }, "stickers": { + "detach_from_sidebar": "Detach from sidebar", "empty": "You don't currently have any stickerpacks enabled", "empty_add_prompt": "Add some now" }, @@ -4057,6 +4058,7 @@ }, "close_to_view_right_panel": "Close this widget to view it in this panel", "context_menu": { + "attach_to_sidebar": "Attach to sidebar", "delete": "Delete widget", "delete_warning": "Deleting a widget removes it for all users in this room. Are you sure you want to delete this widget?", "move_left": "Move left", diff --git a/apps/web/src/stores/right-panel/RightPanelStorePhases.ts b/apps/web/src/stores/right-panel/RightPanelStorePhases.ts index ea47f2ba839..6f8d8d6debd 100644 --- a/apps/web/src/stores/right-panel/RightPanelStorePhases.ts +++ b/apps/web/src/stores/right-panel/RightPanelStorePhases.ts @@ -21,6 +21,7 @@ export enum RightPanelPhases { EncryptionPanel = "EncryptionPanel", RoomSummary = "RoomSummary", Widget = "Widget", + StickerPicker = "StickerPicker", PinnedMessages = "PinnedMessages", Timeline = "Timeline", Extensions = "Extensions", diff --git a/apps/web/src/stores/widgets/ElementWidgetDriver.ts b/apps/web/src/stores/widgets/ElementWidgetDriver.ts index 23ab7cc511b..56696df1401 100644 --- a/apps/web/src/stores/widgets/ElementWidgetDriver.ts +++ b/apps/web/src/stores/widgets/ElementWidgetDriver.ts @@ -115,6 +115,8 @@ export class ElementWidgetDriver extends WidgetDriver { const stickerSendingCap = WidgetEventCapability.forRoomEvent(EventDirection.Send, EventType.Sticker).raw; this.allowedCapabilities.add(MatrixCapabilities.StickerSending); // legacy as far as MSC2762 is concerned this.allowedCapabilities.add(stickerSendingCap); + this.allowedCapabilities.add(MatrixCapabilities.MSC4039UploadFile); + this.allowedCapabilities.add(MatrixCapabilities.MSC4039DownloadFile); // Auto-approve the legacy visibility capability. We send it regardless of capability. // Widgets don't technically need to request this capability, but Scalar still does. diff --git a/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap b/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap index db7b7c0dfa9..f2e3e168ef0 100644 --- a/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/apps/web/test/unit-tests/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -212,7 +212,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]

Could not start a chat with this user

@@ -417,7 +417,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = >
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +