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:
parent
66b015b472
commit
d5024d96f1
|
@ -5,8 +5,11 @@
|
||||||
"chat-drawer"
|
"chat-drawer"
|
||||||
(if this.chatStateManager.isDrawerExpanded "is-expanded")
|
(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-container">
|
||||||
|
<div class="chat-drawer-resizer"></div>
|
||||||
<div
|
<div
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={{i18n "chat.aria_roles.header"}}
|
aria-label={{i18n "chat.aria_roles.header"}}
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import Component from "@ember/component";
|
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 { action } from "@ember/object";
|
||||||
import {
|
import {
|
||||||
CHAT_VIEW,
|
CHAT_VIEW,
|
||||||
|
@ -9,6 +12,8 @@ import {
|
||||||
import { equal } from "@ember/object/computed";
|
import { equal } from "@ember/object/computed";
|
||||||
import { cancel, next, schedule, throttle } from "@ember/runloop";
|
import { cancel, next, schedule, throttle } from "@ember/runloop";
|
||||||
import { inject as service } from "@ember/service";
|
import { inject as service } from "@ember/service";
|
||||||
|
import { htmlSafe } from "@ember/template";
|
||||||
|
import { escapeExpression } from "discourse/lib/utilities";
|
||||||
|
|
||||||
export default Component.extend({
|
export default Component.extend({
|
||||||
tagName: "",
|
tagName: "",
|
||||||
|
@ -17,6 +22,7 @@ export default Component.extend({
|
||||||
draftChannelView: equal("view", DRAFT_CHANNEL_VIEW),
|
draftChannelView: equal("view", DRAFT_CHANNEL_VIEW),
|
||||||
chat: service(),
|
chat: service(),
|
||||||
router: service(),
|
router: service(),
|
||||||
|
chatDrawerSize: service(),
|
||||||
chatChannelsManager: service(),
|
chatChannelsManager: service(),
|
||||||
chatStateManager: service(),
|
chatStateManager: service(),
|
||||||
loading: false,
|
loading: false,
|
||||||
|
@ -25,6 +31,7 @@ export default Component.extend({
|
||||||
rafTimer: null,
|
rafTimer: null,
|
||||||
view: null,
|
view: null,
|
||||||
hasUnreadMessages: false,
|
hasUnreadMessages: false,
|
||||||
|
drawerStyle: null,
|
||||||
|
|
||||||
didInsertElement() {
|
didInsertElement() {
|
||||||
this._super(...arguments);
|
this._super(...arguments);
|
||||||
|
@ -45,12 +52,15 @@ export default Component.extend({
|
||||||
this.appEvents.on("composer:opened", this, "_checkSize");
|
this.appEvents.on("composer:opened", this, "_checkSize");
|
||||||
this.appEvents.on("composer:resized", this, "_checkSize");
|
this.appEvents.on("composer:resized", this, "_checkSize");
|
||||||
this.appEvents.on("composer:div-resizing", this, "_dynamicCheckSize");
|
this.appEvents.on("composer:div-resizing", this, "_dynamicCheckSize");
|
||||||
|
window.addEventListener("resize", this._checkSize);
|
||||||
this.appEvents.on(
|
this.appEvents.on(
|
||||||
"composer:resize-started",
|
"composer:resize-started",
|
||||||
this,
|
this,
|
||||||
"_startDynamicCheckSize"
|
"_startDynamicCheckSize"
|
||||||
);
|
);
|
||||||
this.appEvents.on("composer:resize-ended", this, "_clearDynamicCheckSize");
|
this.appEvents.on("composer:resize-ended", this, "_clearDynamicCheckSize");
|
||||||
|
|
||||||
|
this.computeDrawerStyle();
|
||||||
},
|
},
|
||||||
|
|
||||||
willDestroyElement() {
|
willDestroyElement() {
|
||||||
|
@ -59,6 +69,8 @@ export default Component.extend({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.removeEventListener("resize", this._checkSize);
|
||||||
|
|
||||||
if (this.appEvents) {
|
if (this.appEvents) {
|
||||||
this.appEvents.off("chat:open-url", this, "openURL");
|
this.appEvents.off("chat:open-url", this, "openURL");
|
||||||
this.appEvents.off("chat:toggle-close", this, "close");
|
this.appEvents.off("chat:toggle-close", this, "close");
|
||||||
|
@ -117,10 +129,18 @@ export default Component.extend({
|
||||||
return "chat.channel.info.settings";
|
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) {
|
openChannelAtMessage(channel, messageId) {
|
||||||
this.chat.openChannel(channel, messageId);
|
this.chat.openChannel(channel, messageId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@bind
|
||||||
_dynamicCheckSize() {
|
_dynamicCheckSize() {
|
||||||
if (!this.chatStateManager.isDrawerActive) {
|
if (!this.chatStateManager.isDrawerActive) {
|
||||||
return;
|
return;
|
||||||
|
@ -155,32 +175,28 @@ export default Component.extend({
|
||||||
this._checkSize();
|
this._checkSize();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@bind
|
||||||
_checkSize() {
|
_checkSize() {
|
||||||
if (!this.chatStateManager.isDrawerActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sizeTimer = throttle(this, this._performCheckSize, 150);
|
this.sizeTimer = throttle(this, this._performCheckSize, 150);
|
||||||
},
|
},
|
||||||
|
|
||||||
_performCheckSize() {
|
_performCheckSize() {
|
||||||
if (!this.isDestroying || this.isDestroyed) {
|
if (this.isDestroying || this.isDestroyed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.chatStateManager.isDrawerActive) {
|
const drawerContainer = document.querySelector(
|
||||||
return;
|
".chat-drawer-outlet-container"
|
||||||
}
|
);
|
||||||
|
if (!drawerContainer) {
|
||||||
const drawer = document.querySelector(".chat-drawer");
|
|
||||||
if (!drawer) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const composer = document.getElementById("reply-control");
|
const composer = document.getElementById("reply-control");
|
||||||
const composerIsClosed = composer.classList.contains("closed");
|
const composerIsClosed = composer.classList.contains("closed");
|
||||||
const minRightMargin = 15;
|
const minRightMargin = 15;
|
||||||
drawer.style.setProperty(
|
|
||||||
|
drawerContainer.style.setProperty(
|
||||||
"--composer-right",
|
"--composer-right",
|
||||||
(composerIsClosed
|
(composerIsClosed
|
||||||
? minRightMargin
|
? minRightMargin
|
||||||
|
@ -269,6 +285,7 @@ export default Component.extend({
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleExpand() {
|
toggleExpand() {
|
||||||
|
this.computeDrawerStyle();
|
||||||
this.chatStateManager.didToggleDrawer();
|
this.chatStateManager.didToggleDrawer();
|
||||||
this.appEvents.trigger(
|
this.appEvents.trigger(
|
||||||
"chat:toggle-expand",
|
"chat:toggle-expand",
|
||||||
|
@ -278,11 +295,17 @@ export default Component.extend({
|
||||||
|
|
||||||
@action
|
@action
|
||||||
close() {
|
close() {
|
||||||
|
this.computeDrawerStyle();
|
||||||
this.chatStateManager.didCloseDrawer();
|
this.chatStateManager.didCloseDrawer();
|
||||||
this.chat.setActiveChannel(null);
|
this.chat.setActiveChannel(null);
|
||||||
this.appEvents.trigger("chat:float-toggled", true);
|
this.appEvents.trigger("chat:float-toggled", true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@action
|
||||||
|
didResize(element, { width, height }) {
|
||||||
|
this.chatDrawerSize.size = { width, height };
|
||||||
|
},
|
||||||
|
|
||||||
@action
|
@action
|
||||||
switchChannel(channel) {
|
switchChannel(channel) {
|
||||||
// we need next here to ensure we correctly let the time for routes transitions
|
// we need next here to ensure we correctly let the time for routes transitions
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,12 +2,39 @@ body.composer-open .chat-drawer-outlet-container {
|
||||||
bottom: 11px; // prevent height of grippie from obscuring ...is typing indicator
|
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 {
|
.chat-drawer-outlet-container {
|
||||||
// higher than timeline, lower than composer, lower than user card (bump up below)
|
// higher than timeline, lower than composer, lower than user card (bump up below)
|
||||||
z-index: z("usercard");
|
z-index: z("usercard");
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: var(--composer-right, 20px);
|
right: var(--composer-right, 20px);
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
|
.rtl & {
|
||||||
|
left: var(--composer-right, 20px);
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -37,6 +64,11 @@ body.composer-open .chat-drawer-outlet-container {
|
||||||
|
|
||||||
.chat-drawer {
|
.chat-drawer {
|
||||||
align-self: flex-end;
|
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 {
|
.chat-drawer-container {
|
||||||
background: var(--secondary);
|
background: var(--secondary);
|
||||||
|
@ -48,15 +80,21 @@ body.composer-open .chat-drawer-outlet-container {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-expanded {
|
&.is-expanded {
|
||||||
.chat-drawer-container {
|
.chat-drawer-container {
|
||||||
max-height: $float-height;
|
height: 100%;
|
||||||
height: calc(85vh - var(--composer-height, 0px));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:not(.is-expanded) {
|
||||||
|
min-height: 0 !important;
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-live-pane {
|
.chat-live-pane {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,3 @@
|
||||||
.chat-drawer {
|
|
||||||
width: 400px;
|
|
||||||
max-width: 100vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-card,
|
.user-card,
|
||||||
.group-card {
|
.group-card {
|
||||||
z-index: z("usercard") + 1; // bump up user card
|
z-index: z("usercard") + 1; // bump up user card
|
||||||
|
|
|
@ -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
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in New Issue