UI: redesigned settings/members (#23804)

This PR is a first step towards private groups. It redesigns settings/members area of a channel and also drops the "about" page which is now mixed into settings.

This commit is also:
- introducing chat-form, a small DSL to create forms, ideally I would want something in core for this
- introducing a DToggleSwitch page object component to simplify testing toggles
- migrating various components to gjs
This commit is contained in:
Joffrey JAFFEUX 2023-10-09 14:11:16 +02:00 committed by GitHub
parent 93c96cf6fa
commit 42801c950f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
65 changed files with 1424 additions and 1503 deletions

View File

@ -1,12 +1,14 @@
import { categoryLinkHTML } from "discourse/helpers/category-link"; import { categoryLinkHTML } from "discourse/helpers/category-link";
import { registerUnbound } from "discourse-common/lib/helpers";
import { isPresent } from "@ember/utils"; import { isPresent } from "@ember/utils";
import { registerRawHelper } from "discourse-common/lib/helpers";
registerUnbound("category-badge", function (cat, options) { registerRawHelper("category-badge", categoryBadge);
export default function categoryBadge(cat, options = {}) {
return categoryLinkHTML(cat, { return categoryLinkHTML(cat, {
hideParent: options.hideParent, hideParent: options.hideParent,
allowUncategorized: options.allowUncategorized, allowUncategorized: options.allowUncategorized,
categoryStyle: options.categoryStyle, categoryStyle: options.categoryStyle,
link: isPresent(options.link) ? options.link : false, link: isPresent(options.link) ? options.link : false,
}); });
}); }

View File

@ -1,9 +1,11 @@
import { emojiUnescape } from "discourse/lib/text"; import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe, isHTMLSafe } from "@ember/template"; import { htmlSafe, isHTMLSafe } from "@ember/template";
import { registerUnbound } from "discourse-common/lib/helpers";
import { escapeExpression } from "discourse/lib/utilities"; import { escapeExpression } from "discourse/lib/utilities";
import { registerRawHelper } from "discourse-common/lib/helpers";
registerUnbound("replace-emoji", (text, options) => { registerRawHelper("replace-emoji", replaceEmoji);
export default function replaceEmoji(text, options) {
text = isHTMLSafe(text) ? text.toString() : escapeExpression(text); text = isHTMLSafe(text) ? text.toString() : escapeExpression(text);
return htmlSafe(emojiUnescape(text, options)); return htmlSafe(emojiUnescape(text, options));
}); }

View File

@ -70,7 +70,7 @@ import DDefaultToast from "float-kit/components/d-default-toast";
export const TOAST = { export const TOAST = {
options: { options: {
autoClose: true, autoClose: true,
duration: 10000, duration: 3000,
component: DDefaultToast, component: DDefaultToast,
}, },
}; };

View File

@ -12,7 +12,6 @@ export default function () {
"channel.info", "channel.info",
{ path: "/c/:channelTitle/:channelId/info" }, { path: "/c/:channelTitle/:channelId/info" },
function () { function () {
this.route("about", { path: "/about" });
this.route("members", { path: "/members" }); this.route("members", { path: "/members" });
this.route("settings", { path: "/settings" }); this.route("settings", { path: "/settings" });
} }

View File

@ -1,93 +0,0 @@
{{#if this.channel.isCategoryChannel}}
<div class="chat-form__section">
<div class="chat-form__field">
<label class="chat-form__label">
{{i18n "chat.about_view.associated_category"}}
</label>
<div class="chat-form__control">
{{category-badge
this.channel.chatable
link=true
allowUncategorized=true
}}
</div>
</div>
</div>
{{/if}}
<div class="chat-form__section">
<div class="chat-form__field">
<label class="chat-form__label">
<span>{{i18n "chat.about_view.name"}}</span>
{{#if (chat-guardian "can-edit-chat-channel")}}
<div class="chat-form__label-actions">
<DButton
@label="chat.channel_settings.edit"
@action={{if this.onEditChatChannelName this.onEditChatChannelName}}
class="edit-name-slug-btn btn-flat"
/>
</div>
{{/if}}
</label>
<div class="chat-form__control">
<div class="channel-info-about-view__name">
{{replace-emoji this.channel.title}}
</div>
<div class="channel-info-about-view__slug">
{{this.channel.slug}}
</div>
</div>
</div>
</div>
{{#if
(or (chat-guardian "can-edit-chat-channel") this.channel.description.length)
}}
<div class="chat-form__section">
<div class="chat-form__field">
<label class="chat-form__label">
<span>{{i18n "chat.about_view.description"}}</span>
{{#if (chat-guardian "can-edit-chat-channel")}}
<div class="chat-form__label-actions">
<DButton
@label={{if
this.channel.description.length
"chat.channel_settings.edit"
"chat.channel_settings.add"
}}
@action={{if
this.onEditChatChannelDescription
this.onEditChatChannelDescription
}}
class="edit-description-btn btn-flat"
/>
</div>
{{/if}}
</label>
<div class="chat-form__control">
<div class="channel-info-about-view__description">
{{#if this.channel.description.length}}
{{this.channel.description}}
{{else}}
<div class="channel-info-about-view__description__helper-text">
{{i18n "chat.channel_edit_description_modal.description"}}
</div>
{{/if}}
</div>
</div>
</div>
</div>
{{/if}}
<div class="chat-form__section">
<ToggleChannelMembershipButton
@channel={{this.channel}}
@options={{hash
joinClass="btn-primary"
leaveClass="btn-flat"
joinIcon="sign-in-alt"
leaveIcon="sign-out-alt"
}}
/>
</div>

View File

@ -1,11 +0,0 @@
import Component from "@ember/component";
import { inject as service } from "@ember/service";
export default class ChatChannelAboutView extends Component {
@service chat;
tagName = "";
channel = null;
onEditChatChannelName = null;
onEditChatChannelDescription = null;
isLoading = false;
}

View File

@ -0,0 +1,85 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
import icon from "discourse-common/helpers/d-icon";
import { LinkTo } from "@ember/routing";
import ChatChannelTitle from "discourse/plugins/chat/discourse/components/chat-channel-title";
import ChatChannelStatus from "discourse/plugins/chat/discourse/components/chat-channel-status";
import I18n from "I18n";
export default class ChatChannelMessageEmojiPicker extends Component {
<template>
<div class="chat-full-page-header">
<div class="chat-channel-header-details">
<div class="chat-full-page-header__left-actions">
{{#if this.chatChannelInfoRouteOriginManager.isBrowse}}
<LinkTo
@route="chat.browse"
class="chat-full-page-header__back-btn no-text btn-flat btn"
title={{this.backToAllChannelsLabel}}
>
{{icon "chevron-left"}}
</LinkTo>
{{else}}
<LinkTo
@route="chat.channel"
@models={{@channel.routeModels}}
class="chat-full-page-header__back-btn no-text btn-flat btn"
title={{this.backToChannelLabel}}
>
{{icon "chevron-left"}}
</LinkTo>
{{/if}}
</div>
<ChatChannelTitle @channel={{@channel}} />
</div>
</div>
<ChatChannelStatus @channel={{@channel}} />
<div class="chat-channel-info">
{{#if this.showTabs}}
<nav class="chat-channel-info__nav">
<ul class="nav nav-pills">
<li>
<LinkTo
@route="chat.channel.info.settings"
@model={{@channel}}
@replace={{true}}
>
{{this.settingsLabel}}
</LinkTo>
</li>
<li>
<LinkTo
@route="chat.channel.info.members"
@model={{@channel}}
@replace={{true}}
>
{{this.membersLabel}}
</LinkTo>
</li>
</ul>
</nav>
{{/if}}
{{outlet}}
</div>
</template>
@service chatChannelInfoRouteOriginManager;
@service site;
membersLabel = I18n.t("chat.channel_info.tabs.members");
settingsLabel = I18n.t("chat.channel_info.tabs.settings");
backToChannelLabel = I18n.t("chat.channel_info.back_to_all_channel");
backToAllChannelsLabel = I18n.t("chat.channel_info.back_to_channel");
get showTabs() {
return (
this.site.desktopView &&
this.args.channel.membershipsCount > 1 &&
this.args.channel.isOpen
);
}
}

View File

@ -1,42 +0,0 @@
{{#if (gt this.channel.membershipsCount 0)}}
<LoadMore @selector=".channel-members-view__list-item" @action={{this.load}}>
<div class="channel-members-view-wrapper">
<div
class={{concat
"channel-members-view__search-input-container"
(if this.isSearchFocused " is-focused")
}}
>
<Input
class={{this.inputSelector}}
placeholder={{i18n "chat.members_view.filter_placeholder"}}
{{on "input" (action "onFilterMembers" value="target.value")}}
{{on "focusin" (action (mut this.isSearchFocused) true)}}
{{on "focusout" (action (mut this.isSearchFocused) false)}}
/>
{{d-icon "search"}}
</div>
<div class="channel-members-view__list-container">
<div role="list" class="channel-members-view__list">
{{#each this.members as |membership|}}
<div class="channel-members-view__list-item">
<ChatUserInfo @user={{membership.user}} />
</div>
{{else}}
{{#if this.members.fetchedOnce}}
<div class="chat-thread-list__no-threads">
{{i18n "chat.channel.no_memberships_found"}}
</div>
{{/if}}
{{/each}}
</div>
</div>
</div>
<ConditionalLoadingSpinner @condition={{this.members.loading}} />
</LoadMore>
{{else}}
<div class="channel-members-view-wrapper">
{{i18n "chat.channel.no_memberships"}}
</div>
{{/if}}

View File

@ -1,66 +0,0 @@
import { INPUT_DELAY } from "discourse-common/config/environment";
import Component from "@ember/component";
import { action } from "@ember/object";
import { schedule } from "@ember/runloop";
import discourseDebounce from "discourse-common/lib/debounce";
import { inject as service } from "@ember/service";
export default class ChatChannelMembersView extends Component {
@service chatApi;
tagName = "";
channel = null;
isSearchFocused = false;
onlineUsers = null;
filter = null;
inputSelector = "channel-members-view__search-input";
members = null;
didInsertElement() {
this._super(...arguments);
if (!this.channel) {
return;
}
this._focusSearch();
this.set("members", this.chatApi.listChannelMemberships(this.channel.id));
this.members.load();
this.appEvents.on("chat:refresh-channel-members", this, "onFilterMembers");
}
willDestroyElement() {
this._super(...arguments);
this.appEvents.off("chat:refresh-channel-members", this, "onFilterMembers");
}
@action
onFilterMembers(username) {
this.set("filter", username);
this.set("members", this.chatApi.listChannelMemberships(this.channel.id));
discourseDebounce(
this,
this.members.load,
{ username: this.filter },
INPUT_DELAY
);
}
@action
load() {
discourseDebounce(this, this.members.load, INPUT_DELAY);
}
_focusSearch() {
if (this.capabilities.isIpadOS || this.site.mobileView) {
return;
}
schedule("afterRender", () => {
document.getElementsByClassName(this.inputSelector)[0]?.focus();
});
}
}

View File

@ -0,0 +1,125 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import ChatUserInfo from "discourse/plugins/chat/discourse/components/chat-user-info";
import gt from "truth-helpers/helpers/gt";
import { cached, tracked } from "@glimmer/tracking";
import { INPUT_DELAY } from "discourse-common/config/environment";
import discourseDebounce from "discourse-common/lib/debounce";
import { modifier } from "ember-modifier";
import isElementInViewport from "discourse/lib/is-element-in-viewport";
import DcFilterInput from "discourse/plugins/chat/discourse/components/dc-filter-input";
import I18n from "I18n";
import { hash } from "@ember/helper";
import { schedule } from "@ember/runloop";
export default class ChatChannelMembers extends Component {
<template>
{{! template-lint-disable modifier-name-case }}
<div class="chat-channel-members">
<DcFilterInput
@class="chat-channel-members__filter"
@filterAction={{this.mutFilter}}
@icons={{hash right="search"}}
placeholder={{this.filterPlaceholder}}
{{this.focusInput}}
/>
{{#if (gt @channel.membershipsCount 0)}}
<ul class="chat-channel-members__list" {{this.fill}}>
{{#each this.members as |membership|}}
<li class="chat-channel-members__list-item">
<ChatUserInfo @user={{membership.user}} @avatarSize="tiny" />
</li>
{{else}}
{{#if this.noResults}}
<li
class="chat-channel-members__list-item -no-results alert alert-info"
>
{{this.noMembershipsFoundLabel}}
</li>
{{/if}}
{{/each}}
</ul>
<div {{this.loadMore}}>
<br />
</div>
{{else}}
<p class="alert alert-info">
{{this.noMembershipsLabel}}
</p>
{{/if}}
</div>
</template>
@service chatApi;
@service modal;
@service loadingSlider;
@tracked filter = "";
filterPlaceholder = I18n.t("chat.members_view.filter_placeholder");
noMembershipsFoundLabel = I18n.t("chat.channel.no_memberships_found");
noMembershipsLabel = I18n.t("chat.channel.no_memberships");
focusInput = modifier((element) => {
schedule("afterRender", () => {
element.focus();
});
});
fill = modifier((element) => {
this.resizeObserver = new ResizeObserver(() => {
if (isElementInViewport(element)) {
this.load();
}
});
this.resizeObserver.observe(element);
return () => {
this.resizeObserver.disconnect();
};
});
loadMore = modifier((element) => {
this.intersectionObserver = new IntersectionObserver(this.load);
this.intersectionObserver.observe(element);
return () => {
this.intersectionObserver.disconnect();
};
});
get noResults() {
return this.members.fetchedOnce && !this.members.loading;
}
@cached
get members() {
const params = {};
if (this.filter?.length) {
params.username = this.filter;
}
return this.chatApi.listChannelMemberships(this.args.channel.id, params);
}
@action
load() {
discourseDebounce(this, this.debouncedLoad, INPUT_DELAY);
}
@action
mutFilter(event) {
this.filter = event.target.value;
this.load();
}
async debouncedLoad() {
this.loadingSlider.transitionStarted();
await this.members.load({ limit: 20 });
this.loadingSlider.transitionEnded();
}
}

View File

@ -1,14 +0,0 @@
<span
{{did-update this.activate @property}}
{{will-destroy this.teardown}}
class={{concat-class
"chat-channel-settings-saved-indicator"
(if this.isActive "is-active")
}}
role="status"
>
{{#if this.isActive}}
{{d-icon "check"}}
<span>{{i18n "saved"}}</span>
{{/if}}
</span>

View File

@ -1,28 +0,0 @@
import discourseLater from "discourse-common/lib/later";
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { tracked } from "@glimmer/tracking";
import { cancel } from "@ember/runloop";
const ACTIVE_DURATION = 2000;
export default class ChatChannelSettingsSavedIndicator extends Component {
@tracked isActive = false;
property = null;
@action
activate() {
cancel(this._deactivateHandler);
this.isActive = true;
this._deactivateHandler = discourseLater(() => {
this.isActive = false;
}, ACTIVE_DURATION);
}
@action
teardown() {
cancel(this._deactivateHandler);
}
}

View File

@ -1,199 +0,0 @@
<div class="chat-form__section">
<div class="chat-form__field -mute">
<label class="chat-form__label">
<span>{{i18n "chat.settings.mute"}}</span>
<ChatChannelSettingsSavedIndicator
@property={{@channel.currentUserMembership.muted}}
/>
</label>
<div class="chat-form__control">
<ComboBox
@content={{this.mutedOptions}}
@value={{@channel.currentUserMembership.muted}}
@valueProperty="value"
@class="channel-settings-view__selector"
@onChange={{fn this.saveNotificationSettings "muted" "muted"}}
/>
</div>
</div>
{{#unless @channel.currentUserMembership.muted}}
<div class="chat-form__field -desktop-notification-level">
<label class="chat-form__label">
<span>{{i18n "chat.settings.desktop_notification_level"}}</span>
<ChatChannelSettingsSavedIndicator
@property={{@channel.currentUserMembership.desktopNotificationLevel}}
/>
</label>
<div class="chat-form__control">
<ComboBox
@content={{this.notificationLevels}}
@value={{@channel.currentUserMembership.desktopNotificationLevel}}
@valueProperty="value"
@class="channel-settings-view__selector"
@onChange={{fn
this.saveNotificationSettings
"desktopNotificationLevel"
"desktop_notification_level"
}}
/>
</div>
</div>
<div class="chat-form__field -mobile-notification-level">
<label class="chat-form__label">
<span>{{i18n "chat.settings.mobile_notification_level"}}</span>
<ChatChannelSettingsSavedIndicator
@property={{@channel.currentUserMembership.mobileNotificationLevel}}
/>
</label>
<div class="chat-form__control">
<ComboBox
@content={{this.notificationLevels}}
@value={{@channel.currentUserMembership.mobileNotificationLevel}}
@valueProperty="value"
@class="channel-settings-view__selector"
@onChange={{fn
this.saveNotificationSettings
"mobileNotificationLevel"
"mobile_notification_level"
}}
/>
</div>
</div>
{{/unless}}
<div class="chat-retention-info">
{{d-icon "info-circle"}}
<ChatRetentionReminderText @channel={{@channel}} />
</div>
</div>
{{#if this.adminSectionAvailable}}
<h3 class="chat-form__section-admin-title">
{{i18n "chat.settings.admin_title"}}
</h3>
{{#if this.autoJoinAvailable}}
<div class="chat-form__section -autojoin">
<div class="chat-form__field">
<label class="chat-form__label">
<span>{{i18n "chat.settings.auto_join_users_label"}}</span>
<ChatChannelSettingsSavedIndicator
@property={{@channel.autoJoinUsers}}
/>
</label>
<ComboBox
@content={{this.autoAddUsersOptions}}
@value={{@channel.autoJoinUsers}}
@valueProperty="value"
@class="channel-settings-view__selector"
@onChange={{action
(fn this.onToggleAutoJoinUsers @channel.autoJoinUsers)
}}
/>
<p class="chat-form__description">
{{i18n
"chat.settings.auto_join_users_info"
category=@channel.chatable.name
}}
</p>
</div>
</div>
{{/if}}
{{#if this.togglingChannelWideMentionsAvailable}}
<div class="chat-form__section -channel-wide-mentions">
<div class="chat-form__field">
<label class="chat-form__label">
<span>{{i18n "chat.settings.channel_wide_mentions_label"}}</span>
<ChatChannelSettingsSavedIndicator
@property={{@channel.allowChannelWideMentions}}
/>
</label>
<ComboBox
@content={{this.channelWideMentionsOptions}}
@value={{@channel.allowChannelWideMentions}}
@valueProperty="value"
@class="channel-settings-view__selector"
@onChange={{this.onToggleChannelWideMentions}}
/>
<p class="chat-form__description">
{{i18n
"chat.settings.channel_wide_mentions_description"
channel=@channel.title
}}
</p>
</div>
</div>
{{/if}}
<div class="chat-form__section -threading">
<div class="chat-form__field">
<label class="chat-form__label">
<span>{{i18n "chat.settings.channel_threading_label"}}</span>
<span class="channel-settings-view__channel-threading-tooltip">
{{d-icon "info-circle"}}
<DTooltip>
{{i18n "chat.settings.channel_threading_description"}}
</DTooltip>
</span>
<ChatChannelSettingsSavedIndicator
@property={{@channel.threadingEnabled}}
/>
</label>
<ComboBox
@content={{this.threadingEnabledOptions}}
@value={{@channel.threadingEnabled}}
@valueProperty="value"
@class="channel-settings-view__selector"
@onChange={{this.onToggleThreadingEnabled}}
/>
</div>
</div>
{{/if}}
{{#unless @channel.isDirectMessageChannel}}
<div class="chat-form__section">
{{#if (chat-guardian "can-edit-chat-channel")}}
{{#if (chat-guardian "can-archive-channel" @channel)}}
<div class="chat-form__field">
<DButton
@action={{this.onArchiveChannel}}
@label="chat.channel_settings.archive_channel"
@icon="archive"
class="archive-btn chat-form__btn btn-flat"
/>
</div>
{{/if}}
{{#if @channel.isClosed}}
<div class="chat-form__field">
<DButton
@action={{this.onToggleChannelState}}
@label="chat.channel_settings.open_channel"
@icon="unlock"
class="open-btn chat-form__btn btn-flat"
/>
</div>
{{else}}
<div class="chat-form__field">
<DButton
@action={{this.onToggleChannelState}}
@label="chat.channel_settings.close_channel"
@icon="lock"
class="close-btn chat-form__btn btn-flat"
/>
</div>
{{/if}}
<div class="chat-form__field">
<DButton
@action={{this.onDeleteChannel}}
@label="chat.channel_settings.delete_channel"
@icon="trash-alt"
class="delete-btn chat-form__btn btn-flat"
/>
</div>
{{/if}}
</div>
{{/unless}}

View File

@ -1,203 +0,0 @@
import Component from "@glimmer/component";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import I18n from "I18n";
import ChatModalArchiveChannel from "discourse/plugins/chat/discourse/components/chat/modal/archive-channel";
import ChatModalDeleteChannel from "discourse/plugins/chat/discourse/components/chat/modal/delete-channel";
import ChatModalToggleChannelStatus from "discourse/plugins/chat/discourse/components/chat/modal/toggle-channel-status";
const NOTIFICATION_LEVELS = [
{ name: I18n.t("chat.notification_levels.never"), value: "never" },
{ name: I18n.t("chat.notification_levels.mention"), value: "mention" },
{ name: I18n.t("chat.notification_levels.always"), value: "always" },
];
const MUTED_OPTIONS = [
{ name: I18n.t("chat.settings.muted_on"), value: true },
{ name: I18n.t("chat.settings.muted_off"), value: false },
];
const AUTO_ADD_USERS_OPTIONS = [
{ name: I18n.t("yes_value"), value: true },
{ name: I18n.t("no_value"), value: false },
];
const THREADING_ENABLED_OPTIONS = [
{ name: I18n.t("chat.settings.threading_enabled"), value: true },
{ name: I18n.t("chat.settings.threading_disabled"), value: false },
];
const CHANNEL_WIDE_MENTIONS_OPTIONS = [
{ name: I18n.t("yes_value"), value: true },
{
name: I18n.t("no_value"),
value: false,
},
];
export default class ChatChannelSettingsView extends Component {
@service chat;
@service chatApi;
@service chatGuardian;
@service currentUser;
@service siteSettings;
@service router;
@service dialog;
@service modal;
notificationLevels = NOTIFICATION_LEVELS;
mutedOptions = MUTED_OPTIONS;
threadingEnabledOptions = THREADING_ENABLED_OPTIONS;
autoAddUsersOptions = AUTO_ADD_USERS_OPTIONS;
channelWideMentionsOptions = CHANNEL_WIDE_MENTIONS_OPTIONS;
isSavingNotificationSetting = false;
savedDesktopNotificationLevel = false;
savedMobileNotificationLevel = false;
savedMuted = false;
get togglingChannelWideMentionsAvailable() {
return this.args.channel.isCategoryChannel;
}
get autoJoinAvailable() {
return (
this.siteSettings.max_chat_auto_joined_users > 0 &&
this.args.channel.isCategoryChannel
);
}
get adminSectionAvailable() {
return (
this.chatGuardian.canEditChatChannel() &&
(this.autoJoinAvailable || this.togglingChannelWideMentionsAvailable)
);
}
get canArchiveChannel() {
return (
this.siteSettings.chat_allow_archiving_channels &&
!this.args.channel.isArchived &&
!this.args.channel.isReadOnly
);
}
@action
saveNotificationSettings(frontendKey, backendKey, newValue) {
if (this.args.channel.currentUserMembership[frontendKey] === newValue) {
return;
}
const settings = {};
settings[backendKey] = newValue;
return this.chatApi
.updateCurrentUserChannelNotificationsSettings(
this.args.channel.id,
settings
)
.then((result) => {
this.args.channel.currentUserMembership[frontendKey] =
result.membership[backendKey];
});
}
@action
onArchiveChannel() {
return this.modal.show(ChatModalArchiveChannel, {
model: { channel: this.args.channel },
});
}
@action
onDeleteChannel() {
return this.modal.show(ChatModalDeleteChannel, {
model: { channel: this.args.channel },
});
}
@action
onToggleChannelState() {
this.modal.show(ChatModalToggleChannelStatus, { model: this.args.channel });
}
@action
onToggleAutoJoinUsers() {
if (!this.args.channel.autoJoinUsers) {
this.onEnableAutoJoinUsers();
} else {
this.onDisableAutoJoinUsers();
}
}
@action
onToggleThreadingEnabled(value) {
return this._updateChannelProperty(
this.args.channel,
"threading_enabled",
value
).then((result) => {
this.args.channel.threadingEnabled = result.channel.threading_enabled;
});
}
@action
onToggleChannelWideMentions() {
const newValue = !this.args.channel.allowChannelWideMentions;
if (this.args.channel.allowChannelWideMentions === newValue) {
return;
}
return this._updateChannelProperty(
this.args.channel,
"allow_channel_wide_mentions",
newValue
).then((result) => {
this.args.channel.allowChannelWideMentions =
result.channel.allow_channel_wide_mentions;
});
}
onDisableAutoJoinUsers() {
if (this.args.channel.autoJoinUsers === false) {
return;
}
return this._updateChannelProperty(
this.args.channel,
"auto_join_users",
false
).then((result) => {
this.args.channel.autoJoinUsers = result.channel.auto_join_users;
});
}
onEnableAutoJoinUsers() {
if (this.args.channel.autoJoinUsers === true) {
return;
}
this.dialog.confirm({
message: I18n.t("chat.settings.auto_join_users_warning", {
category: this.args.channel.chatable.name,
}),
didConfirm: () =>
this._updateChannelProperty(
this.args.channel,
"auto_join_users",
true
).then((result) => {
this.args.channel.autoJoinUsers = result.channel.auto_join_users;
}),
});
}
_updateChannelProperty(channel, property, value) {
const payload = {};
payload[property] = value;
return this.chatApi.updateChannel(channel.id, payload).catch((event) => {
if (event.jqXHR?.responseJSON?.errors) {
this.flash(event.jqXHR.responseJSON.errors.join("\n"), "error");
}
});
}
}

View File

@ -0,0 +1,581 @@
import Component from "@glimmer/component";
import ChatForm from "discourse/plugins/chat/discourse/components/chat/form";
import DToggleSwitch from "discourse/components/d-toggle-switch";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
import ChatModalArchiveChannel from "discourse/plugins/chat/discourse/components/chat/modal/archive-channel";
import ChatModalDeleteChannel from "discourse/plugins/chat/discourse/components/chat/modal/delete-channel";
import ChatModalToggleChannelStatus from "discourse/plugins/chat/discourse/components/chat/modal/toggle-channel-status";
import { on } from "@ember/modifier";
import I18n from "I18n";
import { fn, hash } from "@ember/helper";
import { popupAjaxError } from "discourse/lib/ajax-error";
import ComboBox from "select-kit/components/combo-box";
import ChatRetentionReminderText from "discourse/plugins/chat/discourse/components/chat-retention-reminder-text";
import DButton from "discourse/components/d-button";
import ToggleChannelMembershipButton from "discourse/plugins/chat/discourse/components/toggle-channel-membership-button";
import replaceEmoji from "discourse/helpers/replace-emoji";
import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name";
import categoryBadge from "discourse/helpers/category-badge";
import ChatModalEditChannelDescription from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-description";
import { LinkTo } from "@ember/routing";
const NOTIFICATION_LEVELS = [
{ name: I18n.t("chat.notification_levels.never"), value: "never" },
{ name: I18n.t("chat.notification_levels.mention"), value: "mention" },
{ name: I18n.t("chat.notification_levels.always"), value: "always" },
];
export default class ChatAboutScreen extends Component {
<template>
<div class="chat-channel-settings">
<ChatForm as |form|>
{{#if this.shouldRenderTitleSection}}
<form.section @title={{this.titleSectionTitle}} as |section|>
<section.row>
<:default>
<div class="chat-channel-settings__name">
{{replaceEmoji @channel.title}}
</div>
{{#if @channel.isCategoryChannel}}
<div class="chat-channel-settings__slug">
<LinkTo
@route="chat.channel"
@models={{@channel.routeModels}}
>
/chat/c/{{@channel.slug}}/{{@channel.id}}
</LinkTo>
</div>
{{/if}}
</:default>
<:action>
{{#if this.canEditChannel}}
<DButton
@label="chat.channel_settings.edit"
@action={{this.onEditChannelName}}
class="edit-name-slug-btn btn-flat"
/>
{{/if}}
</:action>
</section.row>
</form.section>
{{/if}}
{{#if this.shouldRenderDescriptionSection}}
<form.section @title={{this.descriptionSectionTitle}} as |section|>
<section.row>
<:default>
{{#if @channel.description.length}}
{{@channel.description}}
{{else}}
{{this.descriptionPlaceholder}}
{{/if}}
</:default>
<:action>
{{#if this.canEditChannel}}
<DButton
@label={{if
@channel.description.length
"chat.channel_settings.edit"
"chat.channel_settings.add"
}}
@action={{this.onEditChannelDescription}}
class="edit-description-btn btn-flat"
/>
{{/if}}
</:action>
</section.row>
</form.section>
{{/if}}
{{#if this.site.mobileView}}
<form.section as |section|>
<section.row
@label={{this.membersLabel}}
@route="chat.channel.info.members"
@routeModels={{@channel.routeModels}}
/>
</form.section>
{{/if}}
{{#if @channel.isOpen}}
<form.section @title={{this.settingsSectionTitle}} as |section|>
<section.row @label={{this.muteSectionLabel}}>
<:action>
<DToggleSwitch
@state={{@channel.currentUserMembership.muted}}
class="chat-channel-settings__mute-switch"
{{on "click" this.onToggleMuted}}
/>
</:action>
</section.row>
{{#if this.shouldRenderDesktopNotificationsLevelSection}}
<section.row @label={{this.desktopNotificationsLevelLabel}}>
<:action>
<ComboBox
@content={{this.notificationLevels}}
@value={{@channel.currentUserMembership.desktopNotificationLevel}}
@valueProperty="value"
@class="chat-channel-settings__selector chat-channel-settings__desktop-notifications-selector"
@onChange={{fn
this.saveNotificationSettings
"desktopNotificationLevel"
"desktop_notification_level"
}}
/>
</:action>
</section.row>
{{/if}}
{{#if this.shouldRenderMobileNotificationsLevelSection}}
<section.row @label={{this.mobileNotificationsLevelLabel}}>
<:action>
<ComboBox
@content={{this.notificationLevels}}
@value={{@channel.currentUserMembership.mobileNotificationLevel}}
@valueProperty="value"
@class="chat-channel-settings__selector chat-channel-settings__mobile-notifications-selector"
@onChange={{fn
this.saveNotificationSettings
"mobileNotificationLevel"
"mobile_notification_level"
}}
/>
</:action>
</section.row>
{{/if}}
</form.section>
{{/if}}
<form.section @title={{this.channelInfoSectionTitle}} as |section|>
{{#if @channel.isCategoryChannel}}
<section.row @label={{this.categoryLabel}}>
{{categoryBadge
@channel.chatable
link=true
allowUncategorized=true
}}
</section.row>
{{/if}}
<section.row @label={{this.historyLabel}}>
<ChatRetentionReminderText @channel={{@channel}} />
</section.row>
</form.section>
{{#if this.shouldRenderAdminSection}}
<form.section
@title={{this.adminSectionTitle}}
data-section="admin"
as |section|
>
{{#if this.autoJoinAvailable}}
<section.row @label={{this.autoJoinLabel}}>
<:action>
<DToggleSwitch
@state={{@channel.autoJoinUsers}}
class="chat-channel-settings__auto-join-switch"
{{on
"click"
(fn this.onToggleAutoJoinUsers @channel.autoJoinUsers)
}}
/>
</:action>
</section.row>
{{/if}}
{{#if this.toggleChannelWideMentionsAvailable}}
<section.row @label={{this.channelWideMentionsLabel}}>
<:action>
<DToggleSwitch
class="chat-channel-settings__channel-wide-mentions"
@state={{@channel.allowChannelWideMentions}}
{{on
"click"
(fn
this.onToggleChannelWideMentions
@channel.allowChannelWideMentions
)
}}
/>
</:action>
<:description>
{{this.channelWideMentionsDescription}}
</:description>
</section.row>
{{/if}}
{{#if this.toggleThreadingAvailable}}
<section.row @label={{this.toggleThreadingLabel}}>
<:action>
<DToggleSwitch
@state={{@channel.threadingEnabled}}
class="chat-channel-settings__threading-switch"
{{on
"click"
(fn
this.onToggleThreadingEnabled @channel.threadingEnabled
)
}}
/>
</:action>
<:description>
{{this.toggleThreadingDescription}}
</:description>
</section.row>
{{/if}}
{{#if this.shouldRenderStatusSection}}
{{#if this.shouldRenderArchiveRow}}
<section.row>
<:action>
<DButton
@action={{this.onArchiveChannel}}
@label="chat.channel_settings.archive_channel"
@icon="archive"
class="archive-btn chat-form__btn btn-flat"
/>
</:action>
</section.row>
{{/if}}
<section.row>
<:action>
{{#if @channel.isOpen}}
<DButton
@action={{this.onToggleChannelState}}
@label="chat.channel_settings.close_channel"
@icon="lock"
class="close-btn chat-form__btn btn-flat"
/>
{{else}}
<DButton
@action={{this.onToggleChannelState}}
@label="chat.channel_settings.open_channel"
@icon="unlock"
class="open-btn chat-form__btn btn-flat"
/>
{{/if}}
</:action>
</section.row>
<section.row>
<:action>
<DButton
@action={{this.onDeleteChannel}}
@label="chat.channel_settings.delete_channel"
@icon="trash-alt"
class="delete-btn chat-form__btn btn-flat"
/>
</:action>
</section.row>
{{/if}}
</form.section>
{{/if}}
<form.section as |section|>
<section.row>
<:action>
<ToggleChannelMembershipButton
@channel={{@channel}}
@options={{hash
joinClass="btn-primary"
leaveClass="btn-flat"
joinIcon="sign-in-alt"
leaveIcon="sign-out-alt"
}}
/>
</:action>
</section.row>
</form.section>
</ChatForm>
</div>
</template>
@service chatApi;
@service chatGuardian;
@service currentUser;
@service siteSettings;
@service dialog;
@service modal;
@service site;
@service toasts;
notificationLevels = NOTIFICATION_LEVELS;
settingsSectionTitle = I18n.t("chat.settings.settings_title");
channelInfoSectionTitle = I18n.t("chat.settings.info_title");
categoryLabel = I18n.t("chat.settings.category_label");
historyLabel = I18n.t("chat.settings.history_label");
adminSectionTitle = I18n.t("chat.settings.admin_title");
membersLabel = I18n.t("chat.settings.tabs.members_label");
descriptionSectionTitle = I18n.t("chat.about_view.description");
titleSectionTitle = I18n.t("chat.about_view.title");
descriptionPlaceholder = I18n.t(
"chat.channel_edit_description_modal.description"
);
toggleThreadingLabel = I18n.t("chat.settings.channel_threading_label");
toggleThreadingDescription = I18n.t(
"chat.settings.channel_threading_description"
);
muteSectionLabel = I18n.t("chat.settings.mute");
channelWideMentionsLabel = I18n.t(
"chat.settings.channel_wide_mentions_label"
);
autoJoinLabel = I18n.t("chat.settings.auto_join_users_label");
desktopNotificationsLevelLabel = I18n.t(
"chat.settings.desktop_notification_level"
);
mobileNotificationsLevelLabel = I18n.t(
"chat.settings.mobile_notification_level"
);
get canEditChannel() {
return this.chatGuardian.canEditChatChannel();
}
get shouldRenderTitleSection() {
return this.args.channel.isCategoryChannel;
}
get shouldRenderDescriptionSection() {
return this.args.channel.isCategoryChannel;
}
get shouldRenderStatusSection() {
return this.args.channel.isCategoryChannel;
}
get shouldRenderArchiveRow() {
return this.chatGuardian.canArchiveChannel(this.args.channel);
}
get toggleChannelWideMentionsAvailable() {
return this.args.channel.isCategoryChannel && this.args.channel.isOpen;
}
get toggleThreadingAvailable() {
return this.args.channel.isCategoryChannel && this.args.channel.isOpen;
}
get channelWideMentionsDescription() {
return I18n.t("chat.settings.channel_wide_mentions_description", {
channel: this.args.channel.title,
});
}
get isChannelMuted() {
return this.args.channel.currentUserMembership.muted;
}
get shouldRenderChannelWideMentionsAvailable() {
return this.args.channel.isCategoryChannel;
}
get shouldRenderDesktopNotificationsLevelSection() {
return !this.isChannelMuted;
}
get shouldRenderMobileNotificationsLevelSection() {
return !this.isChannelMuted;
}
get autoJoinAvailable() {
return (
this.siteSettings.max_chat_auto_joined_users > 0 &&
this.args.channel.isCategoryChannel &&
this.args.channel.isOpen
);
}
get shouldRenderAdminSection() {
return (
this.canEditChannel &&
(this.toggleChannelWideMentionsAvailable ||
this.args.channel.isCategoryChannel)
);
}
@action
async onToggleChannelWideMentions() {
const newValue = !this.args.channel.allowChannelWideMentions;
if (this.args.channel.allowChannelWideMentions === newValue) {
return;
}
try {
this.args.channel.allowChannelWideMentions = newValue;
const result = await this._updateChannelProperty(
this.args.channel,
"allow_channel_wide_mentions",
newValue
);
this.args.channel.allowChannelWideMentions =
result.channel.allow_channel_wide_mentions;
} catch (error) {
popupAjaxError(error);
}
}
@action
async onToggleAutoJoinUsers() {
if (this.args.channel.autoJoinUsers) {
return await this.onDisableAutoJoinUsers();
}
return await this.onEnableAutoJoinUsers();
}
@action
async onDisableAutoJoinUsers() {
if (this.args.channel.autoJoinUsers === false) {
return;
}
try {
this.args.channel.autoJoinUsers = false;
const result = await this._updateChannelProperty(
this.args.channel,
"auto_join_users",
false
);
this.args.channel.autoJoinUsers = result.channel.auto_join_users;
} catch (error) {
popupAjaxError(error);
}
}
@action
onEnableAutoJoinUsers() {
if (this.args.channel.autoJoinUsers === true) {
return;
}
return this.dialog.confirm({
message: I18n.t("chat.settings.auto_join_users_warning", {
category: this.args.channel.chatable.name,
}),
didConfirm: async () => {
try {
const result = await this._updateChannelProperty(
this.args.channel,
"auto_join_users",
true
);
this.args.channel.autoJoinUsers = result.channel.auto_join_users;
} catch (error) {
popupAjaxError(error);
}
},
});
}
@action
onToggleMuted() {
const newValue = !this.args.channel.currentUserMembership.muted;
this.saveNotificationSettings("muted", "muted", newValue);
}
@action
async saveNotificationSettings(frontendKey, backendKey, newValue) {
if (this.args.channel.currentUserMembership[frontendKey] === newValue) {
return;
}
this.args.channel.currentUserMembership[frontendKey] = newValue;
const settings = {};
settings[backendKey] = newValue;
try {
const result =
await this.chatApi.updateCurrentUserChannelNotificationsSettings(
this.args.channel.id,
settings
);
this.args.channel.currentUserMembership[frontendKey] =
result.membership[backendKey];
this.toasts.success({ data: { message: I18n.t("saved") } });
} catch (error) {
popupAjaxError(error);
}
}
@action
async _updateChannelProperty(channel, property, value) {
try {
const result = await this.chatApi.updateChannel(channel.id, {
[property]: value,
});
this.toasts.success({ data: { message: I18n.t("saved") } });
return result;
} catch (error) {
popupAjaxError(error);
}
}
@action
async onToggleThreadingEnabled(value) {
try {
this.args.channel.threadingEnabled = !value;
const result = await this._updateChannelProperty(
this.args.channel,
"threading_enabled",
!value
);
this.args.channel.threadingEnabled = result.channel.threading_enabled;
} catch (error) {
popupAjaxError(error);
}
}
@action
onToggleChannelState() {
return this.modal.show(ChatModalToggleChannelStatus, {
model: this.args.channel,
});
}
@action
onArchiveChannel() {
return this.modal.show(ChatModalArchiveChannel, {
model: { channel: this.args.channel },
});
}
@action
onDeleteChannel() {
return this.modal.show(ChatModalDeleteChannel, {
model: { channel: this.args.channel },
});
}
@action
onEditChannelName() {
return this.modal.show(ChatModalEditChannelName, {
model: this.args.channel,
});
}
@action
onEditChannelDescription() {
return this.modal.show(ChatModalEditChannelDescription, {
model: this.args.channel,
});
}
}

View File

@ -3,6 +3,12 @@ import I18n from "I18n";
import { inject as service } from "@ember/service"; import { inject as service } from "@ember/service";
export default class ChatRetentionReminderText extends Component { export default class ChatRetentionReminderText extends Component {
<template>
<span class="chat-retention-reminder-text">
{{this.text}}
</span>
</template>
@service siteSettings; @service siteSettings;
get text() { get text() {

View File

@ -1,3 +0,0 @@
<span class="chat-retention-reminder-text">
{{this.text}}
</span>

View File

@ -0,0 +1,25 @@
import Component from "@glimmer/component";
import { userPath } from "discourse/lib/url";
import ChatUserAvatar from "discourse/plugins/chat/discourse/components/chat/user-avatar";
import ChatUserDisplayName from "discourse/plugins/chat/discourse/components/chat-user-display-name";
export default class ChatUserInfo extends Component {
<template>
{{#if @user}}
<a href={{this.userPath}} data-user-card={{@user.username}}>
<ChatUserAvatar @user={{@user}} @avatarSize={{this.avatarSize}} />
</a>
<a href={{this.userPath}} data-user-card={{@user.username}}>
<ChatUserDisplayName @user={{@user}} />
</a>
{{/if}}
</template>
get avatarSize() {
return this.args.avatarSize ?? "medium";
}
get userPath() {
return userPath(this.args.user.username);
}
}

View File

@ -1,8 +0,0 @@
{{#if @user}}
<a href={{this.userPath}} data-user-card={{@user.username}}>
<Chat::UserAvatar @user={{@user}} @avatarSize="medium" />
</a>
<a href={{this.userPath}} data-user-card={{@user.username}}>
<ChatUserDisplayName @user={{@user}} />
</a>
{{/if}}

View File

@ -1,8 +0,0 @@
import Component from "@glimmer/component";
import { userPath } from "discourse/lib/url";
export default class ChatUserInfo extends Component {
get userPath() {
return userPath(this.args.user.username);
}
}

View File

@ -0,0 +1,14 @@
import Component from "@glimmer/component";
import ChatFormSection from "discourse/plugins/chat/discourse/components/chat/form/section";
export default class ChatForm extends Component {
<template>
<div class="chat-form">
{{yield this.yieldableArgs}}
</div>
</template>
get yieldableArgs() {
return { section: ChatFormSection };
}
}

View File

@ -0,0 +1,48 @@
import Component from "@glimmer/component";
import { LinkTo } from "@ember/routing";
import icon from "discourse-common/helpers/d-icon";
import concatClass from "discourse/helpers/concat-class";
export default class ChatFormRow extends Component {
<template>
{{#if @route}}
<LinkTo
@route={{@route}}
@models={{@routeModels}}
class={{concatClass
"chat-form__row -link"
(if @separator "-separator")
}}
>
<div class="chat-form__row-content">
{{@label}}
{{icon "chevron-right" class="chat-form__row-icon"}}
</div>
</LinkTo>
{{else}}
<div class={{concatClass "chat-form__row" (if @separator "-separator")}}>
<div class="chat-form__row-content">
{{#if @label}}
<span class="chat-form__row-label">{{@label}}</span>
{{/if}}
{{#if (has-block)}}
<span class="chat-form__row-label">
{{yield}}
</span>
{{/if}}
{{#if (has-block "action")}}
<div class="chat-form__row-action">{{yield to="action"}}</div>
{{/if}}
</div>
{{#if (has-block "description")}}
<div class="chat-form__row-description">
{{yield to="description"}}
</div>
{{/if}}
</div>
{{/if}}
</template>
}

View File

@ -0,0 +1,22 @@
import Component from "@glimmer/component";
import ChatFormRow from "discourse/plugins/chat/discourse/components/chat/form/row";
export default class ChatFormSection extends Component {
<template>
<div class="chat-form__section" ...attributes>
{{#if @title}}
<div class="chat-form__section-title">
{{@title}}
</div>
{{/if}}
<div class="chat-form__section-content">
{{yield this.yieldableArgs}}
</div>
</div>
</template>
get yieldableArgs() {
return { row: ChatFormRow };
}
}

View File

@ -25,12 +25,10 @@
<div class="edit-channel-control"> <div class="edit-channel-control">
<label for="channel-slug" class="edit-channel-label"> <label for="channel-slug" class="edit-channel-label">
{{i18n "chat.channel_edit_name_slug_modal.slug"}}&nbsp; {{i18n "chat.channel_edit_name_slug_modal.slug"}}&nbsp;
<span> <DTooltip
{{d-icon "info-circle"}} @icon="info-circle"
<DTooltip>{{i18n @content={{i18n "chat.channel_edit_name_slug_modal.slug_description"}}
"chat.channel_edit_name_slug_modal.slug_description" />
}}</DTooltip>
</span>
</label> </label>
<Input <Input
name="channel-slug" name="channel-slug"

View File

@ -0,0 +1,58 @@
import Component from "@glimmer/component";
import { Input } from "@ember/component";
import { on } from "@ember/modifier";
import noop from "discourse/helpers/noop";
import concatClass from "discourse/helpers/concat-class";
import icon from "discourse-common/helpers/d-icon";
import { modifier } from "ember-modifier";
import { tracked } from "@glimmer/tracking";
export default class DcFilterInput extends Component {
<template>
{{! template-lint-disable modifier-name-case }}
<div
class={{concatClass
@class
"dc-filter-input-container"
(if this.isFocused " is-focused")
}}
>
{{#if @icons.left}}
{{icon @icons.left class="-left"}}
{{/if}}
<Input
class="dc-filter-input"
@value={{@value}}
{{on "input" (if @filterAction @filterAction (noop))}}
{{this.focusState}}
...attributes
/>
{{yield}}
{{#if @icons.right}}
{{icon @icons.right class="-right"}}
{{/if}}
</div>
</template>
@tracked isFocused = false;
focusState = modifier((element) => {
const focusInHandler = () => {
this.isFocused = true;
};
const focusOutHandler = () => {
this.isFocused = false;
};
element.addEventListener("focusin", focusInHandler);
element.addEventListener("focusout", focusOutHandler);
return () => {
element.removeEventListener("focusin", focusInHandler);
element.removeEventListener("focusout", focusOutHandler);
};
});
}

View File

@ -1,26 +0,0 @@
<div
class={{concat
@class
" dc-filter-input-container"
(if this.isFocused " is-focused")
}}
>
{{#if @icons.left}}
{{d-icon @icons.left class="-left"}}
{{/if}}
<Input
class="dc-filter-input"
@value={{@value}}
{{on "input" (if @filterAction @filterAction (noop))}}
{{on "focusin" (action (mut this.isFocused) true)}}
{{on "focusout" (action (mut this.isFocused) false)}}
...attributes
/>
{{yield}}
{{#if @icons.right}}
{{d-icon @icons.right class="-right"}}
{{/if}}
</div>

View File

@ -1,3 +0,0 @@
import Component from "@glimmer/component";
export default class DcFilterInput extends Component {}

View File

@ -1,26 +0,0 @@
import Controller from "@ember/controller";
import { action } from "@ember/object";
import ModalFunctionality from "discourse/mixins/modal-functionality";
import { inject as service } from "@ember/service";
import ChatModalEditChannelDescription from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-description";
import ChatModalEditChannelName from "discourse/plugins/chat/discourse/components/chat/modal/edit-channel-name";
export default class ChatChannelInfoAboutController extends Controller.extend(
ModalFunctionality
) {
@service modal;
@action
onEditChatChannelName() {
return this.modal.show(ChatModalEditChannelName, {
model: this.model,
});
}
@action
onEditChatChannelDescription() {
return this.modal.show(ChatModalEditChannelDescription, {
model: this.model,
});
}
}

View File

@ -1,3 +0,0 @@
import Controller from "@ember/controller";
export default class ChatChannelInfoMembersController extends Controller {}

View File

@ -1,3 +0,0 @@
import Controller from "@ember/controller";
export default class ChatChannelInfoSettingsController extends Controller {}

View File

@ -1,34 +0,0 @@
import Controller from "@ember/controller";
import { inject as service } from "@ember/service";
import { reads } from "@ember/object/computed";
import { computed } from "@ember/object";
export default class ChatChannelInfoIndexController extends Controller {
@service router;
@service chat;
@service chatChannelInfoRouteOriginManager;
@reads("router.currentRoute.localName") tab;
@computed("model.{membershipsCount,status,currentUserMembership.following}")
get tabs() {
const tabs = [];
if (!this.model.isDirectMessageChannel) {
tabs.push("about");
}
if (this.model.isOpen && this.model.membershipsCount >= 1) {
tabs.push("members");
}
if (
this.currentUser?.staff ||
this.model.currentUserMembership?.following
) {
tabs.push("settings");
}
return tabs;
}
}

View File

@ -12,9 +12,10 @@ export default class Collection {
@tracked loading = false; @tracked loading = false;
@tracked fetchedOnce = false; @tracked fetchedOnce = false;
constructor(resourceURL, handler) { constructor(resourceURL, handler, params = {}) {
this._resourceURL = resourceURL; this._resourceURL = resourceURL;
this._handler = handler; this._handler = handler;
this._params = params;
this._fetchedAll = false; this._fetchedAll = false;
} }
@ -94,6 +95,6 @@ export default class Collection {
} }
#fetch(url) { #fetch(url) {
return ajax(url, { type: "GET" }); return ajax(url, { type: "GET", data: this._params });
} }
} }

View File

@ -1,12 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
import { inject as service } from "@ember/service";
export default class ChatChannelInfoAboutRoute extends DiscourseRoute {
@service router;
afterModel(model) {
if (model.isDirectMessageChannel) {
this.router.replaceWith("chat.channel.info.index");
}
}
}

View File

@ -4,15 +4,7 @@ import { inject as service } from "@ember/service";
export default class ChatChannelInfoIndexRoute extends DiscourseRoute { export default class ChatChannelInfoIndexRoute extends DiscourseRoute {
@service router; @service router;
afterModel(model) { afterModel() {
if (model.isDirectMessageChannel) {
if (model.isOpen && model.membershipsCount >= 1) {
this.router.replaceWith("chat.channel.info.members");
} else {
this.router.replaceWith("chat.channel.info.settings"); this.router.replaceWith("chat.channel.info.settings");
} }
} else {
this.router.replaceWith("chat.channel.info.about");
}
}
} }

View File

@ -5,12 +5,8 @@ export default class ChatChannelInfoMembersRoute extends DiscourseRoute {
@service router; @service router;
afterModel(model) { afterModel(model) {
if (!model.isOpen) { if (!model.isOpen || model.membershipsCount < 1) {
return this.router.replaceWith("chat.channel.info.settings"); return this.router.replaceWith("chat.channel.info.settings");
} }
if (model.membershipsCount < 1) {
return this.router.replaceWith("chat.channel.info");
}
} }
} }

View File

@ -1,13 +0,0 @@
import DiscourseRoute from "discourse/routes/discourse";
import { inject as service } from "@ember/service";
export default class ChatChannelInfoSettingsRoute extends DiscourseRoute {
@service router;
@service currentUser;
afterModel(model) {
if (!this.currentUser?.staff && !model.currentUserMembership?.following) {
this.router.replaceWith("chat.channel.info");
}
}
}

View File

@ -233,14 +233,15 @@ export default class ChatApi extends Service {
* @param {number} channelId - The ID of the channel. * @param {number} channelId - The ID of the channel.
* @returns {Collection} * @returns {Collection}
*/ */
listChannelMemberships(channelId) { listChannelMemberships(channelId, params = {}) {
return new Collection( return new Collection(
`${this.#basePath}/channels/${channelId}/memberships`, `${this.#basePath}/channels/${channelId}/memberships`,
(response) => { (response) => {
return response.memberships.map((membership) => return response.memberships.map((membership) =>
UserChatChannelMembership.create(membership) UserChatChannelMembership.create(membership)
); );
} },
params
); );
} }

View File

@ -18,6 +18,7 @@ export default class ChatChannelsManager extends Service {
@service chatSubscriptionsManager; @service chatSubscriptionsManager;
@service chatApi; @service chatApi;
@service currentUser; @service currentUser;
@service router;
@tracked _cached = new TrackedObject(); @tracked _cached = new TrackedObject();
async find(id, options = { fetchIfNotFound: true }) { async find(id, options = { fetchIfNotFound: true }) {
@ -131,12 +132,12 @@ export default class ChatChannelsManager extends Service {
} }
async #find(id) { async #find(id) {
return this.chatApi try {
.channel(id) const result = await this.chatApi.channel(id);
.catch(popupAjaxError)
.then((result) => {
return this.store(result.channel); return this.store(result.channel);
}); } catch (error) {
popupAjaxError(error);
}
} }
#cache(channel) { #cache(channel) {

View File

@ -1,6 +1,9 @@
import Service from "@ember/service"; import Service, { inject as service } from "@ember/service";
export default class ChatGuardian extends Service { export default class ChatGuardian extends Service {
@service currentUser;
@service siteSettings;
canEditChatChannel() { canEditChatChannel() {
return this.canUseChat() && this.currentUser.staff; return this.canUseChat() && this.currentUser.staff;
} }

View File

@ -1,5 +0,0 @@
<ChatChannelAboutView
@channel={{this.model}}
@onEditChatChannelName={{action "onEditChatChannelName"}}
@onEditChatChannelDescription={{action "onEditChatChannelDescription"}}
/>

View File

@ -1 +1 @@
<ChatChannelMembersView @channel={{this.model}} /> <ChatChannelMembers @channel={{this.model}} />

View File

@ -1 +1 @@
<ChatChannelSettingsView @channel={{this.model}} /> <ChatChannelSettings @channel={{this.model}} />

View File

@ -1,65 +1 @@
<div class="channel-info"> <ChatChannelInfo @channel={{this.model}} />
<div class="chat-full-page-header">
<div class="chat-channel-header-details">
<div class="chat-full-page-header__left-actions">
{{#if this.chatChannelInfoRouteOriginManager.isBrowse}}
<LinkTo
@route="chat.browse"
class="chat-full-page-header__back-btn no-text btn-flat btn"
title={{i18n "chat.channel_info.back_to_all_channel"}}
>
{{d-icon "chevron-left"}}
</LinkTo>
{{else}}
<LinkTo
@route="chat.channel"
@models={{this.model.routeModels}}
class="chat-full-page-header__back-btn no-text btn-flat btn"
title={{i18n "chat.channel_info.back_to_channel"}}
>
{{d-icon "chevron-left"}}
</LinkTo>
{{/if}}
</div>
<ChatChannelTitle @channel={{this.model}} />
</div>
</div>
<ChatChannelStatus @channel={{this.model}} />
<div class="chat-tabs chat-info-tabs">
<ul class="chat-tabs-list nav-pills" role="tablist">
{{#each this.tabs as |tab|}}
<li
class="chat-tabs-list__item"
role="tab"
aria-controls={{concat tab "-tab"}}
>
<LinkTo
@route={{concat "chat.channel.info." tab}}
@models={{this.model.routeModels}}
class="chat-tabs-list__link"
>
<span>{{i18n (concat "chat.channel_info.tabs." tab)}}</span>
{{#if (eq tab "members")}}
<span class="chat-tabs__memberships-count">
({{this.model.membershipsCount}})
</span>
{{/if}}
</LinkTo>
</li>
{{/each}}
</ul>
<div
id={{this.tab}}
class="chat-tabs__tabpanel"
aria-hidden={{notEq this.tab this.activeTab}}
role="tabpanel"
aria-labelledby={{concat this.tab "-tab"}}
>
{{outlet}}
</div>
</div>
</div>

View File

@ -1,11 +1,19 @@
.channel-info { .chat-channel-info {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
padding: 1rem;
&__nav {
.nav-pills {
margin: 0;
padding-bottom: 1rem;
}
}
} }
// Info header // Info header
.channel-info-header { .chat-channel-info-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
@ -13,129 +21,7 @@
box-sizing: border-box; box-sizing: border-box;
} }
.channel-info-header__title { .chat-channel-info-header__title {
font-size: var(--font-up-2); font-size: var(--font-up-2);
margin: 0; margin: 0;
} }
// About view
.channel-info-about-view__title-input {
width: 100%;
}
.channel-info-about-view__description-input {
height: 150px;
width: 100%;
}
.channel-info-about-view__description__helper-text {
color: var(--primary-medium);
}
.channel-info-about-view__slug {
color: var(--primary-medium);
font-size: var(--font-down-2);
}
.channel-settings-view__selector {
width: 220px;
}
.channel-settings-view__channel-threading-tooltip {
padding-left: 0.25rem;
color: var(--tertiary);
cursor: pointer;
}
.channel-settings-view__muted-selector,
.chat-form__btn.delete-btn {
.d-icon {
color: var(--danger);
}
}
// Members list
.chat-tabs__memberships-count {
margin-left: 0.25em;
}
.channel-members-view-wrapper {
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
padding: 0 1rem;
}
.channel-members-view__search-input-container {
display: flex;
align-items: center;
border: 1px solid var(--primary-medium);
&.is-focused {
border: 1px solid var(--tertiary);
}
.d-icon {
padding: 0.5rem;
color: var(--primary-medium);
}
}
input.channel-members-view__search-input {
border: 0;
margin: 0;
outline: 0;
width: 100%;
&:focus {
border: 0;
outline: 0;
}
}
.channel-members-view__status {
display: flex;
align-items: center;
}
.channel-members-view__list-container {
display: flex;
flex-direction: column;
margin-top: 1em;
box-sizing: border-box;
}
.channel-members-view__list-item {
display: flex;
align-items: center;
padding: 0.5rem 0 0.5rem 1px;
&:not(:last-child) {
border-bottom: 1px solid var(--primary-low);
}
.chat-user-avatar {
margin-right: 0.5rem;
}
}
// Channel info edit name and slug modal
.chat-channel-edit-name-slug-modal {
.modal-inner-container {
width: 300px;
}
&__name-input,
&__slug-input {
display: flex;
margin: 0;
width: 100%;
}
}
.chat-channel-edit-name-slug-modal__description {
display: flex;
padding: 0.5rem 0;
color: var(--primary-medium);
}

View File

@ -0,0 +1,32 @@
.chat-channel-members {
width: 50%;
min-width: 320px;
&__filter {
margin-bottom: 1rem;
}
&__list {
display: flex;
margin: 0;
flex-direction: column;
gap: 0.5rem;
&-item {
display: flex;
gap: 0.5rem;
list-style: none;
border-bottom: 1px solid var(--primary-low);
height: 42px;
align-items: center;
&.-no-results {
box-sizing: border-box;
}
&:last-child {
border-bottom: none;
}
}
}
}

View File

@ -1,9 +0,0 @@
.chat-channel-settings-saved-indicator {
padding-left: 0.5rem;
color: var(--success);
font-weight: normal;
.d-icon-check {
margin-right: 0.25rem;
}
}

View File

@ -0,0 +1,18 @@
.chat-channel-settings {
width: 50%;
min-width: 320px;
.chat-channel-settings__slug {
max-width: 250px;
@include ellipsis;
}
// category badge margin reset
.badge-wrapper.bullet {
margin-right: 0;
}
.chat-retention-reminder-text {
color: var(--primary-medium);
}
}

View File

@ -1,62 +1,86 @@
.chat-form__section { .chat-form {
margin: 1.5rem 1rem;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
border-bottom: none;
}
}
.chat-form__section-admin-title {
margin-inline: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--primary-low);
}
.chat-form__field {
margin-bottom: 1rem;
&:last-child {
margin-bottom: 0;
}
}
.chat-form__description {
margin-top: 3px;
color: var(--primary-medium);
font-size: var(--font-down-1);
}
.chat-form__btn {
border: 0;
background: none;
padding: 0.25rem 0;
margin: 0;
}
.chat-form__label {
font-weight: 700;
display: flex; display: flex;
align-items: center; flex-direction: column;
} }
.chat-form__label-actions { .chat-form__row {
margin-left: auto; &.-separator {
border-bottom: 1px solid var(--primary-low);
.btn-text {
color: var(--tertiary);
font-size: var(--font-down-1);
} }
} }
.chat-retention-info { .chat-form__section {
margin-top: 2rem; display: flex;
color: var(--primary-high); flex-direction: column;
width: 100%;
& + .chat-form__section {
margin-top: 1rem;
}
&-title {
font-weight: 700;
font-size: var(--font-down-1);
color: var(--primary-medium);
}
&-title + &-content {
margin-top: 0.25rem;
}
&-content {
background: var(--primary-very-low);
gap: 1rem;
display: flex;
padding: 1rem;
flex-direction: column;
}
}
.chat-form__row {
display: flex;
width: 100%;
// background: green;
flex-direction: column;
justify-content: center;
label,
.d-toggle-switch__checkbox-slider {
margin: 0;
}
&-action {
.chat-form__btn:first-child {
padding-left: 0;
}
}
&-label + &-action {
margin-left: auto;
}
&.-link {
color: var(--primary);
.d-icon { .d-icon {
margin-right: 0.5em; color: var(--primary-medium);
}
}
&-content {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
min-height: 40px;
gap: 0.25rem;
}
&-description {
display: flex;
padding-top: 3px;
color: var(--primary-medium);
font-size: var(--font-down-1);
} }
} }

View File

@ -8,7 +8,6 @@
@import "chat-channel-card"; @import "chat-channel-card";
@import "chat-channel-info"; @import "chat-channel-info";
@import "chat-channel-preview-card"; @import "chat-channel-preview-card";
@import "chat-channel-settings-saved-indicator";
@import "chat-channel-title"; @import "chat-channel-title";
@import "chat-composer-dropdown"; @import "chat-composer-dropdown";
@import "chat-composer-upload"; @import "chat-composer-upload";
@ -64,3 +63,5 @@
@import "chat-modal-move-message-to-channel"; @import "chat-modal-move-message-to-channel";
@import "chat-scroll-to-bottom"; @import "chat-scroll-to-bottom";
@import "chat-channel-row"; @import "chat-channel-row";
@import "chat-channel-members";
@import "chat-channel-settings";

View File

@ -0,0 +1,3 @@
.chat-channel-members {
width: 100%;
}

View File

@ -0,0 +1,3 @@
.chat-channel-settings {
width: 100%;
}

View File

@ -1,5 +1,4 @@
@import "base-mobile"; @import "base-mobile";
@import "chat-channel-info";
@import "chat-channel"; @import "chat-channel";
@import "chat-composer"; @import "chat-composer";
@import "chat-index"; @import "chat-index";
@ -15,3 +14,5 @@
@import "chat-message-thread-indicator"; @import "chat-message-thread-indicator";
@import "chat-message-creator"; @import "chat-message-creator";
@import "chat-channel-row"; @import "chat-channel-row";
@import "chat-channel-members";
@import "chat-channel-settings";

View File

@ -320,7 +320,6 @@ en:
back_to_all_channels: "All channels" back_to_all_channels: "All channels"
back_to_channel: "Back" back_to_channel: "Back"
tabs: tabs:
about: About
members: Members members: Members
settings: Settings settings: Settings
@ -462,6 +461,11 @@ en:
saved: "Saved" saved: "Saved"
unfollow: "Leave" unfollow: "Leave"
admin_title: "Admin" admin_title: "Admin"
settings_title: "Settings"
info_title: "Channel info"
category_label: "Category"
history_label: "History"
members_label: "Members"
admin: admin:
title: "Chat" title: "Chat"
@ -538,14 +542,14 @@ en:
other: "%{commaSeparatedUsernames} and %{count} others are typing" other: "%{commaSeparatedUsernames} and %{count} others are typing"
retention_reminders: retention_reminders:
public_none: "Channel history is retained indefinitely." public_none: "indefinitely"
public: public:
one: "Channel history is retained for %{count} day." one: "%{count} day"
other: "Channel history is retained for %{count} days." other: "%{count} days"
dm_none: "Personal chat history is retained indefinitely." dm_none: "indefinitely"
dm: dm:
one: "Personal chat history is retained for %{count} day." one: "%{count} day"
other: "Personal chat history is retained for %{count} days." other: "%{count} days"
flags: flags:
off_topic: "This message is not relevant to the current discussion as defined by the channel title, and should probably be moved elsewhere." off_topic: "This message is not relevant to the current discussion as defined by the channel title, and should probably be moved elsewhere."

View File

@ -1,138 +0,0 @@
# frozen_string_literal: true
RSpec.describe "Channel - Info - About page", type: :system do
fab!(:channel_1) { Fabricate(:category_channel) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:chat_channel_about_page) { PageObjects::Pages::ChatChannelAbout.new }
before { chat_system_bootstrap }
context "as regular user" do
fab!(:current_user) { Fabricate(:user) }
before { sign_in(current_user) }
it "shows channel info" do
chat_page.visit_channel_about(channel_1)
expect(page.find(".category-name")).to have_content(channel_1.chatable.name)
expect(page.find(".channel-info-about-view__name")).to have_content(channel_1.title)
expect(page.find(".channel-info-about-view__slug")).to have_content(channel_1.slug)
end
it "escapes channel title" do
channel_1.update!(name: "<script>alert('hello')</script>")
chat_page.visit_channel_about(channel_1)
expect(page.find(".channel-info-about-view__name")["innerHTML"].strip).to eq(
"&lt;script&gt;alert('hello')&lt;/script&gt;",
)
expect(page.find(".chat-channel-title__name")["innerHTML"].strip).to eq(
"&lt;script&gt;alert('hello')&lt;/script&gt;",
)
end
it "can’t edit name or slug" do
chat_page.visit_channel_about(channel_1)
expect(page).to have_no_selector(".edit-name-slug-btn")
end
it "can’t edit description" do
chat_page.visit_channel_about(channel_1)
expect(page).to have_no_selector(".edit-description-btn")
end
context "as a member" do
before { channel_1.add(current_user) }
it "can leave channel" do
chat_page.visit_channel_about(channel_1)
membership = channel_1.membership_for(current_user)
expect {
click_button(I18n.t("js.chat.channel_settings.leave_channel"))
expect(page).to have_content(I18n.t("js.chat.channel_settings.join_channel"))
}.to change { membership.reload.following }.from(true).to(false)
end
end
context "as not a member" do
it "can join channel" do
chat_page.visit_channel_about(channel_1)
expect {
click_button(I18n.t("js.chat.channel_settings.join_channel"))
expect(page).to have_content(I18n.t("js.chat.channel_settings.leave_channel"))
}.to change {
Chat::UserChatChannelMembership.where(user_id: current_user.id, following: true).count
}.by(1)
end
end
end
context "as admin" do
fab!(:current_user) { Fabricate(:admin) }
before { sign_in(current_user) }
it "can edit name" do
chat_page.visit_channel_about(channel_1)
edit_modal = chat_channel_about_page.open_edit_modal
expect(edit_modal).to have_name_input(channel_1.title)
name = "A new name"
edit_modal.fill_and_save_name(name)
expect(chat_channel_about_page).to have_name(name)
end
it "can edit description" do
chat_page.visit_channel_about(channel_1)
find(".edit-description-btn").click
expect(page).to have_selector(
".chat-modal-edit-channel-description__description-input",
text: channel_1.description,
)
description = "A new description"
find(".chat-modal-edit-channel-description__description-input").fill_in(with: description)
find(".create").click
expect(page).to have_content(description)
end
it "can edit slug" do
chat_page.visit_channel_about(channel_1)
edit_modal = chat_channel_about_page.open_edit_modal
slug = "gonzo-slug"
expect(edit_modal).to have_slug_input(channel_1.slug)
edit_modal.fill_and_save_slug(slug)
expect(chat_channel_about_page).to have_slug(slug)
end
it "can clear the slug to use the autogenerated version based on the name" do
channel_1.update!(name: "test channel")
chat_page.visit_channel_about(channel_1)
edit_modal = chat_channel_about_page.open_edit_modal
expect(edit_modal).to have_slug_input(channel_1.slug)
edit_modal.fill_in_slug_input("")
edit_modal.wait_for_auto_generated_slug
edit_modal.save_changes
expect(chat_channel_about_page).to have_slug("test-channel")
end
end
end

View File

@ -1,38 +0,0 @@
# frozen_string_literal: true
RSpec.describe "Info pages", type: :system do
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:channel) { PageObjects::Pages::ChatChannel.new }
fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:chat_channel) }
before do
chat_system_bootstrap
channel_1.add(current_user)
sign_in(current_user)
end
context "when visiting from browse page" do
context "when clicking back button" do
it "redirects to browse page" do
chat_page.visit_browse
find(".chat-channel-card__setting").click
find(".chat-full-page-header__back-btn").click
expect(page).to have_current_path("/chat/browse/open")
end
end
end
context "when visiting from channel page" do
context "when clicking back button" do
it "redirects to channel page" do
chat_page.visit_channel(channel_1)
find(".chat-channel-title-wrapper").click
find(".chat-full-page-header__back-btn").click
expect(page).to have_current_path(chat.channel_path(channel_1.slug, channel_1.id))
end
end
end
end

View File

@ -14,7 +14,7 @@ RSpec.describe "Channel - Info - Members page", type: :system do
context "as unauthorized user" do context "as unauthorized user" do
before { SiteSetting.chat_allowed_groups = Fabricate(:group).id } before { SiteSetting.chat_allowed_groups = Fabricate(:group).id }
it "cant see channel members" do it "can't see channel members" do
chat_page.visit_channel_members(channel_1) chat_page.visit_channel_members(channel_1)
expect(page).to have_current_path("/latest") expect(page).to have_current_path("/latest")
@ -23,10 +23,10 @@ RSpec.describe "Channel - Info - Members page", type: :system do
context "as authorized user" do context "as authorized user" do
context "with no members" do context "with no members" do
it "redirects to about page" do it "redirects to settings page" do
chat_page.visit_channel_members(channel_1) chat_page.visit_channel_members(channel_1)
expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/about") expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/settings")
end end
end end
@ -44,15 +44,15 @@ RSpec.describe "Channel - Info - Members page", type: :system do
chat_page.visit_channel_members(channel_1) chat_page.visit_channel_members(channel_1)
expect(page).to have_selector(".channel-members-view__list-item", count: 50, wait: 15) expect(page).to have_selector(".chat-channel-members__list-item", count: 60)
scroll_to(find(".channel-members-view__list-item:nth-child(50)")) scroll_to(find(".chat-channel-members__list-item:nth-child(50)"))
expect(page).to have_selector(".channel-members-view__list-item", count: 100, wait: 15) expect(page).to have_selector(".chat-channel-members__list-item", count: 100)
scroll_to(find(".channel-members-view__list-item:nth-child(100)")) scroll_to(find(".chat-channel-members__list-item:nth-child(100)"))
expect(page).to have_selector(".channel-members-view__list-item", count: 100, wait: 15) expect(page).to have_selector(".chat-channel-members__list-item", count: 100)
end end
context "with filter" do context "with filter" do
@ -62,9 +62,9 @@ RSpec.describe "Channel - Info - Members page", type: :system do
Jobs::Chat::UpdateChannelUserCount.new.execute(chat_channel_id: channel_1.id) Jobs::Chat::UpdateChannelUserCount.new.execute(chat_channel_id: channel_1.id)
chat_page.visit_channel_members(channel_1) chat_page.visit_channel_members(channel_1)
find(".channel-members-view__search-input").fill_in(with: "cat") find(".chat-channel-members__filter").fill_in(with: "cat")
expect(page).to have_selector(".channel-members-view__list-item", count: 1, text: "cat") expect(page).to have_selector(".chat-channel-members__list-item", count: 1, text: "cat")
end end
end end
end end

View File

@ -1,15 +1,42 @@
# frozen_string_literal: true # frozen_string_literal: true
RSpec.describe "Channel - Info - Settings page", type: :system do RSpec.describe "Channel - Info - Settings page", type: :system do
let(:chat_page) { PageObjects::Pages::Chat.new }
fab!(:current_user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:user) }
fab!(:channel_1) { Fabricate(:category_channel) } fab!(:channel_1) { Fabricate(:category_channel) }
let(:chat_page) { PageObjects::Pages::Chat.new }
let(:toasts) { PageObjects::Components::Toasts.new }
let(:channel_settings_page) { PageObjects::Pages::ChatChannelSettings.new }
before do before do
chat_system_bootstrap chat_system_bootstrap
sign_in(current_user) sign_in(current_user)
end end
context "when visiting from browse page" do
context "when clicking back button" do
it "redirects to browse page" do
chat_page.visit_browse
find(".chat-channel-card__setting").click
find(".chat-full-page-header__back-btn").click
expect(page).to have_current_path("/chat/browse/open")
end
end
end
context "when visiting from channel page" do
context "when clicking back button" do
it "redirects to channel page" do
chat_page.visit_channel(channel_1)
find(".chat-channel-title-wrapper").click
find(".chat-full-page-header__back-btn").click
expect(page).to have_current_path(chat.channel_path(channel_1.slug, channel_1.id))
end
end
end
context "as unauthorized user" do context "as unauthorized user" do
before { SiteSetting.chat_allowed_groups = Fabricate(:group).id } before { SiteSetting.chat_allowed_groups = Fabricate(:group).id }
@ -20,61 +47,69 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
end end
end end
context "as authorized user" do context "as not allowed to see the channel" do
context "as not member" do fab!(:channel_1) { Fabricate(:private_category_channel) }
it "redirects to about tab" do
it "redirects to browse page" do
chat_page.visit_channel_settings(channel_1) chat_page.visit_channel_settings(channel_1)
expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/about") expect(page).to have_current_path("/chat/browse/open")
end
end end
it "doesn’t have settings tab" do context "as not member of channel" do
it "shows settings page" do
chat_page.visit_channel_settings(channel_1) chat_page.visit_channel_settings(channel_1)
expect(page).to have_no_selector(".chat-tabs-list__item[aria-controls='settings-tab']") expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/settings")
end
context "as an admin" do
before { sign_in(Fabricate(:admin)) }
it "shows settings tab" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_selector(".chat-tabs-list__item[aria-controls='settings-tab']")
end
it "can navigate to settings tab" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_current_path(
"/chat/c/#{channel_1.slug}/#{channel_1.id}/info/settings",
)
end
end end
end end
context "as a member" do context "as regular user of channel" do
before { channel_1.add(current_user) } before { channel_1.add(current_user) }
context "when visiting the settings of a recently joined channel" do it "shows settings page" do
fab!(:channel_2) { Fabricate(:category_channel) } chat_page.visit_channel_settings(channel_1)
it "is correctly populated" do expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/settings")
chat_page.visit_browse
find(
".chat-channel-card[data-channel-id='#{channel_2.id}'] .toggle-channel-membership-button",
).click
expect(
page.find(".chat-channel-card[data-channel-id='#{channel_2.id}']"),
).to have_content(I18n.t("js.chat.joined").upcase)
find(
".chat-channel-card[data-channel-id='#{channel_2.id}'] .chat-channel-card__setting",
).click
expect(page).to have_content(I18n.t("js.chat.notification_levels.mention"))
end end
it "shows channel info" do
chat_page.visit_channel_settings(channel_1)
expect(page.find(".category-name")).to have_content(channel_1.chatable.name)
expect(page.find(".chat-channel-settings__name")).to have_content(channel_1.title)
expect(page.find(".chat-channel-settings__slug")).to have_content(channel_1.slug)
end
it "can’t edit name or slug" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_no_selector(".edit-name-slug-btn")
end
it "can’t edit description" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_no_selector(".edit-description-btn")
end
it "escapes channel title" do
channel_1.update!(name: "<script>alert('hello')</script>")
chat_page.visit_channel_settings(channel_1)
expect(page.find(".chat-channel-settings__name")["innerHTML"].strip).to eq(
"&lt;script&gt;alert('hello')&lt;/script&gt;",
)
expect(page.find(".chat-channel-title__name")["innerHTML"].strip).to eq(
"&lt;script&gt;alert('hello')&lt;/script&gt;",
)
end
it "is not showing admin section" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_no_css("[data-section='admin']")
end end
it "can mute channel" do it "can mute channel" do
@ -82,12 +117,9 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
membership = channel_1.membership_for(current_user) membership = channel_1.membership_for(current_user)
expect { expect {
select_kit = PageObjects::Components::DToggleSwitch.new(".chat-channel-settings__mute-switch").toggle
PageObjects::Components::SelectKit.new(".-mute .channel-settings-view__selector")
select_kit.expand
select_kit.select_row_by_name("On")
expect(page).to have_content(I18n.t("js.chat.settings.saved")) expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { membership.reload.muted }.from(false).to(true) }.to change { membership.reload.muted }.from(false).to(true)
end end
@ -98,12 +130,12 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
expect { expect {
select_kit = select_kit =
PageObjects::Components::SelectKit.new( PageObjects::Components::SelectKit.new(
".-desktop-notification-level .channel-settings-view__selector", ".chat-channel-settings__desktop-notifications-selector",
) )
select_kit.expand select_kit.expand
select_kit.select_row_by_name("Never") select_kit.select_row_by_name("Never")
expect(page).to have_content(I18n.t("js.chat.settings.saved")) expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { membership.reload.desktop_notification_level }.from("mention").to("never") }.to change { membership.reload.desktop_notification_level }.from("mention").to("never")
end end
@ -114,41 +146,92 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
expect { expect {
select_kit = select_kit =
PageObjects::Components::SelectKit.new( PageObjects::Components::SelectKit.new(
".-mobile-notification-level .channel-settings-view__selector", ".chat-channel-settings__mobile-notifications-selector",
) )
select_kit.expand select_kit.expand
select_kit.select_row_by_name("Never") select_kit.select_row_by_name("Never")
expect(page).to have_content(I18n.t("js.chat.settings.saved")) expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { membership.reload.mobile_notification_level }.from("mention").to("never") }.to change { membership.reload.mobile_notification_level }.from("mention").to("never")
end end
it "doesn’t show admin section" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_no_content(I18n.t("js.chat.settings.admin_title"))
end end
context "as an admin" do context "as staff" do
before { sign_in(Fabricate(:admin)) } fab!(:current_user) { Fabricate(:admin) }
it "shows admin section" do it "can edit name" do
chat_page.visit_channel_settings(channel_1) chat_page.visit_channel_settings(channel_1)
expect(page).to have_content(I18n.t("js.chat.settings.admin_title")) edit_modal = channel_settings_page.open_edit_modal
expect(edit_modal).to have_name_input(channel_1.title)
name = "A new name"
edit_modal.fill_and_save_name(name)
expect(channel_settings_page).to have_name(name)
end
it "can edit description" do
chat_page.visit_channel_settings(channel_1)
find(".edit-description-btn").click
expect(page).to have_selector(
".chat-modal-edit-channel-description__description-input",
text: channel_1.description,
)
description = "A new description"
find(".chat-modal-edit-channel-description__description-input").fill_in(with: description)
find(".create").click
expect(page).to have_content(description)
end
it "can edit slug" do
chat_page.visit_channel_settings(channel_1)
edit_modal = channel_settings_page.open_edit_modal
slug = "gonzo-slug"
expect(edit_modal).to have_slug_input(channel_1.slug)
edit_modal.fill_and_save_slug(slug)
expect(channel_settings_page).to have_slug(slug)
end
it "can clear the slug to use the autogenerated version based on the name" do
channel_1.update!(name: "test channel")
chat_page.visit_channel_settings(channel_1)
edit_modal = channel_settings_page.open_edit_modal
expect(edit_modal).to have_slug_input(channel_1.slug)
edit_modal.fill_in_slug_input("")
edit_modal.wait_for_auto_generated_slug
edit_modal.save_changes
expect(channel_settings_page).to have_slug("test-channel")
end
it "shows settings page" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/settings")
end end
it "can change auto join setting" do it "can change auto join setting" do
chat_page.visit_channel_settings(channel_1) chat_page.visit_channel_settings(channel_1)
expect { expect {
select_kit = PageObjects::Components::DToggleSwitch.new(
PageObjects::Components::SelectKit.new(".-autojoin .channel-settings-view__selector") ".chat-channel-settings__auto-join-switch",
select_kit.expand ).toggle
select_kit.select_row_by_name("Yes")
find("#dialog-holder .btn-primary").click find("#dialog-holder .btn-primary").click
expect(page).to have_content(I18n.t("js.chat.settings.saved")) expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { channel_1.reload.auto_join_users }.from(false).to(true) }.to change { channel_1.reload.auto_join_users }.from(false).to(true)
end end
@ -156,14 +239,11 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
chat_page.visit_channel_settings(channel_1) chat_page.visit_channel_settings(channel_1)
expect { expect {
select_kit = PageObjects::Components::DToggleSwitch.new(
PageObjects::Components::SelectKit.new( ".chat-channel-settings__channel-wide-mentions",
".-channel-wide-mentions .channel-settings-view__selector", ).toggle
)
select_kit.expand
select_kit.select_row_by_name("No")
expect(page).to have_content(I18n.t("js.chat.settings.saved")) expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { channel_1.reload.allow_channel_wide_mentions }.from(true).to(false) }.to change { channel_1.reload.allow_channel_wide_mentions }.from(true).to(false)
end end
@ -173,6 +253,7 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
expect { expect {
click_button(I18n.t("js.chat.channel_settings.close_channel")) click_button(I18n.t("js.chat.channel_settings.close_channel"))
find("#chat-channel-toggle-btn").click find("#chat-channel-toggle-btn").click
expect(page).to have_content(I18n.t("js.chat.channel_status.closed_header")) expect(page).to have_content(I18n.t("js.chat.channel_status.closed_header"))
}.to change { channel_1.reload.status }.from("open").to("closed") }.to change { channel_1.reload.status }.from("open").to("closed")
end end
@ -181,11 +262,11 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
chat_page.visit_channel_settings(channel_1) chat_page.visit_channel_settings(channel_1)
expect { expect {
select_kit = PageObjects::Components::DToggleSwitch.new(
PageObjects::Components::SelectKit.new(".-threading .channel-settings-view__selector") ".chat-channel-settings__threading-switch",
select_kit.expand ).toggle
select_kit.select_row_by_name("Enabled")
expect(page).to have_content(I18n.t("js.chat.settings.saved")) expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { channel_1.reload.threading_enabled }.from(false).to(true) }.to change { channel_1.reload.threading_enabled }.from(false).to(true)
end end
@ -198,8 +279,7 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
expect(page).to have_content(I18n.t("js.chat.channel_delete.process_started")) expect(page).to have_content(I18n.t("js.chat.channel_delete.process_started"))
end end
context "when confirmation name is wrong" do it "doesn’t delete when confirmation is wrong" do
it "doesn’t delete submission" do
chat_page.visit_channel_settings(channel_1) chat_page.visit_channel_settings(channel_1)
find(".delete-btn").click find(".delete-btn").click
fill_in("channel-delete-confirm-name", with: channel_1.title + "wrong") fill_in("channel-delete-confirm-name", with: channel_1.title + "wrong")
@ -207,7 +287,4 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
expect(page).to have_button("chat-confirm-delete-channel", disabled: true) expect(page).to have_button("chat-confirm-delete-channel", disabled: true)
end end
end end
end
end
end
end end

View File

@ -24,7 +24,7 @@ RSpec.describe "Drawer", type: :system do
drawer_page.open_channel(channel) drawer_page.open_channel(channel)
page.find(".chat-channel-title").click page.find(".chat-channel-title").click
expect(page).to have_current_path("/chat/c/#{channel.slug}/#{channel.id}/info/about") expect(page).to have_current_path("/chat/c/#{channel.slug}/#{channel.id}/info/settings")
end end
end end
end end

View File

@ -63,10 +63,6 @@ module PageObjects
visit(channel.url + "/info/settings") visit(channel.url + "/info/settings")
end end
def visit_channel_about(channel)
visit(channel.url + "/info/about")
end
def visit_channel_members(channel) def visit_channel_members(channel)
visit(channel.url + "/info/members") visit(channel.url + "/info/members")
end end

View File

@ -2,7 +2,7 @@
module PageObjects module PageObjects
module Pages module Pages
class ChatChannelAbout < PageObjects::Pages::Base class ChatChannelSettings < PageObjects::Pages::Base
EDIT_MODAL_SELECTOR = ".chat-modal-edit-channel-name" EDIT_MODAL_SELECTOR = ".chat-modal-edit-channel-name"
def open_edit_modal def open_edit_modal
@ -12,11 +12,11 @@ module PageObjects
end end
def has_slug?(slug) def has_slug?(slug)
page.has_css?(".channel-info-about-view__slug", text: slug) page.has_css?(".chat-channel-settings__slug", text: slug)
end end
def has_name?(name) def has_name?(name)
page.has_css?(".channel-info-about-view__name", text: name) page.has_css?(".chat-channel-settings__name", text: name)
end end
end end
end end

View File

@ -5,7 +5,7 @@ module PageObjects
class ChatChannelEdit < PageObjects::Modals::Base class ChatChannelEdit < PageObjects::Modals::Base
include SystemHelpers include SystemHelpers
EDIT_MODAL_SELECTOR = PageObjects::Pages::ChatChannelAbout::EDIT_MODAL_SELECTOR EDIT_MODAL_SELECTOR = PageObjects::Pages::ChatChannelSettings::EDIT_MODAL_SELECTOR
SLUG_INPUT_SELECTOR = ".chat-channel-edit-name-slug-modal__slug-input" SLUG_INPUT_SELECTOR = ".chat-channel-edit-name-slug-modal__slug-input"
NAME_INPUT_SELECTOR = ".chat-channel-edit-name-slug-modal__name-input" NAME_INPUT_SELECTOR = ".chat-channel-edit-name-slug-modal__name-input"

View File

@ -1,31 +0,0 @@
import { module, test } from "qunit";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import { render, settled } from "@ember/test-helpers";
import { hbs } from "ember-cli-htmlbars";
module(
"Discourse Chat | Component | chat-channel-settings-saved-indicator",
function (hooks) {
setupRenderingTest(hooks);
test("when property changes", async function (assert) {
await render(
hbs`<ChatChannelSettingsSavedIndicator @property={{this.property}} />`
);
assert
.dom(".chat-channel-settings-saved-indicator.is-active")
.doesNotExist();
this.set("property", 1);
assert.dom(".chat-channel-settings-saved-indicator.is-active").exists();
await settled();
assert
.dom(".chat-channel-settings-saved-indicator.is-active")
.doesNotExist();
});
}
);

View File

@ -1,25 +0,0 @@
import ChatChannel from "discourse/plugins/chat/discourse/models/chat-channel";
import { setupRenderingTest } from "discourse/tests/helpers/component-test";
import hbs from "htmlbars-inline-precompile";
import I18n from "I18n";
import { module, test } from "qunit";
import { render } from "@ember/test-helpers";
module(
"Discourse Chat | Component | chat-channel-settings-view",
function (hooks) {
setupRenderingTest(hooks);
test("display retention info", async function (assert) {
this.set("channel", ChatChannel.create({ chatable_type: "Category" }));
await render(hbs`<ChatChannelSettingsView @channel={{this.channel}} />`);
assert.dom(".chat-retention-info").hasText(
I18n.t("chat.retention_reminders.public", {
count: this.siteSettings.chat_channel_retention_days,
})
);
});
}
);

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module PageObjects
module Components
class DToggleSwitch < PageObjects::Components::Base
attr_reader :context
def initialize(context)
@context = context
end
def component
find(@context, visible: :all).native
end
def toggle
actionbuilder = page.driver.browser.action # workaround zero height button
actionbuilder.click(component).perform
end
end
end
end