Iterate with new buttons and resize locking

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2020-10-15 11:14:48 +01:00
parent d36fafd0c6
commit a6c81a903c
8 changed files with 173 additions and 89 deletions

View file

@ -109,20 +109,55 @@ limitations under the License.
} }
.mx_RoomSummaryCard_appsGroup { .mx_RoomSummaryCard_appsGroup {
.mx_RoomSummaryCard_widgetRow { .mx_RoomSummaryCard_Button {
margin: 0; // this button is special so we have to override some of the original styling
display: flex; // 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_pinToggle,
.mx_RoomSummaryCard_app_options { .mx_RoomSummaryCard_app_options {
position: relative; position: absolute;
height: 16px; top: 0;
width: 16px; height: 100%; // to give bigger interactive zone
padding: 8px; width: 24px;
border-radius: 8px; padding: 10px 4px;
box-sizing: border-box;
min-width: 24px; // prevent flexbox crushing
.mx_AccessibleTooltipButton_container {
// TODO
position: absolute;
top: -50px;
}
&:hover { &: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 { &::before {
@ -138,36 +173,40 @@ limitations under the License.
} }
.mx_RoomSummaryCard_app_pinToggle { .mx_RoomSummaryCard_app_pinToggle {
right: 24px;
&::before { &::before {
mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); mask-image: url('$(res)/img/element-icons/room/pin-upright.svg');
} }
&.mx_RoomSummaryCard_app_pinned {
&::before {
background-color: $accent-color;
}
}
} }
.mx_RoomSummaryCard_app_options { .mx_RoomSummaryCard_app_options {
right: 48px;
display: none;
&::before { &::before {
mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); mask-image: url('$(res)/img/element-icons/room/ellipsis.svg');
} }
} }
}
.mx_RoomSummaryCard_Button { &.mx_RoomSummaryCard_Button_pinned {
padding: 6px 24px 6px 12px; &::after {
color: $tertiary-fg-color; opacity: 0.2;
flex: 1; }
span { .mx_RoomSummaryCard_app_pinToggle::before {
color: $primary-fg-color; background-color: $accent-color;
}
} }
.mx_BaseAvatar_image { &:hover {
vertical-align: top; .mx_RoomSummaryCard_icon_app {
margin-right: 12px; padding-right: 72px;
}
.mx_RoomSummaryCard_app_options {
display: unset;
}
} }
&::before { &::before {
@ -175,7 +214,8 @@ limitations under the License.
} }
&::after { &::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
} }
} }
} }

View file

@ -276,7 +276,7 @@ export default class RoomView extends React.Component<IProps, IState> {
private checkWidgets = (room) => { private checkWidgets = (room) => {
this.setState({ this.setState({
hasPinnedWidgets: WidgetStore.instance.getApps(room, true).length > 0, hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0,
}) })
}; };

View file

@ -26,6 +26,7 @@ interface ITooltipProps extends React.ComponentProps<typeof AccessibleButton> {
tooltip?: React.ReactNode; tooltip?: React.ReactNode;
tooltipClassName?: string; tooltipClassName?: string;
forceHide?: boolean; forceHide?: boolean;
yOffset?: number;
} }
interface IState { interface IState {
@ -63,12 +64,13 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
render() { render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const {title, tooltip, children, tooltipClassName, forceHide, ...props} = this.props; const {title, tooltip, children, tooltipClassName, forceHide, yOffset, ...props} = this.props;
const tip = this.state.hover ? <Tooltip const tip = this.state.hover ? <Tooltip
className="mx_AccessibleTooltipButton_container" className="mx_AccessibleTooltipButton_container"
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)} tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
label={tooltip || title} label={tooltip || title}
yOffset={yOffset}
/> : <div />; /> : <div />;
return ( return (
<AccessibleButton <AccessibleButton

View file

@ -36,6 +36,7 @@ interface IProps {
// the react element to put into the tooltip // the react element to put into the tooltip
label: React.ReactNode; label: React.ReactNode;
forceOnRight?: boolean; forceOnRight?: boolean;
yOffset?: number;
} }
export default class Tooltip extends React.Component<IProps> { export default class Tooltip extends React.Component<IProps> {
@ -46,6 +47,7 @@ export default class Tooltip extends React.Component<IProps> {
public static readonly defaultProps = { public static readonly defaultProps = {
visible: true, visible: true,
yOffset: 0,
}; };
// Create a wrapper for the tooltip outside the parent and attach it to the body element // 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<IProps> {
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT); 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) { 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 { } else {
style.left = parentBox.right + window.pageXOffset + 6; style.left = parentBox.right + window.pageXOffset + 6;
} }

View file

@ -43,7 +43,7 @@ import { E2EStatus } from "../../../utils/ShieldUtils";
import RoomContext from "../../../contexts/RoomContext"; import RoomContext from "../../../contexts/RoomContext";
import {UIFeature} from "../../../settings/UIFeature"; import {UIFeature} from "../../../settings/UIFeature";
import {ContextMenuButton} from "../../../accessibility/context_menu/ContextMenuButton"; 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"; import WidgetContextMenu from "../context_menus/WidgetContextMenu";
interface IProps { interface IProps {
@ -70,11 +70,11 @@ const Button: React.FC<IButtonProps> = ({ children, className, onClick }) => {
}; };
export const useWidgets = (room: Room) => { export const useWidgets = (room: Room) => {
const [apps, setApps] = useState<IApp[]>(WidgetStore.instance.getApps(room)); const [apps, setApps] = useState<IApp[]>(WidgetStore.instance.getApps(room.roomId));
const updateApps = useCallback(() => { const updateApps = useCallback(() => {
// Copy the array so that we always trigger a re-render, as some updates mutate the array of apps/settings // 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]); }, [room]);
useEffect(updateApps, [room]); useEffect(updateApps, [room]);
@ -130,34 +130,39 @@ const AppRow: React.FC<IAppRowProps> = ({ app }) => {
pinTitle = isPinned ? _t("Unpin") : _t("Pin"); pinTitle = isPinned ? _t("Unpin") : _t("Pin");
} }
return <div className="mx_RoomSummaryCard_widgetRow" ref={handle}> const classes = classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", {
mx_RoomSummaryCard_Button_pinned: isPinned,
});
return <div className={classes} ref={handle}>
<AccessibleTooltipButton <AccessibleTooltipButton
className="mx_BaseCard_Button mx_RoomSummaryCard_Button mx_RoomSummaryCard_icon_app" className="mx_RoomSummaryCard_icon_app"
onClick={onOpenWidgetClick} onClick={onOpenWidgetClick}
// only show a tooltip if the widget is pinned // only show a tooltip if the widget is pinned
title={isPinned ? _t("Unpin a widget to view it in this panel") : ""} title={isPinned ? _t("Unpin a widget to view it in this panel") : ""}
forceHide={!isPinned} forceHide={!isPinned}
disabled={isPinned} disabled={isPinned}
yOffset={-48}
> >
<WidgetAvatar app={app} /> <WidgetAvatar app={app} />
<span>{name}</span> <span>{name}</span>
{ subtitle } { subtitle }
</AccessibleTooltipButton> </AccessibleTooltipButton>
<AccessibleTooltipButton <ContextMenuTooltipButton
className={classNames("mx_RoomSummaryCard_app_pinToggle", {
mx_RoomSummaryCard_app_pinned: isPinned,
})}
onClick={togglePin}
title={pinTitle}
disabled={cannotPin}
/>
<ContextMenuButton
className="mx_RoomSummaryCard_app_options" className="mx_RoomSummaryCard_app_options"
isExpanded={menuDisplayed} isExpanded={menuDisplayed}
onClick={openMenu} onClick={openMenu}
label={_t("Options")} title={_t("Options")}
yOffset={-24}
/>
<AccessibleTooltipButton
className="mx_RoomSummaryCard_app_pinToggle"
onClick={togglePin}
title={pinTitle}
disabled={cannotPin}
yOffset={-24}
/> />
{ contextMenu } { contextMenu }

View file

@ -93,8 +93,9 @@ export default class AppsDrawer extends React.Component {
onResizeStop: () => { onResizeStop: () => {
this._resizeContainer.classList.remove("mx_AppsDrawer_resizing"); this._resizeContainer.classList.remove("mx_AppsDrawer_resizing");
// persist to localStorage // persist to localStorage
console.log("@@ _saveResizerPreferences");
localStorage.setItem(this._getStorageKey(), JSON.stringify([ 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), ...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}`; _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"); console.log("@@ _loadResizerPreferences");
try { try {
const [idString, ...sizes] = JSON.parse(localStorage.getItem(this._getStorageKey())); const [idString, ...sizes] = JSON.parse(localStorage.getItem(this._getStorageKey()));
// format: [idString: string, ...percentages: string]; // format: [idString: string, ...percentages: string];
if (this._getIdString() !== idString) return; // TODO determine the exact behaviour we want for layout changing when pinning/unpinning
sizes.forEach((size, i) => { if (this._getAppsHash() === idString || true) {
const distributor = this.resizer.forHandleAt(i); sizes.forEach((size, i) => {
distributor.size = size; const distributor = this.resizer.forHandleAt(i);
distributor.finish(); if (distributor) {
}); distributor.size = size;
distributor.finish();
}
});
return;
}
} catch (e) { } catch (e) {
console.error(e); // this is expected
this.state.apps.slice(1).forEach((_, i) => { }
const distributor = this.resizer.forHandleAt(i);
distributor.item.clearSize(); if (this.state.apps) {
distributor.finish(); 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 = () => { _updateApps = () => {
this.setState({ this.setState({
apps: this._getApps(), apps: this._getApps(),
}, this._loadResizerPreferences); });
}; };
_launchManageIntegrations() { _launchManageIntegrations() {

View file

@ -163,16 +163,20 @@ export default class Resizer<C extends IConfig = IConfig> {
}; };
private onResize = throttle(() => { private onResize = throttle(() => {
const distributors = this.getResizeHandles().map(handle => { const distributors = this.getDistributors();
const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor;
});
// relax all items if they had any overconstrained flexboxes // relax all items if they had any overconstrained flexboxes
distributors.forEach(d => d.start()); distributors.forEach(d => d.start());
distributors.forEach(d => d.finish()); distributors.forEach(d => d.finish());
}, 100, {trailing: true, leading: true}); }, 100, {trailing: true, leading: true});
public getDistributors = () => {
return this.getResizeHandles().map(handle => {
const {distributor} = this.createSizerAndDistributor(<HTMLDivElement>handle);
return distributor;
});
};
private createSizerAndDistributor( private createSizerAndDistributor(
resizeHandle: HTMLDivElement, resizeHandle: HTMLDivElement,
): {sizer: Sizer, distributor: FixedDistributor<any>} { ): {sizer: Sizer, distributor: FixedDistributor<any>} {
@ -186,6 +190,7 @@ export default class Resizer<C extends IConfig = IConfig> {
} }
private getResizeHandles() { private getResizeHandles() {
if (!this.container.children) return [];
return Array.from(this.container.children).filter(el => { return Array.from(this.container.children).filter(el => {
return this.isResizeHandle(<HTMLElement>el); return this.isResizeHandle(<HTMLElement>el);
}) as HTMLElement[]; }) as HTMLElement[];

View file

@ -22,6 +22,7 @@ import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher"; import defaultDispatcher from "../dispatcher/dispatcher";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
import WidgetEchoStore from "../stores/WidgetEchoStore"; import WidgetEchoStore from "../stores/WidgetEchoStore";
import RoomViewStore from "../stores/RoomViewStore";
import ActiveWidgetStore from "../stores/ActiveWidgetStore"; import ActiveWidgetStore from "../stores/ActiveWidgetStore";
import WidgetUtils from "../utils/WidgetUtils"; import WidgetUtils from "../utils/WidgetUtils";
import {SettingLevel} from "../settings/SettingLevel"; import {SettingLevel} from "../settings/SettingLevel";
@ -48,11 +49,6 @@ interface IRoomWidgets {
export const MAX_PINNED = 3; 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 WidgetEchoStore into this
// TODO consolidate ActiveWidgetStore into this // TODO consolidate ActiveWidgetStore into this
export default class WidgetStore extends AsyncStoreWithClient<IState> { export default class WidgetStore extends AsyncStoreWithClient<IState> {
@ -75,7 +71,7 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
private initRoom(roomId: string) { private initRoom(roomId: string) {
if (!this.roomMap.has(roomId)) { if (!this.roomMap.has(roomId)) {
this.roomMap.set(roomId, { this.roomMap.set(roomId, {
pinned: {}, pinned: {}, // ordered
widgets: [], widgets: [],
}); });
} }
@ -163,25 +159,24 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
public isPinned(widgetId: string) { public isPinned(widgetId: string) {
const roomId = this.getRoomId(widgetId); const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId); return !!this.getPinnedApps(roomId).find(w => w.id === widgetId);
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;
} }
public canPin(widgetId: string) { public canPin(widgetId: string) {
const roomId = this.getRoomId(widgetId); const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId); return this.getPinnedApps(roomId).length < MAX_PINNED;
return roomInfo && Object.keys(roomInfo.pinned).filter(k => {
return roomInfo.pinned[k] && roomInfo.widgets.some(app => app.id === k);
}).length < MAX_PINNED;
} }
public pinWidget(widgetId: string) { public pinWidget(widgetId: string) {
this.setPinned(widgetId, true); 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) { public unpinWidget(widgetId: string) {
@ -192,6 +187,10 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
const roomId = this.getRoomId(widgetId); const roomId = this.getRoomId(widgetId);
const roomInfo = this.getRoom(roomId); const roomInfo = this.getRoom(roomId);
if (!roomInfo) return; 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; roomInfo.pinned[widgetId] = value;
// Clean up the pinned record // Clean up the pinned record
@ -206,13 +205,30 @@ export default class WidgetStore extends AsyncStoreWithClient<IState> {
this.emit(UPDATE_EVENT); this.emit(UPDATE_EVENT);
} }
public getApps(room: Room, pinned?: boolean): IApp[] { public getPinnedApps(roomId): IApp[] {
const roomInfo = this.getRoom(room.roomId); // returns the apps in the order they were pinned with, up to the maximum
const roomInfo = this.getRoom(roomId);
if (!roomInfo) return []; 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 { public doesRoomHaveConference(room: Room): boolean {