Compare commits

..

14 Commits
beta ... main

Author SHA1 Message Date
Jarek Radosz
38fdd842f5
UX: Fix chat separator alignment (#20669)
Also: work around 1px svg shift in scroll-to-bottom button
2023-03-18 18:03:54 +01:00
Joffrey JAFFEUX
aeab38aff1
UX: disable arrow up to edit if last message is not editable (#20729) 2023-03-17 23:08:10 +01:00
Joffrey JAFFEUX
aa8eff5e16
FIX: ensures updateLastRead is called when receiving a message (#20728)
This behavior is hard to test as it's mostly fixing a race condition: User A sends a message at the same time than User B, which as a result doesn't cause a scroll for the second message and we don't update last read unless we do a small up and down scroll.

`updateLastRead` is debounced so it has no direct consequences to call it slightly more often than what should ideally be needed.
2023-03-17 22:46:59 +01:00
Daniel Waterworth
293cb7bde2
FIX: An ember build is required to run the system tests (#20725) 2023-03-17 13:20:49 -05:00
Joffrey JAFFEUX
cfee0cfee9
FIX: ensures lightbox is working after collapse/expand (#20724)
Prior to this fix, the upload was removed from DOM when collapsed and not decorated again on expand, which was causing lightbox to not get reapplied. The fix is reverting to previous state where content was not removed from DOM.
2023-03-17 18:26:32 +01:00
Joffrey JAFFEUX
c5e5b6d5ab
DEV: fixes a flakey spec (#20721) 2023-03-17 18:01:19 +01:00
Joffrey JAFFEUX
184ce647ea
FIX: correctly infer polymorphic class from bookmarkable type (#20719)
Prior to this change `registered_bookmarkable` would return `nil` as  `type` in `Bookmark.registered_bookmarkable_from_type(type)` would be `ChatMessage` and we registered a `Chat::Message` class.

This commit will now properly rely on each model `polymorphic_class_for(name)` to help us infer the proper type from a a `bookmarkable_type`.

Tests have also been added to ensure that creating/destroying chat message bookmarks is working correctly.

---

Longer explanation

Currently when you save a bookmark in the database, it's associated to another object through a polymorphic relationship, which will is represented by two columns: `bookmarkable_id` and `bookmarkable_type`. The `bookmarkable_id` contains the id of the relationship (a post ID for example) and the `bookmarkable_type` contains the type of the object as a string by default, (`"Post"` for example).

Chat plugin just started namespacing objects, as a result a model named `ChatMessage` is now named `Chat::Message`, to avoid complex and risky migrations we rely on methods provided by rails to alter the `bookmarkable_type` when we save it: we want to still save it as `"ChatMessage"` and not `"Chat::Message"`. And, to retrieve the correct model when we load the bookmark from the database: we want `"ChatMessage"` to load the `Chat::Message` model and not the `ChatMessage`model which doesn't exist anymore.

On top of this the bookmark codepath is allowing plugins to register types and will check against these types, so we alter this code path to be able to do a similar ChatMessage <-> Chat::Message dance and allow to check the type is valid. In the specific case of this commit, we were retrieving a `"ChatMessage"` bookmarkable_type from the DB and looking for it in the registered bookmarkable types which contain `Chat::Message` and not `ChatMessage`.
2023-03-17 17:20:24 +01:00
TheJammiestDodger
f57ba758ce
UX: Update Install Popular items and links (#20688)
* UX: Update 'Install Popular' items and links

* Update popular-themes.js

* Update popular-themes.js

* Update popular-themes.js

* Lint

---------

Co-authored-by: Penar Musaraj <pmusaraj@gmail.com>
2023-03-17 16:05:36 +00:00
Joffrey JAFFEUX
12a18d4d55
DEV: properly namespace chat (#20690)
This commit main goal was to comply with Zeitwerk and properly rely on autoloading. To achieve this, most resources have been namespaced under the `Chat` module.

- Given all models are now namespaced with `Chat::` and would change the stored types in DB when using polymorphism or STI (single table inheritance), this commit uses various Rails methods to ensure proper class is loaded and the stored name in DB is unchanged, eg: `Chat::Message` model will be stored as `"ChatMessage"`, and `"ChatMessage"` will correctly load `Chat::Message` model.
- Jobs are now using constants only, eg: `Jobs::Chat::Foo` and should only be enqueued this way

Notes:
- This commit also used this opportunity to limit the number of registered css files in plugin.rb
- `discourse_dev` support has been removed within this commit and will be reintroduced later

<!-- NOTE: All pull requests should have tests (rspec in Ruby, qunit in JavaScript). If your code does not include test coverage, please include an explanation of why it was omitted. -->
2023-03-17 14:24:38 +01:00
David Taylor
74349e17c9
DEV: Migrate remaining admin classes to native syntax (#20717)
This commit was generated using the ember-native-class-codemod along with a handful of manual updates
2023-03-17 12:25:05 +00:00
David Taylor
1161c980f2
DEV: Resolve and unsilence ember.built-in-components deprecation (#20716)
- Install `@ember/legacy-built-in-components` and update our import statements to use it
- Remove our custom attributeBinding extensions of `TextField` and `TextArea`. Modern ember 'angle bracket syntax' allows us to apply html attributes to a component's element without needing attributeBindings
2023-03-17 11:55:29 +00:00
David Taylor
5e5024d3e7
DEV: Resolve and unsilence ember-global deprecation (#20702)
One of the problems here was coming from the ember-jquery addon. This commit skips the problematic shim from the addon and re-implements in Discourse. This hack will only be required short-term - we'll be totally dropping the ember-jquery integration as part of our upgrade to Ember 4.x.

Removing this shim means we can also remove our `discourse-ensure-deprecation-order` dummy addon which was ensuring that the ember-jquery-triggered deprecation was covered by ember-cli-deprecation-workflow.
2023-03-17 11:22:12 +00:00
David Taylor
64557c4076
DEV: Update admin models to native class syntax (#20704)
This commit was generated using the ember-native-class-codemod along with a handful of manual updates
2023-03-17 10:18:42 +00:00
David Taylor
303f97ce89
PERF: Use native postgres upsert for ApplicationRequest (#20706)
Using `create_or_find_by!`, followed by `update_all!` requires two or three queries (two when the row doesn't already exist, three when it does). Instead, we can use postgres's native `INSERT ... ON CONFLICT ... DO UPDATE SET` feature to do the logic in a single atomic call.
2023-03-17 09:35:29 +00:00
410 changed files with 10060 additions and 9673 deletions

View File

@ -1,13 +1,13 @@
import RESTAdapter from "discourse/adapters/rest";
import RestAdapter from "discourse/adapters/rest";
export default RESTAdapter.extend({
jsonMode: true,
export default class ApiKey extends RestAdapter {
jsonMode = true;
basePath() {
return "/admin/api/";
},
}
apiNameFor() {
return "key";
},
});
}
}

View File

@ -1,11 +1,11 @@
import RestAdapter from "discourse/adapters/rest";
export default function buildPluginAdapter(pluginName) {
return RestAdapter.extend({
return class extends RestAdapter {
pathFor(store, type, findArgs) {
return (
"/admin/plugins/" + pluginName + this._super(store, type, findArgs)
"/admin/plugins/" + pluginName + super.pathFor(store, type, findArgs)
);
},
});
}
};
}

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
export default class CustomizationBase extends RestAdapter {
basePath() {
return "/admin/customize/";
},
});
}
}

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
export default class EmailStyle extends RestAdapter {
pathFor() {
return "/admin/customize/email_style";
},
});
}
}

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
export default class Embedding extends RestAdapter {
pathFor() {
return "/admin/customize/embedding";
},
});
}
}

View File

@ -1,7 +1,7 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
export default class StaffActionLog extends RestAdapter {
basePath() {
return "/admin/logs/";
},
});
}
}

View File

@ -1,5 +1,5 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
jsonMode: true,
});
export default class TagGroup extends RestAdapter {
jsonMode = true;
}

View File

@ -1,9 +1,10 @@
import RestAdapter from "discourse/adapters/rest";
export default RestAdapter.extend({
export default class Theme extends RestAdapter {
jsonMode = true;
basePath() {
return "/admin/";
},
}
afterFindAll(results) {
let map = {};
@ -20,7 +21,5 @@ export default RestAdapter.extend({
theme.set("parentThemes", mappedParents);
});
return results;
},
jsonMode: true,
});
}
}

View File

@ -1,7 +1,7 @@
import RESTAdapter from "discourse/adapters/rest";
import RestAdapter from "discourse/adapters/rest";
export default RESTAdapter.extend({
export default class WebHookEvent extends RestAdapter {
basePath() {
return "/admin/api/";
},
});
}
}

View File

@ -1,7 +1,7 @@
import RESTAdapter from "discourse/adapters/rest";
import RestAdapter from "discourse/adapters/rest";
export default RESTAdapter.extend({
export default class WebHook extends RestAdapter {
basePath() {
return "/admin/api/";
},
});
}
}

View File

@ -2,7 +2,7 @@ import Helper from "@ember/component/helper";
import { iconHTML } from "discourse-common/lib/icon-library";
import { htmlSafe } from "@ember/template";
export default Helper.extend({
export default class DispositionIcon extends Helper {
compute([disposition]) {
if (!disposition) {
return null;
@ -24,5 +24,5 @@ export default Helper.extend({
}
}
return htmlSafe(iconHTML(icon, { title }));
},
});
}
}

View File

@ -7,19 +7,17 @@ const GENERAL_ATTRIBUTES = [
"release_notes_link",
];
const AdminDashboard = EmberObject.extend({});
AdminDashboard.reopenClass({
fetch() {
export default class AdminDashboard extends EmberObject {
static fetch() {
return ajax("/admin/dashboard.json").then((json) => {
const model = AdminDashboard.create();
model.set("version_check", json.version_check);
return model;
});
},
}
fetchGeneral() {
static fetchGeneral() {
return ajax("/admin/dashboard/general.json").then((json) => {
const model = AdminDashboard.create();
@ -34,15 +32,13 @@ AdminDashboard.reopenClass({
return model;
});
},
}
fetchProblems() {
static fetchProblems() {
return ajax("/admin/dashboard/problems.json").then((json) => {
const model = AdminDashboard.create(json);
model.set("loaded", true);
return model;
});
},
});
export default AdminDashboard;
}
}

View File

@ -10,14 +10,30 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
import { propertyNotEqual } from "discourse/lib/computed";
import { userPath } from "discourse/lib/url";
const wrapAdmin = (user) => (user ? AdminUser.create(user) : null);
export default class AdminUser extends User {
static find(user_id) {
return ajax(`/admin/users/${user_id}.json`).then((result) => {
result.loadedDetails = true;
return AdminUser.create(result);
});
}
const AdminUser = User.extend({
adminUserView: true,
customGroups: filter("groups", (g) => !g.automatic && Group.create(g)),
automaticGroups: filter("groups", (g) => g.automatic && Group.create(g)),
static findAll(query, userFilter) {
return ajax(`/admin/users/list/${query}.json`, {
data: userFilter,
}).then((users) => users.map((u) => AdminUser.create(u)));
}
canViewProfile: or("active", "staged"),
adminUserView = true;
@filter("groups", (g) => !g.automatic && Group.create(g)) customGroups;
@filter("groups", (g) => g.automatic && Group.create(g)) automaticGroups;
@or("active", "staged") canViewProfile;
@gt("bounce_score", 0) canResetBounceScore;
@propertyNotEqual("originalTrustLevel", "trust_level") dirty;
@lt("trust_level", 4) canLockTrustLevel;
@not("staff") canSuspend;
@not("staff") canSilence;
@discourseComputed("bounce_score", "reset_bounce_score_after")
bounceScore(bounce_score, reset_bounce_score_after) {
@ -28,7 +44,7 @@ const AdminUser = User.extend({
} else {
return bounce_score;
}
},
}
@discourseComputed("bounce_score")
bounceScoreExplanation(bounce_score) {
@ -39,14 +55,12 @@ const AdminUser = User.extend({
} else {
return I18n.t("admin.user.bounce_score_explanation.threshold_reached");
}
},
}
@discourseComputed
bounceLink() {
return getURL("/admin/email/bounced");
},
canResetBounceScore: gt("bounce_score", 0),
}
resetBounceScore() {
return ajax(`/admin/users/${this.id}/reset_bounce_score`, {
@ -57,14 +71,14 @@ const AdminUser = User.extend({
reset_bounce_score_after: null,
})
);
},
}
groupAdded(added) {
return ajax(`/admin/users/${this.id}/groups`, {
type: "POST",
data: { group_id: added.id },
}).then(() => this.groups.pushObject(added));
},
}
groupRemoved(groupId) {
return ajax(`/admin/users/${this.id}/groups/${groupId}`, {
@ -75,13 +89,13 @@ const AdminUser = User.extend({
this.set("primary_group_id", null);
}
});
},
}
deleteAllPosts() {
return ajax(`/admin/users/${this.get("id")}/delete_posts_batch`, {
type: "PUT",
});
},
}
revokeAdmin() {
return ajax(`/admin/users/${this.id}/revoke_admin`, {
@ -97,7 +111,7 @@ const AdminUser = User.extend({
can_delete_all_posts: resp.can_delete_all_posts,
});
});
},
}
grantAdmin(data) {
return ajax(`/admin/users/${this.id}/grant_admin`, {
@ -114,7 +128,7 @@ const AdminUser = User.extend({
return resp;
});
},
}
revokeModeration() {
return ajax(`/admin/users/${this.id}/revoke_moderation`, {
@ -130,7 +144,7 @@ const AdminUser = User.extend({
});
})
.catch(popupAjaxError);
},
}
grantModeration() {
return ajax(`/admin/users/${this.id}/grant_moderation`, {
@ -146,7 +160,7 @@ const AdminUser = User.extend({
});
})
.catch(popupAjaxError);
},
}
disableSecondFactor() {
return ajax(`/admin/users/${this.id}/disable_second_factor`, {
@ -156,7 +170,7 @@ const AdminUser = User.extend({
this.set("second_factor_enabled", false);
})
.catch(popupAjaxError);
},
}
approve(approvedBy) {
return ajax(`/admin/users/${this.id}/approve`, {
@ -168,83 +182,76 @@ const AdminUser = User.extend({
approved_by: approvedBy,
});
});
},
}
setOriginalTrustLevel() {
this.set("originalTrustLevel", this.trust_level);
},
dirty: propertyNotEqual("originalTrustLevel", "trust_level"),
}
saveTrustLevel() {
return ajax(`/admin/users/${this.id}/trust_level`, {
type: "PUT",
data: { level: this.trust_level },
});
},
}
restoreTrustLevel() {
this.set("trust_level", this.originalTrustLevel);
},
}
lockTrustLevel(locked) {
return ajax(`/admin/users/${this.id}/trust_level_lock`, {
type: "PUT",
data: { locked: !!locked },
});
},
canLockTrustLevel: lt("trust_level", 4),
canSuspend: not("staff"),
canSilence: not("staff"),
}
@discourseComputed("suspended_till", "suspended_at")
suspendDuration(suspendedTill, suspendedAt) {
suspendedAt = moment(suspendedAt);
suspendedTill = moment(suspendedTill);
return suspendedAt.format("L") + " - " + suspendedTill.format("L");
},
}
suspend(data) {
return ajax(`/admin/users/${this.id}/suspend`, {
type: "PUT",
data,
}).then((result) => this.setProperties(result.suspension));
},
}
unsuspend() {
return ajax(`/admin/users/${this.id}/unsuspend`, {
type: "PUT",
}).then((result) => this.setProperties(result.suspension));
},
}
logOut() {
return ajax("/admin/users/" + this.id + "/log_out", {
type: "POST",
data: { username_or_email: this.username },
});
},
}
impersonate() {
return ajax("/admin/impersonate", {
type: "POST",
data: { username_or_email: this.username },
});
},
}
activate() {
return ajax(`/admin/users/${this.id}/activate`, {
type: "PUT",
});
},
}
deactivate() {
return ajax(`/admin/users/${this.id}/deactivate`, {
type: "PUT",
data: { context: document.location.pathname },
});
},
}
unsilence() {
this.set("silencingUser", true);
@ -254,7 +261,7 @@ const AdminUser = User.extend({
})
.then((result) => this.setProperties(result.unsilence))
.finally(() => this.set("silencingUser", false));
},
}
silence(data) {
this.set("silencingUser", true);
@ -265,20 +272,20 @@ const AdminUser = User.extend({
})
.then((result) => this.setProperties(result.silence))
.finally(() => this.set("silencingUser", false));
},
}
sendActivationEmail() {
return ajax(userPath("action/send_activation_email"), {
type: "POST",
data: { username: this.username },
});
},
}
anonymize() {
return ajax(`/admin/users/${this.id}/anonymize.json`, {
type: "PUT",
});
},
}
destroy(formData) {
return ajax(`/admin/users/${this.id}.json`, {
@ -295,14 +302,14 @@ const AdminUser = User.extend({
.catch(() => {
this.find(this.id).then((u) => this.setProperties(u));
});
},
}
merge(formData) {
return ajax(`/admin/users/${this.id}/merge.json`, {
type: "POST",
data: formData,
});
},
}
loadDetails() {
if (this.loadedDetails) {
@ -313,23 +320,29 @@ const AdminUser = User.extend({
const userProperties = Object.assign(result, { loadedDetails: true });
this.setProperties(userProperties);
});
},
}
@discourseComputed("tl3_requirements")
tl3Requirements(requirements) {
if (requirements) {
return this.store.createRecord("tl3Requirements", requirements);
}
},
}
@discourseComputed("suspended_by")
suspendedBy: wrapAdmin,
suspendedBy(user) {
return user ? AdminUser.create(user) : null;
}
@discourseComputed("silenced_by")
silencedBy: wrapAdmin,
silencedBy(user) {
return user ? AdminUser.create(user) : null;
}
@discourseComputed("approved_by")
approvedBy: wrapAdmin,
approvedBy(user) {
return user ? AdminUser.create(user) : null;
}
deleteSSORecord() {
return ajax(`/admin/users/${this.id}/sso_record.json`, {
@ -339,22 +352,5 @@ const AdminUser = User.extend({
this.set("single_sign_on_record", null);
})
.catch(popupAjaxError);
},
});
AdminUser.reopenClass({
find(user_id) {
return ajax(`/admin/users/${user_id}.json`).then((result) => {
result.loadedDetails = true;
return AdminUser.create(result);
});
},
findAll(query, userFilter) {
return ajax(`/admin/users/list/${query}.json`, {
data: userFilter,
}).then((users) => users.map((u) => AdminUser.create(u)));
},
});
export default AdminUser;
}
}

View File

@ -1,24 +1,26 @@
import { computed } from "@ember/object";
import AdminUser from "admin/models/admin-user";
import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax";
import { computed } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import { fmt } from "discourse/lib/computed";
const ApiKey = RestModel.extend({
user: computed("_user", {
get() {
return this._user;
},
set(key, value) {
if (value && !(value instanceof AdminUser)) {
this.set("_user", AdminUser.create(value));
} else {
this.set("_user", value);
}
return this._user;
},
}),
export default class ApiKey extends RestModel {
@fmt("truncated_key", "%@...") truncatedKey;
@computed("_user")
get user() {
return this._user;
}
set user(value) {
if (value && !(value instanceof AdminUser)) {
this.set("_user", AdminUser.create(value));
} else {
this.set("_user", value);
}
return this._user;
}
@discourseComputed("description")
shortDescription(description) {
@ -26,32 +28,28 @@ const ApiKey = RestModel.extend({
return description;
}
return `${description.substring(0, 40)}...`;
},
truncatedKey: fmt("truncated_key", "%@..."),
}
revoke() {
return ajax(`${this.basePath}/revoke`, {
type: "POST",
}).then((result) => this.setProperties(result.api_key));
},
}
undoRevoke() {
return ajax(`${this.basePath}/undo-revoke`, {
type: "POST",
}).then((result) => this.setProperties(result.api_key));
},
}
createProperties() {
return this.getProperties("description", "username", "scopes");
},
}
@discourseComputed()
basePath() {
return this.store
.adapterFor("api-key")
.pathFor(this.store, "api-key", this.id);
},
});
export default ApiKey;
}
}

View File

@ -1,12 +1,12 @@
import { not } from "@ember/object/computed";
import EmberObject from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import { not } from "@ember/object/computed";
export default EmberObject.extend({
restoreDisabled: not("restoreEnabled"),
export default class BackupStatus extends EmberObject {
@not("restoreEnabled") restoreDisabled;
@discourseComputed("allowRestore", "isOperationRunning")
restoreEnabled(allowRestore, isOperationRunning) {
return allowRestore && !isOperationRunning;
},
});
}
}

View File

@ -2,25 +2,12 @@ import EmberObject from "@ember/object";
import MessageBus from "message-bus-client";
import { ajax } from "discourse/lib/ajax";
const Backup = EmberObject.extend({
destroy() {
return ajax("/admin/backups/" + this.filename, { type: "DELETE" });
},
restore() {
return ajax("/admin/backups/" + this.filename + "/restore", {
type: "POST",
data: { client_id: MessageBus.clientId },
});
},
});
Backup.reopenClass({
find() {
export default class Backup extends EmberObject {
static find() {
return ajax("/admin/backups.json");
},
}
start(withUploads) {
static start(withUploads) {
if (withUploads === undefined) {
withUploads = true;
}
@ -31,19 +18,28 @@ Backup.reopenClass({
client_id: MessageBus.clientId,
},
});
},
}
cancel() {
static cancel() {
return ajax("/admin/backups/cancel.json", {
type: "DELETE",
});
},
}
rollback() {
static rollback() {
return ajax("/admin/backups/rollback.json", {
type: "POST",
});
},
});
}
export default Backup;
destroy() {
return ajax("/admin/backups/" + this.filename, { type: "DELETE" });
}
restore() {
return ajax("/admin/backups/" + this.filename + "/restore", {
type: "POST",
data: { client_id: MessageBus.clientId },
});
}
}

View File

@ -1,19 +1,19 @@
import discourseComputed, {
observes,
on,
} from "discourse-common/utils/decorators";
import discourseComputed from "discourse-common/utils/decorators";
import { observes, on } from "@ember-decorators/object";
import EmberObject from "@ember/object";
import I18n from "I18n";
import { propertyNotEqual } from "discourse/lib/computed";
const ColorSchemeColor = EmberObject.extend({
export default class ColorSchemeColor extends EmberObject {
// Whether the current value is different than Discourse's default color scheme.
@propertyNotEqual("hex", "default_hex") overridden;
@on("init")
startTrackingChanges() {
this.set("originals", { hex: this.hex || "FFFFFF" });
// force changed property to be recalculated
this.notifyPropertyChange("hex");
},
}
// Whether value has changed since it was last saved.
@discourseComputed("hex")
@ -26,26 +26,23 @@ const ColorSchemeColor = EmberObject.extend({
}
return false;
},
// Whether the current value is different than Discourse's default color scheme.
overridden: propertyNotEqual("hex", "default_hex"),
}
// Whether the saved value is different than Discourse's default color scheme.
@discourseComputed("default_hex", "hex")
savedIsOverriden(defaultHex) {
return this.originals.hex !== defaultHex;
},
}
revert() {
this.set("hex", this.default_hex);
},
}
undo() {
if (this.originals) {
this.set("hex", this.originals.hex);
}
},
}
@discourseComputed("name")
translatedName(name) {
@ -54,7 +51,7 @@ const ColorSchemeColor = EmberObject.extend({
} else {
return name;
}
},
}
@discourseComputed("name")
description(name) {
@ -63,7 +60,7 @@ const ColorSchemeColor = EmberObject.extend({
} else {
return "";
}
},
}
/**
brightness returns a number between 0 (darkest) to 255 (brightest).
@ -90,19 +87,17 @@ const ColorSchemeColor = EmberObject.extend({
1000
);
}
},
}
@observes("hex")
hexValueChanged() {
if (this.hex) {
this.set("hex", this.hex.toString().replace(/[^0-9a-fA-F]/g, ""));
}
},
}
@discourseComputed("hex")
valid(hex) {
return hex.match(/^([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/) !== null;
},
});
export default ColorSchemeColor;
}
}

View File

@ -1,3 +1,4 @@
import { not } from "@ember/object/computed";
import { A } from "@ember/array";
import ArrayProxy from "@ember/array/proxy";
import ColorSchemeColor from "admin/models/color-scheme-color";
@ -5,26 +6,56 @@ import EmberObject from "@ember/object";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
import { not } from "@ember/object/computed";
const ColorScheme = EmberObject.extend({
class ColorSchemes extends ArrayProxy {}
export default class ColorScheme extends EmberObject {
static findAll() {
const colorSchemes = ColorSchemes.create({ content: [], loading: true });
return ajax("/admin/color_schemes").then((all) => {
all.forEach((colorScheme) => {
colorSchemes.pushObject(
ColorScheme.create({
id: colorScheme.id,
name: colorScheme.name,
is_base: colorScheme.is_base,
theme_id: colorScheme.theme_id,
theme_name: colorScheme.theme_name,
base_scheme_id: colorScheme.base_scheme_id,
user_selectable: colorScheme.user_selectable,
colors: colorScheme.colors.map((c) => {
return ColorSchemeColor.create({
name: c.name,
hex: c.hex,
default_hex: c.default_hex,
is_advanced: c.is_advanced,
});
}),
})
);
});
return colorSchemes;
});
}
@not("id") newRecord;
init() {
this._super(...arguments);
super.init(...arguments);
this.startTrackingChanges();
},
}
@discourseComputed
description() {
return "" + this.name;
},
}
startTrackingChanges() {
this.set("originals", {
name: this.name,
user_selectable: this.user_selectable,
});
},
}
schemeJson() {
const buffer = [];
@ -33,7 +64,7 @@ const ColorScheme = EmberObject.extend({
});
return [`"${this.name}": {`, buffer.join(",\n"), "}"].join("\n");
},
}
copy() {
const newScheme = ColorScheme.create({
@ -47,7 +78,7 @@ const ColorScheme = EmberObject.extend({
);
});
return newScheme;
},
}
@discourseComputed(
"name",
@ -70,7 +101,7 @@ const ColorScheme = EmberObject.extend({
}
return false;
},
}
@discourseComputed("changed")
disableSave(changed) {
@ -79,9 +110,7 @@ const ColorScheme = EmberObject.extend({
}
return !changed || this.saving || this.colors.any((c) => !c.get("valid"));
},
newRecord: not("id"),
}
save(opts) {
if (this.is_base || this.disableSave) {
@ -124,7 +153,7 @@ const ColorScheme = EmberObject.extend({
this.setProperties({ savingStatus: I18n.t("saved"), saving: false });
this.notifyPropertyChange("description");
});
},
}
updateUserSelectable(value) {
if (!this.id) {
@ -137,45 +166,11 @@ const ColorScheme = EmberObject.extend({
dataType: "json",
contentType: "application/json",
});
},
}
destroy() {
if (this.id) {
return ajax(`/admin/color_schemes/${this.id}`, { type: "DELETE" });
}
},
});
const ColorSchemes = ArrayProxy.extend({});
ColorScheme.reopenClass({
findAll() {
const colorSchemes = ColorSchemes.create({ content: [], loading: true });
return ajax("/admin/color_schemes").then((all) => {
all.forEach((colorScheme) => {
colorSchemes.pushObject(
ColorScheme.create({
id: colorScheme.id,
name: colorScheme.name,
is_base: colorScheme.is_base,
theme_id: colorScheme.theme_id,
theme_name: colorScheme.theme_name,
base_scheme_id: colorScheme.base_scheme_id,
user_selectable: colorScheme.user_selectable,
colors: colorScheme.colors.map((c) => {
return ColorSchemeColor.create({
name: c.name,
hex: c.hex,
default_hex: c.default_hex,
is_advanced: c.is_advanced,
});
}),
})
);
});
return colorSchemes;
});
},
});
export default ColorScheme;
}
}

View File

@ -3,10 +3,8 @@ import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import getURL from "discourse-common/lib/get-url";
const EmailLog = EmberObject.extend({});
EmailLog.reopenClass({
create(attrs) {
export default class EmailLog extends EmberObject {
static create(attrs) {
attrs = attrs || {};
if (attrs.user) {
@ -17,10 +15,10 @@ EmailLog.reopenClass({
attrs.post_url = getURL(attrs.post_url);
}
return this._super(attrs);
},
return super.create(attrs);
}
findAll(filter, offset) {
static findAll(filter, offset) {
filter = filter || {};
offset = offset || 0;
@ -30,7 +28,5 @@ EmailLog.reopenClass({
return ajax(`/admin/email/${status}.json?offset=${offset}`, {
data: filter,
}).then((logs) => logs.map((log) => EmailLog.create(log)));
},
});
export default EmailLog;
}
}

View File

@ -1,25 +1,21 @@
import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
const EmailPreview = EmberObject.extend({});
export function oneWeekAgo() {
return moment().locale("en").subtract(7, "days").format("YYYY-MM-DD");
}
EmailPreview.reopenClass({
findDigest(username, lastSeenAt) {
export default class EmailPreview extends EmberObject {
static findDigest(username, lastSeenAt) {
return ajax("/admin/email/preview-digest.json", {
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username },
}).then((result) => EmailPreview.create(result));
},
}
sendDigest(username, lastSeenAt, email) {
static sendDigest(username, lastSeenAt, email) {
return ajax("/admin/email/send-digest.json", {
type: "POST",
data: { last_seen_at: lastSeenAt || oneWeekAgo(), username, email },
});
},
});
}
}
export default EmailPreview;
export function oneWeekAgo() {
return moment().locale("en").subtract(7, "days").format("YYYY-MM-DD");
}

View File

@ -1,14 +1,10 @@
import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
const EmailSettings = EmberObject.extend({});
EmailSettings.reopenClass({
find() {
export default class EmailSettings extends EmberObject {
static find() {
return ajax("/admin/email.json").then(function (settings) {
return EmailSettings.create(settings);
});
},
});
export default EmailSettings;
}
}

View File

@ -1,10 +1,10 @@
import RestModel from "discourse/models/rest";
export default RestModel.extend({
changed: false,
export default class EmailStyle extends RestModel {
changed = false;
setField(fieldName, value) {
this.set(`${fieldName}`, value);
this.set("changed", true);
},
});
}
}

View File

@ -2,12 +2,12 @@ import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax";
import { getProperties } from "@ember/object";
export default RestModel.extend({
export default class EmailTemplate extends RestModel {
revert() {
return ajax(`/admin/customize/email_templates/${this.id}`, {
type: "DELETE",
}).then((result) =>
getProperties(result.email_template, "subject", "body", "can_revert")
);
},
});
}
}

View File

@ -2,9 +2,9 @@ import I18n from "I18n";
import RestModel from "discourse/models/rest";
import discourseComputed from "discourse-common/utils/decorators";
export default RestModel.extend({
export default class FlagType extends RestModel {
@discourseComputed("id")
name(id) {
return I18n.t(`admin.flags.summary.action_type_${id}`, { count: 1 });
},
});
}
}

View File

@ -1,53 +1,51 @@
import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax";
export default class FormTemplate extends RestModel {}
FormTemplate.reopenClass({
createTemplate(data) {
export default class FormTemplate extends RestModel {
static createTemplate(data) {
return ajax("/admin/customize/form-templates.json", {
type: "POST",
data,
});
},
}
updateTemplate(id, data) {
static updateTemplate(id, data) {
return ajax(`/admin/customize/form-templates/${id}.json`, {
type: "PUT",
data,
});
},
}
createOrUpdateTemplate(data) {
static createOrUpdateTemplate(data) {
if (data.id) {
return this.updateTemplate(data.id, data);
} else {
return this.createTemplate(data);
}
},
}
deleteTemplate(id) {
static deleteTemplate(id) {
return ajax(`/admin/customize/form-templates/${id}.json`, {
type: "DELETE",
});
},
}
findAll() {
static findAll() {
return ajax(`/admin/customize/form-templates.json`).then((model) => {
return model.form_templates.sort((a, b) => a.id - b.id);
});
},
}
findById(id) {
static findById(id) {
return ajax(`/admin/customize/form-templates/${id}.json`).then((model) => {
return model.form_template;
});
},
}
validateTemplate(data) {
static validateTemplate(data) {
return ajax(`/admin/customize/form-templates/preview.json`, {
type: "GET",
data,
});
},
});
}
}

View File

@ -2,10 +2,8 @@ import AdminUser from "admin/models/admin-user";
import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
const IncomingEmail = EmberObject.extend({});
IncomingEmail.reopenClass({
create(attrs) {
export default class IncomingEmail extends EmberObject {
static create(attrs) {
attrs = attrs || {};
if (attrs.user) {
@ -13,17 +11,17 @@ IncomingEmail.reopenClass({
}
return this._super(attrs);
},
}
find(id) {
static find(id) {
return ajax(`/admin/email/incoming/${id}.json`);
},
}
findByBounced(id) {
static findByBounced(id) {
return ajax(`/admin/email/incoming_from_bounced/${id}.json`);
},
}
findAll(filter, offset) {
static findAll(filter, offset) {
filter = filter || {};
offset = offset || 0;
@ -35,11 +33,9 @@ IncomingEmail.reopenClass({
}).then((incomings) =>
incomings.map((incoming) => IncomingEmail.create(incoming))
);
},
}
loadRawEmail(id) {
static loadRawEmail(id) {
return ajax(`/admin/email/incoming/${id}/raw.json`);
},
});
export default IncomingEmail;
}
}

View File

@ -4,7 +4,15 @@ import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
const Permalink = EmberObject.extend({
export default class Permalink extends EmberObject {
static findAll(filter) {
return ajax("/admin/permalinks.json", { data: { filter } }).then(function (
permalinks
) {
return permalinks.map((p) => Permalink.create(p));
});
}
save() {
return ajax("/admin/permalinks.json", {
type: "POST",
@ -14,33 +22,21 @@ const Permalink = EmberObject.extend({
permalink_type_value: this.permalink_type_value,
},
});
},
}
@discourseComputed("category_id")
category(category_id) {
return Category.findById(category_id);
},
}
@discourseComputed("external_url")
linkIsExternal(external_url) {
return !DiscourseURL.isInternal(external_url);
},
}
destroy() {
return ajax("/admin/permalinks/" + this.id + ".json", {
type: "DELETE",
});
},
});
Permalink.reopenClass({
findAll(filter) {
return ajax("/admin/permalinks.json", { data: { filter } }).then(function (
permalinks
) {
return permalinks.map((p) => Permalink.create(p));
});
},
});
export default Permalink;
}
}

View File

@ -19,12 +19,188 @@ import round from "discourse/lib/round";
// and you want to ensure cache is reset
export const SCHEMA_VERSION = 4;
const Report = EmberObject.extend({
average: false,
percent: false,
higher_is_better: true,
description_link: null,
description: null,
export default class Report extends EmberObject {
static groupingForDatapoints(count) {
if (count < DAILY_LIMIT_DAYS) {
return "daily";
}
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
return "weekly";
}
if (count >= WEEKLY_LIMIT_DAYS) {
return "monthly";
}
}
static unitForDatapoints(count) {
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
return "week";
} else if (count >= WEEKLY_LIMIT_DAYS) {
return "month";
} else {
return "day";
}
}
static unitForGrouping(grouping) {
switch (grouping) {
case "monthly":
return "month";
case "weekly":
return "week";
default:
return "day";
}
}
static collapse(model, data, grouping) {
grouping = grouping || Report.groupingForDatapoints(data.length);
if (grouping === "daily") {
return data;
} else if (grouping === "weekly" || grouping === "monthly") {
const isoKind = grouping === "weekly" ? "isoWeek" : "month";
const kind = grouping === "weekly" ? "week" : "month";
const startMoment = moment(model.start_date, "YYYY-MM-DD");
let currentIndex = 0;
let currentStart = startMoment.clone().startOf(isoKind);
let currentEnd = startMoment.clone().endOf(isoKind);
const transformedData = [
{
x: currentStart.format("YYYY-MM-DD"),
y: 0,
},
];
let appliedAverage = false;
data.forEach((d) => {
const date = moment(d.x, "YYYY-MM-DD");
if (
!date.isSame(currentStart) &&
!date.isBetween(currentStart, currentEnd)
) {
if (model.average) {
transformedData[currentIndex].y = applyAverage(
transformedData[currentIndex].y,
currentStart,
currentEnd
);
appliedAverage = true;
}
currentIndex += 1;
currentStart = currentStart.add(1, kind).startOf(isoKind);
currentEnd = currentEnd.add(1, kind).endOf(isoKind);
} else {
appliedAverage = false;
}
if (transformedData[currentIndex]) {
transformedData[currentIndex].y += d.y;
} else {
transformedData[currentIndex] = {
x: d.x,
y: d.y,
};
}
});
if (model.average && !appliedAverage) {
transformedData[currentIndex].y = applyAverage(
transformedData[currentIndex].y,
currentStart,
moment(model.end_date).subtract(1, "day") // remove 1 day as model end date is at 00:00 of next day
);
}
return transformedData;
}
// ensure we return something if grouping is unknown
return data;
}
static fillMissingDates(report, options = {}) {
const dataField = options.dataField || "data";
const filledField = options.filledField || "data";
const startDate = options.startDate || "start_date";
const endDate = options.endDate || "end_date";
if (Array.isArray(report[dataField])) {
const startDateFormatted = moment
.utc(report[startDate])
.locale("en")
.format("YYYY-MM-DD");
const endDateFormatted = moment
.utc(report[endDate])
.locale("en")
.format("YYYY-MM-DD");
if (report.modes[0] === "stacked_chart") {
report[filledField] = report[dataField].map((rep) => {
return {
req: rep.req,
label: rep.label,
color: rep.color,
data: fillMissingDates(
JSON.parse(JSON.stringify(rep.data)),
startDateFormatted,
endDateFormatted
),
};
});
} else {
report[filledField] = fillMissingDates(
JSON.parse(JSON.stringify(report[dataField])),
startDateFormatted,
endDateFormatted
);
}
}
}
static find(type, startDate, endDate, categoryId, groupId) {
return ajax("/admin/reports/" + type, {
data: {
start_date: startDate,
end_date: endDate,
category_id: categoryId,
group_id: groupId,
},
}).then((json) => {
// dont fill for large multi column tables
// which are not date based
const modes = json.report.modes;
if (modes.length !== 1 && modes[0] !== "table") {
Report.fillMissingDates(json.report);
}
const model = Report.create({ type });
model.setProperties(json.report);
if (json.report.related_report) {
// TODO: fillMissingDates if xaxis is date
const related = Report.create({
type: json.report.related_report.type,
});
related.setProperties(json.report.related_report);
model.set("relatedReport", related);
}
return model;
});
}
average = false;
percent = false;
higher_is_better = true;
description_link = null;
description = null;
@discourseComputed("type", "start_date", "end_date")
reportUrl(type, start_date, end_date) {
@ -35,7 +211,7 @@ const Report = EmberObject.extend({
return getURL(
`/admin/reports/${type}?start_date=${start_date}&end_date=${end_date}`
);
},
}
valueAt(numDaysAgo) {
if (this.data) {
@ -49,7 +225,7 @@ const Report = EmberObject.extend({
}
}
return 0;
},
}
valueFor(startDaysAgo, endDaysAgo) {
if (this.data) {
@ -70,46 +246,46 @@ const Report = EmberObject.extend({
}
return round(sum, -2);
}
},
}
@discourseComputed("data", "average")
todayCount() {
return this.valueAt(0);
},
}
@discourseComputed("data", "average")
yesterdayCount() {
return this.valueAt(1);
},
}
@discourseComputed("data", "average")
sevenDaysAgoCount() {
return this.valueAt(7);
},
}
@discourseComputed("data", "average")
thirtyDaysAgoCount() {
return this.valueAt(30);
},
}
@discourseComputed("data", "average")
lastSevenDaysCount() {
return this.averageCount(7, this.valueFor(1, 7));
},
}
@discourseComputed("data", "average")
lastThirtyDaysCount() {
return this.averageCount(30, this.valueFor(1, 30));
},
}
averageCount(count, value) {
return this.average ? value / count : value;
},
}
@discourseComputed("yesterdayCount", "higher_is_better")
yesterdayTrend(yesterdayCount, higherIsBetter) {
return this._computeTrend(this.valueAt(2), yesterdayCount, higherIsBetter);
},
}
@discourseComputed("lastSevenDaysCount", "higher_is_better")
sevenDaysTrend(lastSevenDaysCount, higherIsBetter) {
@ -118,39 +294,39 @@ const Report = EmberObject.extend({
lastSevenDaysCount,
higherIsBetter
);
},
}
@discourseComputed("data")
currentTotal(data) {
return data.reduce((cur, pair) => cur + pair.y, 0);
},
}
@discourseComputed("data", "currentTotal")
currentAverage(data, total) {
return makeArray(data).length === 0
? 0
: parseFloat((total / parseFloat(data.length)).toFixed(1));
},
}
@discourseComputed("trend", "higher_is_better")
trendIcon(trend, higherIsBetter) {
return this._iconForTrend(trend, higherIsBetter);
},
}
@discourseComputed("sevenDaysTrend", "higher_is_better")
sevenDaysTrendIcon(sevenDaysTrend, higherIsBetter) {
return this._iconForTrend(sevenDaysTrend, higherIsBetter);
},
}
@discourseComputed("thirtyDaysTrend", "higher_is_better")
thirtyDaysTrendIcon(thirtyDaysTrend, higherIsBetter) {
return this._iconForTrend(thirtyDaysTrend, higherIsBetter);
},
}
@discourseComputed("yesterdayTrend", "higher_is_better")
yesterdayTrendIcon(yesterdayTrend, higherIsBetter) {
return this._iconForTrend(yesterdayTrend, higherIsBetter);
},
}
@discourseComputed(
"prev_period",
@ -161,7 +337,7 @@ const Report = EmberObject.extend({
trend(prev, currentTotal, currentAverage, higherIsBetter) {
const total = this.average ? currentAverage : currentTotal;
return this._computeTrend(prev, total, higherIsBetter);
},
}
@discourseComputed(
"prev30Days",
@ -180,7 +356,7 @@ const Report = EmberObject.extend({
lastThirtyDaysCount,
higherIsBetter
);
},
}
@discourseComputed("type")
method(type) {
@ -189,7 +365,7 @@ const Report = EmberObject.extend({
} else {
return "sum";
}
},
}
percentChangeString(val1, val2) {
const change = this._computeChange(val1, val2);
@ -201,7 +377,7 @@ const Report = EmberObject.extend({
} else {
return change.toFixed(0) + "%";
}
},
}
@discourseComputed("prev_period", "currentTotal", "currentAverage")
trendTitle(prev, currentTotal, currentAverage) {
@ -224,7 +400,7 @@ const Report = EmberObject.extend({
prev,
current,
});
},
}
changeTitle(valAtT1, valAtT2, prevPeriodString) {
const change = this.percentChangeString(valAtT1, valAtT2);
@ -234,12 +410,12 @@ const Report = EmberObject.extend({
}
title += `Was ${number(valAtT1)} ${prevPeriodString}.`;
return title;
},
}
@discourseComputed("yesterdayCount")
yesterdayCountTitle(yesterdayCount) {
return this.changeTitle(this.valueAt(2), yesterdayCount, "two days ago");
},
}
@discourseComputed("lastSevenDaysCount")
sevenDaysCountTitle(lastSevenDaysCount) {
@ -248,12 +424,12 @@ const Report = EmberObject.extend({
lastSevenDaysCount,
"two weeks ago"
);
},
}
@discourseComputed("prev30Days", "prev_period")
canDisplayTrendIcon(prev30Days, prev_period) {
return prev30Days ?? prev_period;
},
}
@discourseComputed("prev30Days", "prev_period", "lastThirtyDaysCount")
thirtyDaysCountTitle(prev30Days, prev_period, lastThirtyDaysCount) {
@ -262,12 +438,12 @@ const Report = EmberObject.extend({
lastThirtyDaysCount,
"in the previous 30 day period"
);
},
}
@discourseComputed("data")
sortedData(data) {
return this.xAxisIsDate ? data.toArray().reverse() : data.toArray();
},
}
@discourseComputed("data")
xAxisIsDate() {
@ -275,7 +451,7 @@ const Report = EmberObject.extend({
return false;
}
return this.data && this.data[0].x.match(/\d{4}-\d{1,2}-\d{1,2}/);
},
}
@discourseComputed("labels")
computedLabels(labels) {
@ -359,7 +535,7 @@ const Report = EmberObject.extend({
},
};
});
},
}
_userLabel(properties, row) {
const username = row[properties.username];
@ -388,7 +564,7 @@ const Report = EmberObject.extend({
value: username,
formattedValue: username ? formattedValue() : "—",
};
},
}
_topicLabel(properties, row) {
const topicTitle = row[properties.title];
@ -403,7 +579,7 @@ const Report = EmberObject.extend({
value: topicTitle,
formattedValue: topicTitle ? formattedValue() : "—",
};
},
}
_postLabel(properties, row) {
const postTitle = row[properties.truncated_raw];
@ -419,21 +595,21 @@ const Report = EmberObject.extend({
? `<a href='${href}'>${escapeExpression(postTitle)}</a>`
: "—",
};
},
}
_secondsLabel(value) {
return {
value: toNumber(value),
formattedValue: durationTiny(value),
};
},
}
_percentLabel(value) {
return {
value: toNumber(value),
formattedValue: value ? `${value}%` : "—",
};
},
}
_numberLabel(value, options = {}) {
const formatNumbers = isEmpty(options.formatNumbers)
@ -446,21 +622,21 @@ const Report = EmberObject.extend({
value: toNumber(value),
formattedValue: value ? formattedValue() : "—",
};
},
}
_bytesLabel(value) {
return {
value: toNumber(value),
formattedValue: I18n.toHumanSize(value),
};
},
}
_dateLabel(value, date, format = "LL") {
return {
value,
formattedValue: value ? date.format(format) : "—",
};
},
}
_textLabel(value) {
const escaped = escapeExpression(value);
@ -469,7 +645,7 @@ const Report = EmberObject.extend({
value,
formattedValue: value ? escaped : "—",
};
},
}
_linkLabel(properties, row) {
const property = properties[0];
@ -484,11 +660,11 @@ const Report = EmberObject.extend({
value,
formattedValue: value ? formattedValue(value, row[properties[1]]) : "—",
};
},
}
_computeChange(valAtT1, valAtT2) {
return ((valAtT2 - valAtT1) / valAtT1) * 100;
},
}
_computeTrend(valAtT1, valAtT2, higherIsBetter) {
const change = this._computeChange(valAtT1, valAtT2);
@ -504,7 +680,7 @@ const Report = EmberObject.extend({
} else if (change < -2) {
return higherIsBetter ? "trending-down" : "trending-up";
}
},
}
_iconForTrend(trend, higherIsBetter) {
switch (trend) {
@ -519,8 +695,8 @@ const Report = EmberObject.extend({
default:
return "minus";
}
},
});
}
}
export const WEEKLY_LIMIT_DAYS = 365;
export const DAILY_LIMIT_DAYS = 34;
@ -529,183 +705,3 @@ function applyAverage(value, start, end) {
const count = end.diff(start, "day") + 1; // 1 to include start
return parseFloat((value / count).toFixed(2));
}
Report.reopenClass({
groupingForDatapoints(count) {
if (count < DAILY_LIMIT_DAYS) {
return "daily";
}
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
return "weekly";
}
if (count >= WEEKLY_LIMIT_DAYS) {
return "monthly";
}
},
unitForDatapoints(count) {
if (count >= DAILY_LIMIT_DAYS && count < WEEKLY_LIMIT_DAYS) {
return "week";
} else if (count >= WEEKLY_LIMIT_DAYS) {
return "month";
} else {
return "day";
}
},
unitForGrouping(grouping) {
switch (grouping) {
case "monthly":
return "month";
case "weekly":
return "week";
default:
return "day";
}
},
collapse(model, data, grouping) {
grouping = grouping || Report.groupingForDatapoints(data.length);
if (grouping === "daily") {
return data;
} else if (grouping === "weekly" || grouping === "monthly") {
const isoKind = grouping === "weekly" ? "isoWeek" : "month";
const kind = grouping === "weekly" ? "week" : "month";
const startMoment = moment(model.start_date, "YYYY-MM-DD");
let currentIndex = 0;
let currentStart = startMoment.clone().startOf(isoKind);
let currentEnd = startMoment.clone().endOf(isoKind);
const transformedData = [
{
x: currentStart.format("YYYY-MM-DD"),
y: 0,
},
];
let appliedAverage = false;
data.forEach((d) => {
const date = moment(d.x, "YYYY-MM-DD");
if (
!date.isSame(currentStart) &&
!date.isBetween(currentStart, currentEnd)
) {
if (model.average) {
transformedData[currentIndex].y = applyAverage(
transformedData[currentIndex].y,
currentStart,
currentEnd
);
appliedAverage = true;
}
currentIndex += 1;
currentStart = currentStart.add(1, kind).startOf(isoKind);
currentEnd = currentEnd.add(1, kind).endOf(isoKind);
} else {
appliedAverage = false;
}
if (transformedData[currentIndex]) {
transformedData[currentIndex].y += d.y;
} else {
transformedData[currentIndex] = {
x: d.x,
y: d.y,
};
}
});
if (model.average && !appliedAverage) {
transformedData[currentIndex].y = applyAverage(
transformedData[currentIndex].y,
currentStart,
moment(model.end_date).subtract(1, "day") // remove 1 day as model end date is at 00:00 of next day
);
}
return transformedData;
}
// ensure we return something if grouping is unknown
return data;
},
fillMissingDates(report, options = {}) {
const dataField = options.dataField || "data";
const filledField = options.filledField || "data";
const startDate = options.startDate || "start_date";
const endDate = options.endDate || "end_date";
if (Array.isArray(report[dataField])) {
const startDateFormatted = moment
.utc(report[startDate])
.locale("en")
.format("YYYY-MM-DD");
const endDateFormatted = moment
.utc(report[endDate])
.locale("en")
.format("YYYY-MM-DD");
if (report.modes[0] === "stacked_chart") {
report[filledField] = report[dataField].map((rep) => {
return {
req: rep.req,
label: rep.label,
color: rep.color,
data: fillMissingDates(
JSON.parse(JSON.stringify(rep.data)),
startDateFormatted,
endDateFormatted
),
};
});
} else {
report[filledField] = fillMissingDates(
JSON.parse(JSON.stringify(report[dataField])),
startDateFormatted,
endDateFormatted
);
}
}
},
find(type, startDate, endDate, categoryId, groupId) {
return ajax("/admin/reports/" + type, {
data: {
start_date: startDate,
end_date: endDate,
category_id: categoryId,
group_id: groupId,
},
}).then((json) => {
// dont fill for large multi column tables
// which are not date based
const modes = json.report.modes;
if (modes.length !== 1 && modes[0] !== "table") {
Report.fillMissingDates(json.report);
}
const model = Report.create({ type });
model.setProperties(json.report);
if (json.report.related_report) {
// TODO: fillMissingDates if xaxis is date
const related = Report.create({
type: json.report.related_report.type,
});
related.setProperties(json.report.related_report);
model.set("relatedReport", related);
}
return model;
});
},
});
export default Report;

View File

@ -3,21 +3,8 @@ import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
const ScreenedEmail = EmberObject.extend({
@discourseComputed("action")
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
},
clearBlock() {
return ajax("/admin/logs/screened_emails/" + this.id, {
type: "DELETE",
});
},
});
ScreenedEmail.reopenClass({
findAll() {
export default class ScreenedEmail extends EmberObject {
static findAll() {
return ajax("/admin/logs/screened_emails.json").then(function (
screened_emails
) {
@ -25,7 +12,16 @@ ScreenedEmail.reopenClass({
return ScreenedEmail.create(b);
});
});
},
});
}
export default ScreenedEmail;
@discourseComputed("action")
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
}
clearBlock() {
return ajax("/admin/logs/screened_emails/" + this.id, {
type: "DELETE",
});
}
}

View File

@ -1,21 +1,28 @@
import { equal } from "@ember/object/computed";
import EmberObject from "@ember/object";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
import { equal } from "@ember/object/computed";
const ScreenedIpAddress = EmberObject.extend({
export default class ScreenedIpAddress extends EmberObject {
static findAll(filter) {
return ajax("/admin/logs/screened_ip_addresses.json", {
data: { filter },
}).then((screened_ips) =>
screened_ips.map((b) => ScreenedIpAddress.create(b))
);
}
@equal("action_name", "block") isBlocked;
@discourseComputed("action_name")
actionName(actionName) {
return I18n.t(`admin.logs.screened_ips.actions.${actionName}`);
},
isBlocked: equal("action_name", "block"),
}
@discourseComputed("ip_address")
isRange(ipAddress) {
return ipAddress.indexOf("/") > 0;
},
}
save() {
return ajax(
@ -30,23 +37,11 @@ const ScreenedIpAddress = EmberObject.extend({
},
}
);
},
}
destroy() {
return ajax("/admin/logs/screened_ip_addresses/" + this.id + ".json", {
type: "DELETE",
});
},
});
ScreenedIpAddress.reopenClass({
findAll(filter) {
return ajax("/admin/logs/screened_ip_addresses.json", {
data: { filter },
}).then((screened_ips) =>
screened_ips.map((b) => ScreenedIpAddress.create(b))
);
},
});
export default ScreenedIpAddress;
}
}

View File

@ -3,15 +3,8 @@ import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
const ScreenedUrl = EmberObject.extend({
@discourseComputed("action")
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
},
});
ScreenedUrl.reopenClass({
findAll() {
export default class ScreenedUrl extends EmberObject {
static findAll() {
return ajax("/admin/logs/screened_urls.json").then(function (
screened_urls
) {
@ -19,7 +12,10 @@ ScreenedUrl.reopenClass({
return ScreenedUrl.create(b);
});
});
},
});
}
export default ScreenedUrl;
@discourseComputed("action")
actionName(action) {
return I18n.t("admin.logs.screened_actions." + action);
}
}

View File

@ -4,22 +4,8 @@ import Setting from "admin/mixins/setting-object";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
const SiteSetting = EmberObject.extend(Setting, {
@discourseComputed("setting")
staffLogFilter(setting) {
if (!setting) {
return;
}
return {
subject: setting,
action_name: "change_site_setting",
};
},
});
SiteSetting.reopenClass({
findAll() {
export default class SiteSetting extends EmberObject.extend(Setting) {
static findAll() {
return ajax("/admin/site_settings").then(function (settings) {
// Group the results by category
const categories = {};
@ -38,9 +24,9 @@ SiteSetting.reopenClass({
};
});
});
},
}
update(key, value, opts = {}) {
static update(key, value, opts = {}) {
const data = {};
data[key] = value;
@ -49,7 +35,17 @@ SiteSetting.reopenClass({
}
return ajax(`/admin/site_settings/${key}`, { type: "PUT", data });
},
});
}
export default SiteSetting;
@discourseComputed("setting")
staffLogFilter(setting) {
if (!setting) {
return;
}
return {
subject: setting,
action_name: "change_site_setting",
};
}
}

View File

@ -2,10 +2,10 @@ import RestModel from "discourse/models/rest";
import { ajax } from "discourse/lib/ajax";
import { getProperties } from "@ember/object";
export default RestModel.extend({
export default class SiteText extends RestModel {
revert(locale) {
return ajax(`/admin/customize/site_texts/${this.id}?locale=${locale}`, {
type: "DELETE",
}).then((result) => getProperties(result.site_text, "value", "can_revert"));
},
});
}
}

View File

@ -11,13 +11,36 @@ function format(label, value, escape = true) {
: "";
}
const StaffActionLog = RestModel.extend({
showFullDetails: false,
export default class StaffActionLog extends RestModel {
static munge(json) {
if (json.acting_user) {
json.acting_user = AdminUser.create(json.acting_user);
}
if (json.target_user) {
json.target_user = AdminUser.create(json.target_user);
}
return json;
}
static findAll(data) {
return ajax("/admin/logs/staff_action_logs.json", { data }).then(
(result) => {
return {
staff_action_logs: result.staff_action_logs.map((s) =>
StaffActionLog.create(s)
),
user_history_actions: result.user_history_actions,
};
}
);
}
showFullDetails = false;
@discourseComputed("action_name")
actionName(actionName) {
return I18n.t(`admin.logs.staff_actions.actions.${actionName}`);
},
}
@discourseComputed(
"email",
@ -72,42 +95,15 @@ const StaffActionLog = RestModel.extend({
const formatted = lines.filter((l) => l.length > 0).join("<br/>");
return formatted.length > 0 ? formatted + "<br/>" : "";
},
}
@discourseComputed("details")
useModalForDetails(details) {
return details && details.length > 100;
},
}
@discourseComputed("action_name")
useCustomModalForDetails(actionName) {
return ["change_theme", "delete_theme"].includes(actionName);
},
});
StaffActionLog.reopenClass({
munge(json) {
if (json.acting_user) {
json.acting_user = AdminUser.create(json.acting_user);
}
if (json.target_user) {
json.target_user = AdminUser.create(json.target_user);
}
return json;
},
findAll(data) {
return ajax("/admin/logs/staff_action_logs.json", { data }).then(
(result) => {
return {
staff_action_logs: result.staff_action_logs.map((s) =>
StaffActionLog.create(s)
),
user_history_actions: result.user_history_actions,
};
}
);
},
});
export default StaffActionLog;
}
}

View File

@ -1,4 +1,4 @@
import EmberObject from "@ember/object";
import Setting from "admin/mixins/setting-object";
export default EmberObject.extend(Setting, {});
export default class ThemeSettings extends EmberObject.extend(Setting) {}

View File

@ -13,11 +13,13 @@ export const THEMES = "themes";
export const COMPONENTS = "components";
const SETTINGS_TYPE_ID = 5;
const Theme = RestModel.extend({
isActive: or("default", "user_selectable"),
isPendingUpdates: gt("remote_theme.commits_behind", 0),
hasEditedFields: gt("editedFields.length", 0),
hasParents: gt("parent_themes.length", 0),
class Theme extends RestModel {
@or("default", "user_selectable") isActive;
@gt("remote_theme.commits_behind", 0) isPendingUpdates;
@gt("editedFields.length", 0) hasEditedFields;
@gt("parent_themes.length", 0) hasParents;
changed = false;
@discourseComputed("theme_fields.[]")
targets() {
@ -45,7 +47,7 @@ const Theme = RestModel.extend({
target["error"] = this.hasError(target.name);
return target;
});
},
}
@discourseComputed("theme_fields.[]")
fieldNames() {
@ -84,7 +86,7 @@ const Theme = RestModel.extend({
],
extra_scss: scss_fields,
};
},
}
@discourseComputed(
"fieldNames",
@ -118,7 +120,7 @@ const Theme = RestModel.extend({
});
});
return hash;
},
}
@discourseComputed("theme_fields")
themeFields(fields) {
@ -134,7 +136,7 @@ const Theme = RestModel.extend({
}
});
return hash;
},
}
@discourseComputed("theme_fields", "theme_fields.[]")
uploads(fields) {
@ -144,32 +146,32 @@ const Theme = RestModel.extend({
return fields.filter(
(f) => f.target === "common" && f.type_id === THEME_UPLOAD_VAR
);
},
}
@discourseComputed("theme_fields", "theme_fields.@each.error")
isBroken(fields) {
return (
fields && fields.any((field) => field.error && field.error.length > 0)
);
},
}
@discourseComputed("theme_fields.[]")
editedFields(fields) {
return fields.filter(
(field) => !isBlank(field.value) && field.type_id !== SETTINGS_TYPE_ID
);
},
}
@discourseComputed("remote_theme.last_error_text")
remoteError(errorText) {
if (errorText && errorText.length > 0) {
return errorText;
}
},
}
getKey(field) {
return `${field.target} ${field.name}`;
},
}
hasEdited(target, name) {
if (name) {
@ -180,27 +182,27 @@ const Theme = RestModel.extend({
(field) => field.target === target && !isEmpty(field.value)
);
}
},
}
hasError(target, name) {
return this.theme_fields
.filter((f) => f.target === target && (!name || name === f.name))
.any((f) => f.error);
},
}
getError(target, name) {
let themeFields = this.themeFields;
let key = this.getKey({ target, name });
let field = themeFields[key];
return field ? field.error : "";
},
}
getField(target, name) {
let themeFields = this.themeFields;
let key = this.getKey({ target, name });
let field = themeFields[key];
return field ? field.value : "";
},
}
removeField(field) {
this.set("changed", true);
@ -209,7 +211,7 @@ const Theme = RestModel.extend({
field.value = null;
return this.saveChanges("theme_fields");
},
}
setField(target, name, value, upload_id, type_id) {
this.set("changed", true);
@ -249,25 +251,25 @@ const Theme = RestModel.extend({
this.notifyPropertyChange("theme_fields.[]");
}
}
},
}
@discourseComputed("childThemes.[]")
child_theme_ids(childThemes) {
if (childThemes) {
return childThemes.map((theme) => get(theme, "id"));
}
},
}
@discourseComputed("recentlyInstalled", "component", "hasParents")
warnUnassignedComponent(recent, component, hasParents) {
return recent && component && !hasParents;
},
}
removeChildTheme(theme) {
const childThemes = this.childThemes;
childThemes.removeObject(theme);
return this.saveChanges("child_theme_ids");
},
}
addChildTheme(theme) {
let childThemes = this.childThemes;
@ -278,7 +280,7 @@ const Theme = RestModel.extend({
childThemes.removeObject(theme);
childThemes.pushObject(theme);
return this.saveChanges("child_theme_ids");
},
}
addParentTheme(theme) {
let parentThemes = this.parentThemes;
@ -287,38 +289,36 @@ const Theme = RestModel.extend({
this.set("parentThemes", parentThemes);
}
parentThemes.addObject(theme);
},
}
checkForUpdates() {
return this.save({ remote_check: true }).then(() =>
this.set("changed", false)
);
},
}
updateToLatest() {
return this.save({ remote_update: true }).then(() =>
this.set("changed", false)
);
},
changed: false,
}
saveChanges() {
const hash = this.getProperties.apply(this, arguments);
return this.save(hash)
.finally(() => this.set("changed", false))
.catch(popupAjaxError);
},
}
saveSettings(name, value) {
const settings = {};
settings[name] = value;
return this.save({ settings });
},
}
saveTranslation(name, value) {
return this.save({ translations: { [name]: value } });
},
});
}
}
export default Theme;

View File

@ -1,21 +1,21 @@
import EmberObject from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
export default EmberObject.extend({
export default class Tl3Requirements extends EmberObject {
@discourseComputed("days_visited", "time_period")
days_visited_percent(daysVisited, timePeriod) {
return Math.round((daysVisited * 100) / timePeriod);
},
}
@discourseComputed("min_days_visited", "time_period")
min_days_visited_percent(minDaysVisited, timePeriod) {
return Math.round((minDaysVisited * 100) / timePeriod);
},
}
@discourseComputed("num_topics_replied_to", "min_topics_replied_to")
capped_topics_replied_to(numReplied, minReplied) {
return numReplied > minReplied;
},
}
@discourseComputed(
"days_visited",
@ -71,5 +71,5 @@ export default EmberObject.extend({
silenced: this.get("penalty_counts.silenced") === 0,
suspended: this.get("penalty_counts.suspended") === 0,
};
},
});
}
}

View File

@ -2,14 +2,8 @@ import EmberObject from "@ember/object";
import RestModel from "discourse/models/rest";
import { i18n } from "discourse/lib/computed";
const UserField = RestModel.extend();
const UserFieldType = EmberObject.extend({
name: i18n("id", "admin.user_fields.field_types.%@"),
});
UserField.reopenClass({
fieldTypes() {
export default class UserField extends RestModel {
static fieldTypes() {
if (!this._fieldTypes) {
this._fieldTypes = [
UserFieldType.create({ id: "text" }),
@ -20,11 +14,13 @@ UserField.reopenClass({
}
return this._fieldTypes;
},
}
fieldTypeById(id) {
static fieldTypeById(id) {
return this.fieldTypes().findBy("id", id);
},
});
}
}
export default UserField;
class UserFieldType extends EmberObject {
@i18n("id", "admin.user_fields.field_types.%@") name;
}

View File

@ -2,43 +2,39 @@ import EmberObject from "@ember/object";
import { ajax } from "discourse/lib/ajax";
import discourseComputed from "discourse-common/utils/decorators";
const VersionCheck = EmberObject.extend({
export default class VersionCheck extends EmberObject {
static find() {
return ajax("/admin/version_check").then((json) =>
VersionCheck.create(json)
);
}
@discourseComputed("updated_at")
noCheckPerformed(updatedAt) {
return updatedAt === null;
},
}
@discourseComputed("missing_versions_count")
upToDate(missingVersionsCount) {
return missingVersionsCount === 0 || missingVersionsCount === null;
},
}
@discourseComputed("missing_versions_count")
behindByOneVersion(missingVersionsCount) {
return missingVersionsCount === 1;
},
}
@discourseComputed("installed_sha")
gitLink(installedSHA) {
if (installedSHA) {
return `https://github.com/discourse/discourse/commits/${installedSHA}`;
}
},
}
@discourseComputed("installed_sha")
shortSha(installedSHA) {
if (installedSHA) {
return installedSHA.slice(0, 10);
}
},
});
VersionCheck.reopenClass({
find() {
return ajax("/admin/version_check").then((json) =>
VersionCheck.create(json)
);
},
});
export default VersionCheck;
}
}

View File

@ -2,34 +2,8 @@ import EmberObject from "@ember/object";
import I18n from "I18n";
import { ajax } from "discourse/lib/ajax";
const WatchedWord = EmberObject.extend({
save() {
return ajax(
"/admin/customize/watched_words" +
(this.id ? "/" + this.id : "") +
".json",
{
type: this.id ? "PUT" : "POST",
data: {
word: this.word,
replacement: this.replacement,
action_key: this.action,
case_sensitive: this.isCaseSensitive,
},
dataType: "json",
}
);
},
destroy() {
return ajax("/admin/customize/watched_words/" + this.id + ".json", {
type: "DELETE",
});
},
});
WatchedWord.reopenClass({
findAll() {
export default class WatchedWord extends EmberObject {
static findAll() {
return ajax("/admin/customize/watched_words.json").then((list) => {
const actions = {};
@ -50,7 +24,29 @@ WatchedWord.reopenClass({
});
});
});
},
});
}
export default WatchedWord;
save() {
return ajax(
"/admin/customize/watched_words" +
(this.id ? "/" + this.id : "") +
".json",
{
type: this.id ? "PUT" : "POST",
data: {
word: this.word,
replacement: this.replacement,
action_key: this.action,
case_sensitive: this.isCaseSensitive,
},
dataType: "json",
}
);
}
destroy() {
return ajax("/admin/customize/watched_words/" + this.id + ".json", {
type: "DELETE",
});
}
}

View File

@ -1,33 +1,34 @@
import discourseComputed, { observes } from "discourse-common/utils/decorators";
import { computed } from "@ember/object";
import discourseComputed from "discourse-common/utils/decorators";
import { observes } from "@ember-decorators/object";
import Category from "discourse/models/category";
import Group from "discourse/models/group";
import RestModel from "discourse/models/rest";
import Site from "discourse/models/site";
import { isEmpty } from "@ember/utils";
export default RestModel.extend({
content_type: 1, // json
last_delivery_status: 1, // inactive
wildcard_web_hook: false,
verify_certificate: true,
active: false,
web_hook_event_types: null,
groupsFilterInName: null,
export default class WebHook extends RestModel {
content_type = 1; // json
last_delivery_status = 1; // inactive
wildcard_web_hook = false;
verify_certificate = true;
active = false;
web_hook_event_types = null;
groupsFilterInName = null;
@discourseComputed("wildcard_web_hook")
webhookType: {
get(wildcard) {
return wildcard ? "wildcard" : "individual";
},
set(value) {
this.set("wildcard_web_hook", value === "wildcard");
},
},
@computed("wildcard_web_hook")
get wildcard() {
return this.wildcard_web_hook ? "wildcard" : "individual";
}
set wildcard(value) {
this.set("wildcard_web_hook", value === "wildcard");
}
@discourseComputed("category_ids")
categories(categoryIds) {
return Category.findByIds(categoryIds);
},
}
@observes("group_ids")
updateGroupsFilter() {
@ -41,11 +42,11 @@ export default RestModel.extend({
return groupNames;
}, [])
);
},
}
groupFinder(term) {
return Group.findAll({ term, ignore_automatic: false });
},
}
@discourseComputed("wildcard_web_hook", "web_hook_event_types.[]")
description(isWildcardWebHook, types) {
@ -57,7 +58,7 @@ export default RestModel.extend({
});
return isWildcardWebHook ? "*" : desc;
},
}
createProperties() {
const types = this.web_hook_event_types;
@ -92,9 +93,9 @@ export default RestModel.extend({
return groupIds;
}, []),
};
},
}
updateProperties() {
return this.createProperties();
},
});
}
}

View File

@ -10,8 +10,8 @@ import { htmlSafe } from "@ember/template";
// A service that can act as a bridge between the front end Discourse application
// and the admin application. Use this if you need front end code to access admin
// modules. Inject it optionally, and if it exists go to town!
export default Service.extend({
dialog: service(),
export default class AdminToolsService extends Service {
@service dialog;
showActionLogs(target, filters) {
const controller = getOwner(target).lookup(
@ -20,15 +20,15 @@ export default Service.extend({
target.transitionToRoute("adminLogs.staffActionLogs").then(() => {
controller.changeFilters(filters);
});
},
}
checkSpammer(userId) {
return AdminUser.find(userId).then((au) => this.spammerDetails(au));
},
}
deleteUser(id, formData) {
return AdminUser.find(id).then((user) => user.destroy(formData));
},
}
spammerDetails(adminUser) {
return {
@ -37,7 +37,7 @@ export default Service.extend({
adminUser.get("can_be_deleted") &&
adminUser.get("can_delete_all_posts"),
};
},
}
_showControlModal(type, user, opts) {
opts = opts || {};
@ -67,15 +67,15 @@ export default Service.extend({
controller.finishedSetup();
});
},
}
showSilenceModal(user, opts) {
this._showControlModal("silence", user, opts);
},
}
showSuspendModal(user, opts) {
this._showControlModal("suspend", user, opts);
},
}
_deleteSpammer(adminUser) {
// Try loading the email if the site supports it
@ -131,5 +131,5 @@ export default Service.extend({
});
});
});
},
});
}
}

View File

@ -2,7 +2,7 @@ export const POPULAR_THEMES = [
{
name: "Graceful",
value: "https://github.com/discourse/graceful",
preview: "https://theme-creator.discourse.org/theme/awesomerobot/graceful",
preview: "https://discourse.theme-creator.io/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,8 +10,7 @@ export const POPULAR_THEMES = [
{
name: "Material Design Theme",
value: "https://github.com/discourse/material-design-stock-theme",
preview:
"https://theme-creator.discourse.org/theme/tshenry/material-design",
preview: "https://discourse.theme-creator.io/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",
@ -19,7 +18,7 @@ export const POPULAR_THEMES = [
{
name: "Minima",
value: "https://github.com/discourse/minima",
preview: "https://theme-creator.discourse.org/theme/awesomerobot/minima",
preview: "https://discourse.theme-creator.io/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",
@ -27,7 +26,7 @@ export const POPULAR_THEMES = [
{
name: "Sam's Simple Theme",
value: "https://github.com/discourse/discourse-simple-theme",
preview: "https://theme-creator.discourse.org/theme/sam/simple",
preview: "https://discourse.theme-creator.io/theme/sam/simple",
description:
"Simplified front page design with classic colors and typography.",
meta_url:
@ -36,6 +35,8 @@ 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",
@ -45,7 +46,7 @@ export const POPULAR_THEMES = [
name: "Custom Header Links",
value: "https://github.com/discourse/discourse-custom-header-links",
preview:
"https://theme-creator.discourse.org/theme/Johani/custom-header-links",
"https://discourse.theme-creator.io/theme/awesomerobot/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,
@ -61,7 +62,7 @@ export const POPULAR_THEMES = [
name: "Category Banners",
value: "https://github.com/discourse/discourse-category-banners",
preview:
"https://theme-creator.discourse.org/theme/awesomerobot/discourse-category-banners",
"https://discourse.theme-creator.io/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",
@ -70,7 +71,7 @@ export const POPULAR_THEMES = [
{
name: "Kanban Board",
value: "https://github.com/discourse/discourse-kanban-theme",
preview: "https://theme-creator.discourse.org/theme/david/kanban",
preview: "https://discourse.theme-creator.io/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",
@ -84,10 +85,19 @@ 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://theme-creator.discourse.org/theme/Johani/header-submenus",
preview:
"https://discourse.theme-creator.io/theme/awesomerobot/header-submenus",
description: "Lets you build a header menu with submenus (dropdowns).",
meta_url: "https://meta.discourse.org/t/header-submenus/94584",
component: true,
@ -104,7 +114,7 @@ export const POPULAR_THEMES = [
{
name: "Easy Responsive Footer",
value: "https://github.com/discourse/Discourse-easy-footer",
preview: "https://theme-creator.discourse.org/theme/Johani/easy-footer",
preview: "https://discourse.theme-creator.io/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

@ -1,5 +0,0 @@
"use strict";
module.exports = {
name: require("./package").name,
};

View File

@ -1,14 +0,0 @@
{
"name": "discourse-ensure-deprecation-order",
"version": "1.0.0",
"description": "A dummy addon which ensures ember-cli-deprecation-workflow is loaded before @ember/jquery",
"author": "Discourse",
"license": "GPL-2.0-only",
"keywords": [
"ember-addon"
],
"ember-addon": {
"before": "@ember/jquery",
"after": "ember-cli-deprecation-workflow"
}
}

View File

@ -1,4 +1,4 @@
import TextArea from "@ember/component/text-area";
import { TextArea } from "@ember/legacy-built-in-components";
export default TextArea.extend({
attributeBindings: ["aria-label"],

View File

@ -1,5 +1,5 @@
import { observes, on } from "discourse-common/utils/decorators";
import TextArea from "@ember/component/text-area";
import { TextArea } from "@ember/legacy-built-in-components";
import autosize from "discourse/lib/autosize";
import { schedule } from "@ember/runloop";

View File

@ -1,7 +1,7 @@
import { cancel, next } from "@ember/runloop";
import { isLTR, isRTL, siteDir } from "discourse/lib/text-direction";
import I18n from "I18n";
import TextField from "@ember/component/text-field";
import { TextField } from "@ember/legacy-built-in-components";
import discourseComputed from "discourse-common/utils/decorators";
import discourseDebounce from "discourse-common/lib/debounce";

View File

@ -1,22 +0,0 @@
import TextField from "@ember/component/text-field";
import TextArea from "@ember/component/text-area";
let initializedOnce = false;
export default {
name: "ember-input-component-extensions",
initialize() {
if (initializedOnce) {
return;
}
TextField.reopen({
attributeBindings: ["aria-describedby", "aria-invalid"],
});
TextArea.reopen({
attributeBindings: ["aria-describedby", "aria-invalid"],
});
initializedOnce = true;
},
};

View File

@ -2,6 +2,7 @@
import Component from "@ember/component";
import EmberObject from "@ember/object";
import { actionModifier } from "./ember-action-modifier";
import Ember from "ember";
/**
* Classic Ember components (i.e. "@ember/component") rely upon "event

View File

@ -0,0 +1,46 @@
import { assert, deprecate } from "@ember/debug";
import EmberObject from "@ember/object";
import Component from "@ember/component";
import jQuery from "jquery";
let done = false;
// Adapted from https://github.com/emberjs/ember-jquery/blob/master/vendor/jquery/component.dollar.js
// but implemented in a module to avoid transpiled version triggering the Ember Global deprecation.
// To be dropped when we remove the jquery integration as part of the 4.x update.
export default {
name: "deprecate-jquery-integration",
initialize() {
if (done) {
return;
}
EmberObject.reopen.call(Component, {
$(sel) {
assert(
"You cannot access this.$() on a component with `tagName: ''` specified.",
this.tagName !== ""
);
deprecate(
"Using this.$() in a component has been deprecated, consider using this.element",
false,
{
id: "ember-views.curly-components.jquery-element",
since: "3.4.0",
until: "4.0.0",
url: "https://emberjs.com/deprecations/v3.x#toc_jquery-apis",
for: "ember-source",
}
);
if (this.element) {
return sel ? jQuery(sel, this.element) : jQuery(this.element);
}
},
});
done = true;
},
};

View File

@ -94,8 +94,8 @@
@value={{this.accountName}}
@id="new-account-name"
@class={{value-entered this.accountName}}
@aria-describedby="fullname-validation"
@aria-invalid={{this.nameValidation.failed}}
aria-describedby="fullname-validation"
aria-invalid={{this.nameValidation.failed}}
/>
<label class="alt-placeholder" for="new-account-name">
{{i18n "user.name.title"}}
@ -134,8 +134,8 @@
id="new-account-password"
@autocomplete="current-password"
@capsLockOn={{this.capsLockOn}}
@aria-describedby="password-validation"
@aria-invalid={{this.passwordValidation.failed}}
aria-describedby="password-validation"
aria-invalid={{this.passwordValidation.failed}}
/>
<label class="alt-placeholder" for="new-account-password">
{{i18n "user.password.title"}}

View File

@ -3,9 +3,6 @@ globalThis.deprecationWorkflow.config = {
// We're using RAISE_ON_DEPRECATION in environment.js instead of
// `throwOnUnhandled` here since it is easier to toggle.
workflow: [
{ handler: "silence", matchId: "ember-global" },
{ handler: "silence", matchId: "ember.built-in-components.reopen" },
{ handler: "silence", matchId: "ember.built-in-components.import" },
{ handler: "silence", matchId: "implicit-injections" },
{ handler: "silence", matchId: "route-render-template" },
{ handler: "silence", matchId: "routing.transition-methods" },

View File

@ -163,6 +163,16 @@ module.exports = function (defaults) {
}
};
// @ember/jquery introduces a shim which triggers the ember-global deprecation.
// We remove that shim, and re-implement ourselves in the deprecate-jquery-integration pre-initializer
const vendorScripts = app._scriptOutputFiles["/assets/vendor.js"];
const componentDollarShimIndex = vendorScripts.indexOf(
"vendor/jquery/component.dollar.js"
);
if (componentDollarShimIndex) {
vendorScripts.splice(componentDollarShimIndex, 1);
}
// WARNING: We should only import scripts here if they are not in NPM.
// For example: our very specific version of bootstrap-modal.
app.import(vendorJs + "bootbox.js");

View File

@ -23,6 +23,7 @@
"@discourse/virtual-dom": "^2.1.2-0",
"@ember-compat/tracked-built-ins": "^0.9.1",
"@ember/jquery": "^2.0.0",
"@ember/legacy-built-in-components": "^0.4.2",
"@ember/optional-features": "^2.0.0",
"@ember/render-modifiers": "^2.0.5",
"@ember/test-helpers": "^2.9.3",
@ -46,7 +47,6 @@
"deepmerge": "^4.3.0",
"dialog-holder": "1.0.0",
"discourse-common": "1.0.0",
"discourse-ensure-deprecation-order": "1.0.0",
"discourse-hbr": "1.0.0",
"discourse-plugins": "1.0.0",
"discourse-widget-hbs": "1.0.0",

View File

@ -3,6 +3,13 @@
throw "Unsupported browser detected";
}
// In Ember 3.28, the `ember` package is responsible for configuring `Helper.helper`,
// so we need to require('ember') before setting up any helpers.
// https://github.com/emberjs/ember.js/blob/744e536d37/packages/ember/index.js#L493-L493
// In modern Ember, the Helper.helper definition has moved to the helper module itself
// https://github.com/emberjs/ember.js/blob/0c5518ea7b/packages/%40ember/-internals/glimmer/lib/helper.ts#L134-L138
require("ember");
window.__widget_helpers = require("discourse-widget-hbs/helpers").default;
// TODO: Eliminate this global

View File

@ -6,7 +6,6 @@
"dialog-holder",
"discourse",
"discourse-common",
"discourse-ensure-deprecation-order",
"discourse-hbr",
"discourse-plugins",
"discourse-widget-hbs",

View File

@ -1088,6 +1088,16 @@
jquery "^3.5.0"
resolve "^1.15.1"
"@ember/legacy-built-in-components@^0.4.2":
version "0.4.2"
resolved "https://registry.yarnpkg.com/@ember/legacy-built-in-components/-/legacy-built-in-components-0.4.2.tgz#79a97d66153ff17909759b368b2a117bc9e168e5"
integrity sha512-rJulbyVQIVe1zEDQDqAQHechHy44DsS2qxO24+NmU/AYxwPFSzWC/OZNCDFSfLU+Y5BVd/00qjxF0pu7Nk+TNA==
dependencies:
"@embroider/macros" "^1.0.0"
ember-cli-babel "^7.26.6"
ember-cli-htmlbars "^5.7.1"
ember-cli-typescript "^4.1.0"
"@ember/optional-features@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@ember/optional-features/-/optional-features-2.0.0.tgz#c809abd5a27d5b0ef3c6de3941334ab6153313f0"
@ -3999,6 +4009,22 @@ ember-cli-typescript@^2.0.2:
stagehand "^1.0.0"
walk-sync "^1.0.0"
ember-cli-typescript@^4.1.0:
version "4.2.1"
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-4.2.1.tgz#54d08fc90318cc986f3ea562f93ce58a6cc4c24d"
integrity sha512-0iKTZ+/wH6UB/VTWKvGuXlmwiE8HSIGcxHamwNhEC5x1mN3z8RfvsFZdQWYUzIWFN2Tek0gmepGRPTwWdBYl/A==
dependencies:
ansi-to-html "^0.6.15"
broccoli-stew "^3.0.0"
debug "^4.0.0"
execa "^4.0.0"
fs-extra "^9.0.1"
resolve "^1.5.0"
rsvp "^4.8.1"
semver "^7.3.2"
stagehand "^1.0.0"
walk-sync "^2.2.0"
ember-cli-typescript@^5.0.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/ember-cli-typescript/-/ember-cli-typescript-5.1.0.tgz#460eb848564e29d64f2b36b2a75bbe98172b72a4"

View File

@ -32,20 +32,14 @@ class ApplicationRequest < ActiveRecord::Base
end
def self.write_cache!(req_type, count, date)
id = req_id(date, req_type)
where(id: id).update_all(["count = count + ?", count])
end
def self.req_id(date, req_type, retries = 0)
req_type_id = req_types[req_type]
create_or_find_by!(date: date, req_type: req_type_id).id
rescue StandardError # primary key violation
if retries == 0
req_id(date, req_type, 1)
else
raise
end
DB.exec(<<~SQL, date: date, req_type_id: req_type_id, count: count)
INSERT INTO application_requests (date, req_type, count)
VALUES (:date, :req_type_id, :count)
ON CONFLICT (date, req_type)
DO UPDATE SET count = application_requests.count + excluded.count
SQL
end
def self.stats

View File

@ -46,7 +46,8 @@ class Bookmark < ActiveRecord::Base
validates :name, length: { maximum: 100 }
def registered_bookmarkable
Bookmark.registered_bookmarkable_from_type(self.bookmarkable_type)
type = Bookmark.polymorphic_class_for(self.bookmarkable_type).name
Bookmark.registered_bookmarkable_from_type(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.name,
type: target.class.sti_name,
potential_spam: potential_spam == true ? true : nil,
}

View File

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

View File

@ -1,60 +0,0 @@
# 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,11 +0,0 @@
# 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,8 +0,0 @@
# 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

@ -1,29 +0,0 @@
# 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

@ -0,0 +1,64 @@
# 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,9 +1,9 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelThreadsController < Chat::Api
class Chat::Api::ChannelThreadsController < Chat::ApiController
def show
with_service(Chat::Service::LookupThread) do
on_success { render_serialized(result.thread, ChatThreadSerializer, root: "thread") }
with_service(::Chat::LookupThread) do
on_success { render_serialized(result.thread, ::Chat::ThreadSerializer, 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::ChatChannelsArchivesController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsArchivesController < Chat::Api::ChannelsController
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::ChatChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
Chat::ChannelArchiveService.retry_archive_process(chat_channel: channel_from_params)
return render json: success_json
end
@ -20,12 +20,12 @@ class Chat::Api::ChatChannelsArchivesController < Chat::Api::ChatChannelsControl
end
begin
Chat::ChatChannelArchiveService.create_archive_process(
Chat::ChannelArchiveService.create_archive_process(
chat_channel: channel_from_params,
acting_user: current_user,
topic_params: topic_params,
)
rescue Chat::ChatChannelArchiveService::ArchiveValidationError => err
rescue Chat::ChannelArchiveService::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::ChatChannelsController < Chat::Api
class Chat::Api::ChannelsController < Chat::ApiController
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] = ChatChannel.statuses[permitted[:status]] ? permitted[:status] : nil
options[:status] = Chat::Channel.statuses[permitted[:status]] ? permitted[:status] : nil
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChatChannelFetcher.secured_public_channels(guardian, memberships, options)
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
channels = Chat::ChannelFetcher.secured_public_channels(guardian, memberships, options)
serialized_channels =
channels.map do |channel|
ChatChannelSerializer.new(
Chat::ChannelSerializer.new(
channel,
scope: Guardian.new(current_user),
membership: memberships.find { |membership| membership.chat_channel_id == channel.id },
@ -29,7 +29,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
end
def destroy
with_service Chat::Service::TrashChannel do
with_service Chat::TrashChannel do
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
end
end
@ -43,7 +43,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
raise Discourse::InvalidParameters.new(:name)
end
if ChatChannel.exists?(
if Chat::Channel.exists?(
chatable_type: "Category",
chatable_id: channel_params[:chatable_id],
name: channel_params[:name],
@ -69,12 +69,12 @@ class Chat::Api::ChatChannelsController < Chat::Api
channel.user_chat_channel_memberships.create!(user: current_user, following: true)
if channel.auto_join_users
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
render_serialized(
channel,
ChatChannelSerializer,
Chat::ChannelSerializer,
membership: channel.membership_for(current_user),
root: "channel",
)
@ -83,7 +83,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
def show
render_serialized(
channel_from_params,
ChatChannelSerializer,
Chat::ChannelSerializer,
membership: channel_from_params.membership_for(current_user),
root: "channel",
)
@ -96,11 +96,11 @@ class Chat::Api::ChatChannelsController < Chat::Api
auto_join_limiter(channel_from_params).performed!
end
with_service(Chat::Service::UpdateChannel, **params_to_edit) do
with_service(Chat::UpdateChannel, **params_to_edit) do
on_success do
render_serialized(
result.channel,
ChatChannelSerializer,
Chat::ChannelSerializer,
root: "channel",
membership: result.channel.membership_for(current_user),
)
@ -116,7 +116,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
def channel_from_params
@channel ||=
begin
channel = ChatChannel.find(params.require(:channel_id))
channel = Chat::Channel.find(params.require(:channel_id))
guardian.ensure_can_preview_chat_channel!(channel)
channel
end
@ -126,7 +126,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
@membership ||=
begin
membership =
Chat::ChatChannelMembershipManager.new(channel_from_params).find_for_user(current_user)
Chat::ChannelMembershipManager.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::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsCurrentUserMembershipController < Chat::Api::ChannelsController
def create
guardian.ensure_can_join_chat_channel!(channel_from_params)
render_serialized(
channel_from_params.add(current_user),
UserChatChannelMembershipSerializer,
Chat::UserChannelMembershipSerializer,
root: "membership",
)
end
@ -14,7 +14,7 @@ class Chat::Api::ChatChannelsCurrentUserMembershipController < Chat::Api::ChatCh
def destroy
render_serialized(
channel_from_params.remove(current_user),
UserChatChannelMembershipSerializer,
Chat::UserChannelMembershipSerializer,
root: "membership",
)
end

View File

@ -2,13 +2,13 @@
MEMBERSHIP_EDITABLE_PARAMS = %i[muted desktop_notification_level mobile_notification_level]
class Chat::Api::ChatChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsCurrentUserNotificationsSettingsController < Chat::Api::ChannelsController
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,
UserChatChannelMembershipSerializer,
Chat::UserChannelMembershipSerializer,
root: "membership",
)
end

View File

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

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsController
class Chat::Api::ChannelsMessagesMovesController < Chat::Api::ChannelsController
def create
move_params = params.require(:move)
move_params.require(:message_ids)
@ -8,10 +8,7 @@ class Chat::Api::ChatChannelsMessagesMovesController < Chat::Api::ChatChannelsCo
raise Discourse::InvalidAccess if !guardian.can_move_chat_messages?(channel_from_params)
destination_channel =
Chat::ChatChannelFetcher.find_with_access_check(
move_params[:destination_channel_id],
guardian,
)
Chat::ChannelFetcher.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::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,13 +1,14 @@
# frozen_string_literal: true
class Chat::Api::ChatChatablesController < Chat::Api
class Chat::Api::ChatablesController < Chat::ApiController
def index
params.require(:filter)
filter = params[:filter].downcase
memberships = Chat::ChatChannelMembershipManager.all_for_user(current_user)
memberships = Chat::ChannelMembershipManager.all_for_user(current_user)
public_channels =
Chat::ChatChannelFetcher.secured_public_channels(
Chat::ChannelFetcher.secured_public_channels(
guardian,
memberships,
filter: filter,
@ -41,7 +42,7 @@ class Chat::Api::ChatChatablesController < Chat::Api
direct_message_channels =
if users.count > 0
# FIXME: investigate the cost of this query
ChatChannel
Chat::Channel
.includes(chatable: :users)
.joins(direct_message: :direct_message_users)
.group(1)
@ -75,7 +76,7 @@ class Chat::Api::ChatChatablesController < Chat::Api
users: users_without_channel,
memberships: memberships,
},
ChatChannelSearchSerializer,
Chat::ChannelSearchSerializer,
root: false,
)
end

View File

@ -0,0 +1,8 @@
# 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

@ -0,0 +1,32 @@
# 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

@ -0,0 +1,22 @@
# 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

@ -0,0 +1,481 @@
# 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

@ -0,0 +1,57 @@
# 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

@ -0,0 +1,10 @@
# 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

@ -0,0 +1,113 @@
# 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

@ -1,20 +0,0 @@
# 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

@ -1,472 +0,0 @@
# 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

@ -1,55 +0,0 @@
# 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

@ -1,8 +0,0 @@
# 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

@ -1,111 +0,0 @@
# 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

@ -1,15 +0,0 @@
# 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
Chat::ServiceRunner.call(service, object, **dependencies, &merged_block)
ServiceRunner.call(service, object, **dependencies, &merged_block)
end
def run_service(service, dependencies)

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
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

@ -1,79 +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
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

@ -0,0 +1,83 @@
# 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

@ -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
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

@ -0,0 +1,40 @@
# 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

@ -0,0 +1,63 @@
# 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

@ -0,0 +1,15 @@
# 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

@ -0,0 +1,148 @@
# 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

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