diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 29b139613e..be7bffff40 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -109,20 +109,55 @@ limitations under the License. } .mx_RoomSummaryCard_appsGroup { - .mx_RoomSummaryCard_widgetRow { - margin: 0; - display: flex; + .mx_RoomSummaryCard_Button { + // this button is special so we have to override some of the original styling + // as we will be applying it in its children + padding: 0; + height: auto; + color: $tertiary-fg-color; + + .mx_RoomSummaryCard_icon_app { + padding: 8px 48px 8px 12px; + text-overflow: ellipsis; + overflow: hidden; + + .mx_BaseAvatar_image { + vertical-align: top; + margin-right: 12px; + } + + span { + color: $primary-fg-color; + } + } .mx_RoomSummaryCard_app_pinToggle, .mx_RoomSummaryCard_app_options { - position: relative; - height: 16px; - width: 16px; - padding: 8px; - border-radius: 8px; + position: absolute; + top: 0; + height: 100%; // to give bigger interactive zone + width: 24px; + padding: 10px 4px; + box-sizing: border-box; + min-width: 24px; // prevent flexbox crushing + + .mx_AccessibleTooltipButton_container { + // TODO + position: absolute; + top: -50px; + } &:hover { - background-color: rgba(141, 151, 165, 0.1); + &::after { + content: ''; + position: absolute; + height: 24px; + width: 24px; + top: 6px; // equal to top-margin of parent + left: 0; + border-radius: 12px; + background-color: rgba(141, 151, 165, 0.1); + } } &::before { @@ -138,36 +173,40 @@ limitations under the License. } .mx_RoomSummaryCard_app_pinToggle { + right: 24px; + &::before { mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); } - - &.mx_RoomSummaryCard_app_pinned { - &::before { - background-color: $accent-color; - } - } } .mx_RoomSummaryCard_app_options { + right: 48px; + display: none; + &::before { mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); } } - } - .mx_RoomSummaryCard_Button { - padding: 6px 24px 6px 12px; - color: $tertiary-fg-color; - flex: 1; + &.mx_RoomSummaryCard_Button_pinned { + &::after { + opacity: 0.2; + } - span { - color: $primary-fg-color; + .mx_RoomSummaryCard_app_pinToggle::before { + background-color: $accent-color; + } } - .mx_BaseAvatar_image { - vertical-align: top; - margin-right: 12px; + &:hover { + .mx_RoomSummaryCard_icon_app { + padding-right: 72px; + } + + .mx_RoomSummaryCard_app_options { + display: unset; + } } &::before { @@ -175,7 +214,8 @@ limitations under the License. } &::after { - top: 6px; // re-align based on the height change + top: 8px; // re-align based on the height change + pointer-events: none; // pass through to the real button } } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index afa11f9db9..547432fcf6 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -276,7 +276,7 @@ export default class RoomView extends React.Component { private checkWidgets = (room) => { this.setState({ - hasPinnedWidgets: WidgetStore.instance.getApps(room, true).length > 0, + hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0, }) }; diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 29e79dc396..b7c7b78e63 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -26,6 +26,7 @@ interface ITooltipProps extends React.ComponentProps { tooltip?: React.ReactNode; tooltipClassName?: string; forceHide?: boolean; + yOffset?: number; } interface IState { @@ -63,12 +64,13 @@ export default class AccessibleTooltipButton extends React.PureComponent :
; return ( { @@ -46,6 +47,7 @@ export default class Tooltip extends React.Component { public static readonly defaultProps = { visible: true, + yOffset: 0, }; // Create a wrapper for the tooltip outside the parent and attach it to the body element @@ -82,9 +84,9 @@ export default class Tooltip extends React.Component { offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT); } - style.top = (parentBox.top - 2) + window.pageYOffset + offset; + style.top = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset + offset; if (!this.props.forceOnRight && parentBox.right > window.innerWidth / 2) { - style.right = window.innerWidth - parentBox.right - window.pageXOffset - 8; + style.right = window.innerWidth - parentBox.right - window.pageXOffset - 16; } else { style.left = parentBox.right + window.pageXOffset + 6; } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index e9c4c22c2c..5f6f9af27b 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -43,7 +43,7 @@ import { E2EStatus } from "../../../utils/ShieldUtils"; import RoomContext from "../../../contexts/RoomContext"; import {UIFeature} from "../../../settings/UIFeature"; import {ContextMenuButton} from "../../../accessibility/context_menu/ContextMenuButton"; -import {ChevronFace, useContextMenu} from "../../structures/ContextMenu"; +import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu"; import WidgetContextMenu from "../context_menus/WidgetContextMenu"; interface IProps { @@ -70,11 +70,11 @@ const Button: React.FC = ({ children, className, onClick }) => { }; export const useWidgets = (room: Room) => { - const [apps, setApps] = useState(WidgetStore.instance.getApps(room)); + const [apps, setApps] = useState(WidgetStore.instance.getApps(room.roomId)); const updateApps = useCallback(() => { // Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings - setApps([...WidgetStore.instance.getApps(room)]); + setApps([...WidgetStore.instance.getApps(room.roomId)]); }, [room]); useEffect(updateApps, [room]); @@ -130,34 +130,39 @@ const AppRow: React.FC = ({ app }) => { pinTitle = isPinned ? _t("Unpin") : _t("Pin"); } - return
+ const classes = classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", { + mx_RoomSummaryCard_Button_pinned: isPinned, + }); + + return
{name} { subtitle } - - - + + { contextMenu } diff --git a/src/components/views/rooms/AppsDrawer.js b/src/components/views/rooms/AppsDrawer.js index 9c5265c63c..3f2074e793 100644 --- a/src/components/views/rooms/AppsDrawer.js +++ b/src/components/views/rooms/AppsDrawer.js @@ -93,8 +93,9 @@ export default class AppsDrawer extends React.Component { onResizeStop: () => { this._resizeContainer.classList.remove("mx_AppsDrawer_resizing"); // persist to localStorage + console.log("@@ _saveResizerPreferences"); localStorage.setItem(this._getStorageKey(), JSON.stringify([ - this._getIdString(), + this._getAppsHash(this.state.apps), ...this.state.apps.slice(1).map((_, i) => this.resizer.forHandleAt(i).size), ])); }, @@ -121,26 +122,39 @@ export default class AppsDrawer extends React.Component { _getStorageKey = () => `mx_apps_drawer-${this.props.room.roomId}`; - _getIdString = () => this.state.apps.map(app => app.id).join("~"); + _getAppsHash = (apps) => apps.map(app => app.id).join("~"); - _loadResizerPreferences = () => { // TODO call this when changing pinned apps + componentDidUpdate(prevProps, prevState) { + if (this._getAppsHash(this.state.apps) !== this._getAppsHash(prevState.apps)) { + this._loadResizerPreferences(); + } + } + + _loadResizerPreferences = () => { console.log("@@ _loadResizerPreferences"); try { const [idString, ...sizes] = JSON.parse(localStorage.getItem(this._getStorageKey())); // format: [idString: string, ...percentages: string]; - if (this._getIdString() !== idString) return; - sizes.forEach((size, i) => { - const distributor = this.resizer.forHandleAt(i); - distributor.size = size; - distributor.finish(); - }); + // TODO determine the exact behaviour we want for layout changing when pinning/unpinning + if (this._getAppsHash() === idString || true) { + sizes.forEach((size, i) => { + const distributor = this.resizer.forHandleAt(i); + if (distributor) { + distributor.size = size; + distributor.finish(); + } + }); + return; + } } catch (e) { - console.error(e); - this.state.apps.slice(1).forEach((_, i) => { - const distributor = this.resizer.forHandleAt(i); - distributor.item.clearSize(); - distributor.finish(); - }); + // this is expected + } + + if (this.state.apps) { + console.log("@@ full relaxation"); + const distributors = this.resizer.getDistributors(); + distributors.forEach(d => d.item.clearSize()); + distributors.forEach(d => d.finish()); } }; @@ -162,12 +176,12 @@ export default class AppsDrawer extends React.Component { } }; - _getApps = () => WidgetStore.instance.getApps(this.props.room, true); + _getApps = () => WidgetStore.instance.getPinnedApps(this.props.room.roomId); _updateApps = () => { this.setState({ apps: this._getApps(), - }, this._loadResizerPreferences); + }); }; _launchManageIntegrations() { diff --git a/src/resizer/resizer.ts b/src/resizer/resizer.ts index f1ee187be1..322c8fe0a1 100644 --- a/src/resizer/resizer.ts +++ b/src/resizer/resizer.ts @@ -163,16 +163,20 @@ export default class Resizer { }; private onResize = throttle(() => { - const distributors = this.getResizeHandles().map(handle => { - const {distributor} = this.createSizerAndDistributor(handle); - return distributor; - }); + const distributors = this.getDistributors(); // relax all items if they had any overconstrained flexboxes distributors.forEach(d => d.start()); distributors.forEach(d => d.finish()); }, 100, {trailing: true, leading: true}); + public getDistributors = () => { + return this.getResizeHandles().map(handle => { + const {distributor} = this.createSizerAndDistributor(handle); + return distributor; + }); + }; + private createSizerAndDistributor( resizeHandle: HTMLDivElement, ): {sizer: Sizer, distributor: FixedDistributor} { @@ -186,6 +190,7 @@ export default class Resizer { } private getResizeHandles() { + if (!this.container.children) return []; return Array.from(this.container.children).filter(el => { return this.isResizeHandle(el); }) as HTMLElement[]; diff --git a/src/stores/WidgetStore.ts b/src/stores/WidgetStore.ts index e86fb276a1..15886a7817 100644 --- a/src/stores/WidgetStore.ts +++ b/src/stores/WidgetStore.ts @@ -22,6 +22,7 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import SettingsStore from "../settings/SettingsStore"; import WidgetEchoStore from "../stores/WidgetEchoStore"; +import RoomViewStore from "../stores/RoomViewStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import WidgetUtils from "../utils/WidgetUtils"; import {SettingLevel} from "../settings/SettingLevel"; @@ -48,11 +49,6 @@ interface IRoomWidgets { export const MAX_PINNED = 3; -// TODO change order to be order that they were pinned -// TODO HARD cap at 3, truncating if needed -// TODO call finish more proactively to lock things in -// TODO auto-open the appsDrawer for the room when widgets get pinned - // TODO consolidate WidgetEchoStore into this // TODO consolidate ActiveWidgetStore into this export default class WidgetStore extends AsyncStoreWithClient { @@ -75,7 +71,7 @@ export default class WidgetStore extends AsyncStoreWithClient { private initRoom(roomId: string) { if (!this.roomMap.has(roomId)) { this.roomMap.set(roomId, { - pinned: {}, + pinned: {}, // ordered widgets: [], }); } @@ -163,25 +159,24 @@ export default class WidgetStore extends AsyncStoreWithClient { public isPinned(widgetId: string) { const roomId = this.getRoomId(widgetId); - const roomInfo = this.getRoom(roomId); - - let pinned = roomInfo && roomInfo.pinned[widgetId]; - // Jitsi widgets should be pinned by default - const widget = this.widgetMap.get(widgetId); - if (pinned === undefined && WidgetType.JITSI.matches(widget?.type)) pinned = true; - return pinned; + return !!this.getPinnedApps(roomId).find(w => w.id === widgetId); } public canPin(widgetId: string) { const roomId = this.getRoomId(widgetId); - const roomInfo = this.getRoom(roomId); - return roomInfo && Object.keys(roomInfo.pinned).filter(k => { - return roomInfo.pinned[k] && roomInfo.widgets.some(app => app.id === k); - }).length < MAX_PINNED; + return this.getPinnedApps(roomId).length < MAX_PINNED; } public pinWidget(widgetId: string) { this.setPinned(widgetId, true); + + // Show the apps drawer upon the user pinning a widget + if (RoomViewStore.getRoomId() === this.getRoomId(widgetId)) { + defaultDispatcher.dispatch({ + action: "appsDrawer", + show: true, + }) + } } public unpinWidget(widgetId: string) { @@ -192,6 +187,10 @@ export default class WidgetStore extends AsyncStoreWithClient { const roomId = this.getRoomId(widgetId); const roomInfo = this.getRoom(roomId); if (!roomInfo) return; + if (roomInfo.pinned[widgetId] === false && value) { + // delete this before write to maintain the correct object insertion order + delete roomInfo.pinned[widgetId]; + } roomInfo.pinned[widgetId] = value; // Clean up the pinned record @@ -206,13 +205,30 @@ export default class WidgetStore extends AsyncStoreWithClient { this.emit(UPDATE_EVENT); } - public getApps(room: Room, pinned?: boolean): IApp[] { - const roomInfo = this.getRoom(room.roomId); + public getPinnedApps(roomId): IApp[] { + // returns the apps in the order they were pinned with, up to the maximum + const roomInfo = this.getRoom(roomId); if (!roomInfo) return []; - if (pinned) { - return roomInfo.widgets.filter(app => this.isPinned(app.id)); + + // Show Jitsi widgets even if the user already had the maximum pinned, instead of their latest pinned, + // except if the user already explicitly unpinned the Jitsi widget + const priorityWidget = roomInfo.widgets.find(widget => { + return roomInfo.pinned[widget.id] === undefined && WidgetType.JITSI.matches(widget.type); + }); + + const order = Object.keys(roomInfo.pinned).filter(k => roomInfo.pinned[k]); + let apps = order.map(wId => this.widgetMap.get(wId)).filter(Boolean); + apps = apps.slice(0, priorityWidget ? MAX_PINNED - 1 : MAX_PINNED); + if (priorityWidget) { + apps.push(priorityWidget); } - return roomInfo.widgets; + + return apps; + } + + public getApps(roomId: string): IApp[] { + const roomInfo = this.getRoom(roomId); + return roomInfo?.widgets || []; } public doesRoomHaveConference(room: Room): boolean {