Compare commits

..

4 Commits

Author SHA1 Message Date
David Taylor
0537e5086f
Update core references to controller:composer 2023-03-17 17:16:45 +00:00
David Taylor
e7764b5865
Convert composer controller to service with backwards-compatibility shims 2023-03-17 17:16:42 +00:00
David Taylor
32815293e5
Rename files
(for cleaner git history)
2023-03-17 17:05:49 +00:00
David Taylor
887af6d074
DEV: Convert composer controller to native class syntax
Actions are moved from `actions: {}` to top-level functions with `@action` decorator. Previously we had a `save()` action and a top-level function of the same name, so this commit centralizes the logic from both into one function.
2023-03-17 17:04:36 +00:00
378 changed files with 10958 additions and 11297 deletions

View File

@ -2,7 +2,7 @@ export const POPULAR_THEMES = [
{
name: "Graceful",
value: "https://github.com/discourse/graceful",
preview: "https://discourse.theme-creator.io/theme/awesomerobot/graceful",
preview: "https://theme-creator.discourse.org/theme/awesomerobot/graceful",
description: "A light and graceful theme for Discourse.",
meta_url:
"https://meta.discourse.org/t/a-graceful-theme-for-discourse/93040",
@ -10,7 +10,8 @@ export const POPULAR_THEMES = [
{
name: "Material Design Theme",
value: "https://github.com/discourse/material-design-stock-theme",
preview: "https://discourse.theme-creator.io/theme/tshenry/material-design",
preview:
"https://theme-creator.discourse.org/theme/tshenry/material-design",
description:
"Inspired by Material Design, this theme comes with several color palettes (incl. a dark one).",
meta_url: "https://meta.discourse.org/t/material-design-stock-theme/47142",
@ -18,7 +19,7 @@ export const POPULAR_THEMES = [
{
name: "Minima",
value: "https://github.com/discourse/minima",
preview: "https://discourse.theme-creator.io/theme/awesomerobot/minima",
preview: "https://theme-creator.discourse.org/theme/awesomerobot/minima",
description: "A minimal theme with reduced UI elements and focus on text.",
meta_url:
"https://meta.discourse.org/t/minima-a-minimal-theme-for-discourse/108178",
@ -26,7 +27,7 @@ export const POPULAR_THEMES = [
{
name: "Sam's Simple Theme",
value: "https://github.com/discourse/discourse-simple-theme",
preview: "https://discourse.theme-creator.io/theme/sam/simple",
preview: "https://theme-creator.discourse.org/theme/sam/simple",
description:
"Simplified front page design with classic colors and typography.",
meta_url:
@ -35,8 +36,6 @@ export const POPULAR_THEMES = [
{
name: "Brand Header",
value: "https://github.com/discourse/discourse-brand-header",
preview:
"https://discourse.theme-creator.io/theme/vinothkannans/brand-header",
description:
"Add an extra top header with your logo, navigation links and social icons.",
meta_url: "https://meta.discourse.org/t/brand-header-theme-component/77977",
@ -46,7 +45,7 @@ export const POPULAR_THEMES = [
name: "Custom Header Links",
value: "https://github.com/discourse/discourse-custom-header-links",
preview:
"https://discourse.theme-creator.io/theme/awesomerobot/custom-header-links",
"https://theme-creator.discourse.org/theme/Johani/custom-header-links",
description: "Easily add custom text-based links to the header.",
meta_url: "https://meta.discourse.org/t/custom-header-links/90588",
component: true,
@ -62,7 +61,7 @@ export const POPULAR_THEMES = [
name: "Category Banners",
value: "https://github.com/discourse/discourse-category-banners",
preview:
"https://discourse.theme-creator.io/theme/awesomerobot/discourse-category-banners",
"https://theme-creator.discourse.org/theme/awesomerobot/discourse-category-banners",
description:
"Show banners on category pages using your existing category details.",
meta_url: "https://meta.discourse.org/t/discourse-category-banners/86241",
@ -71,7 +70,7 @@ export const POPULAR_THEMES = [
{
name: "Kanban Board",
value: "https://github.com/discourse/discourse-kanban-theme",
preview: "https://discourse.theme-creator.io/theme/david/kanban",
preview: "https://theme-creator.discourse.org/theme/david/kanban",
description: "Display and organize topics using a Kanban board interface.",
meta_url:
"https://meta.discourse.org/t/kanban-board-theme-component/118164",
@ -85,19 +84,10 @@ export const POPULAR_THEMES = [
meta_url: "https://meta.discourse.org/t/hamburger-theme-selector/61210",
component: true,
},
{
name: "Sidebar Theme Toggle",
value: "https://github.com/discourse/discourse-sidebar-theme-toggle",
description:
"Displays a theme selector in the sidebar menus footer provided there is more than one user-selectable theme.",
meta_url: "https://meta.discourse.org/t/sidebar-theme-toggle/242802",
component: true,
},
{
name: "Header Submenus",
value: "https://github.com/discourse/discourse-header-submenus",
preview:
"https://discourse.theme-creator.io/theme/awesomerobot/header-submenus",
preview: "https://theme-creator.discourse.org/theme/Johani/header-submenus",
description: "Lets you build a header menu with submenus (dropdowns).",
meta_url: "https://meta.discourse.org/t/header-submenus/94584",
component: true,
@ -114,7 +104,7 @@ export const POPULAR_THEMES = [
{
name: "Easy Responsive Footer",
value: "https://github.com/discourse/Discourse-easy-footer",
preview: "https://discourse.theme-creator.io/theme/Johani/easy-footer",
preview: "https://theme-creator.discourse.org/theme/Johani/easy-footer",
description: "Add a fully responsive footer without writing any HTML.",
meta_url: "https://meta.discourse.org/t/easy-responsive-footer/95818",
component: true,

View File

@ -101,6 +101,12 @@ const DEPRECATED_MODULES = new Map(
dropFrom: "3.0.0",
silent: true,
},
"controller:composer": {
newName: "service:composer",
since: "3.1.0.beta3",
dropFrom: "3.2.0",
silent: true,
},
})
);

View File

@ -0,0 +1,422 @@
<ComposerBody
@composer={{this.composer.model}}
@showPreview={{this.composer.showPreview}}
@openIfDraft={{this.composer.openIfDraft}}
@typed={{this.composer.typed}}
@cancelled={{this.composer.cancelled}}
@save={{this.composer.save}}
>
<div class="grippie"></div>
{{#if this.composer.visible}}
<ComposerMessages
@composer={{this.composer.model}}
@messageCount={{this.composer.messageCount}}
@addLinkLookup={{this.composer.addLinkLookup}}
/>
{{#if this.composer.showFullScreenPrompt}}
<ComposerFullscreenPrompt
@removeFullScreenExitPrompt={{this.composer.removeFullScreenExitPrompt}}
/>
{{/if}}
{{#if this.composer.model.viewOpenOrFullscreen}}
<div
role="form"
aria-label={{I18n this.composer.saveLabel}}
class="reply-area
{{if this.composer.canEditTags 'with-tags' 'without-tags'}}"
>
<span>
<PluginOutlet
@name="composer-open"
@connectorTagName="div"
@outletArgs={{hash model=this.composer.model}}
/>
</span>
<div class="reply-to">
{{#unless this.composer.model.viewFullscreen}}
<div class="reply-details">
<ComposerActionTitle
@model={{this.composer.model}}
@openComposer={{this.composer.openComposer}}
@closeComposer={{this.composer.closeComposer}}
@canWhisper={{this.composer.canWhisper}}
/>
<PluginOutlet
@name="composer-action-after"
@outletArgs={{hash model=this.composer.model}}
/>
{{#unless this.composer.site.mobileView}}
{{#if this.composer.model.unlistTopic}}
<span class="unlist">({{i18n "composer.unlist"}})</span>
{{/if}}
{{#if this.composer.isWhispering}}
{{#if this.composer.model.noBump}}
<span class="no-bump">{{d-icon "anchor"}}</span>
{{/if}}
{{/if}}
{{/unless}}
{{#if this.composer.canEdit}}
<LinkToInput
@onClick={{this.composer.displayEditReason}}
@showInput={{this.composer.showEditReason}}
@icon="info-circle"
@class="display-edit-reason"
>
<TextField
@value={{this.composer.editReason}}
@id="edit-reason"
@maxlength="255"
@placeholderKey="composer.edit_reason_placeholder"
/>
</LinkToInput>
{{/if}}
</div>
{{/unless}}
<PluginOutlet
@name="before-composer-controls"
@outletArgs={{hash model=this.composer.model}}
/>
<ComposerToggles
@composeState={{this.composer.model.composeState}}
@showToolbar={{this.composer.showToolbar}}
@toggleComposer={{this.composer.toggle}}
@toggleToolbar={{this.composer.toggleToolbar}}
@toggleFullscreen={{this.composer.fullscreenComposer}}
@disableTextarea={{this.composer.disableTextarea}}
/>
</div>
<ComposerEditor
@topic={{this.composer.topic}}
@composer={{this.composer.model}}
@lastValidatedAt={{this.composer.lastValidatedAt}}
@canWhisper={{this.composer.canWhisper}}
@storeToolbarState={{this.composer.storeToolbarState}}
@onPopupMenuAction={{this.composer.onPopupMenuAction}}
@showUploadModal={{route-action "showUploadSelector"}}
@popupMenuOptions={{this.composer.popupMenuOptions}}
@draftStatus={{this.composer.model.draftStatus}}
@isUploading={{this.composer.isUploading}}
@isProcessingUpload={{this.composer.isProcessingUpload}}
@allowUpload={{this.composer.allowUpload}}
@uploadIcon={{this.composer.uploadIcon}}
@isCancellable={{this.composer.isCancellable}}
@uploadProgress={{this.composer.uploadProgress}}
@groupsMentioned={{this.composer.groupsMentioned}}
@cannotSeeMention={{this.composer.cannotSeeMention}}
@hereMention={{this.composer.hereMention}}
@importQuote={{this.composer.importQuote}}
@togglePreview={{this.composer.togglePreview}}
@processPreview={{this.composer.showPreview}}
@showToolbar={{this.composer.showToolbar}}
@afterRefresh={{this.composer.afterRefresh}}
@focusTarget={{this.composer.focusTarget}}
@disableTextarea={{this.composer.disableTextarea}}
>
<div class="composer-fields">
<PluginOutlet
@name="before-composer-fields"
@outletArgs={{hash model=this.composer.model}}
/>
{{#unless this.composer.model.viewFullscreen}}
{{#if this.composer.model.canEditTitle}}
{{#if this.composer.model.creatingPrivateMessage}}
<div class="user-selector">
<ComposerUserSelector
@topicId={{this.composer.topicModel.id}}
@recipients={{this.composer.model.targetRecipients}}
@hasGroups={{this.composer.model.hasTargetGroups}}
@focusTarget={{this.composer.focusTarget}}
@class={{concat
"users-input"
(if this.composer.showWarning " can-warn")
}}
/>
{{#if this.composer.showWarning}}
<label class="add-warning">
<Input
@type="checkbox"
@checked={{this.composer.model.isWarning}}
/>
<span>{{i18n "composer.add_warning"}}</span>
</label>
{{/if}}
</div>
{{/if}}
<div
class="title-and-category
{{if this.composer.showPreview 'with-preview'}}"
>
<ComposerTitle
@composer={{this.composer.model}}
@lastValidatedAt={{this.composer.lastValidatedAt}}
@focusTarget={{this.composer.focusTarget}}
/>
{{#if this.composer.model.showCategoryChooser}}
<div class="category-input">
<CategoryChooser
@value={{this.composer.model.categoryId}}
@onChange={{action
(mut this.composer.model.categoryId)
}}
@options={{hash
disabled=this.composer.disableCategoryChooser
scopedCategoryId=this.composer.scopedCategoryId
prioritizedCategoryId=this.composer.prioritizedCategoryId
}}
/>
<PopupInputTip
@validation={{this.composer.categoryValidation}}
/>
</div>
{{/if}}
{{#if this.composer.canEditTags}}
<MiniTagChooser
@value={{this.composer.model.tags}}
@onChange={{action (mut this.composer.model.tags)}}
@options={{hash
disabled=this.composer.disableTagsChooser
categoryId=this.composer.model.categoryId
minimum=this.composer.model.minimumRequiredTags
}}
/>
<PopupInputTip
@validation={{this.composer.tagValidation}}
/>
{{/if}}
<PluginOutlet
@name="after-title-and-category"
@outletArgs={{hash
model=this.composer.model
tagValidation=this.composer.tagValidation
canEditTags=this.composer.canEditTags
disabled=this.composer.disableTagsChooser
}}
/>
</div>
{{/if}}
<span>
<PluginOutlet
@name="composer-fields"
@connectorTagName="div"
@outletArgs={{hash
model=this.composer.model
showPreview=this.composer.showPreview
}}
/>
</span>
{{/unless}}
</div>
</ComposerEditor>
<span>
<PluginOutlet
@name="composer-after-composer-editor"
@outletArgs={{hash model=this.composer.model}}
/>
</span>
<div class="submit-panel">
<span>
<PluginOutlet
@name="composer-fields-below"
@connectorTagName="div"
@outletArgs={{hash model=this.composer.model}}
/>
</span>
<div class="save-or-cancel">
<ComposerSaveButton
@action={{this.composer.save}}
@icon={{this.composer.saveIcon}}
@label={{this.composer.saveLabel}}
@forwardEvent={{true}}
@disableSubmit={{this.composer.disableSubmit}}
/>
{{#if this.composer.site.mobileView}}
<a
href
{{on "click" this.composer.cancel}}
title={{i18n "cancel"}}
class="cancel"
>
{{#if this.composer.canEdit}}
{{d-icon "times"}}
{{else}}
{{d-icon "far-trash-alt"}}
{{/if}}
</a>
{{else}}
<a href {{on "click" this.composer.cancel}} class="cancel">{{i18n
"close"
}}</a>
{{/if}}
{{#if this.composer.site.mobileView}}
{{#if this.composer.whisperOrUnlistTopic}}
<span class="whisper">
{{d-icon "far-eye-slash"}}
</span>
{{/if}}
{{#if this.composer.model.noBump}}
<span class="no-bump">{{d-icon "anchor"}}</span>
{{/if}}
{{/if}}
{{#if
(or this.composer.isUploading this.composer.isProcessingUpload)
}}
<div id="file-uploading">
{{#if this.composer.isProcessingUpload}}
{{loading-spinner size="small"}}<span>{{i18n
"upload_selector.processing"
}}</span>
{{else}}
{{loading-spinner size="small"}}<span>{{i18n
"upload_selector.uploading"
}}
{{this.composer.uploadProgress}}%</span>
{{/if}}
{{#if this.composer.isCancellable}}
<a
href
id="cancel-file-upload"
{{on "click" this.composer.cancelUpload}}
>{{d-icon "times"}}</a>
{{/if}}
</div>
{{/if}}
<div
class={{if this.composer.isUploading "hidden"}}
id="draft-status"
>
{{#if this.composer.model.draftStatus}}
<span
class="draft-error"
title={{this.composer.model.draftStatus}}
>
{{#if this.composer.model.draftConflictUser}}
{{avatar
this.composer.model.draftConflictUser
imageSize="small"
}}
{{d-icon "user-edit"}}
{{else}}
{{d-icon "exclamation-triangle"}}
{{/if}}
{{#unless this.composer.site.mobileView}}
{{this.composer.model.draftStatus}}
{{/unless}}
</span>
{{/if}}
</div>
<span>
<PluginOutlet
@name="composer-after-save-or-cancel"
@outletArgs={{hash model=this.composer.model}}
/>
</span>
</div>
{{#if this.composer.site.mobileView}}
<span>
<PluginOutlet
@name="composer-mobile-buttons-bottom"
@outletArgs={{hash model=this.composer.model}}
/>
</span>
{{#if this.composer.allowUpload}}
<a
id="mobile-file-upload"
class="btn btn-default no-text mobile-file-upload
{{if this.composer.isUploading 'hidden'}}"
aria-label={{i18n "composer.upload_title"}}
>
{{d-icon this.composer.uploadIcon}}
</a>
{{/if}}
<a
href
class="btn btn-default no-text mobile-preview"
title={{i18n "composer.show_preview"}}
{{on "click" this.composer.togglePreview}}
aria-label={{i18n "preview"}}
>
{{d-icon "desktop"}}
</a>
{{#if this.composer.showPreview}}
<DButton
@action={{this.composer.togglePreview}}
@class="hide-preview"
@ariaLabel="composer.hide_preview"
@icon="pencil-alt"
/>
{{/if}}
{{else}}
<DButton
@action={{this.composer.togglePreview}}
@translatedTitle={{this.composer.toggleText}}
@icon="angle-double-left"
@class={{concat
"btn-flat btn-mini-toggle toggle-preview "
(unless this.composer.showPreview "active")
}}
/>
{{/if}}
</div>
</div>
{{else}}
<div class="saving-text">
{{#if this.composer.model.createdPost}}
{{i18n "composer.saved"}}
<a
href={{this.composer.createdPost.url}}
{{on "click" this.composer.viewNewReply}}
class="permalink"
>{{i18n "composer.view_new_post"}}</a>
{{else}}
{{i18n "composer.saving"}}
{{loading-spinner size="small"}}
{{/if}}
</div>
<div class="draft-text">
{{#if this.composer.model.topic}}
{{d-icon "share"}}
{{html-safe this.composer.draftTitle}}
{{else}}
{{i18n "composer.saved_draft"}}
{{/if}}
</div>
<ComposerToggles
@composeState={{this.composer.model.composeState}}
@toggleFullscreen={{this.composer.openIfDraft}}
@toggleComposer={{this.composer.toggle}}
@toggleToolbar={{this.composer.toggleToolbar}}
/>
{{/if}}
{{/if}}
</ComposerBody>

View File

@ -0,0 +1,6 @@
import Component from "@glimmer/component";
import { inject as service } from "@ember/service";
export default class ComposerContainer extends Component {
@service composer;
}

View File

@ -28,7 +28,7 @@ export default Component.extend({
dismiss() {
this.set("shownAt", null);
const composer = getOwner(this).lookup("controller:composer");
const composer = getOwner(this).lookup("service:composer");
composer.clearLastValidatedAt();
this.element.previousElementSibling?.focus();
},

View File

@ -68,7 +68,7 @@ export default class SidebarUserCommunitySection extends SidebarCommonCommunityS
}
next(() => {
getOwner(this).lookup("controller:composer").open(composerArgs);
getOwner(this).lookup("service:composer").open(composerArgs);
});
}
}

View File

@ -100,7 +100,7 @@ export default Component.extend(LoadMore, {
},
resumeDraft(item) {
const composer = getOwner(this).lookup("controller:composer");
const composer = getOwner(this).lookup("service:composer");
if (composer.get("model.viewOpen")) {
composer.close();
}

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,7 @@ import { Promise } from "rsvp";
import { search as searchCategoryTag } from "discourse/lib/category-tag-search";
import showModal from "discourse/lib/show-modal";
import userSearch from "discourse/lib/user-search";
import { inject as service } from "@ember/service";
const SortOrders = [
{ name: I18n.t("search.relevance"), id: 0 },
@ -39,7 +40,7 @@ const PAGE_LIMIT = 10;
export default Controller.extend({
application: controller(),
composer: controller(),
composer: service(),
bulkSelectEnabled: null,
loading: false,

View File

@ -47,7 +47,7 @@ export function registerCustomPostMessageCallback(type, callback) {
}
export default Controller.extend(bufferedProperty("model"), {
composer: controller(),
composer: service(),
application: controller(),
dialog: service(),
documentTitle: service(),

View File

@ -445,14 +445,14 @@ export default {
return;
}
this.container.lookup("controller:composer").open({
this.container.lookup("service:composer").open({
action: Composer.CREATE_TOPIC,
draftKey: Composer.NEW_TOPIC_KEY,
});
},
focusComposer(event) {
const composer = this.container.lookup("controller:composer");
const composer = this.container.lookup("service:composer");
if (event) {
event.preventDefault();
event.stopPropagation();
@ -461,7 +461,7 @@ export default {
},
fullscreenComposer() {
const composer = this.container.lookup("controller:composer");
const composer = this.container.lookup("service:composer");
if (composer.get("model")) {
composer.toggleFullscreen();
}

View File

@ -58,7 +58,7 @@ import { addPluginReviewableParam } from "discourse/components/reviewable-item";
import {
addComposerSaveErrorCallback,
addPopupMenuOptionsCallback,
} from "discourse/controllers/composer";
} from "discourse/services/composer";
import { addPostClassesCallback } from "discourse/widgets/post";
import {
addGroupPostSmallActionCode,

View File

@ -410,7 +410,7 @@ const DiscourseURL = EmberObject.extend({
},
get isComposerOpen() {
return this.controllerFor("composer")?.visible;
return this.container.lookup("service:composer")?.visible;
},
get router() {

View File

@ -1,6 +1,7 @@
// This mixin allows a route to open the composer
import Composer from "discourse/models/composer";
import Mixin from "@ember/object/mixin";
import { getOwner } from "discourse-common/lib/get-owner";
export default Mixin.create({
openComposer(controller) {
@ -13,13 +14,15 @@ export default Mixin.create({
categoryId = null;
}
this.controllerFor("composer").open({
prioritizedCategoryId: categoryId,
topicCategoryId: categoryId,
action: Composer.CREATE_TOPIC,
draftKey: controller.get("model.draft_key") || Composer.NEW_TOPIC_KEY,
draftSequence: controller.get("model.draft_sequence") || 0,
});
getOwner(this)
.lookup("service:composer")
.open({
prioritizedCategoryId: categoryId,
topicCategoryId: categoryId,
action: Composer.CREATE_TOPIC,
draftKey: controller.get("model.draft_key") || Composer.NEW_TOPIC_KEY,
draftSequence: controller.get("model.draft_sequence") || 0,
});
},
openComposerWithTopicParams(
@ -29,15 +32,17 @@ export default Mixin.create({
topicCategoryId,
topicTags
) {
this.controllerFor("composer").open({
action: Composer.CREATE_TOPIC,
topicTitle,
topicBody,
topicCategoryId,
topicTags,
draftKey: controller.get("model.draft_key") || Composer.NEW_TOPIC_KEY,
draftSequence: controller.get("model.draft_sequence"),
});
getOwner(this)
.lookup("service:composer")
.open({
action: Composer.CREATE_TOPIC,
topicTitle,
topicBody,
topicCategoryId,
topicTags,
draftKey: controller.get("model.draft_key") || Composer.NEW_TOPIC_KEY,
draftSequence: controller.get("model.draft_sequence"),
});
},
openComposerWithMessageParams({
@ -46,7 +51,7 @@ export default Mixin.create({
topicBody = "",
hasGroups = false,
} = {}) {
this.controllerFor("composer").open({
getOwner(this).lookup("service:composer").open({
action: Composer.PRIVATE_MESSAGE,
recipients,
topicTitle,

View File

@ -39,6 +39,7 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, {
shortSiteDescription: setting("short_site_description"),
documentTitle: service(),
dialog: service(),
composer: service(),
actions: {
toggleAnonymous() {
@ -72,13 +73,6 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, {
this.documentTitle.setTitle(tokens.join(" - "));
},
postWasEnqueued(details) {
showModal("post-enqueued", {
model: details,
title: "review.approval.title",
});
},
composePrivateMessage(user, post) {
const recipients = user ? user.get("username") : "";
const reply = post
@ -91,7 +85,7 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, {
: null;
// used only once, one less dependency
return this.controllerFor("composer").open({
return this.composer.open({
action: Composer.PRIVATE_MESSAGE,
recipients,
archetypeId: "private_message",
@ -258,7 +252,6 @@ const ApplicationRoute = DiscourseRoute.extend(OpenComposer, {
this.render("application");
this.render("user-card", { into: "application", outlet: "user-card" });
this.render("modal", { into: "application", outlet: "modal" });
this.render("composer", { into: "application", outlet: "composer" });
},
handleShowLogin() {

View File

@ -16,11 +16,13 @@ import PermissionType from "discourse/models/permission-type";
import TopicList from "discourse/models/topic-list";
import { action } from "@ember/object";
import PreloadStore from "discourse/lib/preload-store";
import { inject as service } from "@ember/service";
// A helper function to create a category route with parameters
export default (filterArg, params) => {
return DiscourseRoute.extend({
queryParams,
composer: service(),
model(modelParams) {
const category = Category.findBySlugPathWithID(
@ -209,7 +211,7 @@ export default (filterArg, params) => {
deactivate() {
this._super(...arguments);
this.controllerFor("composer").set("prioritizedCategoryId", null);
this.composer.set("prioritizedCategoryId", null);
this.searchService.set("searchContext", null);
},

View File

@ -3,6 +3,7 @@ import Draft from "discourse/models/draft";
import Route from "@ember/routing/route";
import { once } from "@ember/runloop";
import { seenUser } from "discourse/lib/user-presence";
import { getOwner } from "discourse-common/lib/get-owner";
const DiscourseRoute = Route.extend({
showFooter: false,
@ -53,7 +54,7 @@ const DiscourseRoute = Route.extend({
},
openTopicDraft() {
const composer = this.controllerFor("composer");
const composer = getOwner(this).lookup("service:composer");
if (
composer.get("model.action") === Composer.CREATE_TOPIC &&

View File

@ -18,11 +18,13 @@ import { setTopicList } from "discourse/lib/topic-list-tracker";
import showModal from "discourse/lib/show-modal";
import { action } from "@ember/object";
import PreloadStore from "discourse/lib/preload-store";
import { inject as service } from "@ember/service";
const NONE = "none";
const ALL = "all";
export default DiscourseRoute.extend(FilterModeMixin, {
composer: service(),
navMode: "latest",
queryParams,
@ -214,8 +216,7 @@ export default DiscourseRoute.extend(FilterModeMixin, {
this.openTopicDraft();
} else {
const controller = this.controllerFor("tag.show");
const composerController = this.controllerFor("composer");
composerController
this.composer
.open({
categoryId: controller.category?.id,
action: Composer.CREATE_TOPIC,
@ -223,8 +224,8 @@ export default DiscourseRoute.extend(FilterModeMixin, {
})
.then(() => {
// Pre-fill the tags input field
if (composerController.canEditTags && controller.tag?.id) {
const composerModel = this.controllerFor("composer").model;
if (this.composer.canEditTags && controller.tag?.id) {
const composerModel = this.composer.model;
composerModel.set("tags", this._controllerTags(controller));
}
});

View File

@ -5,9 +5,12 @@ import { isEmpty } from "@ember/utils";
import { isTesting } from "discourse-common/config/environment";
import { schedule } from "@ember/runloop";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
// This route is used for retrieving a topic based on params
export default DiscourseRoute.extend({
composer: service(),
// Avoid default model hook
model(params) {
params = params || {};
@ -56,7 +59,6 @@ export default DiscourseRoute.extend({
}
const topicController = this.controllerFor("topic");
const composerController = this.controllerFor("composer");
const topic = this.modelFor("topic");
const postStream = topic.postStream;
@ -105,7 +107,7 @@ export default DiscourseRoute.extend({
}
if (!isEmpty(topic.draft)) {
composerController.open({
this.composer.open({
draft: Draft.getLocal(topic.draft_key, topic.draft),
draftKey: topic.draft_key,
draftSequence: topic.draft_sequence,

View File

@ -14,6 +14,7 @@ import PostFlag from "discourse/lib/flag-targets/post-flag";
const SCROLL_DELAY = 500;
const TopicRoute = DiscourseRoute.extend({
composer: service(),
screenTrack: service(),
scheduledReplace: null,
@ -333,7 +334,7 @@ const TopicRoute = DiscourseRoute.extend({
postStream.cancelFilter();
topicController.set("multiSelect", false);
this.controllerFor("composer").set("topic", null);
this.composer.set("topic", null);
this.screenTrack.stop();
this.appEvents.trigger("header:hide-topic");
@ -357,7 +358,7 @@ const TopicRoute = DiscourseRoute.extend({
controller.set("multiSelect", false);
controller.get("quoteState").clear();
this.controllerFor("composer").set("topic", model);
this.composer.set("topic", model);
this.topicTrackingState.trackIncoming("all");
// We reset screen tracking every time a topic is entered

View File

@ -2,8 +2,11 @@ import Composer from "discourse/models/composer";
import DiscourseRoute from "discourse/routes/discourse";
import Draft from "discourse/models/draft";
import { action } from "@ember/object";
import { inject as service } from "@ember/service";
export default DiscourseRoute.extend({
composer: service(),
renderTemplate() {
this.render("user/messages");
},
@ -20,11 +23,9 @@ export default DiscourseRoute.extend({
controller.set("model", model);
if (this.currentUser) {
const composerController = this.controllerFor("composer");
Draft.get("new_private_message").then((data) => {
if (data.draft) {
composerController.open({
this.composer.open({
draft: data.draft,
draftKey: Composer.NEW_PRIVATE_MESSAGE_KEY,
ignoreIfChanged: true,

File diff suppressed because it is too large Load Diff

View File

@ -86,7 +86,7 @@
{{outlet "modal"}}
<DialogHolder />
<TopicEntrance />
{{outlet "composer"}}
<ComposerContainer />
{{#if this.showFooterNav}}
<FooterNav />

View File

@ -1,404 +0,0 @@
<ComposerBody
@composer={{this.model}}
@showPreview={{this.showPreview}}
@openIfDraft={{action "openIfDraft"}}
@typed={{action "typed"}}
@cancelled={{action "cancelled"}}
@save={{action "save"}}
>
<div class="grippie"></div>
{{#if this.visible}}
<ComposerMessages
@composer={{this.model}}
@messageCount={{this.messageCount}}
@addLinkLookup={{action "addLinkLookup"}}
/>
{{#if this.showFullScreenPrompt}}
<ComposerFullscreenPrompt
@removeFullScreenExitPrompt={{action "removeFullScreenExitPrompt"}}
/>
{{/if}}
{{#if this.model.viewOpenOrFullscreen}}
<div
role="form"
aria-label={{I18n this.saveLabel}}
class="reply-area {{if this.canEditTags 'with-tags' 'without-tags'}}"
>
<span>
<PluginOutlet
@name="composer-open"
@connectorTagName="div"
@outletArgs={{hash model=this.model}}
/>
</span>
<div class="reply-to">
{{#unless this.model.viewFullscreen}}
<div class="reply-details">
<ComposerActionTitle
@model={{this.model}}
@openComposer={{action "openComposer"}}
@closeComposer={{action "closeComposer"}}
@canWhisper={{this.canWhisper}}
/>
<PluginOutlet
@name="composer-action-after"
@outletArgs={{hash model=this.model}}
/>
{{#unless this.site.mobileView}}
{{#if this.model.unlistTopic}}
<span class="unlist">({{i18n "composer.unlist"}})</span>
{{/if}}
{{#if this.isWhispering}}
{{#if this.model.noBump}}
<span class="no-bump">{{d-icon "anchor"}}</span>
{{/if}}
{{/if}}
{{/unless}}
{{#if this.canEdit}}
<LinkToInput
@onClick={{action "displayEditReason"}}
@showInput={{this.showEditReason}}
@icon="info-circle"
@class="display-edit-reason"
>
<TextField
@value={{this.editReason}}
@id="edit-reason"
@maxlength="255"
@placeholderKey="composer.edit_reason_placeholder"
/>
</LinkToInput>
{{/if}}
</div>
{{/unless}}
<PluginOutlet
@name="before-composer-controls"
@outletArgs={{hash model=this.model}}
/>
<ComposerToggles
@composeState={{this.model.composeState}}
@showToolbar={{this.showToolbar}}
@toggleComposer={{action "toggle"}}
@toggleToolbar={{action "toggleToolbar"}}
@toggleFullscreen={{action "fullscreenComposer"}}
@disableTextarea={{this.disableTextarea}}
/>
</div>
<ComposerEditor
@topic={{this.topic}}
@composer={{this.model}}
@lastValidatedAt={{this.lastValidatedAt}}
@canWhisper={{this.canWhisper}}
@storeToolbarState={{action "storeToolbarState"}}
@onPopupMenuAction={{action "onPopupMenuAction"}}
@showUploadModal={{route-action "showUploadSelector"}}
@popupMenuOptions={{this.popupMenuOptions}}
@draftStatus={{this.model.draftStatus}}
@isUploading={{this.isUploading}}
@isProcessingUpload={{this.isProcessingUpload}}
@allowUpload={{this.allowUpload}}
@uploadIcon={{this.uploadIcon}}
@isCancellable={{this.isCancellable}}
@uploadProgress={{this.uploadProgress}}
@groupsMentioned={{action "groupsMentioned"}}
@cannotSeeMention={{action "cannotSeeMention"}}
@hereMention={{action "hereMention"}}
@importQuote={{action "importQuote"}}
@togglePreview={{action "togglePreview"}}
@processPreview={{this.showPreview}}
@showToolbar={{this.showToolbar}}
@afterRefresh={{action "afterRefresh"}}
@focusTarget={{this.focusTarget}}
@disableTextarea={{this.disableTextarea}}
>
<div class="composer-fields">
<PluginOutlet
@name="before-composer-fields"
@outletArgs={{hash model=this.model}}
/>
{{#unless this.model.viewFullscreen}}
{{#if this.model.canEditTitle}}
{{#if this.model.creatingPrivateMessage}}
<div class="user-selector">
<ComposerUserSelector
@topicId={{this.topicModel.id}}
@recipients={{this.model.targetRecipients}}
@hasGroups={{this.model.hasTargetGroups}}
@focusTarget={{this.focusTarget}}
@class={{concat
"users-input"
(if this.showWarning " can-warn")
}}
/>
{{#if this.showWarning}}
<label class="add-warning">
<Input
@type="checkbox"
@checked={{this.model.isWarning}}
/>
<span>{{i18n "composer.add_warning"}}</span>
</label>
{{/if}}
</div>
{{/if}}
<div
class="title-and-category
{{if this.showPreview 'with-preview'}}"
>
<ComposerTitle
@composer={{this.model}}
@lastValidatedAt={{this.lastValidatedAt}}
@focusTarget={{this.focusTarget}}
/>
{{#if this.model.showCategoryChooser}}
<div class="category-input">
<CategoryChooser
@value={{this.model.categoryId}}
@onChange={{action (mut this.model.categoryId)}}
@options={{hash
disabled=this.disableCategoryChooser
scopedCategoryId=this.scopedCategoryId
prioritizedCategoryId=this.prioritizedCategoryId
}}
/>
<PopupInputTip @validation={{this.categoryValidation}} />
</div>
{{/if}}
{{#if this.canEditTags}}
<MiniTagChooser
@value={{this.model.tags}}
@onChange={{action (mut this.model.tags)}}
@options={{hash
disabled=this.disableTagsChooser
categoryId=this.model.categoryId
minimum=this.model.minimumRequiredTags
}}
/>
<PopupInputTip @validation={{this.tagValidation}} />
{{/if}}
<PluginOutlet
@name="after-title-and-category"
@outletArgs={{hash
model=this.model
tagValidation=this.tagValidation
canEditTags=this.canEditTags
disabled=this.disableTagsChooser
}}
/>
</div>
{{/if}}
<span>
<PluginOutlet
@name="composer-fields"
@connectorTagName="div"
@outletArgs={{hash
model=this.model
showPreview=this.showPreview
}}
/>
</span>
{{/unless}}
</div>
</ComposerEditor>
<span>
<PluginOutlet
@name="composer-after-composer-editor"
@outletArgs={{hash model=this.model}}
/>
</span>
<div class="submit-panel">
<span>
<PluginOutlet
@name="composer-fields-below"
@connectorTagName="div"
@outletArgs={{hash model=this.model}}
/>
</span>
<div class="save-or-cancel">
<ComposerSaveButton
@action={{action "save"}}
@icon={{this.saveIcon}}
@label={{this.saveLabel}}
@forwardEvent={{true}}
@disableSubmit={{this.disableSubmit}}
/>
{{#if this.site.mobileView}}
<a
href
{{on "click" this.cancel}}
title={{i18n "cancel"}}
class="cancel"
>
{{#if this.canEdit}}
{{d-icon "times"}}
{{else}}
{{d-icon "far-trash-alt"}}
{{/if}}
</a>
{{else}}
<a href {{on "click" this.cancel}} class="cancel">{{i18n
"close"
}}</a>
{{/if}}
{{#if this.site.mobileView}}
{{#if this.whisperOrUnlistTopic}}
<span class="whisper">
{{d-icon "far-eye-slash"}}
</span>
{{/if}}
{{#if this.model.noBump}}
<span class="no-bump">{{d-icon "anchor"}}</span>
{{/if}}
{{/if}}
{{#if (or this.isUploading this.isProcessingUpload)}}
<div id="file-uploading">
{{#if this.isProcessingUpload}}
{{loading-spinner size="small"}}<span>{{i18n
"upload_selector.processing"
}}</span>
{{else}}
{{loading-spinner size="small"}}<span>{{i18n
"upload_selector.uploading"
}}
{{this.uploadProgress}}%</span>
{{/if}}
{{#if this.isCancellable}}
<a
href
id="cancel-file-upload"
{{on "click" this.cancelUpload}}
>{{d-icon "times"}}</a>
{{/if}}
</div>
{{/if}}
<div class={{if this.isUploading "hidden"}} id="draft-status">
{{#if this.model.draftStatus}}
<span class="draft-error" title={{this.model.draftStatus}}>
{{#if this.model.draftConflictUser}}
{{avatar this.model.draftConflictUser imageSize="small"}}
{{d-icon "user-edit"}}
{{else}}
{{d-icon "exclamation-triangle"}}
{{/if}}
{{#unless this.site.mobileView}}
{{this.model.draftStatus}}
{{/unless}}
</span>
{{/if}}
</div>
<span>
<PluginOutlet
@name="composer-after-save-or-cancel"
@outletArgs={{hash model=this.model}}
/>
</span>
</div>
{{#if this.site.mobileView}}
<span>
<PluginOutlet
@name="composer-mobile-buttons-bottom"
@outletArgs={{hash model=this.model}}
/>
</span>
{{#if this.allowUpload}}
<a
id="mobile-file-upload"
class="btn btn-default no-text mobile-file-upload
{{if this.isUploading 'hidden'}}"
aria-label={{i18n "composer.upload_title"}}
>
{{d-icon this.uploadIcon}}
</a>
{{/if}}
<a
href
class="btn btn-default no-text mobile-preview"
title={{i18n "composer.show_preview"}}
{{on "click" this.togglePreview}}
aria-label={{i18n "preview"}}
>
{{d-icon "desktop"}}
</a>
{{#if this.showPreview}}
<DButton
@action={{action "togglePreview"}}
@class="hide-preview"
@ariaLabel="composer.hide_preview"
@icon="pencil-alt"
/>
{{/if}}
{{else}}
<DButton
@action={{action "togglePreview"}}
@translatedTitle={{this.toggleText}}
@icon="angle-double-left"
@class={{concat
"btn-flat btn-mini-toggle toggle-preview "
(unless this.showPreview "active")
}}
/>
{{/if}}
</div>
</div>
{{else}}
<div class="saving-text">
{{#if this.model.createdPost}}
{{i18n "composer.saved"}}
<a
href={{this.createdPost.url}}
{{on "click" this.viewNewReply}}
class="permalink"
>{{i18n "composer.view_new_post"}}</a>
{{else}}
{{i18n "composer.saving"}}
{{loading-spinner size="small"}}
{{/if}}
</div>
<div class="draft-text">
{{#if this.model.topic}}
{{d-icon "share"}}
{{html-safe this.draftTitle}}
{{else}}
{{i18n "composer.saved_draft"}}
{{/if}}
</div>
<ComposerToggles
@composeState={{this.model.composeState}}
@toggleFullscreen={{action "openIfDraft"}}
@toggleComposer={{action "toggle"}}
@toggleToolbar={{action "toggleToolbar"}}
/>
{{/if}}
{{/if}}
</ComposerBody>

View File

@ -12,7 +12,7 @@ import I18n from "I18n";
import selectKit from "discourse/tests/helpers/select-kit-helper";
import sinon from "sinon";
import { test } from "qunit";
import { toggleCheckDraftPopup } from "discourse/controllers/composer";
import { toggleCheckDraftPopup } from "discourse/services/composer";
import userFixtures from "discourse/tests/fixtures/user-fixtures";
import { cloneJSON } from "discourse-common/lib/object";

View File

@ -7,7 +7,7 @@ import {
triggerKeyEvent,
visit,
} from "@ember/test-helpers";
import { toggleCheckDraftPopup } from "discourse/controllers/composer";
import { toggleCheckDraftPopup } from "discourse/services/composer";
import { cloneJSON } from "discourse-common/lib/object";
import TopicFixtures from "discourse/tests/fixtures/topic";
import LinkLookup from "discourse/lib/link-lookup";
@ -996,7 +996,7 @@ acceptance("Composer", function (needs) {
await visit("/t/internationalization-localization/280");
await click("#topic-footer-buttons .create");
this.container.lookup("controller:composer").set(
this.container.lookup("service:composer").set(
"linkLookup",
new LinkLookup({
"github.com": {
@ -1165,7 +1165,7 @@ acceptance("Composer - Focus Open and Closed", function (needs) {
test("Focusing a composer which is not open with create topic", async function (assert) {
await visit("/t/internationalization-localization/280");
const composer = this.container.lookup("controller:composer");
const composer = this.container.lookup("service:composer");
await composer.focusComposer({ fallbackToNewTopic: true });
await settled();
@ -1180,7 +1180,7 @@ acceptance("Composer - Focus Open and Closed", function (needs) {
test("Focusing a composer which is not open with create topic and append text", async function (assert) {
await visit("/t/internationalization-localization/280");
const composer = this.container.lookup("controller:composer");
const composer = this.container.lookup("service:composer");
await composer.focusComposer({
fallbackToNewTopic: true,
insertText: "this is appended",
@ -1202,7 +1202,7 @@ acceptance("Composer - Focus Open and Closed", function (needs) {
await visit("/");
await click("#create-topic");
const composer = this.container.lookup("controller:composer");
const composer = this.container.lookup("service:composer");
await composer.focusComposer();
await settled();
@ -1217,7 +1217,7 @@ acceptance("Composer - Focus Open and Closed", function (needs) {
await visit("/");
await click("#create-topic");
const composer = this.container.lookup("controller:composer");
const composer = this.container.lookup("service:composer");
await composer.focusComposer({ insertText: "this is some appended text" });
await settled();
@ -1239,7 +1239,7 @@ acceptance("Composer - Focus Open and Closed", function (needs) {
await fillIn(".d-editor-input", "This is a dirty reply");
await click(".toggle-minimize");
const composer = this.container.lookup("controller:composer");
const composer = this.container.lookup("service:composer");
await composer.focusComposer({ insertText: "this is some appended text" });
await settled();

View File

@ -542,7 +542,7 @@ acceptance("Tag info", function (needs) {
test("composer will not set tags if user cannot create them", async function (assert) {
await visit("/tag/planters");
await click("#create-topic");
let composer = this.owner.lookup("controller:composer");
let composer = this.owner.lookup("service:composer");
assert.strictEqual(composer.get("model").tags, undefined);
});
});
@ -611,7 +611,7 @@ acceptance("Tag show - create topic", function (needs) {
});
test("composer will not set tags with all/none tags when creating topic", async function (assert) {
const composer = this.owner.lookup("controller:composer");
const composer = this.owner.lookup("service:composer");
await visit("/tag/none");
await click("#create-topic");
@ -623,7 +623,7 @@ acceptance("Tag show - create topic", function (needs) {
});
test("composer will set tags from selected tag", async function (assert) {
const composer = this.owner.lookup("controller:composer");
const composer = this.owner.lookup("service:composer");
await visit("/tag/planters");
await click("#create-topic");

View File

@ -46,8 +46,7 @@ class Bookmark < ActiveRecord::Base
validates :name, length: { maximum: 100 }
def registered_bookmarkable
type = Bookmark.polymorphic_class_for(self.bookmarkable_type).name
Bookmark.registered_bookmarkable_from_type(type)
Bookmark.registered_bookmarkable_from_type(self.bookmarkable_type)
end
def polymorphic_columns_present

View File

@ -129,7 +129,7 @@ class Reviewable < ActiveRecord::Base
update_args = {
status: statuses[:pending],
id: target.id,
type: target.class.sti_name,
type: target.class.name,
potential_spam: potential_spam == true ? true : nil,
}

View File

@ -213,10 +213,7 @@ task "docker:test" do
@good &&= run_or_fail("bundle exec rspec #{params.join(" ")}".strip)
end
if ENV["RUN_SYSTEM_TESTS"]
@good &&= run_or_fail("bin/ember-cli --build")
@good &&= run_or_fail("bundle exec rspec spec/system")
end
@good &&= run_or_fail("bundle exec rspec spec/system".strip) if ENV["RUN_SYSTEM_TESTS"]
end
unless ENV["SKIP_PLUGINS"]

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
class Chat::AdminIncomingChatWebhooksController < Admin::AdminController
requires_plugin Chat::PLUGIN_NAME
def index
render_serialized(
{
chat_channels: ChatChannel.public_channels,
incoming_chat_webhooks: IncomingChatWebhook.includes(:chat_channel).all,
},
AdminChatIndexSerializer,
root: false,
)
end
def create
params.require(%i[name chat_channel_id])
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
webhook = IncomingChatWebhook.new(name: params[:name], chat_channel: chat_channel)
if webhook.save
render_serialized(webhook, IncomingChatWebhookSerializer, root: false)
else
render_json_error(webhook)
end
end
def update
params.require(%i[incoming_chat_webhook_id name chat_channel_id])
webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id])
raise Discourse::NotFound unless webhook
chat_channel = ChatChannel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
if webhook.update(
name: params[:name],
description: params[:description],
emoji: params[:emoji],
username: params[:username],
chat_channel: chat_channel,
)
render json: success_json
else
render_json_error(webhook)
end
end
def destroy
params.require(:incoming_chat_webhook_id)
webhook = IncomingChatWebhook.find_by(id: params[:incoming_chat_webhook_id])
webhook.destroy if webhook
render json: success_json
end
end

View File

@ -1,9 +1,9 @@
# frozen_string_literal: true
class Chat::Api::ChannelThreadsController < Chat::ApiController
class Chat::Api::ChatChannelThreadsController < Chat::Api
def show
with_service(::Chat::LookupThread) do
on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, root: "thread") }
with_service(Chat::Service::LookupThread) do
on_success { render_serialized(result.thread, ChatThreadSerializer, root: "thread") }
on_failed_policy(:threaded_discussions_enabled) { raise Discourse::NotFound }
on_failed_policy(:threading_enabled_for_channel) { raise Discourse::NotFound }
on_model_not_found(:thread) { raise Discourse::NotFound }

View File

@ -1,13 +1,13 @@
# frozen_string_literal: true
class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController
class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsController
def create
existing_archive = channel_from_params.chat_channel_archive
if existing_archive.present?
guardian.ensure_can_change_channel_status!(channel_from_params, :archived)
raise Discourse::InvalidAccess if !existing_archive.failed?
Chat::ChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
Chat::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
return render json: success_json
end
@ -20,12 +20,12 @@ class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController
end
begin
Chat::ChannelArchiveService.create_archive_process(
Chat::ChatChannelArchiveService.create_archive_process(
chat_channel: channel_from_params,
acting_user: current_user,
topic_params: topic_params,
)
rescue Chat::ChannelArchiveService::ArchiveValidationError => err
rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err
return render json: failed_json.merge(errors: err.errors), status: 400
end

View File

@ -3,19 +3,19 @@
CHANNEL_EDITABLE_PARAMS = %i[name description slug]
CATEGORY_CHANNEL_EDITABLE_PARAMS = %i[auto_join_users allow_channel_wide_mentions]
class Chat::Api::ChannelsController < Chat::ApiController
class Chat::Api::ChatChannelsController < Chat::Api
def index
permitted = params.permit(:filter, :limit, :offset, :status)
options = { filter: permitted[:filter], limit: (permitted[:limit] || 25).to_i }
options[:offset] = permitted[:offset].to_i
options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil
options[:status] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options)
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options)
serialized_channels =
channels.map do |channel|
Chat::ChannelSerializer.new(
ChatChannelSerializer.new(
channel,
scope: Guardian.new(current_user),
membership: memberships.find { |membership| membership.chat_channel_id == channel.id },
@ -29,7 +29,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
end
def destroy
with_service Chat::TrashChannel do
with_service Chat::Service::TrashChannel do
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
end
end
@ -43,7 +43,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
raise Discourse::InvalidParameters.new(:name)
end
if Chat::Channel.exists?(
if ChatChannel.exists?(
chatable_type: "Category",
chatable_id: channel_params[:chatable_id],
name: channel_params[:name],
@ -69,12 +69,12 @@ class Chat::Api::ChannelsController < Chat::ApiController
channel.user_chat_channel_memberships.create!(user: current_user, following: true)
if channel.auto_join_users
Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
render_serialized(
channel,
Chat::ChannelSerializer,
ChatChannelSerializer,
membership: channel.membership_for(current_user),
root: "channel",
)
@ -83,7 +83,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
def show
render_serialized(
channel_from_params,
Chat::ChannelSerializer,
ChatChannelSerializer,
membership: channel_from_params.membership_for(current_user),
root: "channel",
)
@ -96,11 +96,11 @@ class Chat::Api::ChannelsController < Chat::ApiController
auto_join_limiter(channel_from_params).performed!
end
with_service(Chat::UpdateChannel, **params_to_edit) do
with_service(Chat::Service::UpdateChannel, **params_to_edit) do
on_success do
render_serialized(
result.channel,
Chat::ChannelSerializer,
ChatChannelSerializer,
root: "channel",
membership: result.channel.membership_for(current_user),
)
@ -116,7 +116,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
def channel_from_params
@channel ||=
begin
channel = Chat::Channel.find(params.require(:channel_id))
channel = ChatChannel.find(params.require(:channel_id))
guardian.ensure_can_preview_chat_channel!(channel)
channel
end
@ -126,7 +126,7 @@ class Chat::Api::ChannelsController < Chat::ApiController
@membership ||=
begin
membership =
Chat::ChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
raise Discourse::NotFound if membership.blank?
membership
end

View File

@ -1,12 +1,12 @@
# frozen_string_literal: true
class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController
class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController
def create
guardian.ensure_can_join_chat_channel!(channel_from_params)
render_serialized(
channel_from_params.add(current_user),
Chat::UserChannelMembershipSerializer,
UserChatChannelMembershipSerializer,
root: "membership",
)
end
@ -14,7 +14,7 @@ class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsCo
def destroy
render_serialized(
channel_from_params.remove(current_user),
Chat::UserChannelMembershipSerializer,
UserChatChannelMembershipSerializer,
root: "membership",
)
end

View File

@ -2,13 +2,13 @@
MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level]
class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController
class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController
def update
settings_params = params.require(:notifications_settings).permit(MEMBERSHIP_EDITABLE_PARAMS)
membership_from_params.update!(settings_params.to_h)
render_serialized(
membership_from_params,
Chat::UserChannelMembershipSerializer,
UserChatChannelMembershipSerializer,
root: "membership",
)
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
class Chat::Api::ChatChannelsMembershipsController < Chat::Api::ChatChannelsController
def index
params.permit(:username, :offset, :limit)
@ -8,7 +8,7 @@ class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
limit = (params[:limit] || 50).to_i.clamp(1, 50)
memberships =
Chat::ChannelMembershipsQuery.call(
ChatChannelMembershipsQuery.call(
channel: channel_from_params,
offset: offset,
limit: limit,
@ -17,7 +17,7 @@ class Chat::Api::ChannelsMembershipsController < Chat::Api::ChannelsController
render_serialized(
memberships,
Chat::UserChannelMembershipSerializer,
UserChatChannelMembershipSerializer,
root: "memberships",
meta: {
total_rows: channel_from_params.user_count,

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController
class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController
def create
move_params = params.require(:move)
move_params.require(:message_ids)
@ -8,7 +8,10 @@ class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController
raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params)
destination_channel =
Chat::ChannelFetcher.find_with_access_check(move_params[:destination_channel_id], guardian)
Chat::ChatChannelFetcher.find_with_access_check(
move_params[:destination_channel_id],
guardian,
)
begin
message_ids = move_params[:message_ids].map(&:to_i)

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController
def update
with_service(Chat::Service::UpdateChannelStatus) do
on_success { render_serialized(result.channel, ChatChannelSerializer, root: "channel") }
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
end
end
end

View File

@ -1,14 +1,13 @@
# frozen_string_literal: true
class Chat::Api::ChatablesController < Chat::ApiController
class Chat::Api::ChatChatablesController < Chat::Api
def index
params.require(:filter)
filter = params[:filter].downcase
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
public_channels =
Chat::ChannelFetcher.secured_public_channels(
Chat::ChatChannelFetcher.secured_public_channels(
guardian,
memberships,
filter: filter,
@ -42,7 +41,7 @@ class Chat::Api::ChatablesController < Chat::ApiController
direct_message_channels =
if users.count > 0
# FIXME: investigate the cost of this query
Chat::Channel
ChatChannel
.includes(chatable: :users)
.joins(direct_message: :direct_message_users)
.group(1)
@ -76,7 +75,7 @@ class Chat::Api::ChatablesController < Chat::ApiController
users: users_without_channel,
memberships: memberships,
},
Chat::ChannelSearchSerializer,
ChatChannelSearchSerializer,
root: false,
)
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Chat::Api::ChatCurrentUserChannelsController < Chat::Api
def index
structured = Chat::ChatChannelFetcher.structured(guardian)
render_serialized(structured, ChatChannelIndexSerializer, root: false)
end
end

View File

@ -0,0 +1,29 @@
# frozen_string_literal: true
class Chat::Api < Chat::ChatBaseController
before_action :ensure_logged_in
before_action :ensure_can_chat
include Chat::WithServiceHelper
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def default_actions_for_service
proc do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do
render(
json: failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
status: 400,
)
end
end
end
end

View File

@ -1,64 +0,0 @@
# frozen_string_literal: true
module Chat
module Admin
class IncomingWebhooksController < ::Admin::AdminController
requires_plugin Chat::PLUGIN_NAME
def index
render_serialized(
{
chat_channels: Chat::Channel.public_channels,
incoming_chat_webhooks: Chat::IncomingWebhook.includes(:chat_channel).all,
},
Chat::AdminChatIndexSerializer,
root: false,
)
end
def create
params.require(%i[name chat_channel_id])
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
webhook = Chat::IncomingWebhook.new(name: params[:name], chat_channel: chat_channel)
if webhook.save
render_serialized(webhook, Chat::IncomingWebhookSerializer, root: false)
else
render_json_error(webhook)
end
end
def update
params.require(%i[incoming_chat_webhook_id name chat_channel_id])
webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id])
raise Discourse::NotFound unless webhook
chat_channel = Chat::Channel.find_by(id: params[:chat_channel_id])
raise Discourse::NotFound if chat_channel.nil? || chat_channel.direct_message_channel?
if webhook.update(
name: params[:name],
description: params[:description],
emoji: params[:emoji],
username: params[:username],
chat_channel: chat_channel,
)
render json: success_json
else
render_json_error(webhook)
end
end
def destroy
params.require(:incoming_chat_webhook_id)
webhook = Chat::IncomingWebhook.find_by(id: params[:incoming_chat_webhook_id])
webhook.destroy if webhook
render json: success_json
end
end
end
end

View File

@ -1,11 +0,0 @@
# frozen_string_literal: true
class Chat::Api::ChannelsStatusController < Chat::Api::ChannelsController
def update
with_service(Chat::UpdateChannelStatus) do
on_success { render_serialized(result.channel, Chat::ChannelSerializer, root: "channel") }
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
end
end
end

View File

@ -1,8 +0,0 @@
# frozen_string_literal: true
class Chat::Api::CurrentUserChannelsController < Chat::ApiController
def index
structured = Chat::ChannelFetcher.structured(guardian)
render_serialized(structured, Chat::ChannelIndexSerializer, root: false)
end
end

View File

@ -1,32 +0,0 @@
# frozen_string_literal: true
module Chat
class ApiController < ::Chat::BaseController
before_action :ensure_logged_in
before_action :ensure_can_chat
include Chat::WithServiceHelper
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def default_actions_for_service
proc do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do
render(
json:
failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
status: 400,
)
end
end
end
end
end

View File

@ -1,22 +0,0 @@
# frozen_string_literal: true
module Chat
class BaseController < ::ApplicationController
before_action :ensure_logged_in
before_action :ensure_can_chat
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def set_channel_and_chatable_with_access_check(chat_channel_id: nil)
params.require(:chat_channel_id) if chat_channel_id.blank?
id_or_name = chat_channel_id || params[:chat_channel_id]
@chat_channel = Chat::ChannelFetcher.find_with_access_check(id_or_name, guardian)
@chatable = @chat_channel.chatable
end
end
end

View File

@ -1,481 +0,0 @@
# frozen_string_literal: true
module Chat
class ChatController < ::Chat::BaseController
PAST_MESSAGE_LIMIT = 40
FUTURE_MESSAGE_LIMIT = 40
PAST = "past"
FUTURE = "future"
CHAT_DIRECTIONS = [PAST, FUTURE]
# Other endpoints use set_channel_and_chatable_with_access_check, but
# these endpoints require a standalone find because they need to be
# able to get deleted channels and recover them.
before_action :find_chatable, only: %i[enable_chat disable_chat]
before_action :find_chat_message,
only: %i[delete restore lookup_message edit_message rebake message_link]
before_action :set_channel_and_chatable_with_access_check,
except: %i[
respond
enable_chat
disable_chat
message_link
lookup_message
set_user_chat_status
dismiss_retention_reminder
flag
]
def respond
render
end
def enable_chat
chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel
if chat_channel && chat_channel.trashed?
chat_channel.recover!
elsif chat_channel
return render_json_error I18n.t("chat.already_enabled")
else
chat_channel = @chatable.chat_channel
guardian.ensure_can_join_chat_channel!(chat_channel)
end
success = chat_channel.save
if success && chat_channel.chatable_has_custom_fields?
@chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true
@chatable.save!
end
if success
membership = Chat::ChannelMembershipManager.new(channel).follow(user)
render_serialized(chat_channel, Chat::ChannelSerializer, membership: membership)
else
render_json_error(chat_channel)
end
Chat::ChannelMembershipManager.new(channel).follow(user)
end
def disable_chat
chat_channel = Chat::Channel.with_deleted.find_by(chatable_id: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel)
return render json: success_json if chat_channel.trashed?
chat_channel.trash!(current_user)
success = chat_channel.save
if success
if chat_channel.chatable_has_custom_fields?
@chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED)
@chatable.save!
end
render json: success_json
else
render_json_error(chat_channel)
end
end
def create_message
raise Discourse::InvalidAccess if current_user.silenced?
Chat::MessageRateLimiter.run!(current_user)
@user_chat_channel_membership =
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::InvalidAccess unless @user_chat_channel_membership
reply_to_msg_id = params[:in_reply_to_id]
if reply_to_msg_id
rm = Chat::Message.find(reply_to_msg_id)
raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id
end
content = params[:message]
chat_message_creator =
Chat::MessageCreator.create(
chat_channel: @chat_channel,
user: current_user,
in_reply_to_id: reply_to_msg_id,
content: content,
staged_id: params[:staged_id],
upload_ids: params[:upload_ids],
)
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
@user_chat_channel_membership.update!(
last_read_message_id: chat_message_creator.chat_message.id,
)
if @chat_channel.direct_message_channel?
# If any of the channel users is ignoring, muting, or preventing DMs from
# the current user then we shold not auto-follow the channel once again or
# publish the new channel.
user_ids_allowing_communication =
UserCommScreener.new(
acting_user: current_user,
target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id),
).allowing_actor_communication
if user_ids_allowing_communication.any?
Chat::Publisher.publish_new_channel(
@chat_channel,
@chat_channel.chatable.users.where(id: user_ids_allowing_communication),
)
@chat_channel
.user_chat_channel_memberships
.where(user_id: user_ids_allowing_communication)
.update_all(following: true)
end
end
Chat::Publisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
chat_message_creator.chat_message.id,
)
render json: success_json
end
def edit_message
chat_message_updater =
Chat::MessageUpdater.update(
guardian: guardian,
chat_message: @message,
new_content: params[:new_message],
upload_ids: params[:upload_ids] || [],
)
return render_json_error(chat_message_updater.error) if chat_message_updater.failed?
render json: success_json
end
def update_user_last_read
membership =
Chat::ChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::NotFound if membership.nil?
if membership.last_read_message_id &&
params[:message_id].to_i < membership.last_read_message_id
raise Discourse::InvalidParameters.new(:message_id)
end
unless Chat::Message.with_deleted.exists?(
chat_channel_id: @chat_channel.id,
id: params[:message_id],
)
raise Discourse::NotFound
end
membership.update!(last_read_message_id: params[:message_id])
Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: current_user)
.where(read: false)
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
.where("chat_messages.id <= ?", params[:message_id].to_i)
.where("chat_messages.chat_channel_id = ?", @chat_channel.id)
.update_all(read: true)
Chat::Publisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
params[:message_id],
)
render json: success_json
end
def messages
page_size = params[:page_size]&.to_i || 1000
direction = params[:direction].to_s
message_id = params[:message_id]
if page_size > 50 ||
(
message_id.blank? ^ direction.blank? &&
(direction.present? && !CHAT_DIRECTIONS.include?(direction))
)
raise Discourse::InvalidParameters
end
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
if message_id.present?
condition = direction == PAST ? "<" : ">"
messages = messages.where("id #{condition} ?", message_id.to_i)
end
# NOTE: This order is reversed when we return the Chat::View below if the direction
# is not FUTURE.
order = direction == FUTURE ? "ASC" : "DESC"
messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a
can_load_more_past = nil
can_load_more_future = nil
if direction == FUTURE
can_load_more_future = messages.size == page_size
elsif direction == PAST
can_load_more_past = messages.size == page_size
else
# When direction is blank, we'll return the latest messages.
can_load_more_future = false
can_load_more_past = messages.size == page_size
end
chat_view =
Chat::View.new(
chat_channel: @chat_channel,
chat_messages: direction == FUTURE ? messages : messages.reverse,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, Chat::ViewSerializer, root: false)
end
def react
params.require(%i[message_id emoji react_action])
guardian.ensure_can_react!
Chat::MessageReactor.new(current_user, @chat_channel).react!(
message_id: params[:message_id],
react_action: params[:react_action].to_sym,
emoji: params[:emoji],
)
render json: success_json
end
def delete
guardian.ensure_can_delete_chat!(@message, @chatable)
Chat::MessageDestroyer.new.trash_message(@message, current_user)
head :ok
end
def restore
chat_channel = @message.chat_channel
guardian.ensure_can_restore_chat!(@message, chat_channel.chatable)
updated = @message.recover!
if updated
Chat::Publisher.publish_restore!(chat_channel, @message)
render json: success_json
else
render_json_error(@message)
end
end
def rebake
guardian.ensure_can_rebake_chat_message!(@message)
@message.rebake!(invalidate_oneboxes: true)
render json: success_json
end
def message_link
raise Discourse::NotFound if @message.blank? || @message.deleted_at.present?
raise Discourse::NotFound if @message.chat_channel.blank?
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
render json:
success_json.merge(
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(current_user),
)
end
def lookup_message
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
past_messages =
messages
.where("created_at < ?", @message.created_at)
.order(created_at: :desc)
.limit(PAST_MESSAGE_LIMIT)
future_messages =
messages
.where("created_at > ?", @message.created_at)
.order(created_at: :asc)
.limit(FUTURE_MESSAGE_LIMIT)
can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT
can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT
messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat)
chat_view =
Chat::View.new(
chat_channel: @chat_channel,
chat_messages: messages,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, Chat::ViewSerializer, root: false)
end
def set_user_chat_status
params.require(:chat_enabled)
current_user.user_option.update(chat_enabled: params[:chat_enabled])
render json: { chat_enabled: current_user.user_option.chat_enabled }
end
def invite_users
params.require(:user_ids)
users =
User
.includes(:groups)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.not_suspended
.where(id: params[:user_ids])
users.each do |user|
guardian = Guardian.new(user)
if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
data = {
message: "chat.invitation_notification",
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(user),
chat_channel_slug: @chat_channel.slug,
invited_by_username: current_user.username,
}
data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id]
user.notifications.create(
notification_type: Notification.types[:chat_invitation],
high_priority: true,
data: data.to_json,
)
end
end
render json: success_json
end
def dismiss_retention_reminder
params.require(:chatable_type)
guardian.ensure_can_chat!
unless Chat::Channel.chatable_types.include?(params[:chatable_type])
raise Discourse::InvalidParameters
end
field =
(
if Chat::Channel.public_channel_chatable_types.include?(params[:chatable_type])
:dismissed_channel_retention_reminder
else
:dismissed_dm_retention_reminder
end
)
current_user.user_option.update(field => true)
render json: success_json
end
def quote_messages
params.require(:message_ids)
message_ids = params[:message_ids].map(&:to_i)
markdown =
Chat::TranscriptService.new(
@chat_channel,
current_user,
messages_or_ids: message_ids,
).generate_markdown
render json: success_json.merge(markdown: markdown)
end
def flag
RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed!
permitted_params =
params.permit(
%i[chat_message_id flag_type_id message is_warning take_action queue_for_review],
)
chat_message =
Chat::Message.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id])
flag_type_id = permitted_params[:flag_type_id].to_i
if !ReviewableScore.types.values.include?(flag_type_id)
raise Discourse::InvalidParameters.new(:flag_type_id)
end
set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id)
result =
Chat::ReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params)
if result[:success]
render json: success_json
else
render_json_error(result[:errors])
end
end
def set_draft
if params[:data].present?
Chat::Draft.find_or_initialize_by(
user: current_user,
chat_channel_id: @chat_channel.id,
).update!(data: params[:data])
else
Chat::Draft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all
end
render json: success_json
end
private
def preloaded_chat_message_query
query =
Chat::Message
.includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]])
.includes(:revisions)
.includes(user: :primary_group)
.includes(chat_webhook_event: :incoming_chat_webhook)
.includes(reactions: :user)
.includes(:bookmarks)
.includes(:uploads)
.includes(chat_channel: :chatable)
query = query.includes(user: :user_status) if SiteSetting.enable_user_status
query
end
def find_chatable
@chatable = Category.find_by(id: params[:chatable_id])
guardian.ensure_can_moderate_chat!(@chatable)
end
def find_chat_message
@message = preloaded_chat_message_query.with_deleted
@message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[
:chat_channel_id
]
@message = @message.find_by(id: params[:message_id])
raise Discourse::NotFound unless @message
end
end
end

View File

@ -1,57 +0,0 @@
# frozen_string_literal: true
module Chat
class DirectMessagesController < ::Chat::BaseController
# NOTE: For V1 of chat channel archiving and deleting we are not doing
# anything for DM channels, their behaviour will stay as is.
def create
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
begin
chat_channel =
Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users)
render_serialized(
chat_channel,
Chat::ChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
rescue Chat::DirectMessageChannelCreator::NotAllowed => err
render_json_error(err.message)
end
end
def index
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
direct_message = Chat::DirectMessage.for_user_ids(users.map(&:id).uniq)
if direct_message
chat_channel = Chat::Channel.find_by(chatable_id: direct_message)
render_serialized(
chat_channel,
Chat::ChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
else
render body: nil, status: 404
end
end
private
def users_from_usernames(current_user, params)
params.require(:usernames)
usernames =
(params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames])
users = [current_user]
other_usernames = usernames - [current_user.username]
users.concat(User.where(username: other_usernames).to_a) if other_usernames.any?
users
end
end
end

View File

@ -1,10 +0,0 @@
# frozen_string_literal: true
module Chat
class EmojisController < ::Chat::BaseController
def index
emojis = Emoji.all.group_by(&:group)
render json: MultiJson.dump(emojis)
end
end
end

View File

@ -1,113 +0,0 @@
# frozen_string_literal: true
module Chat
class IncomingWebhooksController < ::ApplicationController
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10
skip_before_action :verify_authenticity_token, :redirect_to_login_if_required
before_action :validate_payload
def create_message
debug_payload
process_webhook_payload(text: params[:text], key: params[:key])
end
# See https://api.slack.com/reference/messaging/payload for the
# slack message payload format. For now we only support the
# text param, which we preprocess lightly to remove the slack-isms
# in the formatting.
def create_message_slack_compatible
debug_payload
# See note in validate_payload on why this is needed
attachments =
if params[:payload].present?
payload = params[:payload]
if String === payload
payload = JSON.parse(payload)
payload.deep_symbolize_keys!
end
payload[:attachments]
else
params[:attachments]
end
if params[:text].present?
text = Chat::SlackCompatibility.process_text(params[:text])
else
text = Chat::SlackCompatibility.process_legacy_attachments(attachments)
end
process_webhook_payload(text: text, key: params[:key])
rescue JSON::ParserError
raise Discourse::InvalidParameters
end
private
def process_webhook_payload(text:, key:)
validate_message_length(text)
webhook = find_and_rate_limit_webhook(key)
chat_message_creator =
Chat::MessageCreator.create(
chat_channel: webhook.chat_channel,
user: Discourse.system_user,
content: text,
incoming_chat_webhook: webhook,
)
if chat_message_creator.failed?
render_json_error(chat_message_creator.error)
else
render json: success_json
end
end
def find_and_rate_limit_webhook(key)
webhook = Chat::IncomingWebhook.includes(:chat_channel).find_by(key: key)
raise Discourse::NotFound unless webhook
# Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed.
RateLimiter.new(
nil,
"incoming_chat_webhook_#{webhook.id}",
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT,
1.minute,
).performed!
webhook
end
def validate_message_length(message)
return if message.length <= SiteSetting.chat_maximum_message_length
raise Discourse::InvalidParameters.new(
"Body cannot be over #{SiteSetting.chat_maximum_message_length} characters",
)
end
# The webhook POST body can be in 3 different formats:
#
# * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads
# * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments
# * { payload: "<JSON STRING>", attachments: null, text: null }, where JSON STRING can look
# like the `attachments` example above (along with other attributes), which is fired by OpsGenie
def validate_payload
params.require(:key)
if !params[:text] && !params[:payload] && !params[:attachments]
raise Discourse::InvalidParameters
end
end
def debug_payload
return if !SiteSetting.chat_debug_webhook_payloads
Rails.logger.warn(
"Debugging chat webhook payload for endpoint #{params[:key]}: " +
JSON.dump(
{ payload: params[:payload], attachments: params[:attachments], text: params[:text] },
),
)
end
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class Chat::ChatBaseController < ::ApplicationController
before_action :ensure_logged_in
before_action :ensure_can_chat
private
def ensure_can_chat
raise Discourse::NotFound unless SiteSetting.chat_enabled
guardian.ensure_can_chat!
end
def set_channel_and_chatable_with_access_check(chat_channel_id: nil)
params.require(:chat_channel_id) if chat_channel_id.blank?
id_or_name = chat_channel_id || params[:chat_channel_id]
@chat_channel = Chat::ChatChannelFetcher.find_with_access_check(id_or_name, guardian)
@chatable = @chat_channel.chatable
end
end

View File

@ -0,0 +1,472 @@
# frozen_string_literal: true
class Chat::ChatController < Chat::ChatBaseController
PAST_MESSAGE_LIMIT = 40
FUTURE_MESSAGE_LIMIT = 40
PAST = "past"
FUTURE = "future"
CHAT_DIRECTIONS = [PAST, FUTURE]
# Other endpoints use set_channel_and_chatable_with_access_check, but
# these endpoints require a standalone find because they need to be
# able to get deleted channels and recover them.
before_action :find_chatable, only: %i[enable_chat disable_chat]
before_action :find_chat_message,
only: %i[delete restore lookup_message edit_message rebake message_link]
before_action :set_channel_and_chatable_with_access_check,
except: %i[
respond
enable_chat
disable_chat
message_link
lookup_message
set_user_chat_status
dismiss_retention_reminder
flag
]
def respond
render
end
def enable_chat
chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel) if chat_channel
if chat_channel && chat_channel.trashed?
chat_channel.recover!
elsif chat_channel
return render_json_error I18n.t("chat.already_enabled")
else
chat_channel = @chatable.chat_channel
guardian.ensure_can_join_chat_channel!(chat_channel)
end
success = chat_channel.save
if success && chat_channel.chatable_has_custom_fields?
@chatable.custom_fields[Chat::HAS_CHAT_ENABLED] = true
@chatable.save!
end
if success
membership = Chat::ChatChannelMembershipManager.new(channel).follow(user)
render_serialized(chat_channel, ChatChannelSerializer, membership: membership)
else
render_json_error(chat_channel)
end
Chat::ChatChannelMembershipManager.new(channel).follow(user)
end
def disable_chat
chat_channel = ChatChannel.with_deleted.find_by(chatable: @chatable)
guardian.ensure_can_join_chat_channel!(chat_channel)
return render json: success_json if chat_channel.trashed?
chat_channel.trash!(current_user)
success = chat_channel.save
if success
if chat_channel.chatable_has_custom_fields?
@chatable.custom_fields.delete(Chat::HAS_CHAT_ENABLED)
@chatable.save!
end
render json: success_json
else
render_json_error(chat_channel)
end
end
def create_message
raise Discourse::InvalidAccess if current_user.silenced?
Chat::ChatMessageRateLimiter.run!(current_user)
@user_chat_channel_membership =
Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::InvalidAccess unless @user_chat_channel_membership
reply_to_msg_id = params[:in_reply_to_id]
if reply_to_msg_id
rm = ChatMessage.find(reply_to_msg_id)
raise Discourse::NotFound if rm.chat_channel_id != @chat_channel.id
end
content = params[:message]
chat_message_creator =
Chat::ChatMessageCreator.create(
chat_channel: @chat_channel,
user: current_user,
in_reply_to_id: reply_to_msg_id,
content: content,
staged_id: params[:staged_id],
upload_ids: params[:upload_ids],
)
return render_json_error(chat_message_creator.error) if chat_message_creator.failed?
@user_chat_channel_membership.update!(
last_read_message_id: chat_message_creator.chat_message.id,
)
if @chat_channel.direct_message_channel?
# If any of the channel users is ignoring, muting, or preventing DMs from
# the current user then we shold not auto-follow the channel once again or
# publish the new channel.
user_ids_allowing_communication =
UserCommScreener.new(
acting_user: current_user,
target_user_ids: @chat_channel.user_chat_channel_memberships.pluck(:user_id),
).allowing_actor_communication
if user_ids_allowing_communication.any?
ChatPublisher.publish_new_channel(
@chat_channel,
@chat_channel.chatable.users.where(id: user_ids_allowing_communication),
)
@chat_channel
.user_chat_channel_memberships
.where(user_id: user_ids_allowing_communication)
.update_all(following: true)
end
end
ChatPublisher.publish_user_tracking_state(
current_user,
@chat_channel.id,
chat_message_creator.chat_message.id,
)
render json: success_json
end
def edit_message
chat_message_updater =
Chat::ChatMessageUpdater.update(
guardian: guardian,
chat_message: @message,
new_content: params[:new_message],
upload_ids: params[:upload_ids] || [],
)
return render_json_error(chat_message_updater.error) if chat_message_updater.failed?
render json: success_json
end
def update_user_last_read
membership =
Chat::ChatChannelMembershipManager.new(@chat_channel).find_for_user(
current_user,
following: true,
)
raise Discourse::NotFound if membership.nil?
if membership.last_read_message_id && params[:message_id].to_i < membership.last_read_message_id
raise Discourse::InvalidParameters.new(:message_id)
end
unless ChatMessage.with_deleted.exists?(
chat_channel_id: @chat_channel.id,
id: params[:message_id],
)
raise Discourse::NotFound
end
membership.update!(last_read_message_id: params[:message_id])
Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: current_user)
.where(read: false)
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
.where("chat_messages.id <= ?", params[:message_id].to_i)
.where("chat_messages.chat_channel_id = ?", @chat_channel.id)
.update_all(read: true)
ChatPublisher.publish_user_tracking_state(current_user, @chat_channel.id, params[:message_id])
render json: success_json
end
def messages
page_size = params[:page_size]&.to_i || 1000
direction = params[:direction].to_s
message_id = params[:message_id]
if page_size > 50 ||
(
message_id.blank? ^ direction.blank? &&
(direction.present? && !CHAT_DIRECTIONS.include?(direction))
)
raise Discourse::InvalidParameters
end
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
if message_id.present?
condition = direction == PAST ? "<" : ">"
messages = messages.where("id #{condition} ?", message_id.to_i)
end
# NOTE: This order is reversed when we return the ChatView below if the direction
# is not FUTURE.
order = direction == FUTURE ? "ASC" : "DESC"
messages = messages.order("created_at #{order}, id #{order}").limit(page_size).to_a
can_load_more_past = nil
can_load_more_future = nil
if direction == FUTURE
can_load_more_future = messages.size == page_size
elsif direction == PAST
can_load_more_past = messages.size == page_size
else
# When direction is blank, we'll return the latest messages.
can_load_more_future = false
can_load_more_past = messages.size == page_size
end
chat_view =
ChatView.new(
chat_channel: @chat_channel,
chat_messages: direction == FUTURE ? messages : messages.reverse,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, ChatViewSerializer, root: false)
end
def react
params.require(%i[message_id emoji react_action])
guardian.ensure_can_react!
Chat::ChatMessageReactor.new(current_user, @chat_channel).react!(
message_id: params[:message_id],
react_action: params[:react_action].to_sym,
emoji: params[:emoji],
)
render json: success_json
end
def delete
guardian.ensure_can_delete_chat!(@message, @chatable)
ChatMessageDestroyer.new.trash_message(@message, current_user)
head :ok
end
def restore
chat_channel = @message.chat_channel
guardian.ensure_can_restore_chat!(@message, chat_channel.chatable)
updated = @message.recover!
if updated
ChatPublisher.publish_restore!(chat_channel, @message)
render json: success_json
else
render_json_error(@message)
end
end
def rebake
guardian.ensure_can_rebake_chat_message!(@message)
@message.rebake!(invalidate_oneboxes: true)
render json: success_json
end
def message_link
raise Discourse::NotFound if @message.blank? || @message.deleted_at.present?
raise Discourse::NotFound if @message.chat_channel.blank?
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
render json:
success_json.merge(
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(current_user),
)
end
def lookup_message
set_channel_and_chatable_with_access_check(chat_channel_id: @message.chat_channel_id)
messages = preloaded_chat_message_query.where(chat_channel: @chat_channel)
messages = messages.with_deleted if guardian.can_moderate_chat?(@chatable)
past_messages =
messages
.where("created_at < ?", @message.created_at)
.order(created_at: :desc)
.limit(PAST_MESSAGE_LIMIT)
future_messages =
messages
.where("created_at > ?", @message.created_at)
.order(created_at: :asc)
.limit(FUTURE_MESSAGE_LIMIT)
can_load_more_past = past_messages.count == PAST_MESSAGE_LIMIT
can_load_more_future = future_messages.count == FUTURE_MESSAGE_LIMIT
messages = [past_messages.reverse, [@message], future_messages].reduce([], :concat)
chat_view =
ChatView.new(
chat_channel: @chat_channel,
chat_messages: messages,
user: current_user,
can_load_more_past: can_load_more_past,
can_load_more_future: can_load_more_future,
)
render_serialized(chat_view, ChatViewSerializer, root: false)
end
def set_user_chat_status
params.require(:chat_enabled)
current_user.user_option.update(chat_enabled: params[:chat_enabled])
render json: { chat_enabled: current_user.user_option.chat_enabled }
end
def invite_users
params.require(:user_ids)
users =
User
.includes(:groups)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.not_suspended
.where(id: params[:user_ids])
users.each do |user|
guardian = Guardian.new(user)
if guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
data = {
message: "chat.invitation_notification",
chat_channel_id: @chat_channel.id,
chat_channel_title: @chat_channel.title(user),
chat_channel_slug: @chat_channel.slug,
invited_by_username: current_user.username,
}
data[:chat_message_id] = params[:chat_message_id] if params[:chat_message_id]
user.notifications.create(
notification_type: Notification.types[:chat_invitation],
high_priority: true,
data: data.to_json,
)
end
end
render json: success_json
end
def dismiss_retention_reminder
params.require(:chatable_type)
guardian.ensure_can_chat!
unless ChatChannel.chatable_types.include?(params[:chatable_type])
raise Discourse::InvalidParameters
end
field =
(
if ChatChannel.public_channel_chatable_types.include?(params[:chatable_type])
:dismissed_channel_retention_reminder
else
:dismissed_dm_retention_reminder
end
)
current_user.user_option.update(field => true)
render json: success_json
end
def quote_messages
params.require(:message_ids)
message_ids = params[:message_ids].map(&:to_i)
markdown =
ChatTranscriptService.new(
@chat_channel,
current_user,
messages_or_ids: message_ids,
).generate_markdown
render json: success_json.merge(markdown: markdown)
end
def flag
RateLimiter.new(current_user, "flag_chat_message", 4, 1.minutes).performed!
permitted_params =
params.permit(
%i[chat_message_id flag_type_id message is_warning take_action queue_for_review],
)
chat_message =
ChatMessage.includes(:chat_channel, :revisions).find(permitted_params[:chat_message_id])
flag_type_id = permitted_params[:flag_type_id].to_i
if !ReviewableScore.types.values.include?(flag_type_id)
raise Discourse::InvalidParameters.new(:flag_type_id)
end
set_channel_and_chatable_with_access_check(chat_channel_id: chat_message.chat_channel_id)
result =
Chat::ChatReviewQueue.new.flag_message(chat_message, guardian, flag_type_id, permitted_params)
if result[:success]
render json: success_json
else
render_json_error(result[:errors])
end
end
def set_draft
if params[:data].present?
ChatDraft.find_or_initialize_by(
user: current_user,
chat_channel_id: @chat_channel.id,
).update!(data: params[:data])
else
ChatDraft.where(user: current_user, chat_channel_id: @chat_channel.id).destroy_all
end
render json: success_json
end
private
def preloaded_chat_message_query
query =
ChatMessage
.includes(in_reply_to: [:user, chat_webhook_event: [:incoming_chat_webhook]])
.includes(:revisions)
.includes(user: :primary_group)
.includes(chat_webhook_event: :incoming_chat_webhook)
.includes(reactions: :user)
.includes(:bookmarks)
.includes(:uploads)
.includes(chat_channel: :chatable)
query = query.includes(user: :user_status) if SiteSetting.enable_user_status
query
end
def find_chatable
@chatable = Category.find_by(id: params[:chatable_id])
guardian.ensure_can_moderate_chat!(@chatable)
end
def find_chat_message
@message = preloaded_chat_message_query.with_deleted
@message = @message.where(chat_channel_id: params[:chat_channel_id]) if params[:chat_channel_id]
@message = @message.find_by(id: params[:message_id])
raise Discourse::NotFound unless @message
end
end

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
class Chat::DirectMessagesController < Chat::ChatBaseController
# NOTE: For V1 of chat channel archiving and deleting we are not doing
# anything for DM channels, their behaviour will stay as is.
def create
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
begin
chat_channel =
Chat::DirectMessageChannelCreator.create!(acting_user: current_user, target_users: users)
render_serialized(
chat_channel,
ChatChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
rescue Chat::DirectMessageChannelCreator::NotAllowed => err
render_json_error(err.message)
end
end
def index
guardian.ensure_can_chat!
users = users_from_usernames(current_user, params)
direct_message = DirectMessage.for_user_ids(users.map(&:id).uniq)
if direct_message
chat_channel = ChatChannel.find_by(chatable: direct_message)
render_serialized(
chat_channel,
ChatChannelSerializer,
root: "channel",
membership: chat_channel.membership_for(current_user),
)
else
render body: nil, status: 404
end
end
private
def users_from_usernames(current_user, params)
params.require(:usernames)
usernames =
(params[:usernames].is_a?(String) ? params[:usernames].split(",") : params[:usernames])
users = [current_user]
other_usernames = usernames - [current_user.username]
users.concat(User.where(username: other_usernames).to_a) if other_usernames.any?
users
end
end

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class Chat::EmojisController < Chat::ChatBaseController
def index
emojis = Emoji.all.group_by(&:group)
render json: MultiJson.dump(emojis)
end
end

View File

@ -0,0 +1,111 @@
# frozen_string_literal: true
class Chat::IncomingChatWebhooksController < ApplicationController
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT = 10
skip_before_action :verify_authenticity_token, :redirect_to_login_if_required
before_action :validate_payload
def create_message
debug_payload
process_webhook_payload(text: params[:text], key: params[:key])
end
# See https://api.slack.com/reference/messaging/payload for the
# slack message payload format. For now we only support the
# text param, which we preprocess lightly to remove the slack-isms
# in the formatting.
def create_message_slack_compatible
debug_payload
# See note in validate_payload on why this is needed
attachments =
if params[:payload].present?
payload = params[:payload]
if String === payload
payload = JSON.parse(payload)
payload.deep_symbolize_keys!
end
payload[:attachments]
else
params[:attachments]
end
if params[:text].present?
text = Chat::SlackCompatibility.process_text(params[:text])
else
text = Chat::SlackCompatibility.process_legacy_attachments(attachments)
end
process_webhook_payload(text: text, key: params[:key])
rescue JSON::ParserError
raise Discourse::InvalidParameters
end
private
def process_webhook_payload(text:, key:)
validate_message_length(text)
webhook = find_and_rate_limit_webhook(key)
chat_message_creator =
Chat::ChatMessageCreator.create(
chat_channel: webhook.chat_channel,
user: Discourse.system_user,
content: text,
incoming_chat_webhook: webhook,
)
if chat_message_creator.failed?
render_json_error(chat_message_creator.error)
else
render json: success_json
end
end
def find_and_rate_limit_webhook(key)
webhook = IncomingChatWebhook.includes(:chat_channel).find_by(key: key)
raise Discourse::NotFound unless webhook
# Rate limit to 10 messages per-minute. We can move to a site setting in the future if needed.
RateLimiter.new(
nil,
"incoming_chat_webhook_#{webhook.id}",
WEBHOOK_MESSAGES_PER_MINUTE_LIMIT,
1.minute,
).performed!
webhook
end
def validate_message_length(message)
return if message.length <= SiteSetting.chat_maximum_message_length
raise Discourse::InvalidParameters.new(
"Body cannot be over #{SiteSetting.chat_maximum_message_length} characters",
)
end
# The webhook POST body can be in 3 different formats:
#
# * { text: "message text" }, which is the most basic method, and also mirrors Slack payloads
# * { attachments: [ text: "message text" ] }, which is a variant of Slack payloads using legacy attachments
# * { payload: "<JSON STRING>", attachments: null, text: null }, where JSON STRING can look
# like the `attachments` example above (along with other attributes), which is fired by OpsGenie
def validate_payload
params.require(:key)
if !params[:text] && !params[:payload] && !params[:attachments]
raise Discourse::InvalidParameters
end
end
def debug_payload
return if !SiteSetting.chat_debug_webhook_payloads
Rails.logger.warn(
"Debugging chat webhook payload for endpoint #{params[:key]}: " +
JSON.dump(
{ payload: params[:payload], attachments: params[:attachments], text: params[:text] },
),
)
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
DiscoursePluginRegistry.define_register(:chat_markdown_features, Set)
class Plugin::Instance
def chat
ChatPluginApiExtensions
end
module ChatPluginApiExtensions
def self.enable_markdown_feature(name)
DiscoursePluginRegistry.chat_markdown_features << name
end
end
end

View File

@ -12,7 +12,7 @@ module Chat
instance_exec(&object.method(:default_actions_for_service).call) if default_actions
instance_exec(&(block || proc {}))
end
ServiceRunner.call(service, object, **dependencies, &merged_block)
Chat::ServiceRunner.call(service, object, **dependencies, &merged_block)
end
def run_service(service, dependencies)

View File

@ -0,0 +1,81 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
class AutoJoinChannelBatch < ::Jobs::Base
def execute(args)
return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank?
start_user_id = args[:starts_at].to_i
end_user_id = args[:ends_at].to_i
return "End is higher than start" if end_user_id < start_user_id
channel =
ChatChannel.find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel
category = channel.chatable
return if !category
query_args = {
chat_channel_id: channel.id,
start: start_user_id,
end: end_user_id,
suspended_until: Time.zone.now,
last_seen_at: 3.months.ago,
channel_category: channel.chatable_id,
mode: UserChatChannelMembership.join_modes[:automatic],
}
new_member_ids = DB.query_single(create_memberships_query(category), query_args)
# Only do this if we are running auto-join for a single user, if we
# are doing it for many then we should do it after all batches are
# complete for the channel in Jobs::AutoManageChannelMemberships
if start_user_id == end_user_id
Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count
end
ChatPublisher.publish_new_channel(channel.reload, User.where(id: new_member_ids))
end
private
def create_memberships_query(category)
query = <<~SQL
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
FROM users
INNER JOIN user_options uo ON uo.user_id = users.id
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
SQL
query += <<~SQL if category.read_restricted?
INNER JOIN group_users gu ON gu.user_id = users.id
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id
SQL
query += <<~SQL
WHERE (users.id >= :start AND users.id <= :end) AND
users.staged IS FALSE AND users.active AND
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
(last_seen_at > :last_seen_at) AND
uo.chat_enabled AND
uccm.id IS NULL
SQL
query += <<~SQL if category.read_restricted?
AND cg.category_id = :channel_category
SQL
query += "RETURNING user_chat_channel_memberships.user_id"
end
end
end

View File

@ -0,0 +1,79 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
class AutoManageChannelMemberships < ::Jobs::Base
def execute(args)
channel =
ChatChannel.includes(:chatable).find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel&.chatable
processed =
UserChatChannelMembership.where(
chat_channel: channel,
following: true,
join_mode: UserChatChannelMembership.join_modes[:automatic],
).count
auto_join_query(channel).find_in_batches do |batch|
break if processed >= SiteSetting.max_chat_auto_joined_users
starts_at = batch.first.query_user_id
ends_at = batch.last.query_user_id
Jobs.enqueue(
:auto_join_channel_batch,
chat_channel_id: channel.id,
starts_at: starts_at,
ends_at: ends_at,
)
processed += batch.size
end
# The Jobs::AutoJoinChannelBatch job will only do this recalculation
# if it's operating on one user, so we need to make sure we do it for
# the channel here once this job is complete.
Chat::ChatChannelMembershipManager.new(channel).recalculate_user_count
end
private
def auto_join_query(channel)
category = channel.chatable
users =
User
.real
.activated
.not_suspended
.not_staged
.distinct
.select(:id, "users.id AS query_user_id")
.where("last_seen_at > ?", 3.months.ago)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.joins(<<~SQL)
LEFT OUTER JOIN user_chat_channel_memberships uccm
ON uccm.chat_channel_id = #{channel.id} AND
uccm.user_id = users.id
SQL
.where("uccm.id IS NULL")
if category.read_restricted?
users =
users
.joins(:group_users)
.joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id")
.where("cg.category_id = ?", channel.chatable_id)
end
users
end
end
end

View File

@ -1,83 +0,0 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
module Chat
class AutoJoinChannelBatch < ::Jobs::Base
def execute(args)
return "starts_at or ends_at missing" if args[:starts_at].blank? || args[:ends_at].blank?
start_user_id = args[:starts_at].to_i
end_user_id = args[:ends_at].to_i
return "End is higher than start" if end_user_id < start_user_id
channel =
::Chat::Channel.find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel
category = channel.chatable
return if !category
query_args = {
chat_channel_id: channel.id,
start: start_user_id,
end: end_user_id,
suspended_until: Time.zone.now,
last_seen_at: 3.months.ago,
channel_category: channel.chatable_id,
mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
}
new_member_ids = DB.query_single(create_memberships_query(category), query_args)
# Only do this if we are running auto-join for a single user, if we
# are doing it for many then we should do it after all batches are
# complete for the channel in Jobs::Chat::AutoManageChannelMemberships
if start_user_id == end_user_id
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
end
::Chat::Publisher.publish_new_channel(channel.reload, User.where(id: new_member_ids))
end
private
def create_memberships_query(category)
query = <<~SQL
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
FROM users
INNER JOIN user_options uo ON uo.user_id = users.id
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
SQL
query += <<~SQL if category.read_restricted?
INNER JOIN group_users gu ON gu.user_id = users.id
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id
SQL
query += <<~SQL
WHERE (users.id >= :start AND users.id <= :end) AND
users.staged IS FALSE AND users.active AND
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
(last_seen_at > :last_seen_at) AND
uo.chat_enabled AND
uccm.id IS NULL
SQL
query += <<~SQL if category.read_restricted?
AND cg.category_id = :channel_category
SQL
query += "RETURNING user_chat_channel_memberships.user_id"
end
end
end
end

View File

@ -1,81 +0,0 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
module Chat
class AutoManageChannelMemberships < ::Jobs::Base
def execute(args)
channel =
::Chat::Channel.includes(:chatable).find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel&.chatable
processed =
::Chat::UserChatChannelMembership.where(
chat_channel: channel,
following: true,
join_mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
).count
auto_join_query(channel).find_in_batches do |batch|
break if processed >= ::SiteSetting.max_chat_auto_joined_users
starts_at = batch.first.query_user_id
ends_at = batch.last.query_user_id
::Jobs.enqueue(
::Jobs::Chat::AutoJoinChannelBatch,
chat_channel_id: channel.id,
starts_at: starts_at,
ends_at: ends_at,
)
processed += batch.size
end
# The Jobs::Chat::AutoJoinChannelBatch job will only do this recalculation
# if it's operating on one user, so we need to make sure we do it for
# the channel here once this job is complete.
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
end
private
def auto_join_query(channel)
category = channel.chatable
users =
::User
.real
.activated
.not_suspended
.not_staged
.distinct
.select(:id, "users.id AS query_user_id")
.where("last_seen_at > ?", 3.months.ago)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.joins(<<~SQL)
LEFT OUTER JOIN user_chat_channel_memberships uccm
ON uccm.chat_channel_id = #{channel.id} AND
uccm.user_id = users.id
SQL
.where("uccm.id IS NULL")
if category.read_restricted?
users =
users
.joins(:group_users)
.joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id")
.where("cg.category_id = ?", channel.chatable_id)
end
users
end
end
end
end

View File

@ -1,40 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class ChannelArchive < ::Jobs::Base
sidekiq_options retry: false
def execute(args = {})
channel_archive = ::Chat::ChannelArchive.find_by(id: args[:chat_channel_archive_id])
# this should not really happen, but better to do this than throw an error
if channel_archive.blank?
::Rails.logger.warn(
"Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.",
)
return
end
if channel_archive.complete?
channel_archive.chat_channel.update!(status: :archived)
::Chat::Publisher.publish_archive_status(
channel_archive.chat_channel,
archive_status: :success,
archived_messages: channel_archive.archived_messages,
archive_topic_id: channel_archive.destination_topic_id,
total_messages: channel_archive.total_messages,
)
return
end
::DistributedMutex.synchronize(
"archive_chat_channel_#{channel_archive.chat_channel_id}",
validity: 20.minutes,
) { ::Chat::ChannelArchiveService.new(channel_archive).execute }
end
end
end
end

View File

@ -1,63 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class ChannelDelete < ::Jobs::Base
def execute(args = {})
chat_channel = ::Chat::Channel.with_deleted.find_by(id: args[:chat_channel_id])
# this should not really happen, but better to do this than throw an error
if chat_channel.blank?
::Rails.logger.warn(
"Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.",
)
return
end
::DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do
::Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}")
::Chat::Message.transaction do
webhooks = ::Chat::IncomingWebhook.where(chat_channel: chat_channel)
::Chat::WebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all
webhooks.delete_all
end
::Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}")
::Chat::Draft.where(chat_channel: chat_channel).delete_all
::Chat::UserChatChannelMembership.where(chat_channel: chat_channel).delete_all
::Rails.logger.debug(
"Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}",
)
chat_messages = ::Chat::Message.where(chat_channel: chat_channel)
delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any?
end
end
def delete_messages_and_related_records(chat_channel, chat_messages)
message_ids = chat_messages.pluck(:id)
::Chat::Message.transaction do
::Chat::Mention.where(chat_message_id: message_ids).delete_all
::Chat::MessageRevision.where(chat_message_id: message_ids).delete_all
::Chat::MessageReaction.where(chat_message_id: message_ids).delete_all
# if the uploads are not used anywhere else they will be deleted
# by the CleanUpUploads job in core
::DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})")
::UploadReference.where(
target_id: message_ids,
target_type: ::Chat::Message.sti_name,
).delete_all
# only the messages and the channel are Trashable, everything else gets
# permanently destroyed
chat_messages.update_all(
deleted_by_id: chat_channel.deleted_by_id,
deleted_at: Time.zone.now,
)
end
end
end
end
end

View File

@ -1,15 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class DeleteUserMessages < ::Jobs::Base
def execute(args)
return if args[:user_id].nil?
::Chat::MessageDestroyer.new.destroy_in_batches(
::Chat::Message.with_deleted.where(user_id: args[:user_id]),
)
end
end
end
end

View File

@ -1,148 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class NotifyMentioned < ::Jobs::Base
def execute(args = {})
@chat_message =
::Chat::Message.includes(:user, :revisions, chat_channel: :chatable).find_by(
id: args[:chat_message_id],
)
if @chat_message.nil? ||
@chat_message.revisions.where("created_at > ?", args[:timestamp]).any?
return
end
@creator = @chat_message.user
@chat_channel = @chat_message.chat_channel
@already_notified_user_ids = args[:already_notified_user_ids] || []
user_ids_to_notify = args[:to_notify_ids_map] || {}
user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) }
end
private
def get_memberships(user_ids)
query =
::Chat::UserChatChannelMembership.includes(:user).where(
user_id: (user_ids - @already_notified_user_ids),
chat_channel_id: @chat_message.chat_channel_id,
)
query = query.where(following: true) if @chat_channel.public_channel?
query
end
def build_data_for(membership, identifier_type:)
data = {
chat_message_id: @chat_message.id,
chat_channel_id: @chat_channel.id,
mentioned_by_username: @creator.username,
is_direct_message_channel: @chat_channel.direct_message_channel?,
}
if !@is_direct_message_channel
data[:chat_channel_title] = @chat_channel.title(membership.user)
data[:chat_channel_slug] = @chat_channel.slug
end
return data if identifier_type == :direct_mentions
case identifier_type
when :here_mentions
data[:identifier] = "here"
when :global_mentions
data[:identifier] = "all"
else
data[:identifier] = identifier_type if identifier_type
data[:is_group_mention] = true
end
data
end
def build_payload_for(membership, identifier_type:)
payload = {
notification_type: ::Notification.types[:chat_mention],
username: @creator.username,
tag: ::Chat::Notifier.push_notification_tag(:mention, @chat_channel.id),
excerpt: @chat_message.push_notification_excerpt,
post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}",
}
translation_prefix =
(
if @chat_channel.direct_message_channel?
"discourse_push_notifications.popup.direct_message_chat_mention"
else
"discourse_push_notifications.popup.chat_mention"
end
)
translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type"
identifier_text =
case identifier_type
when :here_mentions
"@here"
when :global_mentions
"@all"
when :direct_mentions
""
else
"@#{identifier_type}"
end
payload[:translated_title] = ::I18n.t(
"#{translation_prefix}.#{translation_suffix}",
username: @creator.username,
identifier: identifier_text,
channel: @chat_channel.title(membership.user),
)
payload
end
def create_notification!(membership, mention, mention_type)
notification_data = build_data_for(membership, identifier_type: mention_type)
is_read = ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id)
notification =
::Notification.create!(
notification_type: ::Notification.types[:chat_mention],
user_id: membership.user_id,
high_priority: true,
data: notification_data.to_json,
read: is_read,
)
mention.update!(notification: notification)
end
def send_notifications(membership, mention_type)
payload = build_payload_for(membership, identifier_type: mention_type)
if !membership.desktop_notifications_never? && !membership.muted?
::MessageBus.publish(
"/chat/notification-alert/#{membership.user_id}",
payload,
user_ids: [membership.user_id],
)
end
if !membership.mobile_notifications_never? && !membership.muted?
::PostAlerter.push_notification(membership.user, payload)
end
end
def process_mentions(user_ids, mention_type)
memberships = get_memberships(user_ids)
memberships.each do |membership|
mention = ::Chat::Mention.find_by(user: membership.user, chat_message: @chat_message)
if mention.present?
create_notification!(membership, mention, mention_type)
send_notifications(membership, mention_type)
end
end
end
end
end
end

View File

@ -1,88 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class NotifyWatching < ::Jobs::Base
def execute(args = {})
@chat_message =
::Chat::Message.includes(:user, chat_channel: :chatable).find_by(
id: args[:chat_message_id],
)
return if @chat_message.nil?
@creator = @chat_message.user
@chat_channel = @chat_message.chat_channel
@is_direct_message_channel = @chat_channel.direct_message_channel?
always_notification_level = ::Chat::UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
members =
::Chat::UserChatChannelMembership
.includes(user: :groups)
.joins(user: :user_option)
.where(user_option: { chat_enabled: true })
.where.not(user_id: args[:except_user_ids])
.where(chat_channel_id: @chat_channel.id)
.where(following: true)
.where(
"desktop_notification_level = ? OR mobile_notification_level = ?",
always_notification_level,
always_notification_level,
)
.merge(User.not_suspended)
if @is_direct_message_channel
::UserCommScreener
.new(acting_user: @creator, target_user_ids: members.map(&:user_id))
.allowing_actor_communication
.each do |user_id|
send_notifications(members.find { |member| member.user_id == user_id })
end
else
members.each { |member| send_notifications(member) }
end
end
def send_notifications(membership)
user = membership.user
guardian = ::Guardian.new(user)
return unless guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
return if ::Chat::Notifier.user_has_seen_message?(membership, @chat_message.id)
return if online_user_ids.include?(user.id)
translation_key =
(
if @is_direct_message_channel
"discourse_push_notifications.popup.new_direct_chat_message"
else
"discourse_push_notifications.popup.new_chat_message"
end
)
translation_args = { username: @creator.username }
translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel
payload = {
username: @creator.username,
notification_type: ::Notification.types[:chat_message],
post_url: @chat_channel.relative_url,
translated_title: ::I18n.t(translation_key, translation_args),
tag: ::Chat::Notifier.push_notification_tag(:message, @chat_channel.id),
excerpt: @chat_message.push_notification_excerpt,
}
if membership.desktop_notifications_always? && !membership.muted?
::MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id])
end
if membership.mobile_notifications_always? && !membership.muted?
::PostAlerter.push_notification(user, payload)
end
end
def online_user_ids
@online_user_ids ||= ::PresenceChannel.new("/chat/online").user_ids
end
end
end
end

View File

@ -1,27 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class ProcessMessage < ::Jobs::Base
def execute(args = {})
::DistributedMutex.synchronize(
"jobs_chat_process_message_#{args[:chat_message_id]}",
validity: 10.minutes,
) do
chat_message = ::Chat::Message.find_by(id: args[:chat_message_id])
return if !chat_message
processor = ::Chat::MessageProcessor.new(chat_message)
processor.run!
if args[:is_dirty] || processor.dirty?
chat_message.update(
cooked: processor.html,
cooked_version: ::Chat::Message::BAKED_VERSION,
)
::Chat::Publisher.publish_processed!(chat_message)
end
end
end
end
end
end

View File

@ -1,23 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class SendMessageNotifications < ::Jobs::Base
def execute(args)
reason = args[:reason]
valid_reasons = %w[new edit]
return unless valid_reasons.include?(reason)
return if (timestamp = args[:timestamp]).blank?
return if (message = ::Chat::Message.find_by(id: args[:chat_message_id])).nil?
if reason == "new"
::Chat::Notifier.new(message, timestamp).notify_new
elsif reason == "edit"
::Chat::Notifier.new(message, timestamp).notify_edit
end
end
end
end
end

View File

@ -1,20 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class UpdateChannelUserCount < Jobs::Base
def execute(args = {})
channel = ::Chat::Channel.find_by(id: args[:chat_channel_id])
return if channel.blank?
return if !channel.user_count_stale
channel.update!(
user_count: ::Chat::ChannelMembershipsQuery.count(channel),
user_count_stale: false,
)
::Chat::Publisher.publish_chat_channel_metadata(channel)
end
end
end
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
module Jobs
class ChatChannelArchive < ::Jobs::Base
sidekiq_options retry: false
def execute(args = {})
channel_archive = ::ChatChannelArchive.find_by(id: args[:chat_channel_archive_id])
# this should not really happen, but better to do this than throw an error
if channel_archive.blank?
Rails.logger.warn(
"Chat channel archive #{args[:chat_channel_archive_id]} could not be found, aborting archive job.",
)
return
end
if channel_archive.complete?
channel_archive.chat_channel.update!(status: :archived)
ChatPublisher.publish_archive_status(
channel_archive.chat_channel,
archive_status: :success,
archived_messages: channel_archive.archived_messages,
archive_topic_id: channel_archive.destination_topic_id,
total_messages: channel_archive.total_messages,
)
return
end
DistributedMutex.synchronize(
"archive_chat_channel_#{channel_archive.chat_channel_id}",
validity: 20.minutes,
) { Chat::ChatChannelArchiveService.new(channel_archive).execute }
end
end
end

View File

@ -0,0 +1,58 @@
# frozen_string_literal: true
module Jobs
class ChatChannelDelete < ::Jobs::Base
def execute(args = {})
chat_channel = ::ChatChannel.with_deleted.find_by(id: args[:chat_channel_id])
# this should not really happen, but better to do this than throw an error
if chat_channel.blank?
Rails.logger.warn(
"Chat channel #{args[:chat_channel_id]} could not be found, aborting delete job.",
)
return
end
DistributedMutex.synchronize("delete_chat_channel_#{chat_channel.id}") do
Rails.logger.debug("Deleting webhooks and events for channel #{chat_channel.id}")
ChatMessage.transaction do
webhooks = IncomingChatWebhook.where(chat_channel: chat_channel)
ChatWebhookEvent.where(incoming_chat_webhook_id: webhooks.select(:id)).delete_all
webhooks.delete_all
end
Rails.logger.debug("Deleting drafts and memberships for channel #{chat_channel.id}")
ChatDraft.where(chat_channel: chat_channel).delete_all
UserChatChannelMembership.where(chat_channel: chat_channel).delete_all
Rails.logger.debug(
"Deleting chat messages, mentions, revisions, and uploads for channel #{chat_channel.id}",
)
chat_messages = ChatMessage.where(chat_channel: chat_channel)
delete_messages_and_related_records(chat_channel, chat_messages) if chat_messages.any?
end
end
def delete_messages_and_related_records(chat_channel, chat_messages)
message_ids = chat_messages.pluck(:id)
ChatMessage.transaction do
ChatMention.where(chat_message_id: message_ids).delete_all
ChatMessageRevision.where(chat_message_id: message_ids).delete_all
ChatMessageReaction.where(chat_message_id: message_ids).delete_all
# if the uploads are not used anywhere else they will be deleted
# by the CleanUpUploads job in core
DB.exec("DELETE FROM chat_uploads WHERE chat_message_id IN (#{message_ids.join(",")})")
UploadReference.where(target_id: message_ids, target_type: "ChatMessage").delete_all
# only the messages and the channel are Trashable, everything else gets
# permanently destroyed
chat_messages.update_all(
deleted_by_id: chat_channel.deleted_by_id,
deleted_at: Time.zone.now,
)
end
end
end
end

View File

@ -0,0 +1,146 @@
# frozen_string_literal: true
module Jobs
class ChatNotifyMentioned < ::Jobs::Base
def execute(args = {})
@chat_message =
ChatMessage.includes(:user, :revisions, chat_channel: :chatable).find_by(
id: args[:chat_message_id],
)
if @chat_message.nil? ||
@chat_message.revisions.where("created_at > ?", args[:timestamp]).any?
return
end
@creator = @chat_message.user
@chat_channel = @chat_message.chat_channel
@already_notified_user_ids = args[:already_notified_user_ids] || []
user_ids_to_notify = args[:to_notify_ids_map] || {}
user_ids_to_notify.each { |mention_type, ids| process_mentions(ids, mention_type.to_sym) }
end
private
def get_memberships(user_ids)
query =
UserChatChannelMembership.includes(:user).where(
user_id: (user_ids - @already_notified_user_ids),
chat_channel_id: @chat_message.chat_channel_id,
)
query = query.where(following: true) if @chat_channel.public_channel?
query
end
def build_data_for(membership, identifier_type:)
data = {
chat_message_id: @chat_message.id,
chat_channel_id: @chat_channel.id,
mentioned_by_username: @creator.username,
is_direct_message_channel: @chat_channel.direct_message_channel?,
}
if !@is_direct_message_channel
data[:chat_channel_title] = @chat_channel.title(membership.user)
data[:chat_channel_slug] = @chat_channel.slug
end
return data if identifier_type == :direct_mentions
case identifier_type
when :here_mentions
data[:identifier] = "here"
when :global_mentions
data[:identifier] = "all"
else
data[:identifier] = identifier_type if identifier_type
data[:is_group_mention] = true
end
data
end
def build_payload_for(membership, identifier_type:)
payload = {
notification_type: Notification.types[:chat_mention],
username: @creator.username,
tag: Chat::ChatNotifier.push_notification_tag(:mention, @chat_channel.id),
excerpt: @chat_message.push_notification_excerpt,
post_url: "#{@chat_channel.relative_url}/#{@chat_message.id}",
}
translation_prefix =
(
if @chat_channel.direct_message_channel?
"discourse_push_notifications.popup.direct_message_chat_mention"
else
"discourse_push_notifications.popup.chat_mention"
end
)
translation_suffix = identifier_type == :direct_mentions ? "direct" : "other_type"
identifier_text =
case identifier_type
when :here_mentions
"@here"
when :global_mentions
"@all"
when :direct_mentions
""
else
"@#{identifier_type}"
end
payload[:translated_title] = I18n.t(
"#{translation_prefix}.#{translation_suffix}",
username: @creator.username,
identifier: identifier_text,
channel: @chat_channel.title(membership.user),
)
payload
end
def create_notification!(membership, mention, mention_type)
notification_data = build_data_for(membership, identifier_type: mention_type)
is_read = Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id)
notification =
Notification.create!(
notification_type: Notification.types[:chat_mention],
user_id: membership.user_id,
high_priority: true,
data: notification_data.to_json,
read: is_read,
)
mention.update!(notification: notification)
end
def send_notifications(membership, mention_type)
payload = build_payload_for(membership, identifier_type: mention_type)
if !membership.desktop_notifications_never? && !membership.muted?
MessageBus.publish(
"/chat/notification-alert/#{membership.user_id}",
payload,
user_ids: [membership.user_id],
)
end
if !membership.mobile_notifications_never? && !membership.muted?
PostAlerter.push_notification(membership.user, payload)
end
end
def process_mentions(user_ids, mention_type)
memberships = get_memberships(user_ids)
memberships.each do |membership|
mention = ChatMention.find_by(user: membership.user, chat_message: @chat_message)
if mention.present?
create_notification!(membership, mention, mention_type)
send_notifications(membership, mention_type)
end
end
end
end
end

View File

@ -0,0 +1,84 @@
# frozen_string_literal: true
module Jobs
class ChatNotifyWatching < ::Jobs::Base
def execute(args = {})
@chat_message =
ChatMessage.includes(:user, chat_channel: :chatable).find_by(id: args[:chat_message_id])
return if @chat_message.nil?
@creator = @chat_message.user
@chat_channel = @chat_message.chat_channel
@is_direct_message_channel = @chat_channel.direct_message_channel?
always_notification_level = UserChatChannelMembership::NOTIFICATION_LEVELS[:always]
members =
UserChatChannelMembership
.includes(user: :groups)
.joins(user: :user_option)
.where(user_option: { chat_enabled: true })
.where.not(user_id: args[:except_user_ids])
.where(chat_channel_id: @chat_channel.id)
.where(following: true)
.where(
"desktop_notification_level = ? OR mobile_notification_level = ?",
always_notification_level,
always_notification_level,
)
.merge(User.not_suspended)
if @is_direct_message_channel
UserCommScreener
.new(acting_user: @creator, target_user_ids: members.map(&:user_id))
.allowing_actor_communication
.each do |user_id|
send_notifications(members.find { |member| member.user_id == user_id })
end
else
members.each { |member| send_notifications(member) }
end
end
def send_notifications(membership)
user = membership.user
guardian = Guardian.new(user)
return unless guardian.can_chat? && guardian.can_join_chat_channel?(@chat_channel)
return if Chat::ChatNotifier.user_has_seen_message?(membership, @chat_message.id)
return if online_user_ids.include?(user.id)
translation_key =
(
if @is_direct_message_channel
"discourse_push_notifications.popup.new_direct_chat_message"
else
"discourse_push_notifications.popup.new_chat_message"
end
)
translation_args = { username: @creator.username }
translation_args[:channel] = @chat_channel.title(user) unless @is_direct_message_channel
payload = {
username: @creator.username,
notification_type: Notification.types[:chat_message],
post_url: @chat_channel.relative_url,
translated_title: I18n.t(translation_key, translation_args),
tag: Chat::ChatNotifier.push_notification_tag(:message, @chat_channel.id),
excerpt: @chat_message.push_notification_excerpt,
}
if membership.desktop_notifications_always? && !membership.muted?
MessageBus.publish("/chat/notification-alert/#{user.id}", payload, user_ids: [user.id])
end
if membership.mobile_notifications_always? && !membership.muted?
PostAlerter.push_notification(user, payload)
end
end
def online_user_ids
@online_user_ids ||= PresenceChannel.new("/chat/online").user_ids
end
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Jobs
class DeleteUserMessages < ::Jobs::Base
def execute(args)
return if args[:user_id].nil?
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage.with_deleted.where(user_id: args[:user_id]),
)
end
end
end

View File

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Jobs
class ProcessChatMessage < ::Jobs::Base
def execute(args = {})
DistributedMutex.synchronize(
"process_chat_message_#{args[:chat_message_id]}",
validity: 10.minutes,
) do
chat_message = ChatMessage.find_by(id: args[:chat_message_id])
return if !chat_message
processor = Chat::ChatMessageProcessor.new(chat_message)
processor.run!
if args[:is_dirty] || processor.dirty?
chat_message.update(cooked: processor.html, cooked_version: ChatMessage::BAKED_VERSION)
ChatPublisher.publish_processed!(chat_message)
end
end
end
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Jobs
class SendMessageNotifications < ::Jobs::Base
def execute(args)
reason = args[:reason]
valid_reasons = %w[new edit]
return unless valid_reasons.include?(reason)
return if (timestamp = args[:timestamp]).blank?
return if (message = ChatMessage.find_by(id: args[:chat_message_id])).nil?
if reason == "new"
Chat::ChatNotifier.new(message, timestamp).notify_new
elsif reason == "edit"
Chat::ChatNotifier.new(message, timestamp).notify_edit
end
end
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
module Jobs
class UpdateChannelUserCount < Jobs::Base
def execute(args = {})
channel = ChatChannel.find_by(id: args[:chat_channel_id])
return if channel.blank?
return if !channel.user_count_stale
channel.update!(
user_count: ChatChannelMembershipsQuery.count(channel),
user_count_stale: false,
)
ChatPublisher.publish_chat_channel_metadata(channel)
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Jobs
class AutoJoinUsers < ::Jobs::Scheduled
every 1.hour
def execute(_args)
ChatChannel
.where(auto_join_users: true)
.each do |channel|
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
end
end
end

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class AutoJoinUsers < ::Jobs::Scheduled
every 1.hour
def execute(_args)
::Chat::Channel
.where(auto_join_users: true)
.each do |channel|
::Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
end
end
end
end

View File

@ -1,40 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class DeleteOldMessages < ::Jobs::Scheduled
daily at: 0.hours
def execute(args = {})
delete_public_channel_messages
delete_dm_channel_messages
end
private
def delete_public_channel_messages
return unless valid_day_value?(:chat_channel_retention_days)
::Chat::MessageDestroyer.new.destroy_in_batches(
::Chat::Message.in_public_channel.with_deleted.created_before(
::SiteSetting.chat_channel_retention_days.days.ago,
),
)
end
def delete_dm_channel_messages
return unless valid_day_value?(:chat_dm_retention_days)
::Chat::MessageDestroyer.new.destroy_in_batches(
::Chat::Message.in_dm_channel.with_deleted.created_before(
::SiteSetting.chat_dm_retention_days.days.ago,
),
)
end
def valid_day_value?(setting_name)
(::SiteSetting.public_send(setting_name) || 0).positive?
end
end
end
end

View File

@ -1,15 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class EmailNotifications < ::Jobs::Scheduled
every 5.minutes
def execute(args = {})
return unless ::SiteSetting.chat_enabled
::Chat::Mailer.send_unread_mentions_summary
end
end
end
end

View File

@ -1,16 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class PeriodicalUpdates < ::Jobs::Scheduled
every 15.minutes
def execute(args = nil)
# TODO: Add rebaking of old messages (baked_version <
# Chat::Message::BAKED_VERSION or baked_version IS NULL)
::Chat::Channel.ensure_consistency!
nil
end
end
end
end

View File

@ -1,30 +0,0 @@
# frozen_string_literal: true
module Jobs
# TODO (martin) Move into Chat::Channel.ensure_consistency! so it
# is run with Jobs::Chat::PeriodicalUpdates
module Chat
class UpdateUserCountsForChannels < ::Jobs::Scheduled
every 1.hour
# FIXME: This could become huge as the amount of channels grows, we
# need a different approach here. Perhaps we should only bother for
# channels updated or with new messages in the past N days? Perhaps
# we could update all the counts in a single query as well?
def execute(args = {})
::Chat::Channel
.where(status: %i[open closed])
.find_each { |chat_channel| set_user_count(chat_channel) }
end
def set_user_count(chat_channel)
current_count = chat_channel.user_count || 0
new_count = ::Chat::ChannelMembershipsQuery.count(chat_channel)
return if current_count == new_count
chat_channel.update(user_count: new_count, user_count_stale: false)
::Chat::Publisher.publish_chat_channel_metadata(chat_channel)
end
end
end
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Jobs
class ChatPeriodicalUpdates < ::Jobs::Scheduled
every 15.minutes
def execute(args = nil)
# TODO: Add rebaking of old messages (baked_version <
# ChatMessage::BAKED_VERSION or baked_version IS NULL)
ChatChannel.ensure_consistency!
nil
end
end
end

View File

@ -0,0 +1,38 @@
# frozen_string_literal: true
module Jobs
class DeleteOldChatMessages < ::Jobs::Scheduled
daily at: 0.hours
def execute(args = {})
delete_public_channel_messages
delete_dm_channel_messages
end
private
def delete_public_channel_messages
return unless valid_day_value?(:chat_channel_retention_days)
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage.in_public_channel.with_deleted.created_before(
SiteSetting.chat_channel_retention_days.days.ago,
),
)
end
def delete_dm_channel_messages
return unless valid_day_value?(:chat_dm_retention_days)
ChatMessageDestroyer.new.destroy_in_batches(
ChatMessage.in_dm_channel.with_deleted.created_before(
SiteSetting.chat_dm_retention_days.days.ago,
),
)
end
def valid_day_value?(setting_name)
(SiteSetting.public_send(setting_name) || 0).positive?
end
end
end

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Jobs
class EmailChatNotifications < ::Jobs::Scheduled
every 5.minutes
def execute(args = {})
return unless SiteSetting.chat_enabled
Chat::ChatMailer.send_unread_mentions_summary
end
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
module Jobs
# TODO (martin) Move into ChatChannel.ensure_consistency! so it
# is run with ChatPeriodicalUpdates
class UpdateUserCountsForChatChannels < ::Jobs::Scheduled
every 1.hour
# FIXME: This could become huge as the amount of channels grows, we
# need a different approach here. Perhaps we should only bother for
# channels updated or with new messages in the past N days? Perhaps
# we could update all the counts in a single query as well?
def execute(args = {})
ChatChannel
.where(status: %i[open closed])
.find_each { |chat_channel| set_user_count(chat_channel) }
end
def set_user_count(chat_channel)
current_count = chat_channel.user_count || 0
new_count = ChatChannelMembershipsQuery.count(chat_channel)
return if current_count == new_count
chat_channel.update(user_count: new_count, user_count_stale: false)
ChatPublisher.publish_chat_channel_metadata(chat_channel)
end
end
end

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
class CategoryChannel < ChatChannel
alias_attribute :category, :chatable
delegate :read_restricted?, to: :category
delegate :url, to: :chatable, prefix: true
%i[category_channel? public_channel? chatable_has_custom_fields?].each do |name|
define_method(name) { true }
end
def allowed_group_ids
return if !read_restricted?
staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values
category.secure_group_ids.to_a.concat(staff_groups)
end
def title(_ = nil)
name.presence || category.name
end
def generate_auto_slug
return if self.slug.present?
self.slug = Slug.for(self.title.strip, "")
self.slug = "" if duplicate_slug?
end
def ensure_slug_ok
if self.slug.present?
# if we don't unescape it first we strip the % from the encoded version
slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug
self.slug = Slug.for(slug, "", method: :encoded)
if self.slug.blank?
errors.add(:slug, :invalid)
elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only?
errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars"))
elsif duplicate_slug?
errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use"))
end
end
end
end

View File

@ -1,51 +0,0 @@
# frozen_string_literal: true
module Chat
class CategoryChannel < Channel
alias_attribute :category, :chatable
delegate :read_restricted?, to: :category
delegate :url, to: :chatable, prefix: true
def self.polymorphic_class_for(name)
Chat::Chatable.polymorphic_class_for(name) || super(name)
end
%i[category_channel? public_channel? chatable_has_custom_fields?].each do |name|
define_method(name) { true }
end
def allowed_group_ids
return if !read_restricted?
staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values
category.secure_group_ids.to_a.concat(staff_groups)
end
def title(_ = nil)
name.presence || category.name
end
def generate_auto_slug
return if self.slug.present?
self.slug = Slug.for(self.title.strip, "")
self.slug = "" if duplicate_slug?
end
def ensure_slug_ok
if self.slug.present?
# if we don't unescape it first we strip the % from the encoded version
slug = SiteSetting.slug_generation_method == "encoded" ? CGI.unescape(self.slug) : self.slug
self.slug = Slug.for(slug, "", method: :encoded)
if self.slug.blank?
errors.add(:slug, :invalid)
elsif SiteSetting.slug_generation_method == "ascii" && !CGI.unescape(self.slug).ascii_only?
errors.add(:slug, I18n.t("chat.category_channel.errors.slug_contains_non_ascii_chars"))
elsif duplicate_slug?
errors.add(:slug, I18n.t("chat.category_channel.errors.is_already_in_use"))
end
end
end
end
end

View File

@ -1,196 +0,0 @@
# frozen_string_literal: true
module Chat
class Channel < ActiveRecord::Base
include Trashable
self.table_name = "chat_channels"
belongs_to :chatable, polymorphic: true
def self.sti_class_for(type_name)
Chat::Chatable.sti_class_for(type_name) || super(type_name)
end
def self.sti_name
Chat::Chatable.sti_name_for(self) || super
end
belongs_to :direct_message,
class_name: "Chat::DirectMessage",
foreign_key: :chatable_id,
inverse_of: :direct_message_channel,
optional: true
has_many :chat_messages, class_name: "Chat::Message", foreign_key: :chat_channel_id
has_many :user_chat_channel_memberships,
class_name: "Chat::UserChatChannelMembership",
foreign_key: :chat_channel_id
has_one :chat_channel_archive, class_name: "Chat::ChannelArchive", foreign_key: :chat_channel_id
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
validates :name,
length: {
maximum: Proc.new { SiteSetting.max_topic_title_length },
},
presence: true,
allow_nil: true
validate :ensure_slug_ok, if: :slug_changed?
before_validation :generate_auto_slug
scope :public_channels,
-> {
where(chatable_type: public_channel_chatable_types).where(
"categories.id IS NOT NULL",
).joins(
"LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'",
)
}
delegate :empty?, to: :chat_messages, prefix: true
class << self
def editable_statuses
statuses.filter { |k, _| !%w[read_only archived].include?(k) }
end
def public_channel_chatable_types
%w[Category]
end
def direct_channel_chatable_types
%w[DirectMessage]
end
def chatable_types
public_channel_chatable_types + direct_channel_chatable_types
end
end
statuses.keys.each do |status|
define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) }
end
%i[
category_channel?
direct_message_channel?
public_channel?
chatable_has_custom_fields?
read_restricted?
].each { |name| define_method(name) { false } }
%i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } }
def membership_for(user)
user_chat_channel_memberships.find_by(user: user)
end
def add(user)
Chat::ChannelMembershipManager.new(self).follow(user)
end
def remove(user)
Chat::ChannelMembershipManager.new(self).unfollow(user)
end
def url
"#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}"
end
def relative_url
"#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}"
end
def self.ensure_consistency!
update_counts
end
# TODO (martin) Move Jobs::Chat::UpdateUserCountsForChannels into here
def self.update_counts
# NOTE: Chat::Channel#messages_count is not updated every time
# a message is created or deleted in a channel, so it should not
# be displayed in the UI. It is updated eventually via Jobs::Chat::PeriodicalUpdates
DB.exec <<~SQL
UPDATE chat_channels channels
SET messages_count = subquery.messages_count
FROM (
SELECT COUNT(*) AS messages_count, chat_channel_id
FROM chat_messages
WHERE chat_messages.deleted_at IS NULL
GROUP BY chat_channel_id
) subquery
WHERE channels.id = subquery.chat_channel_id
AND channels.deleted_at IS NULL
AND subquery.messages_count != channels.messages_count
SQL
end
private
def change_status(acting_user, target_status)
return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status)
self.update!(status: target_status)
log_channel_status_change(acting_user: acting_user)
end
def log_channel_status_change(acting_user:)
DiscourseEvent.trigger(
:chat_channel_status_change,
channel: self,
old_status: status_previously_was,
new_status: status,
)
StaffActionLogger.new(acting_user).log_custom(
"chat_channel_status_change",
{
chat_channel_id: self.id,
chat_channel_name: self.name,
previous_value: status_previously_was,
new_value: status,
},
)
Chat::Publisher.publish_channel_status(self)
end
def duplicate_slug?
Chat::Channel.where(slug: self.slug).where.not(id: self.id).any?
end
end
end
# == Schema Information
#
# Table name: chat_channels
#
# id :bigint not null, primary key
# chatable_id :integer not null
# deleted_at :datetime
# deleted_by_id :integer
# featured_in_category_id :integer
# delete_after_seconds :integer
# chatable_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# name :string
# description :text
# status :integer default("open"), not null
# user_count :integer default(0), not null
# last_message_sent_at :datetime not null
# auto_join_users :boolean default(FALSE), not null
# allow_channel_wide_mentions :boolean default(TRUE), not null
# user_count_stale :boolean default(FALSE), not null
# slug :string
# type :string
# threading_enabled :boolean default(FALSE), not null
#
# Indexes
#
# index_chat_channels_on_messages_count (messages_count)
# index_chat_channels_on_chatable_id (chatable_id)
# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type)
# index_chat_channels_on_slug (slug) UNIQUE
# index_chat_channels_on_status (status)
#

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
module Chat
class DeletedUser < User
def username
I18n.t("chat.deleted_chat_username")
end
def avatar_template
"/plugins/chat/images/deleted-chat-user-avatar.png"
end
def bot?
false
end
end
end

View File

@ -1,72 +0,0 @@
# frozen_string_literal: true
module Chat
class DirectMessage < ActiveRecord::Base
self.table_name = "direct_message_channels"
include Chatable
def self.polymorphic_name
Chat::Chatable.polymorphic_name_for(self) || super
end
has_many :direct_message_users,
class_name: "Chat::DirectMessageUser",
foreign_key: :direct_message_channel_id
has_many :users, through: :direct_message_users
has_one :direct_message_channel, as: :chatable, class_name: "Chat::DirectMessageChannel"
def self.for_user_ids(user_ids)
joins(:users)
.group("direct_message_channels.id")
.having("ARRAY[?] = ARRAY_AGG(users.id ORDER BY users.id)", user_ids.sort)
&.first
end
def user_can_access?(user)
users.include?(user)
end
def chat_channel_title_for_user(chat_channel, acting_user)
users =
(direct_message_users.map(&:user) - [acting_user]).map do |user|
user || Chat::DeletedUser.new
end
# direct message to self
if users.empty?
return I18n.t("chat.channel.dm_title.single_user", username: "@#{acting_user.username}")
end
# all users deleted
return chat_channel.id if !users.first
usernames_formatted = users.sort_by(&:username).map { |u| "@#{u.username}" }
if usernames_formatted.size > 5
return(
I18n.t(
"chat.channel.dm_title.multi_user_truncated",
comma_separated_usernames:
usernames_formatted[0..4].join(I18n.t("word_connector.comma")),
count: usernames_formatted.length - 5,
)
)
end
I18n.t(
"chat.channel.dm_title.multi_user",
comma_separated_usernames: usernames_formatted.join(I18n.t("word_connector.comma")),
)
end
end
end
# == Schema Information
#
# Table name: direct_message_channels
#
# id :bigint not null, primary key
# created_at :datetime not null
# updated_at :datetime not null
#

View File

@ -1,35 +0,0 @@
# frozen_string_literal: true
module Chat
class DirectMessageChannel < Channel
alias_attribute :direct_message, :chatable
def self.polymorphic_class_for(name)
Chat::Chatable.polymorphic_class_for(name) || super(name)
end
def direct_message_channel?
true
end
def allowed_user_ids
direct_message.user_ids
end
def read_restricted?
true
end
def title(user)
direct_message.chat_channel_title_for_user(self, user)
end
def ensure_slug_ok
true
end
def generate_auto_slug
self.slug = nil
end
end
end

View File

@ -1,360 +0,0 @@
# frozen_string_literal: true
module Chat
class Message < ActiveRecord::Base
include Trashable
self.table_name = "chat_messages"
attribute :has_oneboxes, default: false
BAKED_VERSION = 2
belongs_to :chat_channel, class_name: "Chat::Channel"
belongs_to :user
belongs_to :in_reply_to, class_name: "Chat::Message"
belongs_to :last_editor, class_name: "User"
belongs_to :thread, class_name: "Chat::Thread"
has_many :replies,
class_name: "Chat::Message",
foreign_key: "in_reply_to_id",
dependent: :nullify
has_many :revisions,
class_name: "Chat::MessageRevision",
dependent: :destroy,
foreign_key: :chat_message_id
has_many :reactions,
class_name: "Chat::MessageReaction",
dependent: :destroy,
foreign_key: :chat_message_id
has_many :bookmarks,
-> {
unscope(where: :bookmarkable_type).where(bookmarkable_type: Chat::Message.sti_name)
},
as: :bookmarkable,
dependent: :destroy
has_many :upload_references,
-> { unscope(where: :target_type).where(target_type: Chat::Message.sti_name) },
dependent: :destroy,
foreign_key: :target_id
has_many :uploads, through: :upload_references, class_name: "::Upload"
CLASS_MAPPING = { "ChatMessage" => Chat::Message }
# the model used when loading type column
def self.sti_class_for(name)
CLASS_MAPPING[name] if CLASS_MAPPING.key?(name)
end
# the type column value
def self.sti_name
CLASS_MAPPING.invert.fetch(self)
end
# the model used when loading chatable_type column
def self.polymorphic_class_for(name)
CLASS_MAPPING[name] if CLASS_MAPPING.key?(name)
end
# the type stored in *_type column of polymorphic associations
def self.polymorphic_name
CLASS_MAPPING.invert.fetch(self) || super
end
# TODO (martin) Remove this when we drop the ChatUpload table
has_many :chat_uploads,
dependent: :destroy,
class_name: "Chat::Upload",
foreign_key: :chat_message_id
has_one :chat_webhook_event,
dependent: :destroy,
class_name: "Chat::WebhookEvent",
foreign_key: :chat_message_id
has_many :chat_mentions,
dependent: :destroy,
class_name: "Chat::Mention",
foreign_key: :chat_message_id
scope :in_public_channel,
-> {
joins(:chat_channel).where(
chat_channel: {
chatable_type: Chat::Channel.public_channel_chatable_types,
},
)
}
scope :in_dm_channel,
-> {
joins(:chat_channel).where(
chat_channel: {
chatable_type: Chat::Channel.direct_channel_chatable_types,
},
)
}
scope :created_before, ->(date) { where("chat_messages.created_at < ?", date) }
before_save { ensure_last_editor_id }
def validate_message(has_uploads:)
WatchedWordsValidator.new(attributes: [:message]).validate(self)
if self.new_record? || self.changed.include?("message")
Chat::DuplicateMessageValidator.new(self).validate
end
if !has_uploads && message_too_short?
self.errors.add(
:base,
I18n.t(
"chat.errors.minimum_length_not_met",
count: SiteSetting.chat_minimum_message_length,
),
)
end
if message_too_long?
self.errors.add(
:base,
I18n.t("chat.errors.message_too_long", count: SiteSetting.chat_maximum_message_length),
)
end
end
def attach_uploads(uploads)
return if uploads.blank? || self.new_record?
now = Time.now
ref_record_attrs =
uploads.map do |upload|
{
upload_id: upload.id,
target_id: self.id,
target_type: self.class.sti_name,
created_at: now,
updated_at: now,
}
end
UploadReference.insert_all!(ref_record_attrs)
end
def excerpt(max_length: 50)
# just show the URL if the whole message is a URL, because we cannot excerpt oneboxes
return message if UrlHelper.relaxed_parse(message).is_a?(URI)
# upload-only messages are better represented as the filename
return uploads.first.original_filename if cooked.blank? && uploads.present?
# this may return blank for some complex things like quotes, that is acceptable
PrettyText.excerpt(message, max_length, { text_entities: true })
end
def cooked_for_excerpt
(cooked.blank? && uploads.present?) ? "<p>#{uploads.first.original_filename}</p>" : cooked
end
def push_notification_excerpt
Emoji.gsub_emoji_to_unicode(message).truncate(400)
end
def to_markdown
upload_markdown =
self
.upload_references
.includes(:upload)
.order(:created_at)
.map(&:to_markdown)
.reject(&:empty?)
return self.message if upload_markdown.empty?
return ["#{self.message}\n"].concat(upload_markdown).join("\n") if self.message.present?
upload_markdown.join("\n")
end
def cook
ensure_last_editor_id
self.cooked = self.class.cook(self.message, user_id: self.last_editor_id)
self.cooked_version = BAKED_VERSION
end
def rebake!(invalidate_oneboxes: false, priority: nil)
ensure_last_editor_id
previous_cooked = self.cooked
new_cooked =
self.class.cook(
message,
invalidate_oneboxes: invalidate_oneboxes,
user_id: self.last_editor_id,
)
update_columns(cooked: new_cooked, cooked_version: BAKED_VERSION)
args = { chat_message_id: self.id }
args[:queue] = priority.to_s if priority && priority != :normal
args[:is_dirty] = true if previous_cooked != new_cooked
Jobs.enqueue(Jobs::Chat::ProcessMessage, args)
end
def self.uncooked
where("cooked_version <> ? or cooked_version IS NULL", BAKED_VERSION)
end
MARKDOWN_FEATURES = %w[
anchor
bbcode-block
bbcode-inline
code
category-hashtag
censored
chat-transcript
discourse-local-dates
emoji
emojiShortcuts
inlineEmoji
html-img
hashtag-autocomplete
mentions
unicodeUsernames
onebox
quotes
spoiler-alert
table
text-post-process
upload-protocol
watched-words
]
MARKDOWN_IT_RULES = %w[
autolink
list
backticks
newline
code
fence
image
table
linkify
link
strikethrough
blockquote
emphasis
]
def self.cook(message, opts = {})
# A rule in our Markdown pipeline may have Guardian checks that require a
# user to be present. The last editing user of the message will be more
# generally up to date than the creating user. For example, we use
# this when cooking #hashtags to determine whether we should render
# the found hashtag based on whether the user can access the channel it
# is referencing.
cooked =
PrettyText.cook(
message,
features_override:
MARKDOWN_FEATURES + DiscoursePluginRegistry.chat_markdown_features.to_a,
markdown_it_rules: MARKDOWN_IT_RULES,
force_quote_link: true,
user_id: opts[:user_id],
hashtag_context: "chat-composer",
)
result =
Oneboxer.apply(cooked) do |url|
if opts[:invalidate_oneboxes]
Oneboxer.invalidate(url)
InlineOneboxer.invalidate(url)
end
onebox = Oneboxer.cached_onebox(url)
onebox
end
cooked = result.to_html if result.changed?
cooked
end
def full_url
"#{Discourse.base_url}#{url}"
end
def url
"/chat/c/-/#{self.chat_channel_id}/#{self.id}"
end
def create_mentions(user_ids)
return if user_ids.empty?
now = Time.zone.now
mentions = []
User
.where(id: user_ids)
.find_each do |user|
mentions << {
chat_message_id: self.id,
user_id: user.id,
created_at: now,
updated_at: now,
}
end
Chat::Mention.insert_all(mentions)
end
def update_mentions(mentioned_user_ids)
old_mentions = chat_mentions.pluck(:user_id)
updated_mentions = mentioned_user_ids
mentioned_user_ids_to_drop = old_mentions - updated_mentions
mentioned_user_ids_to_add = updated_mentions - old_mentions
delete_mentions(mentioned_user_ids_to_drop)
create_mentions(mentioned_user_ids_to_add)
end
private
def delete_mentions(user_ids)
chat_mentions.where(user_id: user_ids).destroy_all
end
def message_too_short?
message.length < SiteSetting.chat_minimum_message_length
end
def message_too_long?
message.length > SiteSetting.chat_maximum_message_length
end
def ensure_last_editor_id
self.last_editor_id ||= self.user_id
end
end
end
# == Schema Information
#
# Table name: chat_messages
#
# id :bigint not null, primary key
# chat_channel_id :integer not null
# user_id :integer
# created_at :datetime not null
# updated_at :datetime not null
# deleted_at :datetime
# deleted_by_id :integer
# in_reply_to_id :integer
# message :text
# cooked :text
# cooked_version :integer
# last_editor_id :integer not null
# thread_id :integer
#
# Indexes
#
# idx_chat_messages_by_created_at_not_deleted (created_at) WHERE (deleted_at IS NULL)
# index_chat_messages_on_chat_channel_id_and_created_at (chat_channel_id,created_at)
# index_chat_messages_on_chat_channel_id_and_id (chat_channel_id,id) WHERE (deleted_at IS NULL)
# index_chat_messages_on_last_editor_id (last_editor_id)
# index_chat_messages_on_thread_id (thread_id)
#

View File

@ -1,159 +0,0 @@
# frozen_string_literal: true
module Chat
class ReviewableMessage < Reviewable
def serializer
Chat::ReviewableMessageSerializer
end
def self.action_aliases
{
agree_and_keep_hidden: :agree_and_delete,
agree_and_silence: :agree_and_delete,
agree_and_suspend: :agree_and_delete,
delete_and_agree: :agree_and_delete,
}
end
def self.score_to_silence_user
sensitivity_score(SiteSetting.chat_silence_user_sensitivity, scale: 0.6)
end
def chat_message
@chat_message ||= (target || Chat::Message.with_deleted.find_by(id: target_id))
end
def chat_message_creator
@chat_message_creator ||= chat_message.user
end
def flagged_by_user_ids
@flagged_by_user_ids ||= reviewable_scores.map(&:user_id)
end
def post
nil
end
def build_actions(actions, guardian, args)
return unless pending?
return if chat_message.blank?
agree =
actions.add_bundle(
"#{id}-agree",
icon: "thumbs-up",
label: "reviewables.actions.agree.title",
)
if chat_message.deleted_at?
build_action(actions, :agree_and_restore, icon: "far-eye", bundle: agree)
build_action(actions, :agree_and_keep_deleted, icon: "thumbs-up", bundle: agree)
build_action(actions, :disagree_and_restore, icon: "thumbs-down")
else
build_action(actions, :agree_and_delete, icon: "far-eye-slash", bundle: agree)
build_action(actions, :agree_and_keep_message, icon: "thumbs-up", bundle: agree)
build_action(actions, :disagree, icon: "thumbs-down")
end
if guardian.can_suspend?(chat_message_creator)
build_action(
actions,
:agree_and_suspend,
icon: "ban",
bundle: agree,
client_action: "suspend",
)
build_action(
actions,
:agree_and_silence,
icon: "microphone-slash",
bundle: agree,
client_action: "silence",
)
end
build_action(actions, :ignore, icon: "external-link-alt")
unless chat_message.deleted_at?
build_action(actions, :delete_and_agree, icon: "far-trash-alt")
end
end
def perform_agree_and_keep_message(performed_by, args)
agree
end
def perform_agree_and_restore(performed_by, args)
agree { chat_message.recover! }
end
def perform_agree_and_delete(performed_by, args)
agree { chat_message.trash!(performed_by) }
end
def perform_disagree_and_restore(performed_by, args)
disagree { chat_message.recover! }
end
def perform_disagree(performed_by, args)
disagree
end
def perform_ignore(performed_by, args)
ignore
end
def perform_delete_and_ignore(performed_by, args)
ignore { chat_message.trash!(performed_by) }
end
private
def agree
yield if block_given?
create_result(:success, :approved) do |result|
result.update_flag_stats = { status: :agreed, user_ids: flagged_by_user_ids }
result.recalculate_score = true
end
end
def disagree
yield if block_given?
UserSilencer.unsilence(chat_message_creator)
create_result(:success, :rejected) do |result|
result.update_flag_stats = { status: :disagreed, user_ids: flagged_by_user_ids }
result.recalculate_score = true
end
end
def ignore
yield if block_given?
create_result(:success, :ignored) do |result|
result.update_flag_stats = { status: :ignored, user_ids: flagged_by_user_ids }
end
end
def build_action(
actions,
id,
icon:,
button_class: nil,
bundle: nil,
client_action: nil,
confirm: false
)
actions.add(id, bundle: bundle) do |action|
prefix = "reviewables.actions.#{id}"
action.icon = icon
action.button_class = button_class
action.label = "chat.#{prefix}.title"
action.description = "chat.#{prefix}.description"
action.client_action = client_action
action.confirm_message = "#{prefix}.confirm" if confirm
end
end
end
end

View File

@ -1,95 +0,0 @@
# frozen_string_literal: true
module Chat
class View
attr_reader :user, :chat_channel, :chat_messages, :can_load_more_past, :can_load_more_future
def initialize(
chat_channel:,
chat_messages:,
user:,
can_load_more_past: nil,
can_load_more_future: nil
)
@chat_channel = chat_channel
@chat_messages = chat_messages
@user = user
@can_load_more_past = can_load_more_past
@can_load_more_future = can_load_more_future
end
def reviewable_ids
return @reviewable_ids if defined?(@reviewable_ids)
@reviewable_ids = @user.staff? ? get_reviewable_ids : nil
end
def user_flag_statuses
return @user_flag_statuses if defined?(@user_flag_statuses)
@user_flag_statuses = get_user_flag_statuses
end
private
def get_reviewable_ids
sql = <<~SQL
SELECT
target_id,
MAX(r.id) reviewable_id
FROM
reviewables r
JOIN
reviewable_scores s ON reviewable_id = r.id
WHERE
r.target_id IN (:message_ids) AND
r.target_type = :target_type AND
s.status = :pending
GROUP BY
target_id
SQL
ids = {}
DB
.query(
sql,
pending: ReviewableScore.statuses[:pending],
message_ids: @chat_messages.map(&:id),
target_type: Chat::Message.sti_name,
)
.each { |row| ids[row.target_id] = row.reviewable_id }
ids
end
def get_user_flag_statuses
sql = <<~SQL
SELECT
target_id,
s.status
FROM
reviewables r
JOIN
reviewable_scores s ON reviewable_id = r.id
WHERE
s.user_id = :user_id AND
r.target_id IN (:message_ids) AND
r.target_type = :target_type
SQL
statuses = {}
DB
.query(
sql,
message_ids: @chat_messages.map(&:id),
user_id: @user.id,
target_type: Chat::Message.sti_name,
)
.each { |row| statuses[row.target_id] = row.status }
statuses
end
end
end

View File

@ -0,0 +1,176 @@
# frozen_string_literal: true
class ChatChannel < ActiveRecord::Base
include Trashable
belongs_to :chatable, polymorphic: true
belongs_to :direct_message,
-> { where(chat_channels: { chatable_type: "DirectMessage" }) },
foreign_key: "chatable_id"
has_many :chat_messages
has_many :user_chat_channel_memberships
has_one :chat_channel_archive
enum :status, { open: 0, read_only: 1, closed: 2, archived: 3 }, scopes: false
validates :name,
length: {
maximum: Proc.new { SiteSetting.max_topic_title_length },
},
presence: true,
allow_nil: true
validate :ensure_slug_ok, if: :slug_changed?
before_validation :generate_auto_slug
scope :public_channels,
-> {
where(chatable_type: public_channel_chatable_types).where(
"categories.id IS NOT NULL",
).joins(
"LEFT JOIN categories ON categories.id = chat_channels.chatable_id AND chat_channels.chatable_type = 'Category'",
)
}
delegate :empty?, to: :chat_messages, prefix: true
class << self
def editable_statuses
statuses.filter { |k, _| !%w[read_only archived].include?(k) }
end
def public_channel_chatable_types
["Category"]
end
def chatable_types
public_channel_chatable_types << "DirectMessage"
end
end
statuses.keys.each do |status|
define_method("#{status}!") { |acting_user| change_status(acting_user, status.to_sym) }
end
%i[
category_channel?
direct_message_channel?
public_channel?
chatable_has_custom_fields?
read_restricted?
].each { |name| define_method(name) { false } }
%i[allowed_user_ids allowed_group_ids chatable_url].each { |name| define_method(name) { nil } }
def membership_for(user)
user_chat_channel_memberships.find_by(user: user)
end
def add(user)
Chat::ChatChannelMembershipManager.new(self).follow(user)
end
def remove(user)
Chat::ChatChannelMembershipManager.new(self).unfollow(user)
end
def url
"#{Discourse.base_url}/chat/c/#{self.slug || "-"}/#{self.id}"
end
def relative_url
"#{Discourse.base_path}/chat/c/#{self.slug || "-"}/#{self.id}"
end
def self.ensure_consistency!
update_counts
end
# TODO (martin) Move UpdateUserCountsForChatChannels into here
def self.update_counts
# NOTE: ChatChannel#messages_count is not updated every time
# a message is created or deleted in a channel, so it should not
# be displayed in the UI. It is updated eventually via Jobs::ChatPeriodicalUpdates
DB.exec <<~SQL
UPDATE chat_channels channels
SET messages_count = subquery.messages_count
FROM (
SELECT COUNT(*) AS messages_count, chat_channel_id
FROM chat_messages
WHERE chat_messages.deleted_at IS NULL
GROUP BY chat_channel_id
) subquery
WHERE channels.id = subquery.chat_channel_id
AND channels.deleted_at IS NULL
AND subquery.messages_count != channels.messages_count
SQL
end
private
def change_status(acting_user, target_status)
return if !Guardian.new(acting_user).can_change_channel_status?(self, target_status)
self.update!(status: target_status)
log_channel_status_change(acting_user: acting_user)
end
def log_channel_status_change(acting_user:)
DiscourseEvent.trigger(
:chat_channel_status_change,
channel: self,
old_status: status_previously_was,
new_status: status,
)
StaffActionLogger.new(acting_user).log_custom(
"chat_channel_status_change",
{
chat_channel_id: self.id,
chat_channel_name: self.name,
previous_value: status_previously_was,
new_value: status,
},
)
ChatPublisher.publish_channel_status(self)
end
def duplicate_slug?
ChatChannel.where(slug: self.slug).where.not(id: self.id).any?
end
end
# == Schema Information
#
# Table name: chat_channels
#
# id :bigint not null, primary key
# chatable_id :integer not null
# deleted_at :datetime
# deleted_by_id :integer
# featured_in_category_id :integer
# delete_after_seconds :integer
# chatable_type :string not null
# created_at :datetime not null
# updated_at :datetime not null
# name :string
# description :text
# status :integer default("open"), not null
# user_count :integer default(0), not null
# last_message_sent_at :datetime not null
# auto_join_users :boolean default(FALSE), not null
# allow_channel_wide_mentions :boolean default(TRUE), not null
# user_count_stale :boolean default(FALSE), not null
# slug :string
# type :string
# threading_enabled :boolean default(FALSE), not null
#
# Indexes
#
# index_chat_channels_on_messages_count (messages_count)
# index_chat_channels_on_chatable_id (chatable_id)
# index_chat_channels_on_chatable_id_and_chatable_type (chatable_id,chatable_type)
# index_chat_channels_on_slug (slug) UNIQUE
# index_chat_channels_on_status (status)
#

Some files were not shown because too many files have changed in this diff Show More