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 { registerUnbound } from "discourse-common/lib/helpers";
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, {
hideParent: options.hideParent,
allowUncategorized: options.allowUncategorized,
categoryStyle: options.categoryStyle,
link: isPresent(options.link) ? options.link : false,
});
});
}

View File

@ -1,9 +1,11 @@
import { emojiUnescape } from "discourse/lib/text";
import { htmlSafe, isHTMLSafe } from "@ember/template";
import { registerUnbound } from "discourse-common/lib/helpers";
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);
return htmlSafe(emojiUnescape(text, options));
});
}

View File

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

View File

@ -12,7 +12,6 @@ export default function () {
"channel.info",
{ path: "/c/:channelTitle/:channelId/info" },
function () {
this.route("about", { path: "/about" });
this.route("members", { path: "/members" });
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";
export default class ChatRetentionReminderText extends Component {
<template>
<span class="chat-retention-reminder-text">
{{this.text}}
</span>
</template>
@service siteSettings;
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">
<label for="channel-slug" class="edit-channel-label">
{{i18n "chat.channel_edit_name_slug_modal.slug"}}&nbsp;
<span>
{{d-icon "info-circle"}}
<DTooltip>{{i18n
"chat.channel_edit_name_slug_modal.slug_description"
}}</DTooltip>
</span>
<DTooltip
@icon="info-circle"
@content={{i18n "chat.channel_edit_name_slug_modal.slug_description"}}
/>
</label>
<Input
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 fetchedOnce = false;
constructor(resourceURL, handler) {
constructor(resourceURL, handler, params = {}) {
this._resourceURL = resourceURL;
this._handler = handler;
this._params = params;
this._fetchedAll = false;
}
@ -94,6 +95,6 @@ export default class Collection {
}
#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 {
@service router;
afterModel(model) {
if (model.isDirectMessageChannel) {
if (model.isOpen && model.membershipsCount >= 1) {
this.router.replaceWith("chat.channel.info.members");
} else {
this.router.replaceWith("chat.channel.info.settings");
}
} else {
this.router.replaceWith("chat.channel.info.about");
}
afterModel() {
this.router.replaceWith("chat.channel.info.settings");
}
}

View File

@ -5,12 +5,8 @@ export default class ChatChannelInfoMembersRoute extends DiscourseRoute {
@service router;
afterModel(model) {
if (!model.isOpen) {
if (!model.isOpen || model.membershipsCount < 1) {
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.
* @returns {Collection}
*/
listChannelMemberships(channelId) {
listChannelMemberships(channelId, params = {}) {
return new Collection(
`${this.#basePath}/channels/${channelId}/memberships`,
(response) => {
return response.memberships.map((membership) =>
UserChatChannelMembership.create(membership)
);
}
},
params
);
}

View File

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

View File

@ -1,11 +1,19 @@
.channel-info {
.chat-channel-info {
display: flex;
flex-direction: column;
height: 100%;
padding: 1rem;
&__nav {
.nav-pills {
margin: 0;
padding-bottom: 1rem;
}
}
}
// Info header
.channel-info-header {
.chat-channel-info-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
@ -13,129 +21,7 @@
box-sizing: border-box;
}
.channel-info-header__title {
.chat-channel-info-header__title {
font-size: var(--font-up-2);
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 {
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;
.chat-form {
display: flex;
align-items: center;
flex-direction: column;
}
.chat-form__label-actions {
margin-left: auto;
.chat-form__row {
&.-separator {
border-bottom: 1px solid var(--primary-low);
}
}
.btn-text {
color: var(--tertiary);
.chat-form__section {
display: flex;
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 {
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);
}
}
.chat-retention-info {
margin-top: 2rem;
color: var(--primary-high);
.d-icon {
margin-right: 0.5em;
}
}

View File

@ -8,7 +8,6 @@
@import "chat-channel-card";
@import "chat-channel-info";
@import "chat-channel-preview-card";
@import "chat-channel-settings-saved-indicator";
@import "chat-channel-title";
@import "chat-composer-dropdown";
@import "chat-composer-upload";
@ -64,3 +63,5 @@
@import "chat-modal-move-message-to-channel";
@import "chat-scroll-to-bottom";
@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 "chat-channel-info";
@import "chat-channel";
@import "chat-composer";
@import "chat-index";
@ -15,3 +14,5 @@
@import "chat-message-thread-indicator";
@import "chat-message-creator";
@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_channel: "Back"
tabs:
about: About
members: Members
settings: Settings
@ -462,6 +461,11 @@ en:
saved: "Saved"
unfollow: "Leave"
admin_title: "Admin"
settings_title: "Settings"
info_title: "Channel info"
category_label: "Category"
history_label: "History"
members_label: "Members"
admin:
title: "Chat"
@ -538,14 +542,14 @@ en:
other: "%{commaSeparatedUsernames} and %{count} others are typing"
retention_reminders:
public_none: "Channel history is retained indefinitely."
public_none: "indefinitely"
public:
one: "Channel history is retained for %{count} day."
other: "Channel history is retained for %{count} days."
dm_none: "Personal chat history is retained indefinitely."
one: "%{count} day"
other: "%{count} days"
dm_none: "indefinitely"
dm:
one: "Personal chat history is retained for %{count} day."
other: "Personal chat history is retained for %{count} days."
one: "%{count} day"
other: "%{count} days"
flags:
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
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)
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 "with no members" do
it "redirects to about page" do
it "redirects to settings page" do
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
@ -44,15 +44,15 @@ RSpec.describe "Channel - Info - Members page", type: :system do
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
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)
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

View File

@ -1,15 +1,42 @@
# frozen_string_literal: true
RSpec.describe "Channel - Info - Settings page", type: :system do
let(:chat_page) { PageObjects::Pages::Chat.new }
fab!(:current_user) { Fabricate(:user) }
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
chat_system_bootstrap
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
context "as unauthorized user" do
before { SiteSetting.chat_allowed_groups = Fabricate(:group).id }
@ -20,194 +47,244 @@ RSpec.describe "Channel - Info - Settings page", type: :system do
end
end
context "as authorized user" do
context "as not member" do
it "redirects to about tab" do
chat_page.visit_channel_settings(channel_1)
context "as not allowed to see the channel" do
fab!(:channel_1) { Fabricate(:private_category_channel) }
expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/about")
end
it "redirects to browse page" do
chat_page.visit_channel_settings(channel_1)
it "doesn’t have settings tab" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_current_path("/chat/browse/open")
end
end
expect(page).to have_no_selector(".chat-tabs-list__item[aria-controls='settings-tab']")
end
context "as not member of channel" do
it "shows settings page" do
chat_page.visit_channel_settings(channel_1)
context "as an admin" do
before { sign_in(Fabricate(:admin)) }
expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/settings")
end
end
it "shows settings tab" do
chat_page.visit_channel_settings(channel_1)
context "as regular user of channel" do
before { channel_1.add(current_user) }
expect(page).to have_selector(".chat-tabs-list__item[aria-controls='settings-tab']")
end
it "shows settings page" do
chat_page.visit_channel_settings(channel_1)
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
expect(page).to have_current_path("/chat/c/#{channel_1.slug}/#{channel_1.id}/info/settings")
end
context "as a member" do
before { channel_1.add(current_user) }
it "shows channel info" do
chat_page.visit_channel_settings(channel_1)
context "when visiting the settings of a recently joined channel" do
fab!(:channel_2) { Fabricate(:category_channel) }
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 "is correctly populated" do
chat_page.visit_browse
find(
".chat-channel-card[data-channel-id='#{channel_2.id}'] .toggle-channel-membership-button",
).click
it "can’t edit name or slug" do
chat_page.visit_channel_settings(channel_1)
expect(
page.find(".chat-channel-card[data-channel-id='#{channel_2.id}']"),
).to have_content(I18n.t("js.chat.joined").upcase)
expect(page).to have_no_selector(".edit-name-slug-btn")
end
find(
".chat-channel-card[data-channel-id='#{channel_2.id}'] .chat-channel-card__setting",
).click
it "can’t edit description" do
chat_page.visit_channel_settings(channel_1)
expect(page).to have_content(I18n.t("js.chat.notification_levels.mention"))
end
end
expect(page).to have_no_selector(".edit-description-btn")
end
it "can mute channel" do
chat_page.visit_channel_settings(channel_1)
membership = channel_1.membership_for(current_user)
it "escapes channel title" do
channel_1.update!(name: "<script>alert('hello')</script>")
chat_page.visit_channel_settings(channel_1)
expect {
select_kit =
PageObjects::Components::SelectKit.new(".-mute .channel-settings-view__selector")
select_kit.expand
select_kit.select_row_by_name("On")
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
expect(page).to have_content(I18n.t("js.chat.settings.saved"))
}.to change { membership.reload.muted }.from(false).to(true)
end
it "is not showing admin section" do
chat_page.visit_channel_settings(channel_1)
it "can change desktop notification level" do
chat_page.visit_channel_settings(channel_1)
membership = channel_1.membership_for(current_user)
expect(page).to have_no_css("[data-section='admin']")
end
expect {
select_kit =
PageObjects::Components::SelectKit.new(
".-desktop-notification-level .channel-settings-view__selector",
)
select_kit.expand
select_kit.select_row_by_name("Never")
it "can mute channel" do
chat_page.visit_channel_settings(channel_1)
membership = channel_1.membership_for(current_user)
expect(page).to have_content(I18n.t("js.chat.settings.saved"))
}.to change { membership.reload.desktop_notification_level }.from("mention").to("never")
end
expect {
PageObjects::Components::DToggleSwitch.new(".chat-channel-settings__mute-switch").toggle
it "can change mobile notification level" do
chat_page.visit_channel_settings(channel_1)
membership = channel_1.membership_for(current_user)
expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { membership.reload.muted }.from(false).to(true)
end
expect {
select_kit =
PageObjects::Components::SelectKit.new(
".-mobile-notification-level .channel-settings-view__selector",
)
select_kit.expand
select_kit.select_row_by_name("Never")
it "can change desktop notification level" do
chat_page.visit_channel_settings(channel_1)
membership = channel_1.membership_for(current_user)
expect(page).to have_content(I18n.t("js.chat.settings.saved"))
}.to change { membership.reload.mobile_notification_level }.from("mention").to("never")
end
expect {
select_kit =
PageObjects::Components::SelectKit.new(
".chat-channel-settings__desktop-notifications-selector",
)
select_kit.expand
select_kit.select_row_by_name("Never")
it "doesn’t show admin section" do
chat_page.visit_channel_settings(channel_1)
expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { membership.reload.desktop_notification_level }.from("mention").to("never")
end
expect(page).to have_no_content(I18n.t("js.chat.settings.admin_title"))
end
it "can change mobile notification level" do
chat_page.visit_channel_settings(channel_1)
membership = channel_1.membership_for(current_user)
context "as an admin" do
before { sign_in(Fabricate(:admin)) }
expect {
select_kit =
PageObjects::Components::SelectKit.new(
".chat-channel-settings__mobile-notifications-selector",
)
select_kit.expand
select_kit.select_row_by_name("Never")
it "shows admin section" do
chat_page.visit_channel_settings(channel_1)
expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { membership.reload.mobile_notification_level }.from("mention").to("never")
end
end
expect(page).to have_content(I18n.t("js.chat.settings.admin_title"))
end
context "as staff" do
fab!(:current_user) { Fabricate(:admin) }
it "can change auto join setting" do
chat_page.visit_channel_settings(channel_1)
it "can edit name" do
chat_page.visit_channel_settings(channel_1)
expect {
select_kit =
PageObjects::Components::SelectKit.new(".-autojoin .channel-settings-view__selector")
select_kit.expand
select_kit.select_row_by_name("Yes")
find("#dialog-holder .btn-primary").click
edit_modal = channel_settings_page.open_edit_modal
expect(page).to have_content(I18n.t("js.chat.settings.saved"))
}.to change { channel_1.reload.auto_join_users }.from(false).to(true)
end
expect(edit_modal).to have_name_input(channel_1.title)
it "can change allow channel wide mentions" do
chat_page.visit_channel_settings(channel_1)
name = "A new name"
expect {
select_kit =
PageObjects::Components::SelectKit.new(
".-channel-wide-mentions .channel-settings-view__selector",
)
select_kit.expand
select_kit.select_row_by_name("No")
edit_modal.fill_and_save_name(name)
expect(page).to have_content(I18n.t("js.chat.settings.saved"))
}.to change { channel_1.reload.allow_channel_wide_mentions }.from(true).to(false)
end
expect(channel_settings_page).to have_name(name)
end
it "can close channel" do
chat_page.visit_channel_settings(channel_1)
it "can edit description" do
chat_page.visit_channel_settings(channel_1)
find(".edit-description-btn").click
expect {
click_button(I18n.t("js.chat.channel_settings.close_channel"))
find("#chat-channel-toggle-btn").click
expect(page).to have_content(I18n.t("js.chat.channel_status.closed_header"))
}.to change { channel_1.reload.status }.from("open").to("closed")
end
expect(page).to have_selector(
".chat-modal-edit-channel-description__description-input",
text: channel_1.description,
)
it "can enable threading" do
chat_page.visit_channel_settings(channel_1)
description = "A new description"
find(".chat-modal-edit-channel-description__description-input").fill_in(with: description)
find(".create").click
expect {
select_kit =
PageObjects::Components::SelectKit.new(".-threading .channel-settings-view__selector")
select_kit.expand
select_kit.select_row_by_name("Enabled")
expect(page).to have_content(I18n.t("js.chat.settings.saved"))
}.to change { channel_1.reload.threading_enabled }.from(false).to(true)
end
expect(page).to have_content(description)
end
it "can delete channel" do
chat_page.visit_channel_settings(channel_1)
it "can edit slug" do
chat_page.visit_channel_settings(channel_1)
edit_modal = channel_settings_page.open_edit_modal
click_button(I18n.t("js.chat.channel_settings.delete_channel"))
fill_in("channel-delete-confirm-name", with: channel_1.title)
find_button("chat-confirm-delete-channel", disabled: false).click
expect(page).to have_content(I18n.t("js.chat.channel_delete.process_started"))
end
slug = "gonzo-slug"
context "when confirmation name is wrong" do
it "doesn’t delete submission" do
chat_page.visit_channel_settings(channel_1)
find(".delete-btn").click
fill_in("channel-delete-confirm-name", with: channel_1.title + "wrong")
expect(edit_modal).to have_slug_input(channel_1.slug)
expect(page).to have_button("chat-confirm-delete-channel", disabled: true)
end
end
end
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
it "can change auto join setting" do
chat_page.visit_channel_settings(channel_1)
expect {
PageObjects::Components::DToggleSwitch.new(
".chat-channel-settings__auto-join-switch",
).toggle
find("#dialog-holder .btn-primary").click
expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { channel_1.reload.auto_join_users }.from(false).to(true)
end
it "can change allow channel wide mentions" do
chat_page.visit_channel_settings(channel_1)
expect {
PageObjects::Components::DToggleSwitch.new(
".chat-channel-settings__channel-wide-mentions",
).toggle
expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { channel_1.reload.allow_channel_wide_mentions }.from(true).to(false)
end
it "can close channel" do
chat_page.visit_channel_settings(channel_1)
expect {
click_button(I18n.t("js.chat.channel_settings.close_channel"))
find("#chat-channel-toggle-btn").click
expect(page).to have_content(I18n.t("js.chat.channel_status.closed_header"))
}.to change { channel_1.reload.status }.from("open").to("closed")
end
it "can enable threading" do
chat_page.visit_channel_settings(channel_1)
expect {
PageObjects::Components::DToggleSwitch.new(
".chat-channel-settings__threading-switch",
).toggle
expect(toasts).to have_success(I18n.t("js.saved"))
}.to change { channel_1.reload.threading_enabled }.from(false).to(true)
end
it "can delete channel" do
chat_page.visit_channel_settings(channel_1)
click_button(I18n.t("js.chat.channel_settings.delete_channel"))
fill_in("channel-delete-confirm-name", with: channel_1.title)
find_button("chat-confirm-delete-channel", disabled: false).click
expect(page).to have_content(I18n.t("js.chat.channel_delete.process_started"))
end
it "doesn’t delete when confirmation is wrong" do
chat_page.visit_channel_settings(channel_1)
find(".delete-btn").click
fill_in("channel-delete-confirm-name", with: channel_1.title + "wrong")
expect(page).to have_button("chat-confirm-delete-channel", disabled: true)
end
end
end

View File

@ -24,7 +24,7 @@ RSpec.describe "Drawer", type: :system do
drawer_page.open_channel(channel)
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

View File

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

View File

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

View File

@ -5,7 +5,7 @@ module PageObjects
class ChatChannelEdit < PageObjects::Modals::Base
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"
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