Merge remote-tracking branch 'upstream/master'

This commit is contained in:
Tarek Khalil 2019-03-08 11:36:38 +00:00
commit 741f5f92a1
103 changed files with 1868 additions and 732 deletions

View File

@ -44,7 +44,7 @@ gem 'redis-namespace'
gem 'active_model_serializers', '~> 0.8.3'
gem 'onebox', '1.8.79'
gem 'onebox', '1.8.82'
gem 'http_accept_language', '~>2.0.5', require: false

View File

@ -261,7 +261,7 @@ GEM
omniauth-twitter (1.4.0)
omniauth-oauth (~> 1.1)
rack
onebox (1.8.79)
onebox (1.8.82)
htmlentities (~> 4.3)
moneta (~> 1.0)
multi_json (~> 1.11)
@ -515,7 +515,7 @@ DEPENDENCIES
omniauth-oauth2
omniauth-openid
omniauth-twitter
onebox (= 1.8.79)
onebox (= 1.8.82)
openid-redis-store
pg
pry-nav

View File

@ -136,7 +136,9 @@ export default Post.extend({
label: I18n.t("yes_value"),
class: "btn-danger",
callback() {
Post.deleteMany(replies.map(r => r.id), { deferFlags: true })
Post.deleteMany(replies.map(r => r.id), {
agreeWithFirstReplyFlag: false
})
.then(action)
.then(resolve)
.catch(error => {

View File

@ -47,7 +47,10 @@ export default Ember.Component.extend(
this.set("hidden", false);
}
buffer.push(`<a href='${href}'>`);
buffer.push(
`<a href='${href}'` + (this.get("active") ? 'class="active"' : "") + `>`
);
if (content.get("hasIcon")) {
buffer.push("<span class='" + content.get("name") + "'></span>");
}

View File

@ -902,7 +902,7 @@ export default Ember.Controller.extend({
composerModel.set("composeState", Composer.OPEN);
composerModel.set("isWarning", false);
if (opts.usernames) {
if (opts.usernames && !this.get("model.targetUsernames")) {
this.set("model.targetUsernames", opts.usernames);
}

View File

@ -103,6 +103,14 @@ export default Ember.Controller.extend(ModalFunctionality, {
actions: {
saveTimer() {
if (!this.get("topicTimer.updateTime")) {
this.flash(
I18n.t("topic.topic_status_update.time_frame_required"),
"alert-error"
);
return;
}
this._setTimer(
this.get("topicTimer.updateTime"),
this.get("topicTimer.status_type")

View File

@ -5,6 +5,7 @@ import { popupAjaxError } from "discourse/lib/ajax-error";
export default Ember.Controller.extend(PreferencesTabController, {
saveAttrNames: [
"muted_usernames",
"ignored_usernames",
"new_topic_duration_minutes",
"auto_track_topics_after_msecs",
"notification_level_when_replying",

View File

@ -24,7 +24,7 @@ export default Ember.Controller.extend({
this.set("searchTerm", "");
},
@observes("searchTearm")
@observes("searchTerm")
_searchTermChanged: debounce(function() {
Invite.findInvitedBy(
this.get("user"),
@ -90,7 +90,6 @@ export default Ember.Controller.extend({
Invite.rescindAll()
.then(() => {
this.set("rescindedAll", true);
this.get("model.invites").clear();
})
.catch(popupAjaxError);
}

View File

@ -46,11 +46,26 @@ export function translateResults(results, opts) {
results.groups = results.groups
.map(group => {
const groupName = Handlebars.Utils.escapeExpression(group.name);
const name = Handlebars.Utils.escapeExpression(group.name);
const fullName = Handlebars.Utils.escapeExpression(
group.full_name || group.display_name
);
const flairUrl = Ember.isEmpty(group.flair_url)
? null
: Handlebars.Utils.escapeExpression(group.flair_url);
const flairColor = Handlebars.Utils.escapeExpression(group.flair_color);
const flairBgColor = Handlebars.Utils.escapeExpression(
group.flair_bg_color
);
return {
id: group.id,
name: groupName,
url: Discourse.getURL(`/g/${groupName}`)
flairUrl,
flairColor,
flairBgColor,
fullName,
name,
url: Discourse.getURL(`/g/${name}`)
};
})
.compact();
@ -72,10 +87,10 @@ export function translateResults(results, opts) {
if (groupedSearchResult) {
[
["topic", "posts"],
["category", "categories"],
["tag", "tags"],
["user", "users"],
["group", "groups"]
["group", "groups"],
["category", "categories"],
["tag", "tags"]
].forEach(function(pair) {
const type = pair[0];
const name = pair[1];

View File

@ -80,6 +80,7 @@ export function transformBasicPost(post) {
expandablePost: false,
replyCount: post.reply_count,
locked: post.locked,
ignored: post.ignored,
userCustomFields: post.user_custom_fields
};
@ -133,6 +134,13 @@ export default function transformPost(
postAtts.topicUrl = topic.get("url");
postAtts.isSaving = post.isSaving;
if (post.post_notice_type) {
postAtts.postNoticeType = post.post_notice_type;
if (postAtts.postNoticeType === "returning") {
postAtts.postNoticeTime = new Date(post.post_notice_time);
}
}
const showPMMap =
topic.archetype === "private_message" && post.post_number === 1;
if (showPMMap) {

View File

@ -378,10 +378,10 @@ Post.reopenClass({
});
},
deleteMany(post_ids, { deferFlags = false } = {}) {
deleteMany(post_ids, { agreeWithFirstReplyFlag = true } = {}) {
return ajax("/posts/destroy_many", {
type: "DELETE",
data: { post_ids, defer_flags: deferFlags }
data: { post_ids, agree_with_first_reply_flag: agreeWithFirstReplyFlag }
});
},

View File

@ -249,6 +249,7 @@ const User = RestModel.extend({
"custom_fields",
"user_fields",
"muted_usernames",
"ignored_usernames",
"profile_background",
"card_background",
"muted_tags",

View File

@ -48,6 +48,8 @@ export default Discourse.Route.extend({
}
})
.catch(() => bootbox.alert(I18n.t("generic_error")));
} else {
e.send("createNewMessageViaParams", null, params.title, params.body);
}
});
} else {

View File

@ -19,6 +19,11 @@
<p>{{model.description}}</p>
</section>
{{plugin-outlet name="about-after-description"
connectorTagName='section'
tagName=''
args=(hash model=model)}}
{{#if model.admins}}
<section class='about admins'>
<h3>{{d-icon "users"}} {{i18n 'about.our_admins'}}</h3>

View File

@ -1 +1 @@
<a href {{action "select"}}>{{title}}</a>
<a href {{action "select"}} class="{{if active 'active'}}">{{title}}</a>

View File

@ -41,11 +41,11 @@
<figure title="{{i18n 'all_time_desc'}}">{{number c.topics_all_time}} <figcaption>{{i18n 'all_time'}}</figcaption></figure>
{{#if c.pickMonth}}
<figure title="{{i18n 'month_desc'}}">{{number c.topics_month}} <figcaption>{{i18n 'month'}}</figcaption></figure>
<figure title="{{i18n 'month_desc'}}">{{number c.topics_month}} <figcaption>/ {{i18n 'month'}}</figcaption></figure>
{{/if}}
{{#if c.pickWeek}}
<figure title="{{i18n 'week_desc'}}">{{number c.topics_week}} <figcaption>{{i18n 'week'}}</figcaption></figure>
<figure title="{{i18n 'week_desc'}}">{{number c.topics_week}} <figcaption>/ {{i18n 'week'}}</figcaption></figure>
{{/if}}
</footer>

View File

@ -81,19 +81,19 @@
{{else}}
<td>{{unbound invite.email}}</td>
<td>{{format-date invite.created_at}}</td>
<td colspan='5'>
<td>
{{#if invite.expired}}
{{i18n 'user.invited.expired'}}
&nbsp;&nbsp;&nbsp;&nbsp;
<div>{{i18n 'user.invited.expired'}}</div>
{{/if}}
{{#if invite.rescinded}}
{{i18n 'user.invited.rescinded'}}
{{else}}
{{d-button icon="times" action=(action "rescind") actionParam=invite label="user.invited.rescind"}}
{{/if}}
&nbsp;&nbsp;&nbsp;&nbsp;
</td>
<td>
{{#if invite.reinvited}}
{{i18n 'user.invited.reinvited'}}
<div>{{i18n 'user.invited.reinvited'}}</div>
{{else}}
{{d-button icon="sync" action=(action "reinvite") actionParam=invite label="user.invited.reinvite"}}
{{/if}}

View File

@ -13,6 +13,7 @@ import {
formatUsername
} from "discourse/lib/utilities";
import hbs from "discourse/widgets/hbs-compiler";
import { relativeAge } from "discourse/lib/formatter";
function transformWithCallbacks(post) {
let transformed = transformBasicPost(post);
@ -427,6 +428,29 @@ createWidget("post-contents", {
}
});
createWidget("post-notice", {
tagName: "div.post-notice",
html(attrs) {
let text, icon;
if (attrs.postNoticeType === "first") {
icon = "hands-helping";
text = I18n.t("post.notice.first", { user: attrs.username });
} else if (attrs.postNoticeType === "returning") {
icon = "far-smile";
text = I18n.t("post.notice.return", {
user: attrs.username,
time: relativeAge(attrs.postNoticeTime, {
format: "tiny",
addAgo: true
})
});
}
return h("p", [iconNode(icon), text]);
}
});
createWidget("post-body", {
tagName: "div.topic-body.clearfix",
@ -505,6 +529,10 @@ createWidget("post-article", {
);
}
if (attrs.postNoticeType) {
rows.push(h("div.row", [this.attach("post-notice", attrs)]));
}
rows.push(
h("div.row", [
this.attach("post-avatar", attrs),
@ -608,6 +636,9 @@ export default createWidget("post", {
} else {
classNames.push("regular");
}
if (attrs.ignored) {
classNames.push("post-ignored");
}
if (addPostClassesCallbacks) {
for (let i = 0; i < addPostClassesCallbacks.length; i++) {
let pluginClasses = addPostClassesCallbacks[i].call(this, attrs);

View File

@ -152,14 +152,17 @@ export default createWidget("private-message-map", {
}
const result = [h(`div.participants${hideNamesClass}`, participants)];
const controls = [];
const controls = [
this.attach("button", {
action: "toggleEditing",
label: "private_message_info.edit",
className: "btn btn-default add-remove-participant-btn"
})
];
if (attrs.canRemoveAllowedUsers || attrs.canRemoveSelfId) {
controls.push(
this.attach("button", {
action: "toggleEditing",
label: "private_message_info.edit",
className: "btn btn-default add-remove-participant-btn"
})
);
}
if (attrs.canInvite && this.state.isEditing) {
controls.push(
@ -171,7 +174,9 @@ export default createWidget("private-message-map", {
);
}
result.push(h("div.controls", controls));
if (controls.length) {
result.push(h("div.controls", controls));
}
return result;
},

View File

@ -90,16 +90,48 @@ createSearchResult({
}
});
createSearchResult({
type: "group",
linkField: "url",
builder(group) {
const fullName = escapeExpression(group.fullName);
const name = escapeExpression(group.name);
const groupNames = [h("span.name", fullName || name)];
if (fullName) {
groupNames.push(h("span.slug", name));
}
let avatarFlair;
if (group.flairUrl) {
avatarFlair = this.attach("avatar-flair", {
primary_group_flair_url: group.flairUrl,
primary_group_flair_bg_color: group.flairBgColor,
primary_group_flair_color: group.flairColor,
primary_group_name: name
});
} else {
avatarFlair = iconNode("users");
}
const groupResultContents = [avatarFlair, h("div.group-names", groupNames)];
return h("div.group-result", groupResultContents);
}
});
createSearchResult({
type: "user",
linkField: "path",
builder(u) {
const userTitles = [h("span.username", formatUsername(u.username))];
const userTitles = [];
if (u.name) {
userTitles.push(h("span.name", u.name));
}
userTitles.push(h("span.username", formatUsername(u.username)));
const userResultContents = [
avatarImg("small", {
template: u.avatar_template,
@ -112,21 +144,6 @@ createSearchResult({
}
});
createSearchResult({
type: "group",
linkField: "url",
builder(group) {
const groupName = escapeExpression(group.name);
return h(
"span",
{
className: `group-${groupName} discourse-group`
},
[iconNode("users"), h("span", groupName)]
);
}
});
createSearchResult({
type: "topic",
linkField: "url",
@ -174,19 +191,12 @@ createWidget("search-menu-results", {
const resultTypes = results.resultTypes || [];
const mainResultsContent = [];
const classificationContents = [];
const otherContents = [];
const assignContainer = (type, node) => {
if (["topic"].includes(type)) {
mainResultsContent.push(node);
} else if (["category", "tag"].includes(type)) {
classificationContents.push(node);
} else {
otherContents.push(node);
}
};
const usersAndGroups = [];
const categoriesAndTags = [];
const usersAndGroupsMore = [];
const categoriesAndTagsMore = [];
resultTypes.forEach(rt => {
const buildMoreNode = result => {
const more = [];
const moreArgs = {
@ -194,23 +204,45 @@ createWidget("search-menu-results", {
contents: () => [I18n.t("more"), "..."]
};
if (rt.moreUrl) {
if (result.moreUrl) {
more.push(
this.attach("link", $.extend(moreArgs, { href: rt.moreUrl }))
this.attach("link", $.extend(moreArgs, { href: result.moreUrl }))
);
} else if (rt.more) {
} else if (result.more) {
more.push(
this.attach(
"link",
$.extend(moreArgs, {
action: "moreOfType",
actionParam: rt.type,
actionParam: result.type,
className: "filter filter-type"
})
)
);
}
if (more.length) {
return more;
}
};
const assignContainer = (result, node) => {
if (["topic"].includes(result.type)) {
mainResultsContent.push(node);
}
if (["user", "group"].includes(result.type)) {
usersAndGroups.push(node);
usersAndGroupsMore.push(buildMoreNode(result));
}
if (["category", "tag"].includes(result.type)) {
categoriesAndTags.push(node);
categoriesAndTagsMore.push(buildMoreNode(result));
}
};
resultTypes.forEach(rt => {
const resultNodeContents = [
this.attach(rt.componentName, {
searchContextEnabled: attrs.searchContextEnabled,
@ -220,14 +252,14 @@ createWidget("search-menu-results", {
})
];
if (more.length) {
resultNodeContents.push(h("div.show-more", more));
if (["topic"].includes(rt.type)) {
const more = buildMoreNode(rt);
if (more) {
resultNodeContents.push(h("div.show-more", more));
}
}
assignContainer(
rt.type,
h(`div.${rt.componentName}`, resultNodeContents)
);
assignContainer(rt, h(`div.${rt.componentName}`, resultNodeContents));
});
const content = [];
@ -236,27 +268,25 @@ createWidget("search-menu-results", {
content.push(h("div.main-results", mainResultsContent));
}
if (classificationContents.length || otherContents.length) {
const secondaryResultsContent = [];
if (usersAndGroups.length || categoriesAndTags.length) {
const secondaryResultsContents = [];
if (classificationContents.length) {
secondaryResultsContent.push(
h("div.classification-results", classificationContents)
);
secondaryResultsContents.push(usersAndGroups);
secondaryResultsContents.push(usersAndGroupsMore);
if (usersAndGroups.length && categoriesAndTags.length) {
secondaryResultsContents.push(h("div.separator"));
}
if (otherContents.length) {
secondaryResultsContent.push(h("div.other-results", otherContents));
}
secondaryResultsContents.push(categoriesAndTags);
secondaryResultsContents.push(categoriesAndTagsMore);
content.push(
h(
`div.secondary-results${
mainResultsContent.length ? "" : ".no-main-results"
}`,
secondaryResultsContent
)
const secondaryResults = h(
"div.secondary-results",
secondaryResultsContents
);
content.push(secondaryResults);
}
return content;

View File

@ -428,6 +428,11 @@
margin-bottom: 0;
}
a.active {
background: $primary-medium;
color: $secondary;
}
a.blank:not(.active) {
color: $primary-medium;
}

View File

@ -76,26 +76,22 @@
flex-direction: column;
flex: 1 1 auto;
.classification-results {
border-bottom: 1px solid $primary-low;
.separator {
margin-bottom: 1em;
padding-bottom: 1em;
}
.search-result-category {
margin-top: 1em;
height: 1px;
background: $primary-low;
}
.search-result-tag {
.list {
.item {
display: inline-flex;
.discourse-tag {
font-size: $font-down-1;
}
}
.widget-link.search-link {
display: inline;
font-size: $font-0;
padding: 5px;
}
}
.search-result-category {
.widget-link {
margin-bottom: 0;
}
}
@ -108,12 +104,71 @@
}
}
.discourse-group {
display: inline-block;
word-break: break-all;
.group-result {
display: flex;
align-items: center;
.d-icon {
margin-right: s(1);
.d-icon,
.avatar-flair {
min-width: 25px;
margin-right: 0.5em;
.d-icon {
margin-right: 0;
}
}
.avatar-flair-image {
background-repeat: no-repeat;
background-size: 100% 100%;
min-height: 25px;
}
.group-names {
display: flex;
flex-direction: column;
overflow: auto;
line-height: $line-height-medium;
&:hover {
.name,
.slug {
color: $primary-high;
}
}
.name,
.slug {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.name {
font-weight: 700;
}
.slug {
font-size: $font-down-1;
color: $primary-high;
}
}
}
}
.search-result-category,
.search-result-user,
.search-result-group,
.search-result-tag {
.list {
display: block;
.item {
.widget-link.search-link {
flex: 1;
font-size: $font-0;
padding: 5px;
}
}
}
}
@ -145,29 +200,17 @@
.username {
color: dark-light-choose($primary-high, $secondary-low);
font-size: $font-0;
font-weight: 700;
font-size: $font-down-1;
}
.name {
color: dark-light-choose($primary-high, $secondary-low);
font-size: $font-down-1;
font-size: $font-0;
font-weight: 700;
}
}
}
}
&.no-main-results .search-result-user {
.user-titles {
flex-direction: row;
align-items: center;
.name {
margin: 0 0 0 0.25em;
font-size: $font-0;
}
}
}
}
.show-more {

View File

@ -214,6 +214,10 @@ aside.quote {
margin: -2px;
}
.post-ignored {
font-style: italic;
}
.post-action {
.undo-action,
.act-action {
@ -353,7 +357,10 @@ aside.quote {
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 0.5em;
& + .controls {
margin-top: 0.5em;
}
&.hide-names .user {
.username,
@ -857,3 +864,22 @@ a.mention-group {
margin-bottom: 1em;
}
}
.post-notice {
background-color: $tertiary-low;
border-top: 1px solid $primary-low;
color: $primary;
padding: 1em;
max-width: calc(
#{$topic-body-width} + #{$topic-avatar-width} - #{$topic-body-width-padding} +
3px
);
p {
margin: 0;
}
.d-icon {
margin-right: 1em;
}
}

View File

@ -48,8 +48,7 @@
}
}
&.active > a,
> a.active {
a.active {
color: $secondary;
background-color: $quaternary;

View File

@ -203,8 +203,24 @@
border: 1px solid $primary-low;
}
.d-editor-preview img {
padding-bottom: 1.4em;
&.emoji,
&.avatar,
&.site-icon {
padding-bottom: 0;
}
}
.d-editor-preview .image-wrapper {
position: relative;
display: inline-block;
padding-bottom: 1.4em;
img {
padding-bottom: 0;
}
&:hover {
.button-wrapper {
opacity: 0.9;
@ -212,21 +228,22 @@
}
.button-wrapper {
opacity: 0;
background: $secondary;
position: absolute;
transition: all 0.25s;
display: flex;
align-items: center;
bottom: 0.75em;
left: 0.75em;
box-shadow: shadow("dropdown");
bottom: 0;
left: 0;
.separator {
color: $primary-low;
color: $primary-low-mid;
}
.scale-btn {
color: $tertiary;
padding: 0.2em 0.6em;
padding: 0 0.4em;
&:first-of-type {
padding-left: 0;
}
&.active {
font-weight: bold;

View File

@ -24,8 +24,6 @@
z-index: z("dropdown");
.select-kit-body {
-webkit-animation: fadein 0.25s;
animation: fadein 0.25s;
display: flex;
flex-direction: column;
left: 0;

View File

@ -47,60 +47,69 @@ section.post-menu-area {
nav.post-controls {
padding: 0;
.like-button {
// Like button wrapper
display: inline-flex;
.like-count {
color: dark-light-choose($primary-low-mid, $secondary-high);
}
.widget-button {
background: none;
}
color: $primary-low-mid;
margin-right: 0.15em;
&:hover {
background: $primary-low;
.like-count {
// Like button wrapper on hover
button {
background: $primary-low;
color: $primary-medium;
}
}
button {
margin-left: 0;
margin-right: 0;
&.my-likes {
// Like count on my posts
.d-icon {
color: $primary-low-mid;
padding-left: 0.45em;
}
}
&.like {
// Like button with 0 likes
&.d-hover {
background: $love-low;
.d-icon {
color: $love;
}
}
}
&.has-like {
// Like button after I've liked
.d-icon {
color: $love;
}
&.d-hover {
background: $primary-low;
.d-icon {
color: $primary-medium;
}
}
}
&[disabled] {
// Disabled like button
cursor: not-allowed;
}
&.like-count {
// Like count button
&:not(.my-likes) {
padding-right: 0;
}
&.d-hover {
color: $primary;
}
}
.d-hover {
background: none;
}
.d-icon {
color: $love;
+ .toggle-like {
// Like button when like count is present
padding-left: 0.45em;
&.d-hover {
background: $primary-low;
}
}
}
}
&:active {
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.4);
.widget-button {
box-shadow: none;
}
}
.like {
&:focus {
background: none;
}
}
.like-count {
font-size: $font-up-1;
margin-left: 0;
.d-icon {
padding-left: 10px;
color: dark-light-choose($primary-low-mid, $secondary-high);
}
&.my-likes {
margin-right: -2px;
}
&.regular-likes {
margin-right: -12px;
}
}
.toggle-like {
padding: 8px 8px;
margin-left: 2px;
}
}
.highlight-action {
color: dark-light-choose($primary-medium, $secondary-high);
}
a,
button {
@ -186,23 +195,6 @@ nav.post-controls {
color: $secondary;
}
}
&.like.d-hover,
&.like:focus {
color: $love;
background: $love-low;
.d-icon {
color: $love;
}
}
&.has-like .d-icon {
color: $love;
}
&.has-like[disabled]:hover {
background: transparent;
}
&.has-like[disabled]:active {
box-shadow: none;
}
&.bookmark {
padding: 8px 11px;
&.bookmarked .d-icon {

View File

@ -115,6 +115,11 @@
.user-invite-list {
width: 100%;
margin-top: 15px;
tr {
td {
padding: 0.667em;
}
}
}
.user-invite-search {

View File

@ -66,7 +66,7 @@
form {
margin-top: 20px;
input[type="text"] {
input:not(.filter-input)[type="text"] {
box-sizing: border-box;
width: 100%;
}

View File

@ -475,3 +475,7 @@ span.highlighted {
margin-bottom: 0;
}
}
.post-notice {
margin-bottom: 1em;
}

View File

@ -113,12 +113,15 @@ class Admin::EmailTemplatesController < Admin::AdminController
def update_key(key, value)
old_value = I18n.t(key)
translation_override = TranslationOverride.upsert!(I18n.locale, key, value)
unless old_value.is_a?(Hash)
translation_override = TranslationOverride.upsert!(I18n.locale, key, value)
end
{
key: key,
old_value: old_value,
error_messages: translation_override.errors.full_messages
error_messages: translation_override&.errors&.full_messages
}
end

View File

@ -143,7 +143,7 @@ class InvitesController < ApplicationController
def rescind_all_invites
guardian.ensure_can_rescind_all_invites!(current_user)
Invite.rescind_all_invites_from(current_user)
Invite.rescind_all_expired_invites_from(current_user)
render body: nil
end

View File

@ -336,7 +336,7 @@ class PostsController < ApplicationController
def destroy_many
params.require(:post_ids)
defer_flags = params[:defer_flags] || false
agree_with_first_reply_flag = (params[:agree_with_first_reply_flag] || true).to_s == "true"
posts = Post.where(id: post_ids_including_replies)
raise Discourse::InvalidParameters.new(:post_ids) if posts.blank?
@ -345,7 +345,9 @@ class PostsController < ApplicationController
posts.each { |p| guardian.ensure_can_delete!(p) }
Post.transaction do
posts.each { |p| PostDestroyer.new(current_user, p, defer_flags: defer_flags).destroy }
posts.each_with_index do |p, i|
PostDestroyer.new(current_user, p, defer_flags: !(agree_with_first_reply_flag && i == 0)).destroy
end
end
render body: nil

View File

@ -1226,6 +1226,7 @@ class UsersController < ApplicationController
:title,
:date_of_birth,
:muted_usernames,
:ignored_usernames,
:theme_ids,
:locale,
:bio_raw,

View File

@ -17,63 +17,74 @@ module Jobs
class Base
class JobInstrumenter
def initialize(job_class:, opts:, db:)
def initialize(job_class:, opts:, db:, jid:)
return unless enabled?
@data = {}
self.class.mutex.synchronize do
@data = {}
@data["hostname"] = `hostname`.strip # Hostname
@data["pid"] = Process.pid # Pid
@data["database"] = db # DB name - multisite db name it ran on
@data["job_name"] = job_class.name # Job Name - eg: Jobs::AboutStats
@data["job_type"] = job_class.try(:scheduled?) ? "scheduled" : "regular" # Job Type - either s for scheduled or r for regular
@data["opts"] = opts.to_json # Params - json encoded params for the job
@data["hostname"] = `hostname`.strip # Hostname
@data["pid"] = Process.pid # Pid
@data["database"] = db # DB name - multisite db name it ran on
@data["job_id"] = jid # Job unique ID
@data["job_name"] = job_class.name # Job Name - eg: Jobs::AboutStats
@data["job_type"] = job_class.try(:scheduled?) ? "scheduled" : "regular" # Job Type - either s for scheduled or r for regular
@data["opts"] = opts.to_json # Params - json encoded params for the job
@data["status"] = 'pending'
@start_timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
if ENV["DISCOURSE_LOG_SIDEKIQ_INTERVAL"]
@data["status"] = "starting"
write_to_log
end
self.class.ensure_interval_logging!
@@active_jobs ||= []
@@active_jobs << self
@data["status"] = "pending"
@start_timestamp = Process.clock_gettime(Process::CLOCK_MONOTONIC)
MethodProfiler.ensure_discourse_instrumentation!
MethodProfiler.start
self.class.ensure_interval_logging!
@@active_jobs ||= []
@@active_jobs << self
MethodProfiler.ensure_discourse_instrumentation!
MethodProfiler.start
end
end
def stop(exception:)
return unless enabled?
self.class.mutex.synchronize do
profile = MethodProfiler.stop
profile = MethodProfiler.stop
@@active_jobs.delete(self)
@@active_jobs.delete(self)
@data["duration"] = profile[:total_duration] # Duration - length in seconds it took to run
@data["sql_duration"] = profile.dig(:sql, :duration) || 0 # Sql Duration (s)
@data["sql_calls"] = profile.dig(:sql, :calls) || 0 # Sql Statements - how many statements ran
@data["redis_duration"] = profile.dig(:redis, :duration) || 0 # Redis Duration (s)
@data["redis_calls"] = profile.dig(:redis, :calls) || 0 # Redis commands
@data["net_duration"] = profile.dig(:net, :duration) || 0 # Redis Duration (s)
@data["net_calls"] = profile.dig(:net, :calls) || 0 # Redis commands
@data["duration"] = profile[:total_duration] # Duration - length in seconds it took to run
@data["sql_duration"] = profile.dig(:sql, :duration) || 0 # Sql Duration (s)
@data["sql_calls"] = profile.dig(:sql, :calls) || 0 # Sql Statements - how many statements ran
@data["redis_duration"] = profile.dig(:redis, :duration) || 0 # Redis Duration (s)
@data["redis_calls"] = profile.dig(:redis, :calls) || 0 # Redis commands
@data["net_duration"] = profile.dig(:net, :duration) || 0 # Redis Duration (s)
@data["net_calls"] = profile.dig(:net, :calls) || 0 # Redis commands
if exception.present?
@data["exception"] = exception # Exception - if job fails a json encoded exception
@data["status"] = 'failed'
else
@data["status"] = 'success' # Status - fail, success, pending
end
if exception.present?
@data["exception"] = exception # Exception - if job fails a json encoded exception
@data["status"] = 'failed'
else
@data["status"] = 'success' # Status - fail, success, pending
write_to_log
end
write_to_log
end
def self.raw_log(message)
@@logger ||= Logger.new("#{Rails.root}/log/sidekiq.log")
@@logger ||= begin
f = File.open "#{Rails.root}/log/sidekiq.log", "a"
f.sync = true
Logger.new f
end
@@log_queue ||= Queue.new
unless @log_thread&.alive?
@@log_thread = Thread.new do
begin
loop { @@logger << @@log_queue.pop }
rescue Exception => e
Discourse.warn_exception(e, message: "Sidekiq logging thread terminated unexpectedly")
end
@@log_thread ||= Thread.new do
begin
loop { @@logger << @@log_queue.pop }
rescue Exception => e
Discourse.warn_exception(e, message: "Sidekiq logging thread terminated unexpectedly")
end
end
@@log_queue.push(message)
@ -94,14 +105,21 @@ module Jobs
ENV["DISCOURSE_LOG_SIDEKIQ"] == "1"
end
def self.mutex
@@mutex ||= Mutex.new
end
def self.ensure_interval_logging!
interval = ENV["DISCOURSE_LOG_SIDEKIQ_INTERVAL"]
return if !interval
interval = interval.to_i
@@interval_thread ||= Thread.new do
begin
loop do
sleep interval.to_i
@@active_jobs.each { |j| j.write_to_log if j.current_duration > interval }
sleep interval
mutex.synchronize do
@@active_jobs.each { |j| j.write_to_log if j.current_duration > interval }
end
end
rescue Exception => e
Discourse.warn_exception(e, message: "Sidekiq interval logging thread terminated unexpectedly")
@ -183,7 +201,7 @@ module Jobs
exception = {}
RailsMultisite::ConnectionManagement.with_connection(db) do
job_instrumenter = JobInstrumenter.new(job_class: self.class, opts: opts, db: db)
job_instrumenter = JobInstrumenter.new(job_class: self.class, opts: opts, db: db, jid: jid)
begin
I18n.locale = SiteSetting.default_locale || "en"
I18n.ensure_all_loaded!

View File

@ -24,7 +24,7 @@ module Jobs
BadgeGranter.grant(badge, user)
SystemMessage.new(user).create('new_user_of_the_month',
month_year: Time.now.strftime("%B %Y"),
month_year: I18n.l(Time.now, format: :no_day),
url: "#{Discourse.base_url}/badges"
)
end

View File

@ -1,27 +0,0 @@
class GoogleUserInfo < ActiveRecord::Base
belongs_to :user
end
# == Schema Information
#
# Table name: google_user_infos
#
# id :integer not null, primary key
# user_id :integer not null
# google_user_id :string not null
# first_name :string
# last_name :string
# email :string
# gender :string
# name :string
# link :string
# profile_link :string
# picture :string
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_google_user_infos_on_google_user_id (google_user_id) UNIQUE
# index_google_user_infos_on_user_id (user_id) UNIQUE
#

View File

@ -290,7 +290,7 @@ class Group < ActiveRecord::Base
# way to have the membership in a table
case name
when :everyone
group.visibility_level = Group.visibility_levels[:owners]
group.visibility_level = Group.visibility_levels[:staff]
group.save!
return group
when :moderators

View File

@ -226,8 +226,9 @@ class Invite < ActiveRecord::Base
end
end
def self.rescind_all_invites_from(user)
Invite.where('invites.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ?', user.id).find_each do |invite|
def self.rescind_all_expired_invites_from(user)
Invite.where('invites.user_id IS NULL AND invites.email IS NOT NULL AND invited_by_id = ? AND invites.created_at < ?',
user.id, SiteSetting.invite_expiry_days.days.ago).find_each do |invite|
invite.trash!(user)
end
end

View File

@ -407,8 +407,8 @@ class OptimizedImage < ActiveRecord::Base
# just ditch the optimized image if there was any errors
optimized_image.destroy
ensure
file&.unlink
file&.close
file&.unlink if file&.respond_to?(:unlink)
end
end
end

View File

@ -194,6 +194,7 @@ class Post < ActiveRecord::Base
def recover!
super
update_flagged_posts_count
delete_post_notices
recover_public_post_actions
TopicLink.extract_from(self)
QuotedPost.extract_from(self)
@ -381,6 +382,11 @@ class Post < ActiveRecord::Base
PostAction.update_flagged_posts_count
end
def delete_post_notices
self.custom_fields.delete("post_notice_type")
self.custom_fields.delete("post_notice_time")
end
def recover_public_post_actions
PostAction.publics
.with_deleted

View File

@ -17,15 +17,18 @@ class S3RegionSiteSetting < EnumSiteSetting
'ap-south-1',
'ap-southeast-1',
'ap-southeast-2',
'ca-central-1',
'cn-north-1',
'cn-northwest-1',
'eu-central-1',
'eu-north-1',
'eu-west-1',
'eu-west-2',
'eu-west-3',
'sa-east-1',
'us-east-1',
'us-east-2',
'us-gov-east-1',
'us-gov-west-1',
'us-west-1',
'us-west-2',

View File

@ -527,10 +527,10 @@ class Topic < ActiveRecord::Base
end
# Atomically creates the next post number
def self.next_post_number(topic_id, reply = false, whisper = false)
def self.next_post_number(topic_id, opts = {})
highest = DB.query_single("SELECT coalesce(max(post_number),0) AS max FROM posts WHERE topic_id = ?", topic_id).first.to_i
if whisper
if opts[:whisper]
result = DB.query_single(<<~SQL, highest, topic_id)
UPDATE topics
@ -543,13 +543,15 @@ class Topic < ActiveRecord::Base
else
reply_sql = reply ? ", reply_count = reply_count + 1" : ""
reply_sql = opts[:reply] ? ", reply_count = reply_count + 1" : ""
posts_sql = opts[:post] ? ", posts_count = posts_count + 1" : ""
result = DB.query_single(<<~SQL, highest: highest, topic_id: topic_id)
UPDATE topics
SET highest_staff_post_number = :highest + 1,
highest_post_number = :highest + 1#{reply_sql},
posts_count = posts_count + 1
highest_post_number = :highest + 1
#{reply_sql}
#{posts_sql}
WHERE id = :topic_id
RETURNING highest_post_number
SQL
@ -585,6 +587,43 @@ class Topic < ActiveRecord::Base
posts_count = Y.posts_count
FROM X, Y
WHERE
topics.archetype <> 'private_message' AND
X.topic_id = topics.id AND
Y.topic_id = topics.id AND (
topics.highest_staff_post_number <> X.highest_post_number OR
topics.highest_post_number <> Y.highest_post_number OR
topics.last_posted_at <> Y.last_posted_at OR
topics.posts_count <> Y.posts_count
)
SQL
DB.exec <<~SQL
WITH
X as (
SELECT topic_id,
COALESCE(MAX(post_number), 0) highest_post_number
FROM posts
WHERE deleted_at IS NULL
GROUP BY topic_id
),
Y as (
SELECT topic_id,
coalesce(MAX(post_number), 0) highest_post_number,
count(*) posts_count,
max(created_at) last_posted_at
FROM posts
WHERE deleted_at IS NULL AND post_type <> 3 AND post_type <> 4
GROUP BY topic_id
)
UPDATE topics
SET
highest_staff_post_number = X.highest_post_number,
highest_post_number = Y.highest_post_number,
last_posted_at = Y.last_posted_at,
posts_count = Y.posts_count
FROM X, Y
WHERE
topics.archetype = 'private_message' AND
X.topic_id = topics.id AND
Y.topic_id = topics.id AND (
topics.highest_staff_post_number <> X.highest_post_number OR
@ -597,32 +636,39 @@ class Topic < ActiveRecord::Base
# If a post is deleted we have to update our highest post counters
def self.reset_highest(topic_id)
archetype = Topic.where(id: topic_id).pluck(:archetype).first
# ignore small_action replies for private messages
post_type = archetype == Archetype.private_message ? " AND post_type <> #{Post.types[:small_action]}" : ''
result = DB.query_single(<<~SQL, topic_id: topic_id)
UPDATE topics
SET
highest_staff_post_number = (
highest_staff_post_number = (
SELECT COALESCE(MAX(post_number), 0) FROM posts
WHERE topic_id = :topic_id AND
deleted_at IS NULL
),
highest_post_number = (
highest_post_number = (
SELECT COALESCE(MAX(post_number), 0) FROM posts
WHERE topic_id = :topic_id AND
deleted_at IS NULL AND
post_type <> 4
#{post_type}
),
posts_count = (
SELECT count(*) FROM posts
WHERE deleted_at IS NULL AND
topic_id = :topic_id AND
post_type <> 4
#{post_type}
),
last_posted_at = (
SELECT MAX(created_at) FROM posts
WHERE topic_id = :topic_id AND
deleted_at IS NULL AND
post_type <> 4
#{post_type}
)
WHERE id = :topic_id
RETURNING highest_post_number

View File

@ -66,7 +66,6 @@ class User < ActiveRecord::Base
has_one :user_avatar, dependent: :destroy
has_many :user_associated_accounts, dependent: :destroy
has_one :github_user_info, dependent: :destroy
has_one :google_user_info, dependent: :destroy
has_many :oauth2_user_infos, dependent: :destroy
has_one :instagram_user_info, dependent: :destroy
has_many :user_second_factors, dependent: :destroy

View File

@ -65,10 +65,12 @@ class WebHook < ActiveRecord::Base
end
end
def self.enqueue_topic_hooks(event, topic)
def self.enqueue_topic_hooks(event, topic, payload = nil)
if active_web_hooks('topic').exists? && topic.present?
topic_view = TopicView.new(topic.id, Discourse.system_user)
payload = WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer)
payload ||= begin
topic_view = TopicView.new(topic.id, Discourse.system_user)
WebHook.generate_payload(:topic, topic_view, WebHookTopicViewSerializer)
end
WebHook.enqueue_hooks(:topic, event,
id: topic.id,
@ -79,9 +81,9 @@ class WebHook < ActiveRecord::Base
end
end
def self.enqueue_post_hooks(event, post)
def self.enqueue_post_hooks(event, post, payload = nil)
if active_web_hooks('post').exists? && post.present?
payload = WebHook.generate_payload(:post, post)
payload ||= WebHook.generate_payload(:post, post)
WebHook.enqueue_hooks(:post, event,
id: post.id,

View File

@ -6,7 +6,8 @@ class BasicPostSerializer < ApplicationSerializer
:avatar_template,
:created_at,
:cooked,
:cooked_hidden
:cooked_hidden,
:ignored
def name
object.user && object.user.name
@ -35,11 +36,18 @@ class BasicPostSerializer < ApplicationSerializer
else
I18n.t('flagging.user_must_edit')
end
elsif ignored
I18n.t('ignored.hidden_content')
else
object.filter_quotes(@parent_post)
end
end
def ignored
object.is_first_post? && IgnoredUser.where(user_id: scope.current_user&.id,
ignored_user_id: object.user_id).present?
end
def include_name?
SiteSetting.enable_names?
end

View File

@ -70,6 +70,8 @@ class PostSerializer < BasicPostSerializer
:is_auto_generated,
:action_code,
:action_code_who,
:post_notice_type,
:post_notice_time,
:last_wiki_edit,
:locked,
:excerpt
@ -363,6 +365,22 @@ class PostSerializer < BasicPostSerializer
include_action_code? && action_code_who.present?
end
def post_notice_type
post_custom_fields["post_notice_type"]
end
def include_post_notice_type?
post_notice_type.present?
end
def post_notice_time
post_custom_fields["post_notice_time"]
end
def include_post_notice_time?
post_notice_time.present?
end
def locked
true
end

View File

@ -53,7 +53,6 @@ class UserAnonymizer
end
@user.user_avatar.try(:destroy)
@user.google_user_info.try(:destroy)
@user.github_user_info.try(:destroy)
@user.single_sign_on_record.try(:destroy)
@user.oauth2_user_infos.try(:destroy_all)

View File

@ -128,6 +128,10 @@ class UserUpdater
update_muted_users(attributes[:muted_usernames])
end
if attributes.key?(:ignored_usernames)
update_ignored_users(attributes[:ignored_usernames])
end
name_changed = user.name_changed?
if (saved = (!save_options || user.user_option.save) && user_profile.save && user.save) &&
(name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0)
@ -157,13 +161,27 @@ class UserUpdater
INSERT into muted_users(user_id, muted_user_id, created_at, updated_at)
SELECT :user_id, id, :now, :now
FROM users
WHERE
id in (:desired_ids) AND
id NOT IN (
SELECT muted_user_id
FROM muted_users
WHERE user_id = :user_id
)
WHERE id in (:desired_ids)
ON CONFLICT DO NOTHING
SQL
end
end
def update_ignored_users(usernames)
usernames ||= ""
desired_ids = User.where(username: usernames.split(",")).pluck(:id)
if desired_ids.empty?
IgnoredUser.where(user_id: user.id).destroy_all
else
IgnoredUser.where('user_id = ? AND ignored_user_id not in (?)', user.id, desired_ids).destroy_all
# SQL is easier here than figuring out how to do the same in AR
DB.exec(<<~SQL, now: Time.now, user_id: user.id, desired_ids: desired_ids)
INSERT into ignored_users(user_id, ignored_user_id, created_at, updated_at)
SELECT :user_id, id, :now, :now
FROM users
WHERE id in (:desired_ids)
ON CONFLICT DO NOTHING
SQL
end
end

View File

@ -17,6 +17,8 @@
<meta name="shared_session_key" content="<%= shared_session_key %>">
<%- end %>
<%= build_plugin_html 'server:before-script-load' %>
<%= preload_script "locales/#{I18n.locale}" %>
<%= preload_script "ember_jquery" %>
<%= preload_script "preload-store" %>

View File

@ -192,15 +192,18 @@ en:
ap_south_1: "Asia Pacific (Mumbai)"
ap_southeast_1: "Asia Pacific (Singapore)"
ap_southeast_2: "Asia Pacific (Sydney)"
ca_central_1: "Canada (Central)"
cn_north_1: "China (Beijing)"
cn_northwest_1: "China (Ningxia)"
eu_central_1: "EU (Frankfurt)"
eu_north_1: "EU (Stockholm)"
eu_west_1: "EU (Ireland)"
eu_west_2: "EU (London)"
eu_west_3: "EU (Paris)"
sa_east_1: "South America (Sao Paulo)"
sa_east_1: "South America (São Paulo)"
us_east_1: "US East (N. Virginia)"
us_east_2: "US East (Ohio)"
us_gov_east_1: "AWS GovCloud (US-East)"
us_gov_west_1: "AWS GovCloud (US)"
us_west_1: "US West (N. California)"
us_west_2: "US West (Oregon)"
@ -1000,9 +1003,9 @@ en:
expired: "This invite has expired."
rescind: "Remove"
rescinded: "Invite removed"
rescind_all: "Remove all Invites"
rescinded_all: "All Invites removed!"
rescind_all_confirm: "Are you sure you want to remove all invites?"
rescind_all: "Remove all Expired Invites"
rescinded_all: "All Expired Invites removed!"
rescind_all_confirm: "Are you sure you want to remove all expired invites?"
reinvite: "Resend Invite"
reinvite_all: "Resend all Invites"
reinvite_all_confirm: "Are you sure you want to resend all invites?"
@ -1809,6 +1812,7 @@ en:
when: "When:"
public_timer_types: Topic Timers
private_timer_types: User Topic Timers
time_frame_required: Please select a time frame
auto_update_input:
none: "Select a timeframe"
later_today: "Later today"
@ -2145,6 +2149,10 @@ en:
one: "view 1 hidden reply"
other: "view {{count}} hidden replies"
notice:
first: "This is the first time {{user}} has posted — let's welcome them to our community!"
return: "It's been a while since we've seen {{user}} — their last post was in {{time}}."
unread: "Post is unread"
has_replies:
one: "{{count}} Reply"

View File

@ -25,14 +25,16 @@ en:
datetime_formats: &datetime_formats
formats:
# Format directives: https://ruby-doc.org/core-2.3.1/Time.html#method-i-strftime
# Format directives: https://ruby-doc.org/core-2.6.1/Time.html#method-i-strftime
short: "%m-%d-%Y"
# Format directives: https://ruby-doc.org/core-2.3.1/Time.html#method-i-strftime
# Format directives: https://ruby-doc.org/core-2.6.1/Time.html#method-i-strftime
short_no_year: "%B %-d"
# Format directives: https://ruby-doc.org/core-2.3.1/Time.html#method-i-strftime
# Format directives: https://ruby-doc.org/core-2.6.1/Time.html#method-i-strftime
date_only: "%B %-d, %Y"
# Format directives: https://ruby-doc.org/core-2.3.1/Time.html#method-i-strftime
# Format directives: https://ruby-doc.org/core-2.6.1/Time.html#method-i-strftime
long: "%B %-d, %Y, %l:%M%P"
# Format directives: https://ruby-doc.org/core-2.6.1/Time.html#method-i-strftime
no_day: "%B %Y"
date:
# Do not remove the brackets and commas and do not translate the first month name. It should be "null".
month_names:
@ -870,6 +872,9 @@ en:
you_must_edit: '<p>Your post was flagged by the community. Please <a href="%{path}">see your messages</a>.</p>'
user_must_edit: "<p>This post was flagged by the community and is temporarily hidden.</p>"
ignored:
hidden_content: '<p>Hidden content</p>'
archetypes:
regular:
title: "Regular Topic"
@ -1896,6 +1901,8 @@ en:
max_allowed_message_recipients: "Maximum recipients allowed in a message."
watched_words_regular_expressions: "Watched words are regular expressions."
returning_users_days: "How many days should pass before a user is considered to be returning."
default_email_digest_frequency: "How often users receive summary emails by default."
default_include_tl0_in_digests: "Include posts from new users in summary emails by default. Users can change this in their preferences."
default_email_personal_messages: "Send an email when someone messages the user by default."

View File

@ -807,6 +807,8 @@ posting:
default: false
client: true
shadowed_by_global: true
returning_users_days:
default: 60
email:
email_time_window_mins:
@ -1251,7 +1253,7 @@ security:
list_type: compact
blacklisted_crawler_user_agents:
type: list
default: "mauibot|semrushbot|ahrefsbot"
default: "mauibot|semrushbot|ahrefsbot|blexbot"
list_type: compact
slow_down_crawler_user_agents:
type: list

View File

@ -0,0 +1,27 @@
class MigrateGoogleUserInfo < ActiveRecord::Migration[5.2]
def up
execute <<~SQL
INSERT INTO user_associated_accounts (
provider_name,
provider_uid,
user_id,
info,
last_used,
created_at,
updated_at
) SELECT
'google_oauth2',
google_user_id,
user_id,
json_build_object('email', email, 'first_name', first_name, 'last_name', last_name, 'name', name),
updated_at,
created_at,
updated_at
FROM google_user_infos
SQL
end
def down
raise ActiveRecord::IrreversibleMigration
end
end

View File

@ -1,5 +1,4 @@
class Auth::GoogleOAuth2Authenticator < Auth::Authenticator
class Auth::GoogleOAuth2Authenticator < Auth::ManagedAuthenticator
def name
"google_oauth2"
end
@ -8,77 +7,10 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator
SiteSetting.enable_google_oauth2_logins
end
def description_for_user(user)
info = GoogleUserInfo.find_by(user_id: user.id)
info&.email || info&.name || ""
end
def can_revoke?
true
end
def revoke(user, skip_remote: false)
info = GoogleUserInfo.find_by(user_id: user.id)
raise Discourse::NotFound if info.nil?
# We get a temporary token from google upon login but do not need it, and do not store it.
# Therefore we do not have any way to revoke the token automatically on google's end
info.destroy!
true
end
def can_connect_existing_user?
true
end
def after_authenticate(auth_hash, existing_account: nil)
session_info = parse_hash(auth_hash)
google_hash = session_info[:google]
result = ::Auth::Result.new
result.email = session_info[:email]
result.email_valid = session_info[:email_valid]
result.name = session_info[:name]
result.extra_data = google_hash
user_info = ::GoogleUserInfo.find_by(google_user_id: google_hash[:google_user_id])
if existing_account && (user_info.nil? || existing_account.id != user_info.user_id)
user_info.destroy! if user_info
result.user = existing_account
user_info = GoogleUserInfo.create!({ user_id: result.user.id }.merge(google_hash))
else
result.user = user_info&.user
end
if !result.user && !result.email.blank? && result.email_valid
result.user = User.find_by_email(result.email)
if result.user
# we've matched an existing user to this login attempt...
if result.user.google_user_info && result.user.google_user_info.google_user_id != google_hash[:google_user_id]
# but the user has changed the google account used to log in...
if result.user.google_user_info.email != google_hash[:email]
# the user changed their email, go ahead and scrub the old record
result.user.google_user_info.destroy!
else
# same email address but different account? likely a takeover scenario
result.failed = true
result.failed_reason = I18n.t('errors.conflicting_google_user_id')
return result
end
end
::GoogleUserInfo.create({ user_id: result.user.id }.merge(google_hash))
end
end
result
end
def after_create_account(user, auth)
data = auth[:extra_data]
GoogleUserInfo.create({ user_id: user.id }.merge(data))
def primary_email_verified?(auth_token)
# note, emails that come back from google via omniauth are always valid
# this protects against future regressions
auth_token[:extra][:raw_info][:email_verified]
end
def register_middleware(omniauth)
@ -95,37 +27,8 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator
if (google_oauth2_prompt = SiteSetting.google_oauth2_prompt).present?
strategy.options[:prompt] = google_oauth2_prompt.gsub("|", " ")
end
},
skip_jwt: true
}
}
# jwt encoding is causing auth to fail in quite a few conditions
# skipping
omniauth.provider :google_oauth2, options
end
protected
def parse_hash(hash)
extra = hash[:extra][:raw_info]
h = {}
h[:email] = hash[:info][:email]
h[:name] = hash[:info][:name]
h[:email_valid] = extra[:email_verified]
h[:google] = {
google_user_id: hash[:uid] || extra[:sub],
email: extra[:email],
first_name: extra[:given_name],
last_name: extra[:family_name],
gender: extra[:gender],
name: extra[:name],
link: extra[:hd],
profile_link: extra[:profile],
picture: extra[:picture]
}
h
end
end

View File

@ -10,6 +10,12 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
true
end
def primary_email_verified?(auth_token)
# Omniauth providers should only provide verified emails in the :info hash.
# This method allows additional checks to be added
true
end
def can_revoke?
true
end
@ -35,7 +41,11 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
end
# Matching an account by email
if match_by_email && association.user.nil? && (user = User.find_by_email(auth_token.dig(:info, :email)))
if primary_email_verified?(auth_token) &&
match_by_email &&
association.user.nil? &&
(user = User.find_by_email(auth_token.dig(:info, :email)))
UserAssociatedAccount.where(user: user, provider_name: auth_token[:provider]).destroy_all # Destroy existing associations for the new user
association.user = user
end
@ -60,7 +70,7 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
result.email = info[:email]
result.name = "#{info[:first_name]} #{info[:last_name]}"
result.username = info[:nickname]
result.email_valid = true if result.email
result.email_valid = primary_email_verified?(auth_token) if result.email
result.extra_data = {
provider: auth_token[:provider],
uid: auth_token[:uid]

View File

@ -29,6 +29,7 @@ module BackupRestore
@client_id = opts[:client_id]
@filename = opts[:filename]
@publish_to_message_bus = opts[:publish_to_message_bus] || false
@disable_emails = opts.fetch(:disable_emails, true)
ensure_restore_is_enabled
ensure_no_operation_is_running
@ -402,9 +403,11 @@ module BackupRestore
log "Reloading site settings..."
SiteSetting.refresh!
log "Disabling outgoing emails for non-staff users..."
user = User.find_by_email(@user_info[:email]) || Discourse.system_user
SiteSetting.set_and_log(:disable_emails, 'non-staff', user)
if @disable_emails
log "Disabling outgoing emails for non-staff users..."
user = User.find_by_email(@user_info[:email]) || Discourse.system_user
SiteSetting.set_and_log(:disable_emails, 'non-staff', user)
end
end
def clear_emoji_cache

View File

@ -899,6 +899,22 @@ module Email
create_post_with_attachments(options)
end
def notification_level_for(body)
# since we are stripping save all this work on long replies
return nil if body.length > 40
body = body.strip.downcase
case body
when "mute"
NotificationLevels.topic_levels[:muted]
when "track"
NotificationLevels.topic_levels[:tracking]
when "watch"
NotificationLevels.topic_levels[:watching]
else nil
end
end
def create_reply(options = {})
raise TopicNotFoundError if options[:topic].nil? || options[:topic].trashed?
raise BouncedEmailError if options[:bounce] && options[:topic].archetype != Archetype.private_message
@ -908,6 +924,8 @@ module Email
if post_action_type = post_action_for(options[:raw])
create_post_action(options[:user], options[:post], post_action_type)
elsif notification_level = notification_level_for(options[:raw])
TopicUser.change(options[:user].id, options[:post].topic_id, notification_level: notification_level)
else
raise TopicClosedError if options[:topic].closed?
options[:topic_id] = options[:topic].id

View File

@ -86,7 +86,7 @@ module JsLocaleHelper
end
def self.load_translations_merged(*locales)
locales = locales.compact
locales = locales.uniq.compact
@loaded_merges ||= {}
@loaded_merges[locales.join('-')] ||= begin
all_translations = {}

View File

@ -165,6 +165,7 @@ class PostCreator
transaction do
build_post_stats
create_topic
create_post_notice
save_post
extract_links
track_topic
@ -247,7 +248,11 @@ class PostCreator
post.word_count = post.raw.scan(/[[:word:]]+/).size
whisper = post.post_type == Post.types[:whisper]
post.post_number ||= Topic.next_post_number(post.topic_id, post.reply_to_post_number.present?, whisper)
increase_posts_count = !post.topic&.private_message? || post.post_type != Post.types[:small_action]
post.post_number ||= Topic.next_post_number(post.topic_id,
reply: post.reply_to_post_number.present?,
whisper: whisper,
post: increase_posts_count)
cooking_options = post.cooking_options || {}
cooking_options[:topic_id] = post.topic_id
@ -508,6 +513,21 @@ class PostCreator
@user.update_attributes(last_posted_at: @post.created_at)
end
def create_post_notice
last_post_time = Post.where(user_id: @user.id)
.order(created_at: :desc)
.limit(1)
.pluck(:created_at)
.first
if !last_post_time
@post.custom_fields["post_notice_type"] = "first"
elsif SiteSetting.returning_users_days > 0 && last_post_time < SiteSetting.returning_users_days.days.ago
@post.custom_fields["post_notice_type"] = "returning"
@post.custom_fields["post_notice_time"] = last_post_time.iso8601
end
end
def publish
return if @opts[:import_mode] || @post.post_number == 1
@post.publish_change_to_clients! :created

View File

@ -61,19 +61,11 @@ class PostDestroyer
mark_for_deletion(delete_removed_posts_after)
end
DiscourseEvent.trigger(:post_destroyed, @post, @opts, @user)
WebHook.enqueue_hooks(:post, :post_destroyed,
id: @post.id,
category_id: @post&.topic&.category_id,
payload: payload
) if WebHook.active_web_hooks(:post).exists?
WebHook.enqueue_post_hooks(:post_destroyed, @post, payload)
if @post.is_first_post? && @post.topic
DiscourseEvent.trigger(:topic_destroyed, @post.topic, @user)
WebHook.enqueue_hooks(:topic, :topic_destroyed,
id: topic.id,
category_id: topic&.category_id,
payload: topic_payload
) if WebHook.active_web_hooks(:topic).exists?
WebHook.enqueue_topic_hooks(:topic_destroyed, @post.topic, topic_payload)
end
end
@ -147,7 +139,7 @@ class PostDestroyer
update_user_counts
TopicUser.update_post_action_cache(post_id: @post.id)
DB.after_commit do
if @opts[:defer_flags].to_s == "true"
if @opts[:defer_flags]
defer_flags
else
agree_with_flags

View File

@ -230,10 +230,11 @@ module PrettyText
return title unless SiteSetting.enable_emoji?
set = SiteSetting.emoji_set.inspect
custom = Emoji.custom.map { |e| [e.name, e.url] }.to_h.to_json
protect do
v8.eval(<<~JS)
__paths = #{paths_json};
__performEmojiUnescape(#{title.inspect}, { getURL: __getURL, emojiSet: #{set} });
__performEmojiUnescape(#{title.inspect}, { getURL: __getURL, emojiSet: #{set}, customEmoji: #{custom} });
JS
end
end

View File

@ -687,7 +687,7 @@ class Search
def groups_search
groups = Group
.visible_groups(@guardian.user, "name ASC", include_everyone: false)
.where("groups.name ILIKE ?", "%#{@term}%")
.where("name ILIKE :term OR full_name ILIKE :term", term: "%#{@term}%")
groups.each { |group| @results.add(group) }
end

View File

@ -144,9 +144,8 @@ COMMENT
end
def to_scss_variable(name, value)
escaped = value.to_s.gsub('"', "\\22")
escaped.gsub!("\n", "\\A")
"$#{name}: unquote(\"#{escaped}\");\n"
escaped = SassC::Script::Value::String.quote(value, sass: true)
"$#{name}: unquote(#{escaped});\n"
end
def imports(asset, parent_path)

View File

@ -118,6 +118,7 @@ module SvgSprite
"globe",
"globe-americas",
"hand-point-right",
"hands-helping",
"heading",
"heart",
"home",
@ -224,10 +225,10 @@ module SvgSprite
icons = all_icons(theme_ids)
doc = File.open("#{Rails.root}/vendor/assets/svg-icons/fontawesome/solid.svg") { |f| Nokogiri::XML(f) }
fa_license = doc.at('//comment()').text
svg_subset = """<!--
Discourse SVG subset of #{fa_license}
Discourse SVG subset of Font Awesome Free by @fontawesome - https://fontawesome.com
License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
-->
<svg xmlns='http://www.w3.org/2000/svg' style='display: none;'>
""".dup

View File

@ -58,7 +58,7 @@ task 'assets:precompile:css' => 'environment' do
STDERR.puts "Compiling css for #{db} #{Time.zone.now}"
begin
Stylesheet::Manager.precompile_css
rescue PG::UndefinedColumn => e
rescue PG::UndefinedColumn, ActiveModel::MissingAttributeError => e
STDERR.puts "#{e.class} #{e.message}: #{e.backtrace.join("\n")}"
STDERR.puts "Skipping precompilation of CSS cause schema is old, you are precompiling prior to running migrations."
end

View File

@ -495,102 +495,6 @@ def list_missing_uploads(skip_optimized: false)
Discourse.store.list_missing_uploads(skip_optimized: skip_optimized)
end
################################################################################
# Recover from tombstone #
################################################################################
task "uploads:recover_from_tombstone" => :environment do
if ENV["RAILS_DB"]
recover_from_tombstone
else
RailsMultisite::ConnectionManagement.each_connection { recover_from_tombstone }
end
end
def recover_from_tombstone
if Discourse.store.external?
puts "This task only works for internal storages."
return
end
begin
previous_image_size = SiteSetting.max_image_size_kb
previous_attachment_size = SiteSetting.max_attachment_size_kb
previous_extensions = SiteSetting.authorized_extensions
SiteSetting.max_image_size_kb = 10 * 1024
SiteSetting.max_attachment_size_kb = 10 * 1024
SiteSetting.authorized_extensions = "*"
current_db = RailsMultisite::ConnectionManagement.current_db
public_path = Rails.root.join("public")
paths = Dir.glob(File.join(public_path, 'uploads', 'tombstone', current_db, '**', '*.*'))
max = paths.size
paths.each_with_index do |path, index|
filename = File.basename(path)
printf("%9d / %d (%5.1f%%)\n", (index + 1), max, (((index + 1).to_f / max.to_f) * 100).round(1))
Post.where("raw LIKE ?", "%#{filename}%").find_each do |post|
doc = Nokogiri::HTML::fragment(post.raw)
updated = false
image_urls = doc.css("img[src]").map { |img| img["src"] }
attachment_urls = doc.css("a.attachment[href]").map { |a| a["href"] }
(image_urls + attachment_urls).each do |url|
next if !url.start_with?("/uploads/")
next if Upload.exists?(url: url)
puts "Restoring #{path}..."
tombstone_path = File.join(public_path, 'uploads', 'tombstone', url.gsub(/^\/uploads\//, ""))
if File.exists?(tombstone_path)
File.open(tombstone_path) do |file|
new_upload = UploadCreator.new(file, File.basename(url)).create_for(Discourse::SYSTEM_USER_ID)
if new_upload.persisted?
puts "Restored into #{new_upload.url}"
DbHelper.remap(url, new_upload.url)
updated = true
else
puts "Failed to create upload for #{url}: #{new_upload.errors.full_messages}."
end
end
else
puts "Failed to find file (#{tombstone_path}) in tombstone."
end
end
post.rebake! if updated
end
sha1 = File.basename(filename, File.extname(filename))
short_url = "upload://#{Base62.encode(sha1.hex)}"
Post.where("raw LIKE ?", "%#{short_url}%").find_each do |post|
puts "Restoring #{path}..."
File.open(path) do |file|
new_upload = UploadCreator.new(file, filename).create_for(Discourse::SYSTEM_USER_ID)
if new_upload.persisted?
puts "Restored into #{new_upload.short_url}"
DbHelper.remap(short_url, new_upload.short_url) if short_url != new_upload.short_url
post.rebake!
else
puts "Failed to create upload for #{filename}: #{new_upload.errors.full_messages}."
end
end
end
end
ensure
SiteSetting.max_image_size_kb = previous_image_size
SiteSetting.max_attachment_size_kb = previous_attachment_size
SiteSetting.authorized_extensions = previous_extensions
end
end
################################################################################
# regenerate_missing_optimized #
################################################################################
@ -795,6 +699,10 @@ task "uploads:fix_incorrect_extensions" => :environment do
UploadFixer.fix_all_extensions
end
task "uploads:recover_from_tombstone" => :environment do
Rake::Task["uploads:recover"].invoke
end
task "uploads:recover" => :environment do
require_dependency "upload_recovery"

View File

@ -18,7 +18,7 @@ class TopicView
end
def self.default_post_custom_fields
@default_post_custom_fields ||= ["action_code_who"]
@default_post_custom_fields ||= ["action_code_who", "post_notice_type", "post_notice_time"]
end
def self.post_custom_fields_whitelisters

View File

@ -6,7 +6,7 @@
"author": "Discourse",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "5.5.0",
"@fortawesome/fontawesome-free": "5.7.2",
"ace-builds": "1.4.2",
"bootbox": "3.2.0",
"chart.js": "2.7.3",

View File

@ -420,7 +420,9 @@ createWidget("discourse-poll-buttons", {
const castVotesDisabled = !attrs.canCastVotes;
contents.push(
this.attach("button", {
className: `btn cast-votes ${castVotesDisabled ? "" : "btn-primary"}`,
className: `btn cast-votes ${
castVotesDisabled ? "btn-default" : "btn-primary"
}`,
label: "poll.cast-votes.label",
title: "poll.cast-votes.title",
disabled: castVotesDisabled,
@ -433,7 +435,7 @@ createWidget("discourse-poll-buttons", {
if (attrs.showResults || hideResultsDisabled) {
contents.push(
this.attach("button", {
className: "btn toggle-results",
className: "btn btn-default toggle-results",
label: "poll.hide-results.label",
title: "poll.hide-results.title",
icon: "far-eye-slash",
@ -449,7 +451,7 @@ createWidget("discourse-poll-buttons", {
} else {
contents.push(
this.attach("button", {
className: "btn toggle-results",
className: "btn btn-default toggle-results",
label: "poll.show-results.label",
title: "poll.show-results.title",
icon: "far-eye",
@ -492,7 +494,7 @@ createWidget("discourse-poll-buttons", {
if (!attrs.isAutomaticallyClosed) {
contents.push(
this.attach("button", {
className: "btn toggle-status",
className: "btn btn-default toggle-status",
label: "poll.open.label",
title: "poll.open.title",
icon: "unlock-alt",

View File

@ -59,6 +59,10 @@ div.poll {
margin: 0.25em 0;
color: $primary-medium;
}
.info-text + .info-text,
button + .info-text {
margin-left: 0.5em;
}
}
.poll-voters:not(:empty) {

View File

@ -72,8 +72,8 @@ en:
confirm: "Are you sure you want to close this poll?"
automatic_close:
closes_in: "closes in <strong>%{timeLeft}</strong>"
age: "closed <strong>%{age}</strong>"
closes_in: "Closes in <strong>%{timeLeft}</strong>."
age: "Closed <strong>%{age}</strong>"
error_while_toggling_status: "Sorry, there was an error toggling the status of this poll."
error_while_casting_votes: "Sorry, there was an error casting your votes."

View File

@ -151,7 +151,7 @@ class BulkImport::DiscourseMerger < BulkImport::Base
copy_model(c, skip_if_merged: true, is_a_user_model: true, skip_processing: true)
end
[UserAssociatedAccount, GithubUserInfo, GoogleUserInfo, Oauth2UserInfo,
[UserAssociatedAccount, GithubUserInfo, Oauth2UserInfo,
SingleSignOnRecord, EmailChangeRequest
].each do |c|
copy_model(c, skip_if_merged: true, is_a_user_model: true)
@ -628,11 +628,6 @@ class BulkImport::DiscourseMerger < BulkImport::Base
r
end
def process_google_user_info(r)
return nil if GoogleUserInfo.where(google_user_id: r['google_user_id']).exists?
r
end
def process_oauth2_user_info(r)
return nil if Oauth2UserInfo.where(uid: r['uid'], provider: r['provider']).exists?
r

View File

@ -106,6 +106,7 @@ class DiscourseCLI < Thor
end
desc "restore", "Restore a Discourse backup"
option :disable_emails, type: :boolean, default: true
def restore(filename = nil)
if File.exist?('/usr/local/bin/discourse')
@ -132,7 +133,11 @@ class DiscourseCLI < Thor
begin
puts "Starting restore: #{filename}"
restorer = BackupRestore::Restorer.new(Discourse.system_user.id, filename: filename)
restorer = BackupRestore::Restorer.new(
Discourse.system_user.id,
filename: filename,
disable_emails: options[:disable_emails]
)
restorer.run
puts 'Restore done.'
rescue BackupRestore::FilenameMissingError

View File

@ -563,7 +563,7 @@ class ImportScripts::Base
post_creator = PostCreator.new(user, opts)
post = post_creator.create
post_create_action.try(:call, post) if post
post ? post : post_creator.errors.full_messages
post && post_creator.errors.empty? ? post : post_creator.errors.full_messages
end
def create_upload(user_id, path, source_filename)

View File

@ -144,6 +144,7 @@ class ImportScripts::NodeBB < ImportScripts::Base
suspended_till: suspended_till,
primary_group_id: group_id_from_imported_group_id(user["groupTitle"]),
created_at: user["joindate"],
active: true,
custom_fields: {
import_pass: user["password"]
},
@ -197,13 +198,14 @@ class ImportScripts::NodeBB < ImportScripts::Base
upload = UploadCreator.new(file, filename).create_for(imported_user.id)
else
# remove "/assets/uploads/" from attachment
# remove "/assets/uploads/" and "/uploads" from attachment
picture = picture.gsub("/assets/uploads", "")
picture = picture.gsub("/uploads", "")
filepath = File.join(ATTACHMENT_DIR, picture)
filename = File.basename(picture)
unless File.exists?(filepath)
puts "Avatar file doesn't exist: #{filename}"
puts "Avatar file doesn't exist: #{filepath}"
return nil
end
@ -256,13 +258,14 @@ class ImportScripts::NodeBB < ImportScripts::Base
upload = UploadCreator.new(file, filename).create_for(imported_user.id)
else
# remove "/assets/uploads/" from attachment
# remove "/assets/uploads/" and "/uploads" from attachment
picture = picture.gsub("/assets/uploads", "")
picture = picture.gsub("/uploads", "")
filepath = File.join(ATTACHMENT_DIR, picture)
filename = File.basename(picture)
unless File.exists?(filepath)
puts "Background file doesn't exist: #{filename}"
puts "Background file doesn't exist: #{filepath}"
return nil
end
@ -509,13 +512,6 @@ class ImportScripts::NodeBB < ImportScripts::Base
end
end
# @username with dash to underscore
raw = raw.gsub(/@([a-zA-Z0-9-]+)/) do
username = $1
username.gsub('-', '_')
end
raw
end
end

View File

@ -10,6 +10,7 @@ describe Auth::GoogleOAuth2Authenticator do
user = Fabricate(:user)
hash = {
provider: "google_oauth2",
uid: "123456789",
info: {
name: "John Doe",
@ -35,6 +36,7 @@ describe Auth::GoogleOAuth2Authenticator do
user = Fabricate(:user)
hash = {
provider: "google_oauth2",
uid: "123456789",
info: {
name: "John Doe",
@ -59,9 +61,10 @@ describe Auth::GoogleOAuth2Authenticator do
user1 = Fabricate(:user)
user2 = Fabricate(:user)
GoogleUserInfo.create!(user_id: user1.id, google_user_id: 100)
UserAssociatedAccount.create!(provider_name: "google_oauth2", user_id: user1.id, provider_uid: 100)
hash = {
provider: "google_oauth2",
uid: "100",
info: {
name: "John Doe",
@ -79,14 +82,17 @@ describe Auth::GoogleOAuth2Authenticator do
result = authenticator.after_authenticate(hash, existing_account: user2)
expect(result.user.id).to eq(user2.id)
expect(GoogleUserInfo.exists?(user_id: user1.id)).to eq(false)
expect(GoogleUserInfo.exists?(user_id: user2.id)).to eq(true)
expect(UserAssociatedAccount.exists?(user_id: user1.id)).to eq(false)
expect(UserAssociatedAccount.exists?(user_id: user2.id)).to eq(true)
end
it 'can create a proper result for non existing users' do
hash = {
provider: "google_oauth2",
uid: "123456789",
info: {
first_name: "Jane",
last_name: "Doe",
name: "Jane Doe",
email: "jane.doe@the.google.com"
},
@ -103,7 +109,7 @@ describe Auth::GoogleOAuth2Authenticator do
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(nil)
expect(result.extra_data[:name]).to eq("Jane Doe")
expect(result.name).to eq("Jane Doe")
end
end
@ -116,7 +122,7 @@ describe Auth::GoogleOAuth2Authenticator do
end
it 'revokes correctly' do
GoogleUserInfo.create!(user_id: user.id, google_user_id: 12345, email: 'someuser@somedomain.tld')
UserAssociatedAccount.create!(provider_name: "google_oauth2", user_id: user.id, provider_uid: 12345)
expect(authenticator.can_revoke?).to eq(true)
expect(authenticator.revoke(user)).to eq(true)
expect(authenticator.description_for_user(user)).to eq("")

View File

@ -251,6 +251,10 @@ describe Email::Receiver do
)
end
let :topic_user do
TopicUser.find_by(topic_id: topic.id, user_id: user.id)
end
it "uses MD5 of 'mail_string' there is no message_id" do
mail_string = email(:missing_message_id)
expect { Email::Receiver.new(mail_string).process! }.to change { IncomingEmail.count }
@ -285,14 +289,34 @@ describe Email::Receiver do
expect { process(:reply_user_matching) }.to raise_error(Email::Receiver::TopicNotFoundError)
end
it "raises a TopicClosedError when the topic was closed" do
topic.update_columns(closed: true)
expect { process(:reply_user_matching) }.to raise_error(Email::Receiver::TopicClosedError)
end
context "a closed topic" do
it "does not raise TopicClosedError when performing a like action" do
topic.update_columns(closed: true)
expect { process(:like) }.to change(PostAction, :count)
before do
topic.update_columns(closed: true)
end
it "raises a TopicClosedError when the topic was closed" do
expect { process(:reply_user_matching) }.to raise_error(Email::Receiver::TopicClosedError)
end
it "Can watch topics via the watch command" do
# TODO support other locales as well, the tricky thing is that these string live in
# client.yml not on server yml so it is a bit tricky to find
topic.update_columns(closed: true)
process(:watch)
expect(topic_user.notification_level).to eq(NotificationLevels.topic_levels[:watching])
end
it "Can mute topics via the mute command" do
process(:mute)
expect(topic_user.notification_level).to eq(NotificationLevels.topic_levels[:muted])
end
it "can track a topic via the track command" do
process(:track)
expect(topic_user.notification_level).to eq(NotificationLevels.topic_levels[:tracking])
end
end
it "raises an InvalidPost when there was an error while creating the post" do

View File

@ -776,6 +776,28 @@ describe PostCreator do
expect(post.topic.topic_allowed_users.where(user_id: admin2.id).count).to eq(0)
end
it 'does not increase posts count for small actions' do
topic = Fabricate(:private_message_topic, user: Fabricate(:user))
Fabricate(:post, topic: topic)
1.upto(3) do |i|
user = Fabricate(:user)
topic.invite(topic.user, user.username)
topic.reload
expect(topic.posts_count).to eq(1)
expect(topic.posts.where(post_type: Post.types[:small_action]).count).to eq(i)
end
Fabricate(:post, topic: topic)
Topic.reset_highest(topic.id)
expect(topic.reload.posts_count).to eq(2)
Fabricate(:post, topic: topic)
Topic.reset_all_highest!
expect(topic.reload.posts_count).to eq(3)
end
end
context "warnings" do
@ -1238,4 +1260,32 @@ describe PostCreator do
end
end
end
context "#create_post_notice" do
let(:user) { Fabricate(:user) }
let(:new_user) { Fabricate(:user) }
let(:returning_user) { Fabricate(:user) }
it "generates post notices" do
# new users
post = PostCreator.create(new_user, title: "one of my first topics", raw: "one of my first posts")
expect(post.custom_fields["post_notice_type"]).to eq("first")
post = PostCreator.create(new_user, title: "another one of my first topics", raw: "another one of my first posts")
expect(post.custom_fields["post_notice_type"]).to eq(nil)
# returning users
SiteSetting.returning_users_days = 30
old_post = Fabricate(:post, user: returning_user, created_at: 31.days.ago)
post = PostCreator.create(returning_user, title: "this is a returning topic", raw: "this is a post")
expect(post.custom_fields["post_notice_type"]).to eq("returning")
expect(post.custom_fields["post_notice_time"]).to eq(old_post.created_at.iso8601)
end
it "does not generate post notices" do
Fabricate(:post, user: user, created_at: 3.days.ago)
post = PostCreator.create(user, title: "this is another topic", raw: "this is my another post")
expect(post.custom_fields["post_notice_type"]).to eq(nil)
expect(post.custom_fields["post_notice_time"]).to eq(nil)
end
end
end

View File

@ -14,7 +14,9 @@ describe MaxEmojisValidator do
shared_examples "validating any topic title" do
it 'adds an error when emoji count is greater than SiteSetting.max_emojis_in_title' do
SiteSetting.max_emojis_in_title = 3
record.title = '🧐 Lots of emojis here 🎃 :joy: :)'
CustomEmoji.create!(name: 'trout', upload: Fabricate(:upload))
Emoji.clear_cache
record.title = '🧐 Lots of emojis here 🎃 :trout: :)'
validate
expect(record.errors[:title][0]).to eq(I18n.t("errors.messages.max_emojis", max_emojis_count: 3))

10
spec/fixtures/emails/mute.eml vendored Normal file
View File

@ -0,0 +1,10 @@
Return-Path: <discourse@bar.com>
From: Foo Bar <discourse@bar.com>
To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com
Date: Fri, 15 Jan 2016 00:12:43 +0100
Message-ID: <13@foo.bar.mail>
Mime-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
mute

10
spec/fixtures/emails/track.eml vendored Normal file
View File

@ -0,0 +1,10 @@
Return-Path: <discourse@bar.com>
From: Foo Bar <discourse@bar.com>
To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com
Date: Fri, 15 Jan 2016 00:12:43 +0100
Message-ID: <13@foo.bar.mail>
Mime-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
track

10
spec/fixtures/emails/watch.eml vendored Normal file
View File

@ -0,0 +1,10 @@
Return-Path: <discourse@bar.com>
From: Foo Bar <discourse@bar.com>
To: reply+4f97315cc828096c9cb34c6f1a0d6fe8@bar.com
Date: Fri, 15 Jan 2016 00:12:43 +0100
Message-ID: <13@foo.bar.mail>
Mime-Version: 1.0
Content-Type: text/plain
Content-Transfer-Encoding: 7bit
watch

View File

@ -38,13 +38,13 @@ describe Jobs::InvalidateInactiveAdmins do
before do
GithubUserInfo.create!(user_id: not_seen_admin.id, screen_name: 'bob', github_user_id: 100)
UserOpenId.create!(url: 'https://me.yahoo.com/id/123' , user_id: not_seen_admin.id, email: 'bob@example.com', active: true)
GoogleUserInfo.create!(user_id: not_seen_admin.id, google_user_id: 100, email: 'bob@example.com')
UserAssociatedAccount.create!(provider_name: "google_oauth2", user_id: not_seen_admin.id, provider_uid: 100, info: { email: "bob@google.account.com" })
end
it 'removes the social logins' do
subject
expect(GithubUserInfo.where(user_id: not_seen_admin.id).exists?).to eq(false)
expect(GoogleUserInfo.where(user_id: not_seen_admin.id).exists?).to eq(false)
expect(UserAssociatedAccount.where(user_id: not_seen_admin.id).exists?).to eq(false)
expect(UserOpenId.where(user_id: not_seen_admin.id).exists?).to eq(false)
end
end

View File

@ -221,9 +221,9 @@ describe Group do
end
describe '.refresh_automatic_group!' do
it "makes sure the everyone group is not visible" do
it "makes sure the everyone group is not visible except to staff" do
g = Group.refresh_automatic_group!(:everyone)
expect(g.visibility_level).to eq(Group.visibility_levels[:owners])
expect(g.visibility_level).to eq(Group.visibility_levels[:staff])
end
it "ensures that the moderators group is messageable by all" do

View File

@ -477,16 +477,21 @@ describe Invite do
end
describe '.rescind_all_invites_from' do
it 'removes all invites sent by a user' do
describe '.rescind_all_expired_invites_from' do
it 'removes all expired invites sent by a user' do
SiteSetting.invite_expiry_days = 1
user = Fabricate(:user)
invite_1 = Fabricate(:invite, invited_by: user)
invite_2 = Fabricate(:invite, invited_by: user)
Invite.rescind_all_invites_from(user)
expired_invite = Fabricate(:invite, invited_by: user)
expired_invite.update!(created_at: 2.days.ago)
Invite.rescind_all_expired_invites_from(user)
invite_1.reload
invite_2.reload
expect(invite_1.deleted_at).to be_present
expect(invite_2.deleted_at).to be_present
expired_invite.reload
expect(invite_1.deleted_at).to eq(nil)
expect(invite_2.deleted_at).to eq(nil)
expect(expired_invite.deleted_at).to be_present
end
end
end

View File

@ -134,6 +134,29 @@ describe Post do
end
end
context 'a post with notices' do
let(:post) {
post = Fabricate(:post, post_args)
post.custom_fields["post_notice_type"] = "returning"
post.custom_fields["post_notice_time"] = 1.day.ago
post
}
before do
post.trash!
post.reload
end
describe 'recovery' do
it 'deletes notices' do
post.recover!
expect(post.custom_fields).not_to have_key("post_notice_type")
expect(post.custom_fields).not_to have_key("post_notice_time")
end
end
end
end
describe 'flagging helpers' do

View File

@ -310,6 +310,18 @@ HTML
scss, _map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id)
expect(scss).to include("font-size:30px")
# Escapes correctly. If not, compiling this would throw an exception
setting.value = <<~MULTILINE
\#{$fakeinterpolatedvariable}
andanothervalue 'withquotes'; margin: 0;
MULTILINE
theme.set_field(target: :common, name: :scss, value: 'body {font-size: quote($font-size)}')
theme.save!
scss, _map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id)
expect(scss).to include('font-size:"#{$fakeinterpolatedvariable}\a andanothervalue \'withquotes\'; margin: 0;\a"')
end
it "allows values to be used in JS" do

View File

@ -428,7 +428,7 @@ describe User do
UserAssociatedAccount.create(user_id: user.id, provider_name: "twitter", provider_uid: "1", info: { nickname: "sam" })
UserAssociatedAccount.create(user_id: user.id, provider_name: "facebook", provider_uid: "1234", info: { email: "test@example.com" })
UserAssociatedAccount.create(user_id: user.id, provider_name: "instagram", provider_uid: "examplel123123", info: { nickname: "sam" })
GoogleUserInfo.create(user_id: user.id, email: "sam@sam.com", google_user_id: 1)
UserAssociatedAccount.create(user_id: user.id, provider_name: "google_oauth2", provider_uid: "1", info: { email: "sam@sam.com" })
GithubUserInfo.create(user_id: user.id, screen_name: "sam", github_user_id: 1)
user.reload

View File

@ -257,6 +257,33 @@ describe WebHook do
expect(payload["id"]).to eq(post.topic.id)
end
it 'should enqueue the destroyed hooks with tag filter for post events' do
tag = Fabricate(:tag)
Fabricate(:web_hook, tags: [tag])
post = PostCreator.create!(user,
raw: 'post',
topic_id: topic.id,
reply_to_post_number: 1,
skip_validations: true
)
topic.tags = [tag]
topic.save!
Jobs::EmitWebHookEvent.jobs.clear
PostDestroyer.new(user, post).destroy
job = Jobs::EmitWebHookEvent.new
job.expects(:web_hook_request).times(2)
args = Jobs::EmitWebHookEvent.jobs[1]["args"].first
job.execute(args.with_indifferent_access)
args = Jobs::EmitWebHookEvent.jobs[2]["args"].first
job.execute(args.with_indifferent_access)
end
it 'should enqueue the right hooks for user events' do
Fabricate(:user_web_hook, active: true)

View File

@ -214,6 +214,21 @@ RSpec.describe Admin::EmailTemplatesController do
end
end
context "when subject has plural keys" do
it "doesn't update the subject" do
old_subject = I18n.t('system_messages.pending_users_reminder.subject_template')
expect(old_subject).to be_a(Hash)
put '/admin/customize/email_templates/system_messages.pending_users_reminder', params: {
email_template: { subject: '', body: 'Lorem ipsum' }
}, headers: headers
expect(response.status).to eq(200)
expect(I18n.t('system_messages.pending_users_reminder.subject_template')).to eq(old_subject)
expect(I18n.t('system_messages.pending_users_reminder.text_body_template')).to eq('Lorem ipsum')
end
end
end
end

View File

@ -96,7 +96,9 @@ RSpec.describe Users::OmniauthCallbacksController do
uid: '123545',
info: OmniAuth::AuthHash::InfoHash.new(
email: email,
name: 'Some name'
name: 'Some name',
first_name: "Some",
last_name: "name"
),
extra: {
raw_info: OmniAuth::AuthHash.new(
@ -107,7 +109,7 @@ RSpec.describe Users::OmniauthCallbacksController do
gender: 'male',
name: "Some name Huh",
)
},
}
)
Rails.application.env_config["omniauth.auth"] = OmniAuth.config.mock_auth[:google_oauth2]
@ -262,7 +264,7 @@ RSpec.describe Users::OmniauthCallbacksController do
@sso.return_sso_url = "http://somewhere.over.rainbow/sso"
cookies[:sso_payload] = @sso.payload
GoogleUserInfo.create!(google_user_id: '12345', user: user)
UserAssociatedAccount.create!(provider_name: "google_oauth2", provider_uid: '12345', user: user)
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
provider: 'google_oauth2',
@ -299,7 +301,7 @@ RSpec.describe Users::OmniauthCallbacksController do
context 'when user has not verified his email' do
before do
GoogleUserInfo.create!(google_user_id: '12345', user: user)
UserAssociatedAccount.create!(provider_name: "google_oauth2", provider_uid: '12345', user: user)
user.update!(active: false)
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
@ -341,8 +343,8 @@ RSpec.describe Users::OmniauthCallbacksController do
context 'when attempting reconnect' do
let(:user2) { Fabricate(:user) }
before do
GoogleUserInfo.create!(google_user_id: '12345', user: user)
GoogleUserInfo.create!(google_user_id: '123456', user: user2)
UserAssociatedAccount.create!(provider_name: "google_oauth2", provider_uid: '12345', user: user)
UserAssociatedAccount.create!(provider_name: "google_oauth2", provider_uid: '123456', user: user2)
OmniAuth.config.mock_auth[:google_oauth2] = OmniAuth::AuthHash.new(
provider: 'google_oauth2',
@ -385,7 +387,7 @@ RSpec.describe Users::OmniauthCallbacksController do
get "/auth/google_oauth2/callback.json"
expect(response.status).to eq(200)
expect(session[:current_user_id]).to eq(user2.id)
expect(GoogleUserInfo.count).to eq(2)
expect(UserAssociatedAccount.count).to eq(2)
end
it 'should reconnect if parameter supplied' do
@ -402,7 +404,7 @@ RSpec.describe Users::OmniauthCallbacksController do
expect(session[:auth_reconnect]).to eq(nil)
# Disconnect
GoogleUserInfo.find_by(user_id: user.id).destroy
UserAssociatedAccount.find_by(user_id: user.id).destroy
# Reconnect flow:
get "/auth/google_oauth2?reconnect=true"
@ -414,7 +416,7 @@ RSpec.describe Users::OmniauthCallbacksController do
expect(response.status).to eq(200)
expect(JSON.parse(response.body)["authenticated"]).to eq(true)
expect(session[:current_user_id]).to eq(user.id)
expect(GoogleUserInfo.count).to eq(1)
expect(UserAssociatedAccount.count).to eq(1)
end
end

View File

@ -248,15 +248,22 @@ describe PostsController do
let(:moderator) { Fabricate(:moderator) }
before do
sign_in(moderator)
PostAction.act(moderator, post1, PostActionType.types[:off_topic])
PostAction.act(moderator, post2, PostActionType.types[:off_topic])
Jobs::SendSystemMessage.clear
end
it "defers the posts" do
sign_in(moderator)
it "defers the child posts by default" do
expect(PostAction.flagged_posts_count).to eq(2)
delete "/posts/destroy_many.json", params: { post_ids: [post1.id, post2.id], defer_flags: true }
delete "/posts/destroy_many.json", params: { post_ids: [post1.id, post2.id] }
expect(Jobs::SendSystemMessage.jobs.size).to eq(1)
expect(PostAction.flagged_posts_count).to eq(0)
end
it "can defer all posts based on `agree_with_first_reply_flag` param" do
expect(PostAction.flagged_posts_count).to eq(2)
delete "/posts/destroy_many.json", params: { post_ids: [post1.id, post2.id], agree_with_first_reply_flag: false }
expect(Jobs::SendSystemMessage.jobs.size).to eq(0)
expect(PostAction.flagged_posts_count).to eq(0)
end

View File

@ -190,7 +190,6 @@ describe UserAnonymizer do
end
it "removes external auth assocations" do
user.google_user_info = GoogleUserInfo.create(user_id: user.id, google_user_id: "google@gmail.com")
user.github_user_info = GithubUserInfo.create(user_id: user.id, screen_name: "example", github_user_id: "examplel123123")
user.user_associated_accounts = [UserAssociatedAccount.create(user_id: user.id, provider_uid: "example", provider_name: "facebook")]
user.single_sign_on_record = SingleSignOnRecord.create(user_id: user.id, external_id: "example", last_payload: "looks good")
@ -198,7 +197,6 @@ describe UserAnonymizer do
UserOpenId.create(user_id: user.id, email: user.email, url: "http://example.com/openid", active: true)
make_anonymous
user.reload
expect(user.google_user_info).to eq(nil)
expect(user.github_user_info).to eq(nil)
expect(user.user_associated_accounts).to be_empty
expect(user.single_sign_on_record).to eq(nil)

View File

@ -978,7 +978,6 @@ describe UserMerger do
it "deletes external auth infos of source user" do
UserAssociatedAccount.create(user_id: source_user.id, provider_name: "facebook", provider_uid: "1234")
GithubUserInfo.create(user_id: source_user.id, screen_name: "example", github_user_id: "examplel123123")
GoogleUserInfo.create(user_id: source_user.id, google_user_id: "google@gmail.com")
Oauth2UserInfo.create(user_id: source_user.id, uid: "example", provider: "example")
SingleSignOnRecord.create(user_id: source_user.id, external_id: "example", last_payload: "looks good")
UserOpenId.create(user_id: source_user.id, email: source_user.email, url: "http://example.com/openid", active: true)
@ -987,7 +986,6 @@ describe UserMerger do
expect(UserAssociatedAccount.where(user_id: source_user.id).count).to eq(0)
expect(GithubUserInfo.where(user_id: source_user.id).count).to eq(0)
expect(GoogleUserInfo.where(user_id: source_user.id).count).to eq(0)
expect(Oauth2UserInfo.where(user_id: source_user.id).count).to eq(0)
expect(SingleSignOnRecord.where(user_id: source_user.id).count).to eq(0)
expect(UserOpenId.where(user_id: source_user.id).count).to eq(0)

View File

@ -22,7 +22,27 @@ describe UserUpdater do
expect(MutedUser.where(user_id: u2.id).count).to eq 2
expect(MutedUser.where(user_id: u1.id).count).to eq 2
expect(MutedUser.where(user_id: u3.id).count).to eq 0
end
end
describe '#update_ignored_users' do
it 'updates ignored users' do
u1 = Fabricate(:user)
u2 = Fabricate(:user)
u3 = Fabricate(:user)
updater = UserUpdater.new(u1, u1)
updater.update_ignored_users("#{u2.username},#{u3.username}")
updater = UserUpdater.new(u2, u2)
updater.update_ignored_users("#{u3.username},#{u1.username}")
updater = UserUpdater.new(u3, u3)
updater.update_ignored_users("")
expect(IgnoredUser.where(user_id: u2.id).count).to eq 2
expect(IgnoredUser.where(user_id: u1.id).count).to eq 2
expect(IgnoredUser.where(user_id: u3.id).count).to eq 0
end
end

View File

@ -600,6 +600,24 @@ QUnit.test("Checks for existing draft", async assert => {
toggleCheckDraftPopup(false);
});
QUnit.test("Loading draft also replaces the recipients", async assert => {
toggleCheckDraftPopup(true);
// prettier-ignore
server.get("/draft.json", () => { // eslint-disable-line no-undef
return [ 200, { "Content-Type": "application/json" }, {
"draft":"{\"reply\":\"hello\",\"action\":\"privateMessage\",\"title\":\"hello\",\"categoryId\":null,\"archetypeId\":\"private_message\",\"metaData\":null,\"usernames\":\"codinghorror\",\"composerTime\":9159,\"typingTime\":2500}",
"draft_sequence":0
} ];
});
await visit("/u/charlie");
await click("button.compose-pm");
await click(".modal .btn-default");
assert.equal(find(".users-input .item:eq(0)").text(), "codinghorror");
});
const assertImageResized = (assert, uploads) => {
assert.equal(
find(".d-editor-input").val(),

View File

@ -2276,5 +2276,334 @@ export default {
}
]
}
},
"/u/charlie.json": {
user_badges: [
{
id: 17,
granted_at: "2019-03-06T19:08:28.230Z",
count: 1,
badge_id: 3,
user_id: 5,
granted_by_id: -1
}
],
badges: [
{
id: 3,
name: "Regular",
description:
'\u003ca href="https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/"\u003eGranted\u003c/a\u003e recategorize, rename, followed links, wiki, more likes',
grant_count: 3,
allow_title: true,
multiple_grant: false,
icon: "fa-user",
image: null,
listable: true,
enabled: true,
badge_grouping_id: 4,
system: true,
slug: "regular",
manually_grantable: false,
badge_type_id: 2
}
],
badge_types: [{ id: 2, name: "Silver", sort_order: 8 }],
users: [
{
id: 5,
username: "charlie",
name: null,
avatar_template: "/letter_avatar_proxy/v3/letter/c/d6d6ee/{size}.png",
moderator: false,
admin: false
},
{
id: -1,
username: "system",
name: "system",
avatar_template: "/user_avatar/localhost/system/{size}/2_2.png",
moderator: true,
admin: true
}
],
user: {
id: 5,
username: "charlie",
name: null,
avatar_template: "/letter_avatar_proxy/v3/letter/c/d6d6ee/{size}.png",
last_posted_at: null,
last_seen_at: null,
created_at: "2019-03-06T19:06:20.340Z",
can_edit: true,
can_edit_username: true,
can_edit_email: true,
can_edit_name: true,
ignored: false,
can_ignore_user: false,
can_send_private_messages: true,
can_send_private_message_to_user: true,
trust_level: 3,
moderator: false,
admin: false,
title: null,
uploaded_avatar_id: null,
badge_count: 3,
has_title_badges: true,
custom_fields: {},
pending_count: 0,
profile_view_count: 1,
time_read: 0,
recent_time_read: 0,
primary_group_name: null,
primary_group_flair_url: null,
primary_group_flair_bg_color: null,
primary_group_flair_color: null,
staged: false,
second_factor_enabled: false,
post_count: 0,
can_be_deleted: true,
can_delete_all_posts: true,
locale: null,
muted_category_ids: [],
watched_tags: [],
watching_first_post_tags: [],
tracked_tags: [],
muted_tags: [],
tracked_category_ids: [],
watched_category_ids: [],
watched_first_post_category_ids: [],
system_avatar_upload_id: null,
system_avatar_template:
"/letter_avatar_proxy/v3/letter/c/d6d6ee/{size}.png",
muted_usernames: [],
ignored_usernames: [],
mailing_list_posts_per_day: 0,
can_change_bio: true,
user_api_keys: null,
user_auth_tokens: [],
user_auth_token_logs: [],
invited_by: null,
groups: [
{
id: 10,
automatic: true,
name: "trust_level_0",
display_name: "trust_level_0",
user_count: 14,
mentionable_level: 0,
messageable_level: 0,
visibility_level: 0,
automatic_membership_email_domains: null,
automatic_membership_retroactive: false,
primary_group: false,
title: null,
grant_trust_level: null,
incoming_email: null,
has_messages: false,
flair_url: null,
flair_bg_color: null,
flair_color: null,
bio_raw: null,
bio_cooked: null,
public_admission: false,
public_exit: false,
allow_membership_requests: false,
full_name: null,
default_notification_level: 3,
membership_request_template: null
},
{
id: 11,
automatic: true,
name: "trust_level_1",
display_name: "trust_level_1",
user_count: 9,
mentionable_level: 0,
messageable_level: 0,
visibility_level: 0,
automatic_membership_email_domains: null,
automatic_membership_retroactive: false,
primary_group: false,
title: null,
grant_trust_level: null,
incoming_email: null,
has_messages: false,
flair_url: null,
flair_bg_color: null,
flair_color: null,
bio_raw: null,
bio_cooked: null,
public_admission: false,
public_exit: false,
allow_membership_requests: false,
full_name: null,
default_notification_level: 3,
membership_request_template: null
},
{
id: 12,
automatic: true,
name: "trust_level_2",
display_name: "trust_level_2",
user_count: 6,
mentionable_level: 0,
messageable_level: 0,
visibility_level: 0,
automatic_membership_email_domains: null,
automatic_membership_retroactive: false,
primary_group: false,
title: null,
grant_trust_level: null,
incoming_email: null,
has_messages: false,
flair_url: null,
flair_bg_color: null,
flair_color: null,
bio_raw: null,
bio_cooked: null,
public_admission: false,
public_exit: false,
allow_membership_requests: false,
full_name: null,
default_notification_level: 3,
membership_request_template: null
},
{
id: 13,
automatic: true,
name: "trust_level_3",
display_name: "trust_level_3",
user_count: 3,
mentionable_level: 0,
messageable_level: 0,
visibility_level: 0,
automatic_membership_email_domains: null,
automatic_membership_retroactive: false,
primary_group: false,
title: null,
grant_trust_level: null,
incoming_email: null,
has_messages: false,
flair_url: null,
flair_bg_color: null,
flair_color: null,
bio_raw: null,
bio_cooked: null,
public_admission: false,
public_exit: false,
allow_membership_requests: false,
full_name: null,
default_notification_level: 3,
membership_request_template: null
}
],
group_users: [
{ group_id: 10, user_id: 5, notification_level: 3 },
{ group_id: 11, user_id: 5, notification_level: 3 },
{ group_id: 12, user_id: 5, notification_level: 3 },
{ group_id: 13, user_id: 5, notification_level: 3 }
],
featured_user_badge_ids: [17],
user_option: {
user_id: 5,
email_always: false,
mailing_list_mode: false,
mailing_list_mode_frequency: 1,
email_digests: true,
email_private_messages: true,
email_direct: true,
external_links_in_new_tab: false,
dynamic_favicon: false,
enable_quoting: true,
disable_jump_reply: false,
digest_after_minutes: 10080,
automatically_unpin_topics: true,
auto_track_topics_after_msecs: 240000,
notification_level_when_replying: 2,
new_topic_duration_minutes: 2880,
email_previous_replies: 2,
email_in_reply_to: true,
like_notification_frequency: 1,
include_tl0_in_digests: false,
theme_ids: [2],
theme_key_seq: 0,
allow_private_messages: true,
homepage_id: null,
hide_profile_and_presence: false,
text_size: "normal",
text_size_seq: 0
}
}
},
"/u/charlie/summary.json": {
topics: [],
badges: [
{
id: 3,
name: "Regular",
description:
'\u003ca href="https://blog.discourse.org/2018/06/understanding-discourse-trust-levels/"\u003eGranted\u003c/a\u003e recategorize, rename, followed links, wiki, more likes',
grant_count: 3,
allow_title: true,
multiple_grant: false,
icon: "fa-user",
image: null,
listable: true,
enabled: true,
badge_grouping_id: 4,
system: true,
slug: "regular",
manually_grantable: false,
badge_type_id: 2
}
],
badge_types: [{ id: 2, name: "Silver", sort_order: 8 }],
users: [
{
id: 5,
username: "charlie",
name: null,
avatar_template: "/letter_avatar_proxy/v3/letter/c/d6d6ee/{size}.png",
moderator: false,
admin: false
},
{
id: -1,
username: "system",
name: "system",
avatar_template: "/user_avatar/localhost/system/{size}/2_2.png",
moderator: true,
admin: true
}
],
user_summary: {
likes_given: 0,
likes_received: 0,
topics_entered: 0,
posts_read_count: 0,
days_visited: 0,
topic_count: 0,
post_count: 0,
time_read: 0,
recent_time_read: 0,
topic_ids: [],
replies: [],
links: [],
most_liked_by_users: [],
most_liked_users: [],
most_replied_to_users: [],
badges: [
{
id: 17,
granted_at: "2019-03-06T19:08:28.230Z",
count: 1,
badge_id: 3,
user_id: 5,
granted_by_id: -1
}
],
top_categories: []
}
}
};

View File

@ -852,3 +852,22 @@ widgetTest("pm map", {
assert.equal(find(".private-message-map .user").length, 1);
}
});
widgetTest("post notice", {
template: '{{mount-widget widget="post" args=args}}',
beforeEach() {
this.set("args", {
postNoticeType: "returning",
postNoticeTime: new Date("2010-01-01 12:00:00 UTC"),
username: "codinghorror"
});
},
test(assert) {
assert.equal(
find(".post-notice")
.text()
.trim(),
I18n.t("post.notice.return", { user: "codinghorror", time: "Jan '10" })
);
}
});

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 427 KiB

After

Width:  |  Height:  |  Size: 440 KiB

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