FEATURE: resizeable chat drawer (#20160)

This commit implements a requested feature: resizing the chat drawer.

The user can now adjust the drawer size to their liking, and the new size will be stored in localstorage so that it persists across refreshes. In addition to this feature, a bug was fixed where the --composer-right margin was not being correctly computed. This bug could have resulted in incorrectly positioned drawer when the composer was expanded.

Note that it includes support for RTL.
This commit is contained in:
Joffrey JAFFEUX 2023-02-03 15:11:12 +01:00 committed by GitHub
parent 66b015b472
commit d5024d96f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 294 additions and 20 deletions

View File

@ -5,8 +5,11 @@
"chat-drawer"
(if this.chatStateManager.isDrawerExpanded "is-expanded")
}}
{{chat/resizable-node ".chat-drawer-resizer" this.didResize}}
style={{this.drawerStyle}}
>
<div class="chat-drawer-container">
<div class="chat-drawer-resizer"></div>
<div
role="region"
aria-label={{i18n "chat.aria_roles.header"}}

View File

@ -1,5 +1,8 @@
import Component from "@ember/component";
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import discourseComputed, {
bind,
observes,
} from "discourse-common/utils/decorators";
import { action } from "@ember/object";
import {
CHAT_VIEW,
@ -9,6 +12,8 @@ import {
import { equal } from "@ember/object/computed";
import { cancel, next, schedule, throttle } from "@ember/runloop";
import { inject as service } from "@ember/service";
import { htmlSafe } from "@ember/template";
import { escapeExpression } from "discourse/lib/utilities";
export default Component.extend({
tagName: "",
@ -17,6 +22,7 @@ export default Component.extend({
draftChannelView: equal("view", DRAFT_CHANNEL_VIEW),
chat: service(),
router: service(),
chatDrawerSize: service(),
chatChannelsManager: service(),
chatStateManager: service(),
loading: false,
@ -25,6 +31,7 @@ export default Component.extend({
rafTimer: null,
view: null,
hasUnreadMessages: false,
drawerStyle: null,
didInsertElement() {
this._super(...arguments);
@ -45,12 +52,15 @@ export default Component.extend({
this.appEvents.on("composer:opened", this, "_checkSize");
this.appEvents.on("composer:resized", this, "_checkSize");
this.appEvents.on("composer:div-resizing", this, "_dynamicCheckSize");
window.addEventListener("resize", this._checkSize);
this.appEvents.on(
"composer:resize-started",
this,
"_startDynamicCheckSize"
);
this.appEvents.on("composer:resize-ended", this, "_clearDynamicCheckSize");
this.computeDrawerStyle();
},
willDestroyElement() {
@ -59,6 +69,8 @@ export default Component.extend({
return;
}
window.removeEventListener("resize", this._checkSize);
if (this.appEvents) {
this.appEvents.off("chat:open-url", this, "openURL");
this.appEvents.off("chat:toggle-close", this, "close");
@ -117,10 +129,18 @@ export default Component.extend({
return "chat.channel.info.settings";
},
computeDrawerStyle() {
const { width, height } = this.chatDrawerSize.size;
let style = `width: ${escapeExpression((width || "0").toString())}px;`;
style += `height: ${escapeExpression((height || "0").toString())}px;`;
this.set("drawerStyle", htmlSafe(style));
},
openChannelAtMessage(channel, messageId) {
this.chat.openChannel(channel, messageId);
},
@bind
_dynamicCheckSize() {
if (!this.chatStateManager.isDrawerActive) {
return;
@ -155,32 +175,28 @@ export default Component.extend({
this._checkSize();
},
@bind
_checkSize() {
if (!this.chatStateManager.isDrawerActive) {
return;
}
this.sizeTimer = throttle(this, this._performCheckSize, 150);
},
_performCheckSize() {
if (!this.isDestroying || this.isDestroyed) {
if (this.isDestroying || this.isDestroyed) {
return;
}
if (!this.chatStateManager.isDrawerActive) {
return;
}
const drawer = document.querySelector(".chat-drawer");
if (!drawer) {
const drawerContainer = document.querySelector(
".chat-drawer-outlet-container"
);
if (!drawerContainer) {
return;
}
const composer = document.getElementById("reply-control");
const composerIsClosed = composer.classList.contains("closed");
const minRightMargin = 15;
drawer.style.setProperty(
drawerContainer.style.setProperty(
"--composer-right",
(composerIsClosed
? minRightMargin
@ -269,6 +285,7 @@ export default Component.extend({
@action
toggleExpand() {
this.computeDrawerStyle();
this.chatStateManager.didToggleDrawer();
this.appEvents.trigger(
"chat:toggle-expand",
@ -278,11 +295,17 @@ export default Component.extend({
@action
close() {
this.computeDrawerStyle();
this.chatStateManager.didCloseDrawer();
this.chat.setActiveChannel(null);
this.appEvents.trigger("chat:float-toggled", true);
},
@action
didResize(element, { width, height }) {
this.chatDrawerSize.size = { width, height };
},
@action
switchChannel(channel) {
// we need next here to ensure we correctly let the time for routes transitions

View File

@ -0,0 +1,124 @@
import Modifier from "ember-modifier";
import { registerDestructor } from "@ember/destroyable";
import { bind } from "discourse-common/utils/decorators";
import { throttle } from "@ember/runloop";
const MINIMUM_SIZE = 20;
export default class ResizableNode extends Modifier {
element = null;
resizerSelector = null;
didResizeContainer = null;
_originalWidth = 0;
_originalHeight = 0;
_originalX = 0;
_originalY = 0;
_originalMouseX = 0;
_originalMouseY = 0;
constructor(owner, args) {
super(owner, args);
registerDestructor(this, (instance) => instance.cleanup());
}
modify(element, [resizerSelector, didResizeContainer]) {
this.resizerSelector = resizerSelector;
this.element = element;
this.didResizeContainer = didResizeContainer;
this.element
.querySelector(this.resizerSelector)
?.addEventListener("mousedown", this._startResize);
}
cleanup() {
this.element
.querySelector(this.resizerSelector)
?.removeEventListener("mousedown", this._startResize);
}
@bind
_startResize(event) {
event.preventDefault();
this._originalWidth = parseFloat(
getComputedStyle(this.element, null)
.getPropertyValue("width")
.replace("px", "")
);
this._originalHeight = parseFloat(
getComputedStyle(this.element, null)
.getPropertyValue("height")
.replace("px", "")
);
this._originalX = this.element.getBoundingClientRect().left;
this._originalY = this.element.getBoundingClientRect().top;
this._originalMouseX = event.pageX;
this._originalMouseY = event.pageY;
window.addEventListener("mousemove", this._resize);
window.addEventListener("mouseup", this._stopResize);
}
@bind
_resize(event) {
throttle(this, this._resizeThrottled, event, 24);
}
/*
The bulk of the logic is to calculate the new width and height of the element
based on the current mouse position: width is calculated by subtracting
the difference between the current event.pageX and the original this._originalMouseX
from the original this._originalWidth, and rounding up to the nearest integer.
height is calculated in a similar way using event.pageY and this._originalMouseY.
In this example (B) is the current element top/left and (A) is x/y of the mouse after dragging:
A------
| |
| B--|
| | |
-------
*/
@bind
_resizeThrottled(event) {
let width = this._originalWidth;
let diffWidth = event.pageX - this._originalMouseX;
if (document.documentElement.classList.contains("rtl")) {
width = Math.ceil(width + diffWidth);
} else {
width = Math.ceil(width - diffWidth);
}
const height = Math.ceil(
this._originalHeight - (event.pageY - this._originalMouseY)
);
const newStyle = {};
if (width > MINIMUM_SIZE) {
newStyle.width = width + "px";
newStyle.left =
Math.ceil(this._originalX + (event.pageX - this._originalMouseX)) +
"px";
}
if (height > MINIMUM_SIZE) {
newStyle.height = height + "px";
newStyle.top =
Math.ceil(this._originalY + (event.pageY - this._originalMouseY)) +
"px";
}
Object.assign(this.element.style, newStyle);
this.didResizeContainer?.(this.element, { width, height });
}
@bind
_stopResize() {
window.removeEventListener("mousemove", this._resize);
window.removeEventListener("mouseup", this._stopResize);
}
}

View File

@ -0,0 +1,32 @@
import Service from "@ember/service";
import KeyValueStore from "discourse/lib/key-value-store";
export default class ChatDrawerSize extends Service {
STORE_NAMESPACE = "discourse_chat_drawer_size_";
MIN_HEIGHT = 300;
MIN_WIDTH = 250;
store = new KeyValueStore(this.STORE_NAMESPACE);
get size() {
return {
width: this.store.getObject("width") || 400,
height: this.store.getObject("height") || 530,
};
}
set size({ width, height }) {
this.store.setObject({
key: "width",
value: this.#min(width, this.MIN_WIDTH),
});
this.store.setObject({
key: "height",
value: this.#min(height, this.MIN_HEIGHT),
});
}
#min(number, min) {
return Math.max(number, min);
}
}

View File

@ -2,12 +2,39 @@ body.composer-open .chat-drawer-outlet-container {
bottom: 11px; // prevent height of grippie from obscuring ...is typing indicator
}
.chat-drawer-resizer {
position: absolute;
top: -5px;
width: 15px;
height: 15px;
}
html:not(.rtl) {
.chat-drawer-resizer {
cursor: nwse-resize;
left: -5px;
}
}
html.rtl {
.chat-drawer-resizer {
cursor: nesw-resize;
right: -5px;
}
}
.chat-drawer-outlet-container {
// higher than timeline, lower than composer, lower than user card (bump up below)
z-index: z("usercard");
position: fixed;
right: var(--composer-right, 20px);
left: 0;
.rtl & {
left: var(--composer-right, 20px);
right: 0;
}
margin: 0;
padding: 0;
display: flex;
@ -37,6 +64,11 @@ body.composer-open .chat-drawer-outlet-container {
.chat-drawer {
align-self: flex-end;
width: 400px;
min-width: 250px !important; // important to override inline styles
max-width: calc(100% - var(--composer-right));
max-height: 85vh;
min-height: 300px !important; // important to override inline styles
.chat-drawer-container {
background: var(--secondary);
@ -48,15 +80,21 @@ body.composer-open .chat-drawer-outlet-container {
box-sizing: border-box;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
&.is-expanded {
.chat-drawer-container {
max-height: $float-height;
height: calc(85vh - var(--composer-height, 0px));
height: 100%;
}
}
&:not(.is-expanded) {
min-height: 0 !important;
height: auto !important;
}
.chat-live-pane {
height: 100%;
}

View File

@ -1,8 +1,3 @@
.chat-drawer {
width: 400px;
max-width: 100vw;
}
.user-card,
.group-card {
z-index: z("usercard") + 1; // bump up user card

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
RSpec.describe "Drawer", type: :system, js: true do
fab!(:current_user) { Fabricate(:admin) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:drawer) { PageObjects::Pages::ChatDrawer.new }
before do
chat_system_bootstrap
sign_in(current_user)
end
context "when opening" do
it "uses stored size" do
visit("/") # we need to visit the page first to set the local storage
page.execute_script "window.localStorage.setItem('discourse_chat_drawer_size_width','500');"
page.execute_script "window.localStorage.setItem('discourse_chat_drawer_size_height','500');"
visit("/")
chat_page.open_from_header
expect(page.find(".chat-drawer").native.style("width")).to eq("500px")
expect(page.find(".chat-drawer").native.style("height")).to eq("500px")
end
it "has a default size" do
visit("/")
chat_page.open_from_header
expect(page.find(".chat-drawer").native.style("width")).to eq("400px")
expect(page.find(".chat-drawer").native.style("height")).to eq("530px")
end
end
end

View File

@ -0,0 +1,22 @@
import { module, test } from "qunit";
import { getOwner } from "discourse-common/lib/get-owner";
module("Discourse Chat | Unit | Service | chat-drawer-size", function (hooks) {
hooks.beforeEach(function () {
this.subject = getOwner(this).lookup("service:chat-drawer-size");
});
test("get size (with default)", async function (assert) {
assert.deepEqual(this.subject.size, { width: 400, height: 530 });
});
test("set size", async function (assert) {
this.subject.size = { width: 400, height: 500 };
assert.deepEqual(this.subject.size, { width: 400, height: 500 });
});
test("min size", async function (assert) {
this.subject.size = { width: 100, height: 100 };
assert.deepEqual(this.subject.size, { width: 250, height: 300 });
});
});