Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Support room-defined widget layouts #5553

Merged
merged 21 commits into from
Jan 21, 2021
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
1f219d8
Simple support for generics off the settings store
turt2live Jan 19, 2021
39ce5d0
Describe how the widget layouts are supposed to look in time
turt2live Jan 19, 2021
4ee29d4
Split out ready states of stores to its own store
turt2live Jan 19, 2021
0001e1e
Support initial ordering and calculation for widgets by layout
turt2live Jan 19, 2021
2548a43
Render ordering changes in the AppsDrawer
turt2live Jan 19, 2021
cfb583d
Calculate widget widths in the WidgetLayoutStore
turt2live Jan 19, 2021
0d29d15
Support room-defined height as well
turt2live Jan 19, 2021
5b5c338
Render layout changes in the timeline
turt2live Jan 19, 2021
1768d6e
Move all widget pinning logic to the WidgetLayoutStore
turt2live Jan 19, 2021
7ff76b2
Don't forget to run cleanup
turt2live Jan 19, 2021
5c393cc
Add in some local echo support to make changes appear faster
turt2live Jan 19, 2021
04d1f5d
Implement a "Copy my layout to the room" button
turt2live Jan 19, 2021
6227d3c
Appease the linters
turt2live Jan 19, 2021
6d770cb
Appease the linter 2
turt2live Jan 19, 2021
ab51404
Appease the tests?
turt2live Jan 19, 2021
f06aa00
Fix percentage calculation
turt2live Jan 19, 2021
0359a97
Update docs/widget-layouts.md
turt2live Jan 19, 2021
6985e8f
Update documentation words
turt2live Jan 19, 2021
f8fe454
Replace looping resizer with actual math
turt2live Jan 20, 2021
29780d9
Try to reduce WidgetStore causing downstream problems
turt2live Jan 20, 2021
f023fc5
Update src/stores/widgets/WidgetLayoutStore.ts
turt2live Jan 21, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/widget-layouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Widget layout support

Rooms can have a default widget layout to auto-pin certain widgets, make the container different
sizes, etc. These are defined through the `io.element.widgets.layout` state event (empty state key).

Full example content:
```json5
{
"widgets": {
"first-widget-id": {
"container": "top",
"index": 0,
"width": 60,
"height": 40
},
"second-widget-id": {
"container": "right"
}
}
}
```

As shown, there are two containers possible for widgets. These containers have different behaviour
and interpret the other options differently.

## `top` container

This is the "App Drawer" or any pinned widgets in a room. This is by far the most versatile container
though does introduce potential usability issues upon members of the room (widgets take up space and
therefore less messages can be shown).
turt2live marked this conversation as resolved.
Show resolved Hide resolved

The `index` for a widget determines which order the widgets show up in from left to right. Widgets
without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined
without an `index`) are resolved by comparing widget IDs. A maximum of 3 widgets can be in the top
container - any which exceed this will be ignored. Smaller numbers represent leftmost widgets.
turt2live marked this conversation as resolved.
Show resolved Hide resolved

The `width` is relative width within the container in percentage points. This will be clamped to a
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if a system like the flex CSS property would be better, where the numbers are just "ratios" rather than percentages? So, if widget 1 has "width: 1" and widget 2 has "width: 2", it means widget 2 is twice as wide as widget 1. Since there are two widgets in this example, it means widget 1 will get 1/3 of available space, while widget 2 will get 2/3.

Maybe it falls apart though because we already allow dragging to adjust layout to arbitrary points, which would encode some quite strange ratios... 😅 Anyway, seemed worth consideration at least.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is be worried about values getting obscenely large or out of hand with ratios - we can't store floats.

Percentages also feel somewhat more predictable, imo.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, I think I just find the percentages distasteful in some way, like they are too low level or something, but I don't think it's worth holding up progress for that, so feel free to continue as you like.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside from feelings about percentages vs other units of measure, there's also time pressure, external requirements, etc pushing this into needing to be percentages :(

We can revist this one day - it is deliberately in our namespace to let us make changes as needed.

range of 0-100 (inclusive). The rightmost widget will have its percentage adjusted to fill the
container appropriately, shrinking and growing if required. For example, if three widgets are in the
top container at 40% width each then the 3rd widget will be shrunk to 20% because 120% > 100%.
Similarly, if all three widgets were set to 10% width each then the 3rd widget would grow to be 80%.

Note that the client may impose minimum widths on the widgets, such as a 10% minimum to avoid pinning
hidden widgets. In general, widgets defined in the 30-70% range each will be free of these restrictions.

The `height` is not in fact applied per-widget but is recorded per-widget for potential future
capabilities in future containers. The top container will take the tallest `height` and use that for
the height of the whole container, and thus all widgets in that container. The `height` is relative
to the container, like with `width`, meaning that 100% will consume as much space as the client is
willing to sacrifice to the widget container. Like with `width`, the client may impose minimums to avoid
the container being uselessly small. Heights in the 30-100% range are generally acceptable. The height
is also clamped to be within 0-100, inclusive.

## `right` container

This is the default container and has no special configuration. Widgets which overflow from the top
container will be put in this container instead. Putting a widget in the right container does not
automatically show it - it only mentions that widgets should not be in another container.

The behaviour of this container may change in the future.

2 changes: 2 additions & 0 deletions src/@types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {Analytics} from "../Analytics";
import CountlyAnalytics from "../CountlyAnalytics";
import UserActivity from "../UserActivity";
import {ModalWidgetStore} from "../stores/ModalWidgetStore";
import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";

declare global {
interface Window {
Expand All @@ -59,6 +60,7 @@ declare global {
mxNotifier: typeof Notifier;
mxRightPanelStore: RightPanelStore;
mxWidgetStore: WidgetStore;
mxWidgetLayoutStore: WidgetLayoutStore;
mxCallHandler: CallHandler;
mxAnalytics: Analytics;
mxCountlyAnalytics: typeof CountlyAnalytics;
Expand Down
7 changes: 7 additions & 0 deletions src/TextForEvent.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as Roles from './Roles';
import {isValid3pidInvite} from "./RoomInvite";
import SettingsStore from "./settings/SettingsStore";
import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList";
import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore";

function textForMemberEvent(ev) {
// XXX: SYJS-16 "sender is sometimes null for join messages"
Expand Down Expand Up @@ -477,6 +478,11 @@ function textForWidgetEvent(event) {
}
}

function textForWidgetLayoutEvent(event) {
const senderName = event.sender?.name || event.getSender();
return _t("%(senderName)s has updated the widget layout", {senderName});
}

function textForMjolnirEvent(event) {
const senderName = event.getSender();
const {entity: prevEntity} = event.getPrevContent();
Expand Down Expand Up @@ -583,6 +589,7 @@ const stateHandlers = {

// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
'im.vector.modular.widgets': textForWidgetEvent,
[WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent,
};

// Add all the Mjolnir stuff to the renderer
Expand Down
5 changes: 3 additions & 2 deletions src/components/structures/RoomView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {UPDATE_EVENT} from "../../stores/AsyncStore";
import Notifier from "../../Notifier";
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore";
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";

const DEBUG = false;
let debuglog = function(msg: string) {};
Expand Down Expand Up @@ -280,8 +281,8 @@ export default class RoomView extends React.Component<IProps, IState> {

private checkWidgets = (room) => {
this.setState({
hasPinnedWidgets: WidgetStore.instance.getPinnedApps(room.roomId).length > 0,
})
hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0,
});
};

private onReadReceiptsChange = () => {
Expand Down
11 changes: 6 additions & 5 deletions src/components/views/context_menus/WidgetContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {MatrixCapabilities} from "matrix-widget-api";
import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu";
import {ChevronFace} from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler";
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
import {IApp} from "../../../stores/WidgetStore";
import WidgetUtils from "../../../utils/WidgetUtils";
import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore";
import RoomContext from "../../../contexts/RoomContext";
Expand All @@ -30,6 +30,7 @@ import Modal from "../../../Modal";
import QuestionDialog from "../dialogs/QuestionDialog";
import {WidgetType} from "../../../widgets/WidgetType";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";

interface IProps extends React.ComponentProps<typeof IconizedContextMenu> {
app: IApp;
Expand All @@ -56,7 +57,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let unpinButton;
if (showUnpin) {
const onUnpinClick = () => {
WidgetStore.instance.unpinWidget(room.roomId, app.id);
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
onFinished();
};

Expand Down Expand Up @@ -137,13 +138,13 @@ const WidgetContextMenu: React.FC<IProps> = ({
revokeButton = <IconizedContextMenuOption onClick={onRevokeClick} label={_t("Revoke permissions")} />;
}

const pinnedWidgets = WidgetStore.instance.getPinnedApps(roomId);
const pinnedWidgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top);
const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id);

let moveLeftButton;
if (showUnpin && widgetIndex > 0) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(roomId, app.id, -1);
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1);
onFinished();
};

Expand All @@ -153,7 +154,7 @@ const WidgetContextMenu: React.FC<IProps> = ({
let moveRightButton;
if (showUnpin && widgetIndex < pinnedWidgets.length - 1) {
const onClick = () => {
WidgetStore.instance.movePinnedWidget(roomId, app.id, 1);
WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1);
onFinished();
};

Expand Down
7 changes: 6 additions & 1 deletion src/components/views/messages/MJitsiWidgetEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler";
import WidgetStore from "../../../stores/WidgetStore";
import EventTileBubble from "./EventTileBubble";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";

interface IProps {
mxEvent: MatrixEvent;
Expand All @@ -33,9 +35,12 @@ export default class MJitsiWidgetEvent extends React.PureComponent<IProps> {
const url = this.props.mxEvent.getContent()['url'];
const prevUrl = this.props.mxEvent.getPrevContent()['url'];
const senderName = this.props.mxEvent.sender?.name || this.props.mxEvent.getSender();
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const widgetId = this.props.mxEvent.getStateKey();
const widget = WidgetStore.instance.getRoom(room.roomId).widgets.find(w => w.id === widgetId);

let joinCopy = _t('Join the conference at the top of this room');
if (!WidgetStore.instance.isPinned(this.props.mxEvent.getRoomId(), this.props.mxEvent.getStateKey())) {
if (widget && WidgetLayoutStore.instance.isInContainer(room, widget, Container.Right)) {
joinCopy = _t('Join the conference from the room information card on the right');
}

Expand Down
23 changes: 17 additions & 6 deletions src/components/views/right_panel/RoomSummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,14 @@ import SettingsStore from "../../../settings/SettingsStore";
import TextWithTooltip from "../elements/TextWithTooltip";
import WidgetAvatar from "../avatars/WidgetAvatar";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import WidgetStore, {IApp, MAX_PINNED} from "../../../stores/WidgetStore";
import WidgetStore, {IApp} from "../../../stores/WidgetStore";
import { E2EStatus } from "../../../utils/ShieldUtils";
import RoomContext from "../../../contexts/RoomContext";
import {UIFeature} from "../../../settings/UIFeature";
import {ChevronFace, ContextMenuTooltipButton, useContextMenu} from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import {useRoomMemberCount} from "../../../hooks/useRoomMembers";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";

interface IProps {
room: Room;
Expand Down Expand Up @@ -78,6 +79,7 @@ export const useWidgets = (room: Room) => {

useEffect(updateApps, [room]);
useEventEmitter(WidgetStore.instance, room.roomId, updateApps);
useEventEmitter(WidgetLayoutStore.instance, WidgetLayoutStore.emissionForRoom(room), updateApps);

return apps;
};
Expand All @@ -102,10 +104,10 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
});
};

const isPinned = WidgetStore.instance.isPinned(room.roomId, app.id);
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
const togglePin = isPinned
? () => { WidgetStore.instance.unpinWidget(room.roomId, app.id); }
: () => { WidgetStore.instance.pinWidget(room.roomId, app.id); };
? () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); }
: () => { WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top); };

const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
let contextMenu;
Expand All @@ -120,7 +122,7 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
/>;
}

const cannotPin = !isPinned && !WidgetStore.instance.canPin(room.roomId, app.id);
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);

let pinTitle: string;
if (cannotPin) {
Expand Down Expand Up @@ -184,9 +186,18 @@ const AppsSection: React.FC<IAppsSectionProps> = ({ room }) => {
}
};

let copyLayoutBtn = null;
if (apps.length > 0 && WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) {
copyLayoutBtn = (
<AccessibleButton kind="link" onClick={() => WidgetLayoutStore.instance.copyLayoutToRoom(room)}>
{ _t("Set my room layout for everyone") }
</AccessibleButton>
);
}

return <Group className="mx_RoomSummaryCard_appsGroup" title={_t("Widgets")}>
{ apps.map(app => <AppRow key={app.id} app={app} room={room} />) }

{ copyLayoutBtn }
<AccessibleButton kind="link" onClick={onManageIntegrations}>
{ apps.length > 0 ? _t("Edit widgets, bridges & bots") : _t("Add widgets, bridges & bots") }
</AccessibleButton>
Expand Down
20 changes: 10 additions & 10 deletions src/components/views/right_panel/WidgetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import React, {useContext, useEffect} from "react";
import {Room} from "matrix-js-sdk/src/models/room";
import React, { useContext, useEffect } from "react";
import { Room } from "matrix-js-sdk/src/models/room";

import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseCard from "./BaseCard";
import WidgetUtils from "../../../utils/WidgetUtils";
import AppTile from "../elements/AppTile";
import {_t} from "../../../languageHandler";
import {useWidgets} from "./RoomSummaryCard";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import { _t } from "../../../languageHandler";
import { useWidgets } from "./RoomSummaryCard";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {SetRightPanelPhasePayload} from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import {Action} from "../../../dispatcher/actions";
import WidgetStore from "../../../stores/WidgetStore";
import {ChevronFace, ContextMenuButton, useContextMenu} from "../../structures/ContextMenu";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";

interface IProps {
room: Room;
Expand All @@ -42,7 +42,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {

const apps = useWidgets(room);
const app = apps.find(a => a.id === widgetId);
const isPinned = app && WidgetStore.instance.isPinned(room.roomId, app.id);
const isPinned = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);

const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();

Expand Down
Loading