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 '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 gem 'http_accept_language', '~>2.0.5', require: false

View File

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

View File

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

View File

@ -47,7 +47,10 @@ export default Ember.Component.extend(
this.set("hidden", false); this.set("hidden", false);
} }
buffer.push(`<a href='${href}'>`); buffer.push(
`<a href='${href}'` + (this.get("active") ? 'class="active"' : "") + `>`
);
if (content.get("hasIcon")) { if (content.get("hasIcon")) {
buffer.push("<span class='" + content.get("name") + "'></span>"); 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("composeState", Composer.OPEN);
composerModel.set("isWarning", false); composerModel.set("isWarning", false);
if (opts.usernames) { if (opts.usernames && !this.get("model.targetUsernames")) {
this.set("model.targetUsernames", opts.usernames); this.set("model.targetUsernames", opts.usernames);
} }

View File

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

View File

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

View File

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

View File

@ -46,11 +46,26 @@ export function translateResults(results, opts) {
results.groups = results.groups results.groups = results.groups
.map(group => { .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 { return {
id: group.id, id: group.id,
name: groupName, flairUrl,
url: Discourse.getURL(`/g/${groupName}`) flairColor,
flairBgColor,
fullName,
name,
url: Discourse.getURL(`/g/${name}`)
}; };
}) })
.compact(); .compact();
@ -72,10 +87,10 @@ export function translateResults(results, opts) {
if (groupedSearchResult) { if (groupedSearchResult) {
[ [
["topic", "posts"], ["topic", "posts"],
["category", "categories"],
["tag", "tags"],
["user", "users"], ["user", "users"],
["group", "groups"] ["group", "groups"],
["category", "categories"],
["tag", "tags"]
].forEach(function(pair) { ].forEach(function(pair) {
const type = pair[0]; const type = pair[0];
const name = pair[1]; const name = pair[1];

View File

@ -80,6 +80,7 @@ export function transformBasicPost(post) {
expandablePost: false, expandablePost: false,
replyCount: post.reply_count, replyCount: post.reply_count,
locked: post.locked, locked: post.locked,
ignored: post.ignored,
userCustomFields: post.user_custom_fields userCustomFields: post.user_custom_fields
}; };
@ -133,6 +134,13 @@ export default function transformPost(
postAtts.topicUrl = topic.get("url"); postAtts.topicUrl = topic.get("url");
postAtts.isSaving = post.isSaving; 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 = const showPMMap =
topic.archetype === "private_message" && post.post_number === 1; topic.archetype === "private_message" && post.post_number === 1;
if (showPMMap) { 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", { return ajax("/posts/destroy_many", {
type: "DELETE", 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", "custom_fields",
"user_fields", "user_fields",
"muted_usernames", "muted_usernames",
"ignored_usernames",
"profile_background", "profile_background",
"card_background", "card_background",
"muted_tags", "muted_tags",

View File

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

View File

@ -19,6 +19,11 @@
<p>{{model.description}}</p> <p>{{model.description}}</p>
</section> </section>
{{plugin-outlet name="about-after-description"
connectorTagName='section'
tagName=''
args=(hash model=model)}}
{{#if model.admins}} {{#if model.admins}}
<section class='about admins'> <section class='about admins'>
<h3>{{d-icon "users"}} {{i18n 'about.our_admins'}}</h3> <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> <figure title="{{i18n 'all_time_desc'}}">{{number c.topics_all_time}} <figcaption>{{i18n 'all_time'}}</figcaption></figure>
{{#if c.pickMonth}} {{#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}}
{{#if c.pickWeek}} {{#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}} {{/if}}
</footer> </footer>

View File

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

View File

@ -13,6 +13,7 @@ import {
formatUsername formatUsername
} from "discourse/lib/utilities"; } from "discourse/lib/utilities";
import hbs from "discourse/widgets/hbs-compiler"; import hbs from "discourse/widgets/hbs-compiler";
import { relativeAge } from "discourse/lib/formatter";
function transformWithCallbacks(post) { function transformWithCallbacks(post) {
let transformed = transformBasicPost(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", { createWidget("post-body", {
tagName: "div.topic-body.clearfix", 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( rows.push(
h("div.row", [ h("div.row", [
this.attach("post-avatar", attrs), this.attach("post-avatar", attrs),
@ -608,6 +636,9 @@ export default createWidget("post", {
} else { } else {
classNames.push("regular"); classNames.push("regular");
} }
if (attrs.ignored) {
classNames.push("post-ignored");
}
if (addPostClassesCallbacks) { if (addPostClassesCallbacks) {
for (let i = 0; i < addPostClassesCallbacks.length; i++) { for (let i = 0; i < addPostClassesCallbacks.length; i++) {
let pluginClasses = addPostClassesCallbacks[i].call(this, attrs); 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 result = [h(`div.participants${hideNamesClass}`, participants)];
const controls = [];
const controls = [ if (attrs.canRemoveAllowedUsers || attrs.canRemoveSelfId) {
this.attach("button", { controls.push(
action: "toggleEditing", this.attach("button", {
label: "private_message_info.edit", action: "toggleEditing",
className: "btn btn-default add-remove-participant-btn" label: "private_message_info.edit",
}) className: "btn btn-default add-remove-participant-btn"
]; })
);
}
if (attrs.canInvite && this.state.isEditing) { if (attrs.canInvite && this.state.isEditing) {
controls.push( 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; 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({ createSearchResult({
type: "user", type: "user",
linkField: "path", linkField: "path",
builder(u) { builder(u) {
const userTitles = [h("span.username", formatUsername(u.username))]; const userTitles = [];
if (u.name) { if (u.name) {
userTitles.push(h("span.name", u.name)); userTitles.push(h("span.name", u.name));
} }
userTitles.push(h("span.username", formatUsername(u.username)));
const userResultContents = [ const userResultContents = [
avatarImg("small", { avatarImg("small", {
template: u.avatar_template, 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({ createSearchResult({
type: "topic", type: "topic",
linkField: "url", linkField: "url",
@ -174,19 +191,12 @@ createWidget("search-menu-results", {
const resultTypes = results.resultTypes || []; const resultTypes = results.resultTypes || [];
const mainResultsContent = []; const mainResultsContent = [];
const classificationContents = []; const usersAndGroups = [];
const otherContents = []; const categoriesAndTags = [];
const assignContainer = (type, node) => { const usersAndGroupsMore = [];
if (["topic"].includes(type)) { const categoriesAndTagsMore = [];
mainResultsContent.push(node);
} else if (["category", "tag"].includes(type)) {
classificationContents.push(node);
} else {
otherContents.push(node);
}
};
resultTypes.forEach(rt => { const buildMoreNode = result => {
const more = []; const more = [];
const moreArgs = { const moreArgs = {
@ -194,23 +204,45 @@ createWidget("search-menu-results", {
contents: () => [I18n.t("more"), "..."] contents: () => [I18n.t("more"), "..."]
}; };
if (rt.moreUrl) { if (result.moreUrl) {
more.push( 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( more.push(
this.attach( this.attach(
"link", "link",
$.extend(moreArgs, { $.extend(moreArgs, {
action: "moreOfType", action: "moreOfType",
actionParam: rt.type, actionParam: result.type,
className: "filter filter-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 = [ const resultNodeContents = [
this.attach(rt.componentName, { this.attach(rt.componentName, {
searchContextEnabled: attrs.searchContextEnabled, searchContextEnabled: attrs.searchContextEnabled,
@ -220,14 +252,14 @@ createWidget("search-menu-results", {
}) })
]; ];
if (more.length) { if (["topic"].includes(rt.type)) {
resultNodeContents.push(h("div.show-more", more)); const more = buildMoreNode(rt);
if (more) {
resultNodeContents.push(h("div.show-more", more));
}
} }
assignContainer( assignContainer(rt, h(`div.${rt.componentName}`, resultNodeContents));
rt.type,
h(`div.${rt.componentName}`, resultNodeContents)
);
}); });
const content = []; const content = [];
@ -236,27 +268,25 @@ createWidget("search-menu-results", {
content.push(h("div.main-results", mainResultsContent)); content.push(h("div.main-results", mainResultsContent));
} }
if (classificationContents.length || otherContents.length) { if (usersAndGroups.length || categoriesAndTags.length) {
const secondaryResultsContent = []; const secondaryResultsContents = [];
if (classificationContents.length) { secondaryResultsContents.push(usersAndGroups);
secondaryResultsContent.push( secondaryResultsContents.push(usersAndGroupsMore);
h("div.classification-results", classificationContents)
); if (usersAndGroups.length && categoriesAndTags.length) {
secondaryResultsContents.push(h("div.separator"));
} }
if (otherContents.length) { secondaryResultsContents.push(categoriesAndTags);
secondaryResultsContent.push(h("div.other-results", otherContents)); secondaryResultsContents.push(categoriesAndTagsMore);
}
content.push( const secondaryResults = h(
h( "div.secondary-results",
`div.secondary-results${ secondaryResultsContents
mainResultsContent.length ? "" : ".no-main-results"
}`,
secondaryResultsContent
)
); );
content.push(secondaryResults);
} }
return content; return content;

View File

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

View File

@ -76,26 +76,22 @@
flex-direction: column; flex-direction: column;
flex: 1 1 auto; flex: 1 1 auto;
.classification-results { .separator {
border-bottom: 1px solid $primary-low;
margin-bottom: 1em; margin-bottom: 1em;
padding-bottom: 1em; margin-top: 1em;
} height: 1px;
background: $primary-low;
.search-result-category {
} }
.search-result-tag { .search-result-tag {
.list { .discourse-tag {
.item { font-size: $font-down-1;
display: inline-flex; }
}
.widget-link.search-link { .search-result-category {
display: inline; .widget-link {
font-size: $font-0; margin-bottom: 0;
padding: 5px;
}
}
} }
} }
@ -108,12 +104,71 @@
} }
} }
.discourse-group { .group-result {
display: inline-block; display: flex;
word-break: break-all; align-items: center;
.d-icon { .d-icon,
margin-right: s(1); .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 { .username {
color: dark-light-choose($primary-high, $secondary-low); color: dark-light-choose($primary-high, $secondary-low);
font-size: $font-0; font-size: $font-down-1;
font-weight: 700;
} }
.name { .name {
color: dark-light-choose($primary-high, $secondary-low); 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 { .show-more {

View File

@ -214,6 +214,10 @@ aside.quote {
margin: -2px; margin: -2px;
} }
.post-ignored {
font-style: italic;
}
.post-action { .post-action {
.undo-action, .undo-action,
.act-action { .act-action {
@ -353,7 +357,10 @@ aside.quote {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
margin-bottom: 0.5em;
& + .controls {
margin-top: 0.5em;
}
&.hide-names .user { &.hide-names .user {
.username, .username,
@ -857,3 +864,22 @@ a.mention-group {
margin-bottom: 1em; 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; color: $secondary;
background-color: $quaternary; background-color: $quaternary;

View File

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

View File

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

View File

@ -47,60 +47,69 @@ section.post-menu-area {
nav.post-controls { nav.post-controls {
padding: 0; padding: 0;
.like-button { .like-button {
// Like button wrapper
display: inline-flex; display: inline-flex;
.like-count { color: $primary-low-mid;
color: dark-light-choose($primary-low-mid, $secondary-high); margin-right: 0.15em;
}
.widget-button {
background: none;
}
&:hover { &:hover {
background: $primary-low; // Like button wrapper on hover
.like-count { button {
background: $primary-low;
color: $primary-medium; 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 { &.d-hover {
color: $primary; color: $primary;
} }
} + .toggle-like {
.d-hover { // Like button when like count is present
background: none; padding-left: 0.45em;
} &.d-hover {
.d-icon { background: $primary-low;
color: $love; }
}
} }
} }
&: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, a,
button { button {
@ -186,23 +195,6 @@ nav.post-controls {
color: $secondary; 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 { &.bookmark {
padding: 8px 11px; padding: 8px 11px;
&.bookmarked .d-icon { &.bookmarked .d-icon {

View File

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

View File

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

View File

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

View File

@ -113,12 +113,15 @@ class Admin::EmailTemplatesController < Admin::AdminController
def update_key(key, value) def update_key(key, value)
old_value = I18n.t(key) 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, key: key,
old_value: old_value, old_value: old_value,
error_messages: translation_override.errors.full_messages error_messages: translation_override&.errors&.full_messages
} }
end end

View File

@ -143,7 +143,7 @@ class InvitesController < ApplicationController
def rescind_all_invites def rescind_all_invites
guardian.ensure_can_rescind_all_invites!(current_user) 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 render body: nil
end end

View File

@ -336,7 +336,7 @@ class PostsController < ApplicationController
def destroy_many def destroy_many
params.require(:post_ids) 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) posts = Post.where(id: post_ids_including_replies)
raise Discourse::InvalidParameters.new(:post_ids) if posts.blank? raise Discourse::InvalidParameters.new(:post_ids) if posts.blank?
@ -345,7 +345,9 @@ class PostsController < ApplicationController
posts.each { |p| guardian.ensure_can_delete!(p) } posts.each { |p| guardian.ensure_can_delete!(p) }
Post.transaction do 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 end
render body: nil render body: nil

View File

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

View File

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

View File

@ -24,7 +24,7 @@ module Jobs
BadgeGranter.grant(badge, user) BadgeGranter.grant(badge, user)
SystemMessage.new(user).create('new_user_of_the_month', 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" url: "#{Discourse.base_url}/badges"
) )
end 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 # way to have the membership in a table
case name case name
when :everyone when :everyone
group.visibility_level = Group.visibility_levels[:owners] group.visibility_level = Group.visibility_levels[:staff]
group.save! group.save!
return group return group
when :moderators when :moderators

View File

@ -226,8 +226,9 @@ class Invite < ActiveRecord::Base
end end
end end
def self.rescind_all_invites_from(user) 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 = ?', user.id).find_each do |invite| 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) invite.trash!(user)
end end
end end

View File

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

View File

@ -194,6 +194,7 @@ class Post < ActiveRecord::Base
def recover! def recover!
super super
update_flagged_posts_count update_flagged_posts_count
delete_post_notices
recover_public_post_actions recover_public_post_actions
TopicLink.extract_from(self) TopicLink.extract_from(self)
QuotedPost.extract_from(self) QuotedPost.extract_from(self)
@ -381,6 +382,11 @@ class Post < ActiveRecord::Base
PostAction.update_flagged_posts_count PostAction.update_flagged_posts_count
end 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 def recover_public_post_actions
PostAction.publics PostAction.publics
.with_deleted .with_deleted

View File

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

View File

@ -527,10 +527,10 @@ class Topic < ActiveRecord::Base
end end
# Atomically creates the next post number # 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 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) result = DB.query_single(<<~SQL, highest, topic_id)
UPDATE topics UPDATE topics
@ -543,13 +543,15 @@ class Topic < ActiveRecord::Base
else 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) result = DB.query_single(<<~SQL, highest: highest, topic_id: topic_id)
UPDATE topics UPDATE topics
SET highest_staff_post_number = :highest + 1, SET highest_staff_post_number = :highest + 1,
highest_post_number = :highest + 1#{reply_sql}, highest_post_number = :highest + 1
posts_count = posts_count + 1 #{reply_sql}
#{posts_sql}
WHERE id = :topic_id WHERE id = :topic_id
RETURNING highest_post_number RETURNING highest_post_number
SQL SQL
@ -585,6 +587,43 @@ class Topic < ActiveRecord::Base
posts_count = Y.posts_count posts_count = Y.posts_count
FROM X, Y FROM X, Y
WHERE 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 X.topic_id = topics.id AND
Y.topic_id = topics.id AND ( Y.topic_id = topics.id AND (
topics.highest_staff_post_number <> X.highest_post_number OR 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 # If a post is deleted we have to update our highest post counters
def self.reset_highest(topic_id) 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) result = DB.query_single(<<~SQL, topic_id: topic_id)
UPDATE topics UPDATE topics
SET SET
highest_staff_post_number = ( highest_staff_post_number = (
SELECT COALESCE(MAX(post_number), 0) FROM posts SELECT COALESCE(MAX(post_number), 0) FROM posts
WHERE topic_id = :topic_id AND WHERE topic_id = :topic_id AND
deleted_at IS NULL deleted_at IS NULL
), ),
highest_post_number = ( highest_post_number = (
SELECT COALESCE(MAX(post_number), 0) FROM posts SELECT COALESCE(MAX(post_number), 0) FROM posts
WHERE topic_id = :topic_id AND WHERE topic_id = :topic_id AND
deleted_at IS NULL AND deleted_at IS NULL AND
post_type <> 4 post_type <> 4
#{post_type}
), ),
posts_count = ( posts_count = (
SELECT count(*) FROM posts SELECT count(*) FROM posts
WHERE deleted_at IS NULL AND WHERE deleted_at IS NULL AND
topic_id = :topic_id AND topic_id = :topic_id AND
post_type <> 4 post_type <> 4
#{post_type}
), ),
last_posted_at = ( last_posted_at = (
SELECT MAX(created_at) FROM posts SELECT MAX(created_at) FROM posts
WHERE topic_id = :topic_id AND WHERE topic_id = :topic_id AND
deleted_at IS NULL AND deleted_at IS NULL AND
post_type <> 4 post_type <> 4
#{post_type}
) )
WHERE id = :topic_id WHERE id = :topic_id
RETURNING highest_post_number RETURNING highest_post_number

View File

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

View File

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

View File

@ -6,7 +6,8 @@ class BasicPostSerializer < ApplicationSerializer
:avatar_template, :avatar_template,
:created_at, :created_at,
:cooked, :cooked,
:cooked_hidden :cooked_hidden,
:ignored
def name def name
object.user && object.user.name object.user && object.user.name
@ -35,11 +36,18 @@ class BasicPostSerializer < ApplicationSerializer
else else
I18n.t('flagging.user_must_edit') I18n.t('flagging.user_must_edit')
end end
elsif ignored
I18n.t('ignored.hidden_content')
else else
object.filter_quotes(@parent_post) object.filter_quotes(@parent_post)
end end
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? def include_name?
SiteSetting.enable_names? SiteSetting.enable_names?
end end

View File

@ -70,6 +70,8 @@ class PostSerializer < BasicPostSerializer
:is_auto_generated, :is_auto_generated,
:action_code, :action_code,
:action_code_who, :action_code_who,
:post_notice_type,
:post_notice_time,
:last_wiki_edit, :last_wiki_edit,
:locked, :locked,
:excerpt :excerpt
@ -363,6 +365,22 @@ class PostSerializer < BasicPostSerializer
include_action_code? && action_code_who.present? include_action_code? && action_code_who.present?
end 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 def locked
true true
end end

View File

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

View File

@ -128,6 +128,10 @@ class UserUpdater
update_muted_users(attributes[:muted_usernames]) update_muted_users(attributes[:muted_usernames])
end end
if attributes.key?(:ignored_usernames)
update_ignored_users(attributes[:ignored_usernames])
end
name_changed = user.name_changed? name_changed = user.name_changed?
if (saved = (!save_options || user.user_option.save) && user_profile.save && user.save) && if (saved = (!save_options || user.user_option.save) && user_profile.save && user.save) &&
(name_changed && old_user_name.casecmp(attributes.fetch(:name)) != 0) (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) INSERT into muted_users(user_id, muted_user_id, created_at, updated_at)
SELECT :user_id, id, :now, :now SELECT :user_id, id, :now, :now
FROM users FROM users
WHERE WHERE id in (:desired_ids)
id in (:desired_ids) AND ON CONFLICT DO NOTHING
id NOT IN ( SQL
SELECT muted_user_id end
FROM muted_users end
WHERE user_id = :user_id
) 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 SQL
end end
end end

View File

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

View File

@ -192,15 +192,18 @@ en:
ap_south_1: "Asia Pacific (Mumbai)" ap_south_1: "Asia Pacific (Mumbai)"
ap_southeast_1: "Asia Pacific (Singapore)" ap_southeast_1: "Asia Pacific (Singapore)"
ap_southeast_2: "Asia Pacific (Sydney)" ap_southeast_2: "Asia Pacific (Sydney)"
ca_central_1: "Canada (Central)"
cn_north_1: "China (Beijing)" cn_north_1: "China (Beijing)"
cn_northwest_1: "China (Ningxia)" cn_northwest_1: "China (Ningxia)"
eu_central_1: "EU (Frankfurt)" eu_central_1: "EU (Frankfurt)"
eu_north_1: "EU (Stockholm)"
eu_west_1: "EU (Ireland)" eu_west_1: "EU (Ireland)"
eu_west_2: "EU (London)" eu_west_2: "EU (London)"
eu_west_3: "EU (Paris)" 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_1: "US East (N. Virginia)"
us_east_2: "US East (Ohio)" us_east_2: "US East (Ohio)"
us_gov_east_1: "AWS GovCloud (US-East)"
us_gov_west_1: "AWS GovCloud (US)" us_gov_west_1: "AWS GovCloud (US)"
us_west_1: "US West (N. California)" us_west_1: "US West (N. California)"
us_west_2: "US West (Oregon)" us_west_2: "US West (Oregon)"
@ -1000,9 +1003,9 @@ en:
expired: "This invite has expired." expired: "This invite has expired."
rescind: "Remove" rescind: "Remove"
rescinded: "Invite removed" rescinded: "Invite removed"
rescind_all: "Remove all Invites" rescind_all: "Remove all Expired Invites"
rescinded_all: "All Invites removed!" rescinded_all: "All Expired Invites removed!"
rescind_all_confirm: "Are you sure you want to remove all invites?" rescind_all_confirm: "Are you sure you want to remove all expired invites?"
reinvite: "Resend Invite" reinvite: "Resend Invite"
reinvite_all: "Resend all Invites" reinvite_all: "Resend all Invites"
reinvite_all_confirm: "Are you sure you want to resend all invites?" reinvite_all_confirm: "Are you sure you want to resend all invites?"
@ -1809,6 +1812,7 @@ en:
when: "When:" when: "When:"
public_timer_types: Topic Timers public_timer_types: Topic Timers
private_timer_types: User Topic Timers private_timer_types: User Topic Timers
time_frame_required: Please select a time frame
auto_update_input: auto_update_input:
none: "Select a timeframe" none: "Select a timeframe"
later_today: "Later today" later_today: "Later today"
@ -2145,6 +2149,10 @@ en:
one: "view 1 hidden reply" one: "view 1 hidden reply"
other: "view {{count}} hidden replies" 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" unread: "Post is unread"
has_replies: has_replies:
one: "{{count}} Reply" one: "{{count}} Reply"

View File

@ -25,14 +25,16 @@ en:
datetime_formats: &datetime_formats datetime_formats: &datetime_formats
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" 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" 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" 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" 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: date:
# Do not remove the brackets and commas and do not translate the first month name. It should be "null". # Do not remove the brackets and commas and do not translate the first month name. It should be "null".
month_names: 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>' 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>" user_must_edit: "<p>This post was flagged by the community and is temporarily hidden.</p>"
ignored:
hidden_content: '<p>Hidden content</p>'
archetypes: archetypes:
regular: regular:
title: "Regular Topic" title: "Regular Topic"
@ -1896,6 +1901,8 @@ en:
max_allowed_message_recipients: "Maximum recipients allowed in a message." max_allowed_message_recipients: "Maximum recipients allowed in a message."
watched_words_regular_expressions: "Watched words are regular expressions." 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_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_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." default_email_personal_messages: "Send an email when someone messages the user by default."

View File

@ -807,6 +807,8 @@ posting:
default: false default: false
client: true client: true
shadowed_by_global: true shadowed_by_global: true
returning_users_days:
default: 60
email: email:
email_time_window_mins: email_time_window_mins:
@ -1251,7 +1253,7 @@ security:
list_type: compact list_type: compact
blacklisted_crawler_user_agents: blacklisted_crawler_user_agents:
type: list type: list
default: "mauibot|semrushbot|ahrefsbot" default: "mauibot|semrushbot|ahrefsbot|blexbot"
list_type: compact list_type: compact
slow_down_crawler_user_agents: slow_down_crawler_user_agents:
type: list 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 def name
"google_oauth2" "google_oauth2"
end end
@ -8,77 +7,10 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator
SiteSetting.enable_google_oauth2_logins SiteSetting.enable_google_oauth2_logins
end end
def description_for_user(user) def primary_email_verified?(auth_token)
info = GoogleUserInfo.find_by(user_id: user.id) # note, emails that come back from google via omniauth are always valid
info&.email || info&.name || "" # this protects against future regressions
end auth_token[:extra][:raw_info][:email_verified]
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))
end end
def register_middleware(omniauth) def register_middleware(omniauth)
@ -95,37 +27,8 @@ class Auth::GoogleOAuth2Authenticator < Auth::Authenticator
if (google_oauth2_prompt = SiteSetting.google_oauth2_prompt).present? if (google_oauth2_prompt = SiteSetting.google_oauth2_prompt).present?
strategy.options[:prompt] = google_oauth2_prompt.gsub("|", " ") strategy.options[:prompt] = google_oauth2_prompt.gsub("|", " ")
end end
}, }
skip_jwt: true
} }
# jwt encoding is causing auth to fail in quite a few conditions
# skipping
omniauth.provider :google_oauth2, options omniauth.provider :google_oauth2, options
end 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 end

View File

@ -10,6 +10,12 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
true true
end 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? def can_revoke?
true true
end end
@ -35,7 +41,11 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
end end
# Matching an account by email # 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 UserAssociatedAccount.where(user: user, provider_name: auth_token[:provider]).destroy_all # Destroy existing associations for the new user
association.user = user association.user = user
end end
@ -60,7 +70,7 @@ class Auth::ManagedAuthenticator < Auth::Authenticator
result.email = info[:email] result.email = info[:email]
result.name = "#{info[:first_name]} #{info[:last_name]}" result.name = "#{info[:first_name]} #{info[:last_name]}"
result.username = info[:nickname] 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 = { result.extra_data = {
provider: auth_token[:provider], provider: auth_token[:provider],
uid: auth_token[:uid] uid: auth_token[:uid]

View File

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

View File

@ -899,6 +899,22 @@ module Email
create_post_with_attachments(options) create_post_with_attachments(options)
end 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 = {}) def create_reply(options = {})
raise TopicNotFoundError if options[:topic].nil? || options[:topic].trashed? raise TopicNotFoundError if options[:topic].nil? || options[:topic].trashed?
raise BouncedEmailError if options[:bounce] && options[:topic].archetype != Archetype.private_message 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]) if post_action_type = post_action_for(options[:raw])
create_post_action(options[:user], options[:post], post_action_type) 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 else
raise TopicClosedError if options[:topic].closed? raise TopicClosedError if options[:topic].closed?
options[:topic_id] = options[:topic].id options[:topic_id] = options[:topic].id

View File

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

View File

@ -165,6 +165,7 @@ class PostCreator
transaction do transaction do
build_post_stats build_post_stats
create_topic create_topic
create_post_notice
save_post save_post
extract_links extract_links
track_topic track_topic
@ -247,7 +248,11 @@ class PostCreator
post.word_count = post.raw.scan(/[[:word:]]+/).size post.word_count = post.raw.scan(/[[:word:]]+/).size
whisper = post.post_type == Post.types[:whisper] 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 = post.cooking_options || {}
cooking_options[:topic_id] = post.topic_id cooking_options[:topic_id] = post.topic_id
@ -508,6 +513,21 @@ class PostCreator
@user.update_attributes(last_posted_at: @post.created_at) @user.update_attributes(last_posted_at: @post.created_at)
end 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 def publish
return if @opts[:import_mode] || @post.post_number == 1 return if @opts[:import_mode] || @post.post_number == 1
@post.publish_change_to_clients! :created @post.publish_change_to_clients! :created

View File

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

View File

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

View File

@ -687,7 +687,7 @@ class Search
def groups_search def groups_search
groups = Group groups = Group
.visible_groups(@guardian.user, "name ASC", include_everyone: false) .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) } groups.each { |group| @results.add(group) }
end end

View File

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

View File

@ -118,6 +118,7 @@ module SvgSprite
"globe", "globe",
"globe-americas", "globe-americas",
"hand-point-right", "hand-point-right",
"hands-helping",
"heading", "heading",
"heart", "heart",
"home", "home",
@ -224,10 +225,10 @@ module SvgSprite
icons = all_icons(theme_ids) icons = all_icons(theme_ids)
doc = File.open("#{Rails.root}/vendor/assets/svg-icons/fontawesome/solid.svg") { |f| Nokogiri::XML(f) } doc = File.open("#{Rails.root}/vendor/assets/svg-icons/fontawesome/solid.svg") { |f| Nokogiri::XML(f) }
fa_license = doc.at('//comment()').text
svg_subset = """<!-- 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;'> <svg xmlns='http://www.w3.org/2000/svg' style='display: none;'>
""".dup """.dup

View File

@ -58,7 +58,7 @@ task 'assets:precompile:css' => 'environment' do
STDERR.puts "Compiling css for #{db} #{Time.zone.now}" STDERR.puts "Compiling css for #{db} #{Time.zone.now}"
begin begin
Stylesheet::Manager.precompile_css 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 "#{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." STDERR.puts "Skipping precompilation of CSS cause schema is old, you are precompiling prior to running migrations."
end end

View File

@ -495,102 +495,6 @@ def list_missing_uploads(skip_optimized: false)
Discourse.store.list_missing_uploads(skip_optimized: skip_optimized) Discourse.store.list_missing_uploads(skip_optimized: skip_optimized)
end 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 # # regenerate_missing_optimized #
################################################################################ ################################################################################
@ -795,6 +699,10 @@ task "uploads:fix_incorrect_extensions" => :environment do
UploadFixer.fix_all_extensions UploadFixer.fix_all_extensions
end end
task "uploads:recover_from_tombstone" => :environment do
Rake::Task["uploads:recover"].invoke
end
task "uploads:recover" => :environment do task "uploads:recover" => :environment do
require_dependency "upload_recovery" require_dependency "upload_recovery"

View File

@ -18,7 +18,7 @@ class TopicView
end end
def self.default_post_custom_fields 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 end
def self.post_custom_fields_whitelisters def self.post_custom_fields_whitelisters

View File

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

View File

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

View File

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

View File

@ -72,8 +72,8 @@ en:
confirm: "Are you sure you want to close this poll?" confirm: "Are you sure you want to close this poll?"
automatic_close: automatic_close:
closes_in: "closes in <strong>%{timeLeft}</strong>" closes_in: "Closes in <strong>%{timeLeft}</strong>."
age: "closed <strong>%{age}</strong>" age: "Closed <strong>%{age}</strong>"
error_while_toggling_status: "Sorry, there was an error toggling the status of this poll." 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." 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) copy_model(c, skip_if_merged: true, is_a_user_model: true, skip_processing: true)
end end
[UserAssociatedAccount, GithubUserInfo, GoogleUserInfo, Oauth2UserInfo, [UserAssociatedAccount, GithubUserInfo, Oauth2UserInfo,
SingleSignOnRecord, EmailChangeRequest SingleSignOnRecord, EmailChangeRequest
].each do |c| ].each do |c|
copy_model(c, skip_if_merged: true, is_a_user_model: true) copy_model(c, skip_if_merged: true, is_a_user_model: true)
@ -628,11 +628,6 @@ class BulkImport::DiscourseMerger < BulkImport::Base
r r
end 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) def process_oauth2_user_info(r)
return nil if Oauth2UserInfo.where(uid: r['uid'], provider: r['provider']).exists? return nil if Oauth2UserInfo.where(uid: r['uid'], provider: r['provider']).exists?
r r

View File

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

View File

@ -563,7 +563,7 @@ class ImportScripts::Base
post_creator = PostCreator.new(user, opts) post_creator = PostCreator.new(user, opts)
post = post_creator.create post = post_creator.create
post_create_action.try(:call, post) if post 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 end
def create_upload(user_id, path, source_filename) def create_upload(user_id, path, source_filename)

View File

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

View File

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

View File

@ -251,6 +251,10 @@ describe Email::Receiver do
) )
end 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 it "uses MD5 of 'mail_string' there is no message_id" do
mail_string = email(:missing_message_id) mail_string = email(:missing_message_id)
expect { Email::Receiver.new(mail_string).process! }.to change { IncomingEmail.count } 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) expect { process(:reply_user_matching) }.to raise_error(Email::Receiver::TopicNotFoundError)
end end
it "raises a TopicClosedError when the topic was closed" do context "a closed topic" do
topic.update_columns(closed: true)
expect { process(:reply_user_matching) }.to raise_error(Email::Receiver::TopicClosedError)
end
it "does not raise TopicClosedError when performing a like action" do before do
topic.update_columns(closed: true) topic.update_columns(closed: true)
expect { process(:like) }.to change(PostAction, :count) 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 end
it "raises an InvalidPost when there was an error while creating the post" do 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) expect(post.topic.topic_allowed_users.where(user_id: admin2.id).count).to eq(0)
end 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 end
context "warnings" do context "warnings" do
@ -1238,4 +1260,32 @@ describe PostCreator do
end end
end 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 end

View File

@ -14,7 +14,9 @@ describe MaxEmojisValidator do
shared_examples "validating any topic title" do shared_examples "validating any topic title" do
it 'adds an error when emoji count is greater than SiteSetting.max_emojis_in_title' do it 'adds an error when emoji count is greater than SiteSetting.max_emojis_in_title' do
SiteSetting.max_emojis_in_title = 3 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 validate
expect(record.errors[:title][0]).to eq(I18n.t("errors.messages.max_emojis", max_emojis_count: 3)) 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 before do
GithubUserInfo.create!(user_id: not_seen_admin.id, screen_name: 'bob', github_user_id: 100) 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) 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 end
it 'removes the social logins' do it 'removes the social logins' do
subject subject
expect(GithubUserInfo.where(user_id: not_seen_admin.id).exists?).to eq(false) 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) expect(UserOpenId.where(user_id: not_seen_admin.id).exists?).to eq(false)
end end
end end

View File

@ -221,9 +221,9 @@ describe Group do
end end
describe '.refresh_automatic_group!' do 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) 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 end
it "ensures that the moderators group is messageable by all" do it "ensures that the moderators group is messageable by all" do

View File

@ -477,16 +477,21 @@ describe Invite do
end end
describe '.rescind_all_invites_from' do describe '.rescind_all_expired_invites_from' do
it 'removes all invites sent by a user' do it 'removes all expired invites sent by a user' do
SiteSetting.invite_expiry_days = 1
user = Fabricate(:user) user = Fabricate(:user)
invite_1 = Fabricate(:invite, invited_by: user) invite_1 = Fabricate(:invite, invited_by: user)
invite_2 = 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_1.reload
invite_2.reload invite_2.reload
expect(invite_1.deleted_at).to be_present expired_invite.reload
expect(invite_2.deleted_at).to be_present 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 end
end end

View File

@ -134,6 +134,29 @@ describe Post do
end end
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 end
describe 'flagging helpers' do 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) scss, _map = Stylesheet::Compiler.compile('@import "theme_variables"; @import "desktop_theme"; ', "theme.scss", theme_id: theme.id)
expect(scss).to include("font-size:30px") 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 end
it "allows values to be used in JS" do 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: "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: "facebook", provider_uid: "1234", info: { email: "test@example.com" })
UserAssociatedAccount.create(user_id: user.id, provider_name: "instagram", provider_uid: "examplel123123", info: { nickname: "sam" }) 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) GithubUserInfo.create(user_id: user.id, screen_name: "sam", github_user_id: 1)
user.reload user.reload

View File

@ -257,6 +257,33 @@ describe WebHook do
expect(payload["id"]).to eq(post.topic.id) expect(payload["id"]).to eq(post.topic.id)
end 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 it 'should enqueue the right hooks for user events' do
Fabricate(:user_web_hook, active: true) Fabricate(:user_web_hook, active: true)

View File

@ -214,6 +214,21 @@ RSpec.describe Admin::EmailTemplatesController do
end end
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
end end

View File

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

View File

@ -248,15 +248,22 @@ describe PostsController do
let(:moderator) { Fabricate(:moderator) } let(:moderator) { Fabricate(:moderator) }
before do before do
sign_in(moderator)
PostAction.act(moderator, post1, PostActionType.types[:off_topic]) PostAction.act(moderator, post1, PostActionType.types[:off_topic])
PostAction.act(moderator, post2, PostActionType.types[:off_topic]) PostAction.act(moderator, post2, PostActionType.types[:off_topic])
Jobs::SendSystemMessage.clear Jobs::SendSystemMessage.clear
end end
it "defers the posts" do it "defers the child posts by default" do
sign_in(moderator)
expect(PostAction.flagged_posts_count).to eq(2) 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(Jobs::SendSystemMessage.jobs.size).to eq(0)
expect(PostAction.flagged_posts_count).to eq(0) expect(PostAction.flagged_posts_count).to eq(0)
end end

View File

@ -190,7 +190,6 @@ describe UserAnonymizer do
end end
it "removes external auth assocations" do 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.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.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") 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) UserOpenId.create(user_id: user.id, email: user.email, url: "http://example.com/openid", active: true)
make_anonymous make_anonymous
user.reload user.reload
expect(user.google_user_info).to eq(nil)
expect(user.github_user_info).to eq(nil) expect(user.github_user_info).to eq(nil)
expect(user.user_associated_accounts).to be_empty expect(user.user_associated_accounts).to be_empty
expect(user.single_sign_on_record).to eq(nil) 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 it "deletes external auth infos of source user" do
UserAssociatedAccount.create(user_id: source_user.id, provider_name: "facebook", provider_uid: "1234") 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") 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") 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") 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) 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(UserAssociatedAccount.where(user_id: source_user.id).count).to eq(0)
expect(GithubUserInfo.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(Oauth2UserInfo.where(user_id: source_user.id).count).to eq(0)
expect(SingleSignOnRecord.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) 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: u2.id).count).to eq 2
expect(MutedUser.where(user_id: u1.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 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
end end

View File

@ -600,6 +600,24 @@ QUnit.test("Checks for existing draft", async assert => {
toggleCheckDraftPopup(false); 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) => { const assertImageResized = (assert, uploads) => {
assert.equal( assert.equal(
find(".d-editor-input").val(), 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); 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