Version bump

This commit is contained in:
Neil Lalonde 2016-02-22 11:30:25 -05:00
commit db9bc24742
365 changed files with 16212 additions and 7112 deletions

View File

@ -1,6 +1,6 @@
[main]
host = https://www.transifex.com
lang_map = es_ES: es, fr_FR: fr, ko_KR: ko, pt_PT: pt, sk_SK: sk
lang_map = es_ES: es, fr_FR: fr, ko_KR: ko, pt_PT: pt, sk_SK: sk, vi_VN: vi
[discourse-org.clientenyml]
file_filter = config/locales/client.<lang>.yml

View File

@ -64,7 +64,7 @@ gem 'aws-sdk', require: false
gem 'excon', require: false
gem 'unf', require: false
gem 'email_reply_trimmer', '0.0.5'
gem 'email_reply_trimmer', '0.0.8'
# note: for image_optim to correctly work you need to follow
# https://github.com/toy/image_optim

View File

@ -76,7 +76,7 @@ GEM
docile (1.1.5)
domain_name (0.5.25)
unf (>= 0.0.5, < 1.0.0)
email_reply_trimmer (0.0.5)
email_reply_trimmer (0.0.8)
ember-data-source (1.0.0.beta.16.1)
ember-source (~> 1.8)
ember-handlebars-template (0.1.5)
@ -149,7 +149,7 @@ GEM
thor (~> 0.15)
libv8 (3.16.14.13)
listen (0.7.3)
logster (1.0.1)
logster (1.1.1)
loofah (2.0.3)
nokogiri (>= 1.5.9)
lru_redux (1.1.0)
@ -211,7 +211,7 @@ GEM
omniauth-twitter (1.2.1)
json (~> 1.3)
omniauth-oauth (~> 1.1)
onebox (1.5.34)
onebox (1.5.35)
moneta (~> 0.8)
multi_json (~> 1.11)
mustache
@ -411,7 +411,7 @@ DEPENDENCIES
byebug
certified
discourse-qunit-rails
email_reply_trimmer (= 0.0.5)
email_reply_trimmer (= 0.0.8)
ember-rails
ember-source (= 1.12.2)
excon

View File

@ -0,0 +1,17 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import IncomingEmail from 'admin/models/incoming-email';
import computed from 'ember-addons/ember-computed-decorators';
import { longDate } from 'discourse/lib/formatter';
export default Ember.Controller.extend(ModalFunctionality, {
@computed("model.date")
date(d) {
return longDate(d);
},
load(id) {
return IncomingEmail.find(id).then(result => this.set("model", result));
}
});

View File

@ -402,7 +402,7 @@ const AdminUser = Discourse.User.extend({
}
}
}).catch(function() {
AdminUser.find( user.get('username') ).then(function(u){ user.setProperties(u); });
AdminUser.find(user.get('id')).then(u => user.setProperties(u));
bootbox.alert(I18n.t("admin.user.delete_failed"));
});
};
@ -475,7 +475,7 @@ const AdminUser = Discourse.User.extend({
if (user.get('loadedDetails')) { return Ember.RSVP.resolve(user); }
return AdminUser.find(user.get('username_lower')).then(function (result) {
return AdminUser.find(user.get('id')).then(result => {
user.setProperties(result);
user.set('loadedDetails', true);
});
@ -533,8 +533,8 @@ AdminUser.reopenClass({
});
},
find(username) {
return Discourse.ajax("/admin/users/" + username + ".json").then(function (result) {
find(user_id) {
return Discourse.ajax("/admin/users/" + user_id + ".json").then(result => {
result.loadedDetails = true;
return AdminUser.create(result);
});

View File

@ -14,6 +14,10 @@ IncomingEmail.reopenClass({
return this._super(attrs);
},
find(id) {
return Discourse.ajax(`/admin/email/incoming/${id}.json`);
},
findAll(filter, offset) {
filter = filter || {};
offset = offset || 0;

View File

@ -5,9 +5,9 @@ export default AdminEmailIncomings.extend({
status: "rejected",
actions: {
showRawEmail(incomingEmailId) {
showModal('raw-email');
this.controllerFor('raw_email').loadIncomingRawEmail(incomingEmailId);
showIncomingEmail(id) {
showModal('modals/admin-incoming-email');
this.controllerFor("modals/admin-incoming-email").load(id);
}
}

View File

@ -62,7 +62,7 @@ export default {
});
this.resource('adminUsers', { path: '/users' }, function() {
this.resource('adminUser', { path: '/:username' }, function() {
this.resource('adminUser', { path: '/:user_id/:username' }, function() {
this.route('badges');
this.route('tl3Requirements', { path: '/tl3_requirements' });
});

View File

@ -2,11 +2,11 @@ import AdminUser from 'admin/models/admin-user';
export default Discourse.Route.extend({
serialize(model) {
return { username: model.get('username').toLowerCase() };
return { user_id: model.get('id'), username: model.get('username').toLowerCase() };
},
model(params) {
return AdminUser.find(Em.get(params, 'username').toLowerCase());
return AdminUser.find(Em.get(params, 'user_id'));
},
renderTemplate() {

View File

@ -42,7 +42,7 @@
</td>
<td>{{email.subject}}</td>
<td class="error">
<a {{action "showRawEmail" email.id}}>{{email.error}}</a>
<a {{action "showIncomingEmail" email.id}}>{{email.error}}</a>
</td>
</tr>
{{else}}

View File

@ -30,7 +30,13 @@
</td>
<td><a href='mailto:{{unbound l.to_address}}'>{{l.to_address}}</a></td>
<td>{{l.email_type}}</td>
<td>{{l.reply_key}}</td>
<td>
{{#if l.post_url}}
<a href="{{l.post_url}}">{{l.reply_key}}</a>
{{else}}
{{l.reply_key}}
{{/if}}
</td>
</tr>
{{else}}
<tr><td colspan="5">{{i18n 'admin.email.logs.none'}}</td></tr>

View File

@ -30,7 +30,13 @@
</td>
<td><a href='mailto:{{unbound l.to_address}}'>{{l.to_address}}</a></td>
<td>{{l.email_type}}</td>
<td>{{l.skipped_reason}}</td>
<td>
{{#if l.post_url}}
<a href="{{l.post_url}}">{{l.skipped_reason}}</a>
{{else}}
{{l.skipped_reason}}
{{/if}}
</td>
</tr>
{{else}}
<tr><td colspan="5">{{i18n 'admin.email.logs.none'}}</td></tr>

View File

@ -4,7 +4,11 @@
<ul>
{{#each group in controller}}
<li>
{{#link-to "adminGroup" group.type group.name}}{{group.name}} <span class="count">{{group.userCountDisplay}}</span>{{/link-to}}
{{#link-to "adminGroup" group.type group.name}}{{group.name}}
{{#if group.userCountDisplay}}
<span class="count">{{group.userCountDisplay}}</span>
{{/if}}
{{/link-to}}
</li>
{{/each}}
</ul>

View File

@ -0,0 +1,98 @@
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.error"}}</label>
<div class="controls">
<p>{{model.error}}</p>
{{#if model.error_description}}
<p class="error-description">{{model.error_description}}</p>
{{/if}}
</div>
</div>
<hr>
{{#if model.return_path}}
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.return_path"}}</label>
<div class="controls">
{{model.return_path}}
</div>
</div>
{{/if}}
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.message_id"}}</label>
<div class="controls">
{{model.message_id}}
</div>
</div>
{{#if model.references}}
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.references"}}</label>
<div class="controls">
<ul>
{{#each reference in model.references}}
<li>{{reference}}</li>
{{/each}}
</ul>
</div>
</div>
{{/if}}
{{#if model.in_reply_to}}
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.in_reply_to"}}</label>
<div class="controls">
{{model.in_reply_to}}
</div>
</div>
{{/if}}
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.date"}}</label>
<div class="controls">
{{date}}
</div>
</div>
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.from"}}</label>
<div class="controls">
{{model.from}}
</div>
</div>
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.to"}}</label>
<div class="controls">
<ul>
{{#each to in model.to}}
<li>{{to}}</li>
{{/each}}
</ul>
</div>
</div>
{{#if model.cc}}
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.cc"}}</label>
<div class="controls">
{{model.cc}}
</div>
</div>
{{/if}}
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.subject"}}</label>
<div class="controls">
{{model.subject}}
</div>
</div>
<div class="control-group">
<label>{{i18n "admin.email.incoming_emails.modal.body"}}</label>
<div class="controls">
{{textarea value=model.body}}
</div>
</div>

View File

@ -0,0 +1,7 @@
import ModalBodyView from "discourse/views/modal-body";
export default ModalBodyView.extend({
templateName: 'admin/templates/modal/admin_incoming_email',
classNames: ['incoming-emails'],
title: I18n.t('admin.email.incoming_emails.modal.title')
});

View File

@ -1,4 +1,3 @@
import RestAdapter from 'discourse/adapters/rest';
import StaleLocalStorage from 'discourse/mixins/stale-local-storage';
export default RestAdapter.extend(StaleLocalStorage);
export default RestAdapter.extend({cache: true});

View File

@ -1,6 +1,8 @@
import StaleResult from 'discourse/lib/stale-result';
import { hashString } from 'discourse/lib/hash';
const ADMIN_MODELS = ['plugin', 'site-customization', 'embeddable-host'];
export function Result(payload, responseJson) {
this.payload = payload;
this.responseJson = responseJson;
@ -19,6 +21,15 @@ function rethrow(error) {
export default Ember.Object.extend({
storageKey(type, findArgs, options) {
if (options && options.cacheKey) {
return options.cacheKey;
}
const hashedArgs = Math.abs(hashString(JSON.stringify(findArgs)));
return `${type}_${hashedArgs}`;
},
basePath(store, type) {
if (ADMIN_MODELS.indexOf(type.replace('_', '-')) !== -1) { return "/admin/"; }
return "/";
@ -56,8 +67,15 @@ export default Ember.Object.extend({
return ajax(this.pathFor(store, type, findArgs)).catch(rethrow);
},
findStale() {
return new StaleResult();
findStale(store, type, findArgs, options) {
if (this.cached) {
return this.cached[this.storageKey(type, findArgs, options)];
}
},
cacheFind(store, type, findArgs, opts, hydrated) {
this.cached = this.cached || {};
this.cached[this.storageKey(type,findArgs,opts)] = hydrated;
},
update(store, type, id, attrs) {

View File

@ -5,7 +5,7 @@ import { linkSeenCategoryHashtags, fetchUnseenCategoryHashtags } from 'discourse
export default Ember.Component.extend({
classNames: ['wmd-controls'],
classNameBindings: [':wmd-controls', 'showPreview', 'showPreview::hide-preview'],
classNameBindings: ['showToolbar:toolbar-visible', ':wmd-controls', 'showPreview', 'showPreview::hide-preview'],
uploadProgress: 0,
showPreview: true,
@ -343,12 +343,34 @@ export default Ember.Component.extend({
},
showOptions() {
// long term we want some smart positioning algorithm in popup-menu
// the problem is that positioning in a fixed panel is a nightmare
// cause offsetParent can end up returning a fixed element and then
// using offset() is not going to work, so you end up needing special logic
// especially since we allow for negative .top, provided there is room on screen
const myPos = this.$().position();
const buttonPos = this.$('.options').position();
const popupHeight = $('#reply-control .popup-menu').height();
const popupWidth = $('#reply-control .popup-menu').width();
var top = myPos.top + buttonPos.top - 15;
var left = myPos.left + buttonPos.left - (popupWidth/2);
const composerPos = $('#reply-control').position();
if (composerPos.top + top - popupHeight < 0) {
top = top + popupHeight + this.$('.options').height() + 50;
}
var replyWidth = $('#reply-control').width();
if (left + popupWidth > replyWidth) {
left = replyWidth - popupWidth - 40;
}
this.sendAction('showOptions', { position: "absolute",
left: myPos.left + buttonPos.left,
top: myPos.top + buttonPos.top });
left: left,
top: top });
},
showUploadModal(toolbarEvent) {

View File

@ -31,7 +31,7 @@ function Toolbar() {
this.groups = [
{group: 'fontStyles', buttons: []},
{group: 'insertions', buttons: []},
{group: 'extras', buttons: [], lastGroup: true}
{group: 'extras', buttons: []}
];
this.addButton({
@ -105,6 +105,20 @@ function Toolbar() {
title: 'composer.hr_title',
perform: e => e.addText("\n\n----------\n")
});
if (Discourse.Mobile.mobileView) {
this.groups.push({group: 'mobileExtras', buttons: []});
this.addButton({
id: 'preview',
group: 'mobileExtras',
icon: 'television',
title: 'composer.hr_preview',
perform: e => e.preview()
});
}
this.groups[this.groups.length-1].lastGroup = true;
};
Toolbar.prototype.addButton = function(button) {
@ -166,6 +180,7 @@ export function onToolbarCreate(func) {
export default Ember.Component.extend({
classNames: ['d-editor'],
ready: false,
forcePreview: false,
insertLinkHidden: true,
link: '',
lastSel: null,
@ -446,6 +461,10 @@ export default Ember.Component.extend({
Ember.run.scheduleOnce("afterRender", () => this.$("textarea.d-editor-input").focus());
},
_togglePreview() {
this.toggleProperty('forcePreview');
},
actions: {
toolbarButton(button) {
const selected = this._getSelected();
@ -453,7 +472,8 @@ export default Ember.Component.extend({
selected,
applySurround: (head, tail, exampleKey) => this._applySurround(selected, head, tail, exampleKey),
applyList: (head, exampleKey) => this._applyList(selected, head, exampleKey),
addText: text => this._addText(selected, text)
addText: text => this._addText(selected, text),
preview: () => this._togglePreview()
};
if (button.sendAction) {
@ -463,6 +483,10 @@ export default Ember.Component.extend({
}
},
hidePreview() {
this.set('forcePreview', false);
},
showLinkModal() {
this._lastSel = this._getSelected();
this.set('insertLinkHidden', false);

View File

@ -17,6 +17,14 @@ export default Em.Component.extend({
format: "YYYY-MM-DD",
defaultDate: moment().add(1, "day").toDate(),
minDate: new Date(),
firstDay: moment.localeData().firstDayOfWeek(),
i18n: {
previousMonth: I18n.t('dates.previous_month'),
nextMonth: I18n.t('dates.next_month'),
months: moment.months(),
weekdays: moment.weekdays(),
weekdaysShort: moment.weekdaysShort()
},
onSelect: date => this.set("value", moment(date).format("YYYY-MM-DD"))
};

View File

@ -0,0 +1,54 @@
import { on, observes } from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
@on('init')
_init() {
if (!this.get('site.mobileView')) {
var classes = this.get('desktopClass');
if (classes) {
classes = classes.split(' ');
this.set('classNames', classes);
}
}
},
tagName: 'ul',
classNames: ['mobile-nav'],
@observes('currentPath')
currentPathChanged() {
this.set('expanded', false);
Em.run.next(() => this._updateSelectedHtml());
},
_updateSelectedHtml(){
const active = this.$('.active');
if (active && active.html) {
this.set('selectedHtml', active.html());
}
},
didInsertElement() {
this._updateSelectedHtml();
},
@on('didInsertElement')
_bindClick() {
this.$().on("click.mobile-nav", 'ul li', () => {
this.set('expanded', false);
});
},
@on('willDestroyElement')
_unbindClick() {
this.$().off("click.mobile-nav", 'ul li');
},
actions: {
toggleExpanded(){
this.toggleProperty('expanded');
}
}
});

View File

@ -61,12 +61,18 @@ export default Ember.Component.extend({
_markRead: function(){
this.$('a').click(() => {
this.set('notification.read', true);
Discourse.setTransientHeader("Discourse-Clear-Notifications", this.get('notification.id'));
if (document && document.cookie) {
document.cookie = `cn=${this.get('notification.id')}; expires=Fri, 31 Dec 9999 23:59:59 GMT`;
}
return true;
});
}.on('didInsertElement'),
render(buffer) {
const notification = this.get('notification');
// since we are reusing views now sometimes this can be unset
if (!notification) { return; }
const description = this.get('description');
const username = notification.get('data.display_username');
var text;

View File

@ -54,7 +54,7 @@ export default Ember.Component.extend({
// TODO: It's a bit odd to use the store in a component, but this one really
// wants to reach out and grab notifications
const store = this.container.lookup('store:main');
const stale = store.findStale('notification', {recent: true, limit }, {storageKey: 'recent-notifications'});
const stale = store.findStale('notification', {recent: true, limit }, {cacheKey: 'recent-notifications'});
if (stale.hasResults) {
const results = stale.results;

View File

@ -54,12 +54,10 @@ export default Ember.Controller.extend({
similarTopicsMessage: null,
lastSimilaritySearch: null,
optionsVisible: false,
lastValidatedAt: null,
isUploading: false,
topic: null,
showToolbar: false,
_initializeSimilar: function() {
this.set('similarTopics', []);
@ -90,6 +88,10 @@ export default Ember.Controller.extend({
this.toggleProperty('model.whisper');
},
toggleToolbar() {
this.toggleProperty('showToolbar');
},
showOptions(loc) {
this.appEvents.trigger('popup-menu:open', loc);
this.set('optionsVisible', true);

View File

@ -141,8 +141,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
fetchUserDetails() {
if (Discourse.User.currentProp('staff') && this.get('model.username')) {
const AdminUser = require('admin/models/admin-user').default;
AdminUser.find(this.get('model.username').toLowerCase())
.then(user => this.set('userDetails', user));
AdminUser.find(this.get('model.id')).then(user => this.set('userDetails', user));
}
}

View File

@ -21,13 +21,7 @@ const HeaderController = Ember.Controller.extend({
actions: {
toggleSearch() {
// there may be a cleaner way, but this is so trivial code wise
const $fullpageSearch = $('input.full-page-search');
if ($fullpageSearch.length === 1) {
$fullpageSearch.focus().select();
} else {
this.toggleProperty('searchVisible');
}
this.toggleProperty('searchVisible');
},
showUserMenu() {
if (!this.get('userMenuVisible')) {

View File

@ -69,6 +69,9 @@ export default Ember.Controller.extend(ModalFunctionality, {
sentTo: result.sent_to_email,
currentEmail: result.current_email
});
} else if (result.reason === 'suspended' ) {
self.send("closeModal");
bootbox.alert(result.error);
} else {
self.flash(result.error, 'error');
}

View File

@ -62,6 +62,12 @@ export default Ember.Controller.extend(CanCheckEmails, {
return this.siteSettings.available_locales.split('|').map(s => ({ name: s, value: s }));
},
previousRepliesOptions: [
{name: I18n.t('user.email_previous_replies.always'), value: 0},
{name: I18n.t('user.email_previous_replies.unless_emailed'), value: 1},
{name: I18n.t('user.email_previous_replies.never'), value: 2}
],
digestFrequencies: [{ name: I18n.t('user.email_digests.daily'), value: 1 },
{ name: I18n.t('user.email_digests.every_three_days'), value: 3 },
{ name: I18n.t('user.email_digests.weekly'), value: 7 },

View File

@ -632,6 +632,14 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}
return;
}
case "move_to_inbox": {
topic.set("message_archived",false);
return;
}
case "archived": {
topic.set("message_archived",true);
return;
}
default: {
Em.Logger.warn("unknown topic bus message type", data);
}

View File

@ -3,7 +3,7 @@ import { exportUserArchive } from 'discourse/lib/export-csv';
export default Ember.Controller.extend({
userActionType: null,
needs: ["application", "user"],
currentPath: Em.computed.alias('controllers.application.currentPath'),
viewingSelf: Em.computed.alias("controllers.user.viewingSelf"),
showBookmarks: Em.computed.alias("controllers.user.showBookmarks"),

View File

@ -7,6 +7,8 @@ export default Ember.ArrayController.extend({
showDismissButton: Ember.computed.gt('user.total_unread_notifications', 0),
currentPath: Em.computed.alias('controllers.application.currentPath'),
actions: {
resetNew: function() {
Discourse.ajax('/notifications/mark-read', { method: 'PUT' }).then(() => {

View File

@ -3,11 +3,11 @@ import Topic from 'discourse/models/topic';
export default Ember.Controller.extend({
needs: ["user-topics-list", "user"],
needs: ["application", "user-topics-list", "user"],
pmView: false,
viewingSelf: Em.computed.alias("controllers.user.viewingSelf"),
viewingSelf: Em.computed.alias('controllers.user.viewingSelf'),
isGroup: Em.computed.equal('pmView', 'groups'),
currentPath: Em.computed.alias('controllers.application.currentPath'),
selected: Em.computed.alias('controllers.user-topics-list.selected'),
bulkSelectEnabled: Em.computed.alias('controllers.user-topics-list.bulkSelectEnabled'),

View File

@ -6,7 +6,8 @@ import User from 'discourse/models/user';
export default Ember.Controller.extend(CanCheckEmails, {
indexStream: false,
userActionType: null,
needs: ['user-notifications', 'user-topics-list'],
needs: ['application','user-notifications', 'user-topics-list'],
currentPath: Em.computed.alias('controllers.application.currentPath'),
@computed("content.username")
viewingSelf(username) {
@ -84,8 +85,7 @@ export default Ember.Controller.extend(CanCheckEmails, {
adminDelete() {
// I really want this deferred, don't want to bring in all this code till used
const AdminUser = require('admin/models/admin-user').default;
AdminUser.find(this.get('model.username').toLowerCase())
.then(user => user.destroy({deletePosts: true}));
AdminUser.find(this.get('model.id')).then(user => user.destroy({deletePosts: true}));
},
}

View File

@ -9,10 +9,11 @@ export default {
site = container.lookup('site:main'),
siteSettings = container.lookup('site-settings:main'),
bus = container.lookup('message-bus:main'),
keyValueStore = container.lookup('key-value-store:main');
keyValueStore = container.lookup('key-value-store:main'),
store = container.lookup('store:main');
// clear old cached notifications
// they could be a week old for all we know
// clear old cached notifications, we used to store in local storage
// TODO 2017 delete this line
keyValueStore.remove('recent-notifications');
if (user) {
@ -40,29 +41,43 @@ export default {
user.set('lastNotificationChange', new Date());
}
var stale = keyValueStore.getObject('recent-notifications');
const stale = store.findStale('notification', {}, {cacheKey: 'recent-notifications'});
const lastNotification = data.last_notification && data.last_notification.notification;
if (stale && stale.notifications && lastNotification) {
if (stale && stale.hasResults && lastNotification) {
const oldNotifications = stale.notifications;
const oldNotifications = stale.results.get('content');
const staleIndex = _.findIndex(oldNotifications, {id: lastNotification.id});
if (staleIndex > -1) {
oldNotifications.splice(staleIndex, 1);
if (staleIndex === -1) {
// this gets a bit tricky, uread pms are bumped to front
var insertPosition = 0;
if (lastNotification.notification_type !== 6) {
insertPosition = _.findIndex(oldNotifications, function(n){
return n.notification_type !== 6 || n.read;
});
insertPosition = insertPosition === -1 ? oldNotifications.length - 1 : insertPosition;
}
oldNotifications.insertAt(insertPosition, Em.Object.create(lastNotification));
}
// this gets a bit tricky, uread pms are bumped to front
var insertPosition = 0;
if (lastNotification.notification_type !== 6) {
insertPosition = _.findIndex(oldNotifications, function(n){
return n.notification_type !== 6 || n.read;
});
insertPosition = insertPosition === -1 ? oldNotifications.length - 1 : insertPosition;
}
for (var idx=0; idx < data.recent.length; idx++) {
var old;
while(old = oldNotifications[idx]) {
var info = data.recent[idx];
oldNotifications.splice(insertPosition, 0, lastNotification);
keyValueStore.setItem('recent-notifications', JSON.stringify(stale));
if (old.get('id') !== info[0]) {
oldNotifications.removeAt(idx);
} else {
if (old.get('read') !== info[1]) {
old.set('read', info[1]);
}
break;
}
}
if ( !old ) { break; }
}
}
}, user.notification_channel_position);

View File

@ -47,7 +47,8 @@ export default function(options) {
$(this).off('keypress.autocomplete')
.off('keydown.autocomplete')
.off('paste.autocomplete');
.off('paste.autocomplete')
.off('click.autocomplete');
return;
}
@ -241,6 +242,15 @@ export default function(options) {
});
};
const dataSource = (term, opts) => {
if (term.length !== 0 && term.trim().length === 0) {
closeAutocomplete();
return null;
} else {
return opts.dataSource(term);
}
};
var updateAutoComplete = function(r) {
if (completeStart === null) return;
@ -276,6 +286,10 @@ export default function(options) {
closeAutocomplete();
});
$(this).on('click.autocomplete', function() {
closeAutocomplete();
});
$(this).on('paste.autocomplete', function() {
_.delay(function(){
me.trigger("keydown");
@ -299,13 +313,13 @@ export default function(options) {
var prevChar = me.val().charAt(caretPosition - 1);
if (checkTriggerRule() && (!prevChar || allowedLettersRegex.test(prevChar))) {
completeStart = completeEnd = caretPosition;
updateAutoComplete(options.dataSource(""));
updateAutoComplete(dataSource("", options));
}
} else if ((completeStart !== null) && (e.charCode !== 0)) {
caretPosition = Discourse.Utilities.caretPosition(me[0]);
term = me.val().substring(completeStart + (options.key ? 1 : 0), caretPosition);
term += String.fromCharCode(e.charCode);
updateAutoComplete(options.dataSource(term));
updateAutoComplete(dataSource(term, options));
}
});
@ -355,7 +369,7 @@ export default function(options) {
completeStart = c;
caretPosition = completeEnd = initial;
term = me[0].value.substring(c + 1, initial);
updateAutoComplete(options.dataSource(term));
updateAutoComplete(dataSource(term, options));
return true;
}
}
@ -375,16 +389,21 @@ export default function(options) {
if (completeStart !== null) {
caretPosition = Discourse.Utilities.caretPosition(me[0]);
// allow people to right arrow out of completion
if (e.which === keys.rightArrow && me[0].value[caretPosition] === ' ') {
closeAutocomplete();
return true;
}
// If we've backspaced past the beginning, cancel unless no key
if (caretPosition <= completeStart && options.key) {
closeAutocomplete();
return false;
return true;
}
// Keyboard codes! So 80's.
switch (e.which) {
case keys.enter:
case keys.rightArrow:
case keys.tab:
if (!autocompleteOptions) return true;
if (selectedOption >= 0 && (userToComplete = autocompleteOptions[selectedOption])) {
@ -430,7 +449,11 @@ export default function(options) {
term = me.val().substring(completeStart + (options.key ? 1 : 0), caretPosition);
updateAutoComplete(options.dataSource(term));
if ((completeStart === caretPosition) && (term === options.key)) {
closeAutocomplete();
}
updateAutoComplete(dataSource(term, options));
return true;
default:
completeEnd = caretPosition;

View File

@ -89,11 +89,15 @@ function positioningWorkaround($fixedElement) {
}
const checkForInputs = _.debounce(function(){
$fixedElement.find('button,a:not(.mobile-file-upload)').each(function(idx, elem){
$fixedElement.find('button:not(.hide-preview),a:not(.mobile-file-upload):not(.toggle-toolbar)').each(function(idx, elem){
if ($(elem).parents('.autocomplete').length > 0) {
return;
}
if ($(elem).parents('.d-editor-button-bar').length > 0) {
return;
}
attachTouchStart(this, function(evt){
done = true;
$(document.activeElement).blur();

View File

@ -1,12 +0,0 @@
const StaleResult = function() {
this.hasResults = false;
};
StaleResult.prototype.setResults = function(results) {
if (results) {
this.results = results;
this.hasResults = true;
}
};
export default StaleResult;

View File

@ -3,9 +3,14 @@
respect Discourse paths and the run loop.
**/
var _trackView = false;
var _transientHeader = null;
Discourse.Ajax = Em.Mixin.create({
setTransientHeader: function(k, v) {
_transientHeader = {key: k, value: v};
},
viewTrackingRequired: function() {
_trackView = true;
},
@ -43,6 +48,11 @@ Discourse.Ajax = Em.Mixin.create({
args.headers = args.headers || {};
if (_transientHeader) {
args.headers[_transientHeader.key] = _transientHeader.value;
_transientHeader = null;
}
if (_trackView && (!args.type || args.type === "GET")) {
_trackView = false;
// DON'T CHANGE: rack is prepending "HTTP_" in the header's name

View File

@ -1,34 +0,0 @@
import StaleResult from 'discourse/lib/stale-result';
import { hashString } from 'discourse/lib/hash';
// Mix this in to an adapter to provide stale caching in our key value store
export default {
storageKey(type, findArgs) {
const hashedArgs = Math.abs(hashString(JSON.stringify(findArgs)));
return `${type}_${hashedArgs}`;
},
findStale(store, type, findArgs, opts) {
const staleResult = new StaleResult();
const key = (opts && opts.storageKey) || this.storageKey(type, findArgs);
try {
const stored = this.keyValueStore.getItem(key);
if (stored) {
const parsed = JSON.parse(stored);
staleResult.setResults(parsed);
}
} catch(e) {
// JSON parsing error
}
return staleResult;
},
find(store, type, findArgs, opts) {
const key = (opts && opts.storageKey) || this.storageKey(type, findArgs);
return this._super(store, type, findArgs).then((results) => {
this.keyValueStore.setItem(key, JSON.stringify(results));
return results;
});
}
};

View File

@ -17,11 +17,11 @@ const Group = Discourse.Model.extend({
return this.get("automatic") ? "automatic" : "custom";
}.property("automatic"),
userCountDisplay: function(){
var c = this.get('user_count');
@computed('user_count')
userCountDisplay(userCount) {
// don't display zero its ugly
if (c > 0) { return c; }
}.property('user_count'),
if (userCount > 0) { return userCount; }
},
findMembers() {
if (Em.isEmpty(this.get('name'))) { return ; }

View File

@ -73,19 +73,43 @@ export default Ember.Object.extend({
// refresh it in the background.
findStale(type, findArgs, opts) {
const stale = this.adapterFor(type).findStale(this, type, findArgs, opts);
if (stale.hasResults) {
stale.results = this._hydrateFindResults(stale.results, type, findArgs);
}
stale.refresh = () => this.find(type, findArgs, opts);
return stale;
return {
hasResults: (stale !== undefined),
results: stale,
refresh: () => this.find(type, findArgs, opts)
};
},
find(type, findArgs, opts) {
return this.adapterFor(type).find(this, type, findArgs, opts).then(result => {
return this._hydrateFindResults(result, type, findArgs, opts);
var adapter = this.adapterFor(type);
return adapter.find(this, type, findArgs, opts).then(result => {
var hydrated = this._hydrateFindResults(result, type, findArgs, opts);
if (adapter.cache) {
const stale = adapter.findStale(this, type, findArgs, opts);
hydrated = this._updateStale(stale, hydrated);
adapter.cacheFind(this, type, findArgs, opts, hydrated);
}
return hydrated;
});
},
_updateStale(stale, hydrated) {
if (!stale) {
return hydrated;
}
hydrated.set('content', hydrated.get('content').map((item) => {
var staleItem = stale.content.findBy('id', item.get('id'));
if (staleItem) {
staleItem.setProperties(item);
} else {
staleItem = item;
}
return staleItem;
}));
return hydrated;
},
refreshResults(resultSet, type, url) {
const self = this;
return Discourse.ajax(url).then(result => {

View File

@ -90,7 +90,7 @@ const User = RestModel.extend({
},
adminPath: url('username_lower', "/admin/users/%@"),
adminPath: url('id', 'username_lower', "/admin/users/%@1/%@2"),
mutedTopicsPath: url('/latest?state=muted'),
@ -141,31 +141,36 @@ const User = RestModel.extend({
save() {
const data = this.getProperties(
'auto_track_topics_after_msecs',
'bio_raw',
'website',
'location',
'name',
'locale',
'email_digests',
'email_direct',
'email_always',
'email_private_messages',
'dynamic_favicon',
'digest_after_days',
'new_topic_duration_minutes',
'external_links_in_new_tab',
'mailing_list_mode',
'enable_quoting',
'disable_jump_reply',
'custom_fields',
'user_fields',
'muted_usernames',
'profile_background',
'card_background',
'automatically_unpin_topics'
'card_background'
);
[ 'email_always',
'mailing_list_mode',
'external_links_in_new_tab',
'email_digests',
'email_direct',
'email_private_messages',
'email_previous_replies',
'dynamic_favicon',
'enable_quoting',
'disable_jump_reply',
'automatically_unpin_topics',
'digest_after_days',
'new_topic_duration_minutes',
'auto_track_topics_after_msecs'
].forEach(s => {
data[s] = this.get(`user_option.${s}`);
});
['muted','watched','tracked'].forEach(s => {
let cats = this.get(s + 'Categories').map(c => c.get('id'));
// HACK: denote lack of categories
@ -174,7 +179,7 @@ const User = RestModel.extend({
});
if (!Discourse.SiteSettings.edit_history_visible_to_public) {
data['edit_history_public'] = this.get('edit_history_public');
data['edit_history_public'] = this.get('user_option.edit_history_public');
}
// TODO: We can remove this when migrated fully to rest model.
@ -184,7 +189,7 @@ const User = RestModel.extend({
type: 'PUT'
}).then(result => {
this.set('bio_excerpt', result.user.bio_excerpt);
const userProps = this.getProperties('enable_quoting', 'external_links_in_new_tab', 'dynamic_favicon');
const userProps = Em.getProperties(this.get('user_option'),'enable_quoting', 'external_links_in_new_tab', 'dynamic_favicon');
Discourse.User.current().setProperties(userProps);
}).finally(() => {
this.set('isSaving', false);

View File

@ -53,9 +53,18 @@ const ApplicationRoute = Discourse.Route.extend(OpenComposer, {
},
composePrivateMessage(user, post) {
const self = this;
this.transitionTo('userActivity', user).then(function () {
self.controllerFor('user-activity').send('composePrivateMessage', user, post);
const recipient = user ? user.get('username') : '',
reply = post ? window.location.protocol + "//" + window.location.host + post.get("url") : null;
// used only once, one less dependency
const Composer = require('discourse/models/composer').default;
return this.controllerFor('composer').open({
action: Composer.PRIVATE_MESSAGE,
usernames: recipient,
archetypeId: 'private_message',
draftKey: 'new_private_message',
reply: reply
});
},

View File

@ -1,4 +1,5 @@
import { translateResults, getSearchKey, isValidSearchTerm } from "discourse/lib/search";
import Composer from 'discourse/models/composer';
export default Discourse.Route.extend({
queryParams: { q: {}, context_id: {}, context: {}, skip_context: {} },
@ -39,6 +40,17 @@ export default Discourse.Route.extend({
didTransition() {
this.controllerFor("full-page-search")._showFooter();
return true;
},
createTopic(searchTerm) {
let category;
if (searchTerm.indexOf("category:")) {
const match = searchTerm.match(/category:(\S*)/);
if (match && match[1]) {
category = match[1];
}
}
this.container.lookup('controller:composer').open({action: Composer.CREATE_TOPIC, draftKey: Composer.CREATE_TOPIC, topicCategory: category});
}
}

View File

@ -11,21 +11,6 @@ export default Discourse.Route.extend({
},
actions: {
composePrivateMessage(user, post) {
const recipient = user ? user.get('username') : '',
reply = post ? window.location.protocol + "//" + window.location.host + post.get("url") : null;
// used only once, one less dependency
const Composer = require('discourse/models/composer').default;
return this.controllerFor('composer').open({
action: Composer.PRIVATE_MESSAGE,
usernames: recipient,
archetypeId: 'private_message',
draftKey: 'new_private_message',
reply: reply
});
},
willTransition(transition) {
// will reset the indexStream when transitioning to routes that aren't "indexStream"
// otherwise the "header" will jump

View File

@ -25,9 +25,12 @@
{{popup-input-tip validation=validation}}
</div>
<div class="d-editor-preview-wrapper">
<div class="d-editor-preview-wrapper {{if forcePreview 'force-preview'}}">
<div class="d-editor-preview">
{{{preview}}}
</div>
{{#if site.mobileView}}
{{d-button action='hidePreview' class='hide-preview' label='composer.hide_preview'}}
{{/if}}
</div>
</div>

View File

@ -0,0 +1 @@
{{yield}}

View File

@ -3,9 +3,9 @@
<button class='btn btn-primary' {{action "toggleSummary"}}>{{i18n 'summary.disable'}}</button>
{{else}}
{{#if topic.estimatedReadingTime}}
<p>{{{i18n 'summary.description_time' count=topic.posts_count readingTime=topic.estimatedReadingTime}}}</p>
<p>{{{i18n 'summary.description_time' replyCount=topic.replyCount readingTime=topic.estimatedReadingTime}}}</p>
{{else}}
<p>{{{i18n 'summary.description' count=topic.posts_count}}}</p>
<p>{{{i18n 'summary.description' replyCount=topic.replyCount}}}</p>
{{/if}}
<button class='btn btn-primary' {{action "toggleSummary"}}>{{i18n 'summary.enable'}}</button>

View File

@ -11,6 +11,10 @@
{{render "composer-messages"}}
<div class='control'>
{{#if site.mobileView}}
<a href class='toggle-toolbar' {{action "toggleToolbar" bubbles=false}}></a>
{{/if}}
<a href class='toggler' {{action "toggle" bubbles=false}} title={{i18n 'composer.toggler'}}></a>
{{#if model.viewOpen}}
@ -20,9 +24,11 @@
<div class='reply-to'>
{{{model.actionTitle}}}
{{#unless site.mobileView}}
{{#if model.whisper}}
<span class='whisper'>({{i18n "composer.whisper"}})</span>
{{/if}}
{{/unless}}
{{#if canEdit}}
{{#if showEditReason}}
@ -85,6 +91,7 @@
groupsMentioned="groupsMentioned"
importQuote="importQuote"
showOptions="showOptions"
showToolbar=showToolbar
showUploadSelector="showUploadSelector"}}
{{#if currentUser}}
@ -92,6 +99,12 @@
{{plugin-outlet "composer-fields-below"}}
<button {{action "save"}} tabindex="5" {{bind-attr class=":btn :btn-primary :create disableSubmit:disabled"}} title="{{i18n 'composer.title'}}">{{{model.saveIcon}}}{{model.saveText}}</button>
<a href {{action "cancel"}} class='cancel' tabindex="6">{{i18n 'cancel'}}</a>
{{#if site.mobileView}}
{{#if model.whisper}}
<span class='whisper'><i class='fa fa-eye-slash'></i></span>
{{/if}}
{{/if}}
</div>
{{/if}}
</div>

View File

@ -1,6 +1,11 @@
<div class="search row clearfix">
{{search-text-field value=searchTerm class="full-page-search input-xxlarge search no-blur" action="search" hasAutofocus=hasAutofocus}}
{{d-button action="search" icon="search" class="btn-primary" disabled=searchButtonDisabled}}
{{#if currentUser}}
{{#unless site.mobileView}}
<span class="new-topic-btn">{{d-button id="create-topic" class="btn-default" action="createTopic" actionParam=searchTerm icon="plus" label="topic.create"}}</span>
{{/unless}}
{{/if}}
{{#if canBulkSelect}}
{{#if model.posts}}
{{d-button icon="list" class="bulk-select" title="topics.bulk.toggle" action="toggleBulkSelect"}}

View File

@ -0,0 +1,4 @@
<li><a {{action 'toggleExpanded'}}>{{{selectedHtml}}} <i class='fa fa-caret-down'></i></a></li>
<ul class='drop {{if expanded 'expanded'}}'>
{{yield}}
</ul>

View File

@ -110,7 +110,7 @@
{{#if model.details.suggested_topics.length}}
<div id="suggested-topics">
<h3>{{unbound view.suggestedTitle}}</h3>
<h3>{{{view.suggestedTitle}}}</h3>
<div class="topics">
{{#if model.isPrivateMessage}}
{{basic-topic-list

View File

@ -1,26 +1,21 @@
<section class='user-navigation'>
<ul class='action-list activity-list nav-stacked'>
{{#mobile-nav class='activity-nav' desktopClass='action-list activity-list nav-stacked' currentPath=currentPath}}
<li class='no-glyph'>
{{#link-to 'userActivity.index'}}{{i18n 'user.filters.all'}}{{/link-to}}
</li>
<li class='no-glyph'>
{{#link-to 'userActivity.topics'}}{{i18n 'user_action_groups.4'}}{{/link-to}}
</li>
<li>
{{#link-to 'userActivity.replies'}}
<i class="glyph fa fa-reply"></i>{{i18n 'user_action_groups.5'}}
{{/link-to}}
</li>
<li>
{{#link-to 'userActivity.likesGiven'}}
<i class="glyph fa fa-heart"></i>{{i18n 'user_action_groups.1'}}
{{/link-to}}
</li>
{{#if showBookmarks}}
<li>
{{#link-to 'userActivity.bookmarks'}}
@ -28,7 +23,7 @@
{{/link-to}}
</li>
{{/if}}
</ul>
{{/mobile-nav}}
{{#if viewingSelf}}
<div class='user-archive'>

View File

@ -4,7 +4,8 @@
{{d-button class="btn-primary new-private-message" action="composePrivateMessage" icon="envelope" label="user.new_private_message"}}
{{/if}}
{{/unless}}
<ul class='action-list nav-stacked'>
{{#mobile-nav class='messages-nav' desktopClass='nav-stacked action-list' currentPath=currentPath}}
<li class="noGlyph">
{{#link-to 'userPrivateMessages.index' model}}
{{i18n 'user.messages.inbox'}}
@ -35,7 +36,7 @@
</li>
{{/if}}
{{/each}}
</ul>
{{/mobile-nav}}
</section>

View File

@ -1,6 +1,6 @@
<section class='user-navigation'>
<ul class='notification-list action-list nav-stacked'>
{{#mobile-nav class='notifications-nav' desktopClass='notification-list action-list nav-stacked' currentPath=currentPath}}
{{#if model}}
<li class='no-glyph'>
{{#link-to 'userNotifications.index'}}{{i18n 'user.filters.all'}}{{/link-to}}
@ -19,7 +19,7 @@
</li>
<li>{{#link-to 'userNotifications.mentions'}}<i class="glyph fa fa-at"></i>{{i18n 'user_action_groups.7'}}{{/link-to}}</li>
<li>{{#link-to 'userNotifications.edits'}}<i class="glyph fa fa-pencil"></i>{{i18n 'user_action_groups.11'}}{{/link-to}}</li>
</ul>
{{/mobile-nav}}
</section>
<section class='user-right'>

View File

@ -28,7 +28,7 @@
{{#if model.can_edit_name}}
{{text-field value=newNameInput classNames="input-xxlarge"}}
{{else}}
<span class='static'>{{name}}</span>
<span class='static'>{{model.name}}</span>
{{/if}}
</div>
<div class='instructions'>
@ -169,17 +169,21 @@
<div class="control-group pref-email-settings">
<label class="control-label">{{i18n 'user.email_settings'}}</label>
{{#if canReceiveDigest}}
{{preference-checkbox labelKey="user.email_digests.title" checked=model.email_digests}}
{{#if model.email_digests}}
{{preference-checkbox labelKey="user.email_digests.title" checked=model.user_option.email_digests}}
{{#if model.user_option.email_digests}}
<div class='controls controls-dropdown'>
{{combo-box valueAttribute="value" content=digestFrequencies value=model.digest_after_days}}
{{combo-box valueAttribute="value" content=digestFrequencies value=model.user_option.digest_after_days}}
</div>
{{/if}}
{{/if}}
{{preference-checkbox labelKey="user.email_private_messages" checked=model.email_private_messages}}
{{preference-checkbox labelKey="user.email_direct" checked=model.email_direct}}
<span class="pref-mailing-list-mode">{{preference-checkbox labelKey="user.mailing_list_mode" checked=model.mailing_list_mode}}</span>
{{preference-checkbox labelKey="user.email_always" checked=model.email_always}}
<div class='controls controls-dropdown'>
<label>{{i18n 'user.email_previous_replies.title'}}</label>
{{combo-box valueAttribute="value" content=previousRepliesOptions value=model.user_option.email_previous_replies}}
</div>
{{preference-checkbox labelKey="user.email_private_messages" checked=model.user_option.email_private_messages}}
{{preference-checkbox labelKey="user.email_direct" checked=model.user_option.email_direct}}
<span class="pref-mailing-list-mode">{{preference-checkbox labelKey="user.mailing_list_mode" checked=model.user_option.mailing_list_mode}}</span>
{{preference-checkbox labelKey="user.email_always" checked=model.user_option.email_always}}
<div class='instructions'>
{{#if siteSettings.email_time_window_mins}}
@ -188,6 +192,9 @@
{{i18n 'user.email.frequency_immediately'}}
{{/if}}
</div>
</div>
<div class="control-group notifications">
@ -201,20 +208,20 @@
<div class="controls controls-dropdown">
<label>{{i18n 'user.new_topic_duration.label'}}</label>
{{combo-box valueAttribute="value" content=considerNewTopicOptions value=model.new_topic_duration_minutes}}
{{combo-box valueAttribute="value" content=considerNewTopicOptions value=model.user_option.new_topic_duration_minutes}}
</div>
<div class="controls controls-dropdown">
<label>{{i18n 'user.auto_track_topics'}}</label>
{{combo-box valueAttribute="value" content=autoTrackDurations value=model.auto_track_topics_after_msecs}}
{{combo-box valueAttribute="value" content=autoTrackDurations value=model.user_option.auto_track_topics_after_msecs}}
</div>
{{preference-checkbox labelKey="user.external_links_in_new_tab" checked=model.external_links_in_new_tab}}
{{preference-checkbox labelKey="user.enable_quoting" checked=model.enable_quoting}}
{{preference-checkbox labelKey="user.dynamic_favicon" checked=model.dynamic_favicon}}
{{preference-checkbox labelKey="user.disable_jump_reply" checked=model.disable_jump_reply}}
{{preference-checkbox labelKey="user.external_links_in_new_tab" checked=model.user_option.external_links_in_new_tab}}
{{preference-checkbox labelKey="user.enable_quoting" checked=model.user_option.enable_quoting}}
{{preference-checkbox labelKey="user.dynamic_favicon" checked=model.user_option.dynamic_favicon}}
{{preference-checkbox labelKey="user.disable_jump_reply" checked=model.user_option.disable_jump_reply}}
{{#unless siteSettings.edit_history_visible_to_public}}
{{preference-checkbox labelKey="user.edit_history_public" checked=model.edit_history_public}}
{{preference-checkbox labelKey="user.edit_history_public" checked=model.user_option.edit_history_public}}
{{/unless}}
{{plugin-outlet "user-custom-preferences"}}
@ -254,7 +261,7 @@
{{#if siteSettings.automatically_unpin_topics}}
<div class="control-group topics">
<label class="control-label">{{i18n 'categories.topics'}}</label>
{{preference-checkbox labelKey="user.automatically_unpin_topics" checked=model.automatically_unpin_topics}}
{{preference-checkbox labelKey="user.automatically_unpin_topics" checked=model.user_option.automatically_unpin_topics}}
</div>
{{/if}}

View File

@ -148,13 +148,12 @@
{{/unless}}
</section>
<ul class="nav nav-pills user-nav">
{{#mobile-nav class='main-nav' desktopClass="nav nav-pills user-nav" currentPath=currentPath}}
<li>{{#link-to 'userActivity'}}{{i18n 'user.activity_stream'}}{{/link-to}}</li>
{{#if showNotificationsTab}}
<li>
{{#link-to 'userNotifications'}}
{{fa-icon "comment" class="glyph"}}
{{i18n 'user.notifications'}}
{{fa-icon "comment" class="glyph"}}{{i18n 'user.notifications'}}
{{/link-to}}
</li>
{{/if}}
@ -171,7 +170,7 @@
{{#if model.can_edit}}
<li>{{#link-to 'preferences'}}{{fa-icon "cog"}}{{i18n 'user.preferences'}}{{/link-to}}</li>
{{/if}}
</ul>
{{/mobile-nav}}
<div class='user-table'>
<div class='wrapper'>

View File

@ -186,10 +186,12 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, {
const $link = $(this),
href = $link.attr('href');
let valid = !lc.internal && href === lc.url;
let valid = href === lc.url;
// this might be an attachment
if (lc.internal) { valid = href.indexOf(lc.url) >= 0; }
if (lc.internal && /^\/uploads\//.test(lc.url)) {
valid = href.indexOf(lc.url) >= 0;
}
if (valid) {
// don't display badge counts on category badge & oneboxes (unless when explicitely stated)

View File

@ -167,9 +167,9 @@ const TopicView = Ember.View.extend(AddCategoryClass, AddArchetypeClass, Scrolli
suggestedTitle: function(){
return this.get('controller.model.isPrivateMessage') ?
I18n.t("suggested_topics.pm_title") :
"<i class='private-message-glyph fa fa-envelope'></i> " + I18n.t("suggested_topics.pm_title") :
I18n.t("suggested_topics.title");
}.property()
}.property('topic')
});
function highlight(postNumber) {

View File

@ -7,7 +7,6 @@
//= require ./ember-addons/macro-alias
//= require ./ember-addons/ember-computed-decorators
//= require ./discourse/lib/hash
//= require ./discourse/lib/stale-result
//= require ./discourse/lib/load-script
//= require ./discourse/lib/notification-levels
//= require ./discourse/lib/app-events

View File

@ -1798,6 +1798,42 @@ table#user-badges {
}
}
.incoming-emails {
.control-group {
margin: 8px 0;
}
.controls {
margin-left: 110px;
}
p {
margin: 5px 10px;
}
.error-description {
color: #919191;
font-size: 90%;
}
hr {
margin: 0;
}
label {
font-weight: bold;
float: left;
width: 100px;
text-align: right;
margin: 0 10px;
}
ul {
list-style: none;
margin: 0 10px;
}
textarea {
width: 95%;
height: 150px;
font-family: monospace;
box-shadow: none;
}
}
// Mobile specific styles
// Mobile view text-inputs need some padding
.mobile-view .admin-contents {

View File

@ -226,8 +226,11 @@ ol.category-breadcrumb {
}
.fa-thumb-tack.unpinned {
@include fa-icon-rotate(315deg, 1);
@include fa-icon-rotate(180deg, 1);
color: $primary;
/* because it is rotated, right becomes left! */
padding-left: 3px;
padding-right: 0 !important;
}
.topic-statuses .fa {

View File

@ -33,7 +33,6 @@ small {
blockquote {
@include post-aside;
overflow: hidden;
clear: both;
}
@ -219,3 +218,17 @@ body {
display: inline-block;
}
}
// don't wrap relative dates, we want
//
// Jul 26, '15
//
// not
//
// Jul
// 26,
// '15
//
span.relative-date {
white-space:nowrap;
}

View File

@ -74,6 +74,10 @@
height: 22px;
padding-left: 6px;
}
.new-topic-btn {
float:right;
}
}
.search-footer {

View File

@ -152,6 +152,10 @@ aside.quote {
}
.topic-body {
// this is necessary for ANYTHING that extends past the right edge of
// the post body, such as an image in a deeply nested list, image in
// a deeply nested blockquote, and so on.. you get the idea.
overflow: hidden;
&.highlighted {
background-color: dark-light-diff($tertiary, $secondary, 85%, -65%);
}
@ -172,7 +176,7 @@ aside.quote {
}
&.wiki {
cursor: pointer;
color: $wiki;
color: #408040;
}
&.via-email {
color: dark-light-choose(scale-color($primary, $lightness: 70%), scale-color($secondary, $lightness: 30%));

View File

@ -25,6 +25,7 @@
&.bar { //bar category style
line-height: 1.25;
margin-right: 5px;
display: inline-flex;
span.badge-category {
color: $primary !important;
@ -130,27 +131,9 @@
}
}
@mixin cooked-badge-bullet($length, $offset:0px) {
.badge-wrapper.bullet {
span {
position: relative;
&.badge-category-bg {
width: $length;
height: $length;
top: $offset;
}
&.badge-category-parent-bg {
width: $length / 2;
height: $length;
top: $offset;
& + .badge-category-bg {
width: $length / 2;
}
}
}
.autocomplete, td.category {
.badge-wrapper {
max-width: 230px;
}
}

View File

@ -8,4 +8,3 @@ $highlight: #ffff4d !default;
$danger: #e45735 !default;
$success: #009900 !default;
$love: #fa6c8d !default;
$wiki: #408040 !default;

View File

@ -706,6 +706,7 @@ $topic-avatar-width: 45px;
border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -75%);
padding: 12px $topic-body-width-padding 15px $topic-body-width-padding;
}
.topic-avatar {
border-top: 1px solid dark-light-diff($primary, $secondary, 90%, -75%);
padding-top: 15px;
@ -1038,16 +1039,3 @@ and (max-width : 767px) {
}
}
@media all
and (max-height: 700px) {
.post-menu-area {
margin-bottom: 0px;
margin-top: -18px;
}
.topic-body {
padding: 5px 11px 2px;
}
}

View File

@ -197,9 +197,9 @@
margin-bottom: 10px;
}
table {
.user-invite-list {
width: 100%;
margin-top: 10px;
margin-top: 15px;
th {
text-align: left;
@ -222,10 +222,6 @@
}
}
.user-invite-list {
margin-top: 15px;
}
.user-invite-controls {
background-color: dark-light-diff($primary, $secondary, 90%, -75%);
padding: 5px 10px 0px 0;
@ -306,6 +302,11 @@
background: dark-light-choose(rgba($primary, .85), rgba($secondary, .85));
transition: margin .15s linear;
blockquote {
background-color: dark-light-diff($secondary, $primary, 30%, -70%);
border-left-color: dark-light-diff($secondary, $primary, 50%, -50%);
}
h1 {
font-size: 2.143em;
font-weight: normal;

View File

@ -28,7 +28,6 @@ article.post {
margin: 0 0 10px 0;
background-color: dark-light-diff($primary, $secondary, 97%, -45%);
border-left: 5px solid darken(dark-light-diff($primary, $secondary, 97%, -45%), 10%);
overflow: hidden;
p {
margin: 0 0 10px 0;
}

View File

@ -36,7 +36,7 @@ input {
bottom: 0;
font-size: 1em;
position: fixed;
.toggler {
.toggle-toolbar, .toggler {
width: 15px;
right: 1px;
position: absolute;
@ -48,6 +48,14 @@ input {
content: "\f078";
}
}
.toggle-toolbar {
right: 30px;
&:before {
content: "\f0c9";
}
}
a.cancel {
padding-left: 7px;
line-height: 30px;
@ -56,7 +64,7 @@ input {
margin: 0 0 0 5px;
.reply-to {
overflow: hidden;
max-width: 92%;
max-width: 80%;
white-space: nowrap;
i {
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
@ -235,6 +243,31 @@ input {
.d-editor-preview-wrapper {
display: none;
}
.d-editor-preview-wrapper.force-preview {
display: block;
position: fixed;
z-index: 1000000;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: $secondary;
.d-editor-preview {
height: 90%;
height: calc(100% - 60px);
border: 0;
overflow: auto;
}
.hide-preview {
position: fixed;
right: 5px;
bottom: 5px;
z-index: 1000001;
}
}
.d-editor-input {
width: 100%;
height: 100%;
@ -260,6 +293,40 @@ input {
.d-editor-button-bar {
display: none;
}
.wmd-controls.toolbar-visible .d-editor-input {
padding-top: 40px;
}
.wmd-controls.toolbar-visible .d-editor-button-bar {
.btn.link, .btn.upload, .btn.rule, .btn.bullet, .btn.list, .btn.heading {
display: none;
}
display: block;
margin: 1px 4px;
position: absolute;
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
background-color: $secondary;
z-index: 100;
overflow: hidden;
width: 100%;
width: calc(100% - 10px);
-moz-box-sizing: border-box;
box-sizing: border-box;
button {
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
}
button.btn.no-text {
margin: 0 2px;
padding: 2px 5px;
position: static;
}
}
}
// make sure the category selector *NEVER* gets focus by default on mobile anywhere

View File

@ -93,3 +93,70 @@ h2#site-text-logo
.badge-wrapper {
font-weight: normal;
}
.user-table {
position: relative;
}
.mobile-view .mobile-nav {
&.messages-nav, &.notifications-nav, &.activity-nav {
position: absolute;
right: 0px;
top: -55px;
}
}
.mobile-view .mobile-nav {
a .fa {
margin-right: 8px;
}
a {
color: $primary;
}
margin: 0;
padding: 0;
background: dark-light-diff($primary, $secondary, 90%, -65%);
list-style: none;
overflow: visible;
position: relative;
width: 40%;
> li > a {
padding: 8px 10px;
height: 100%;
width: 100%;
box-sizing: border-box;
display: block;
}
.fa-caret-down {
position: absolute;
right: 0px;
}
.drop {
display: none;
}
.drop.expanded {
left: 0;
display: block;
position: absolute;
z-index: 10000000;
background-color: $secondary;
width: 100%;
list-style: none;
margin: 0;
padding: 5px;
border: 1px solid #eaeaea;
box-sizing: border-box;
li {
margin: 5px 0;
padding: 0;
a {
height: 100%;
display: block;
padding: 5px 8px;
}
}
}
}

View File

@ -352,6 +352,17 @@ span.post-count {
padding: 15px 0;
}
// mobile has no fixed width on topic-body so overflow: hidden causes problems
.topic-body {
overflow:inherit;
}
// instead, for mobile we set overflow hidden on the cooked part of post body
// this prevents image overflow on deeply nested blockquotes, lists, etc
.cooked {
overflow: hidden;
}
.moderator .topic-body {
background-color: dark-light-diff($highlight, $secondary, 70%, -80%);
}

View File

@ -38,7 +38,6 @@
/* both blocks that appear under the standard post control buttons */
.notification-options, .pinned-options {
clear: both;
float: left;
margin-top: 0px;
padding-top: 1px;

View File

@ -67,9 +67,8 @@
}
.profile-image {
height: 150px;
height: 25px;
width: 100%;
background-size: cover;
}
@ -209,8 +208,7 @@
}
.about {
background-size: cover;
background: dark-light-diff($primary, $secondary, 0%, -75%) center center;
background: dark-light-diff($primary, $secondary, 0%, -75%) center;
width: 100%;
margin-bottom: 10px;
overflow: hidden;
@ -252,6 +250,11 @@
background-color: dark-light-choose(rgba($primary, .9), rgba($secondary, .9));
opacity: 0.8;
blockquote {
background-color: dark-light-diff($secondary, $primary, 30%, -70%);
border-left-color: dark-light-diff($secondary, $primary, 50%, -50%);
}
h1 {
font-size: 2.143em;
font-weight: normal;
@ -300,6 +303,7 @@
.primary-textual {
float: left;
padding-left: 15px;
a[href] {
color: dark-light-diff($secondary, $primary, 75%, -10%);
}
@ -324,16 +328,15 @@
}
.controls {
width: 160px;
float: right;
text-align: right;
float: left;
padding-left: 15px;
ul {
list-style-type: none;
margin: 0;
}
a {
padding: 5px 10px;
width: 140px;
width: 120px;
margin-bottom: 10px;
}
}

View File

@ -57,10 +57,17 @@ class Admin::EmailController < Admin::AdminController
render json: { raw_email: incoming_email.raw }
end
def incoming
params.require(:id)
incoming_email = IncomingEmail.find(params[:id].to_i)
serializer = IncomingEmailDetailsSerializer.new(incoming_email, root: false)
render_json_dump(serializer)
end
private
def filter_email_logs(email_logs, params)
email_logs = email_logs.includes(:user)
email_logs = email_logs.includes(:user, { post: :topic })
.references(:user)
.order(created_at: :desc)
.offset(params[:offset] || 0)

View File

@ -26,7 +26,8 @@ class Admin::EmailTemplatesController < Admin::AdminController
"user_notifications.user_invited_to_private_message_pm",
"user_notifications.user_invited_to_topic", "user_notifications.user_mentioned",
"user_notifications.user_posted", "user_notifications.user_posted_pm",
"user_notifications.user_quoted", "user_notifications.user_replied"]
"user_notifications.user_quoted", "user_notifications.user_replied",
"user_notifications.user_linked"]
end
def show

View File

@ -28,28 +28,7 @@ class Admin::GroupsController < Admin::AdminController
if group.present?
users = (params[:users] || []).map {|u| u.downcase}
user_ids = User.where("username_lower in (:users) OR email IN (:users)", users: users).pluck(:id)
if user_ids.present?
Group.exec_sql("INSERT INTO group_users
(group_id, user_id, created_at, updated_at)
SELECT #{group.id},
u.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM users AS u
WHERE u.id IN (#{user_ids.join(', ')})
AND NOT EXISTS(SELECT 1 FROM group_users AS gu
WHERE gu.user_id = u.id AND
gu.group_id = #{group.id})")
if group.primary_group?
User.where(id: user_ids).update_all(primary_group_id: group.id)
end
if group.title.present?
User.where(id: user_ids).update_all(title: group.title)
end
end
group.bulk_add(user_ids) if user_ids.present?
end
render json: success_json

View File

@ -37,7 +37,7 @@ class Admin::UsersController < Admin::AdminController
end
def show
@user = User.find_by(username_lower: params[:id])
@user = User.find_by(id: params[:id])
raise Discourse::NotFound unless @user
render_serialized(@user, AdminDetailedUserSerializer, root: false)
end

View File

@ -33,6 +33,7 @@ class ApplicationController < ActionController::Base
end
before_filter :set_current_user_for_logs
before_filter :clear_notifications
before_filter :set_locale
before_filter :set_mobile_view
before_filter :inject_preview_style
@ -137,6 +138,31 @@ class ApplicationController < ActionController::Base
response.headers["X-Discourse-Route"] = "#{controller_name}/#{action_name}"
end
def clear_notifications
if current_user && !Discourse.readonly_mode?
cookie_notifications = cookies['cn'.freeze]
notifications = request.headers['Discourse-Clear-Notifications'.freeze]
if cookie_notifications
if notifications.present?
notifications += "," << cookie_notifications
else
notifications = cookie_notifications
end
end
if notifications.present?
notification_ids = notifications.split(",").map(&:to_i)
count = Notification.where(user_id: current_user.id, id: notification_ids, read: false).update_all(read: true)
if count > 0
current_user.publish_notifications_state
end
cookies.delete('cn')
end
end
end
def set_locale
I18n.locale = current_user.try(:effective_locale) || SiteSetting.default_locale
I18n.ensure_all_loaded!

View File

@ -25,9 +25,12 @@ class EmailController < ApplicationController
end
if params[:from_all]
@user.update_columns(email_digests: false, email_direct: false, email_private_messages: false, email_always: false)
@user.user_option.update_columns(email_always: false,
email_digests: false,
email_direct: false,
email_private_messages: false)
else
@user.update_column(:email_digests, false)
@user.user_option.update_column(:email_digests, false)
end
@success = true
@ -36,7 +39,7 @@ class EmailController < ApplicationController
def resubscribe
@user = DigestUnsubscribeKey.user_for_key(params[:key])
raise Discourse::NotFound unless @user.present?
@user.update_column(:email_digests, true)
@user.user_option.update_column(:email_digests, true)
end
end

View File

@ -261,8 +261,10 @@ class SessionController < ApplicationController
def failed_to_login(user)
message = user.suspend_reason ? "login.suspended_with_reason" : "login.suspended"
render json: { error: I18n.t(message, { date: I18n.l(user.suspended_till, format: :date_only),
reason: user.suspend_reason}) }
render json: {
error: I18n.t(message, { date: I18n.l(user.suspended_till, format: :date_only), reason: user.suspend_reason}),
reason: 'suspended'
}
end
def login(user)

View File

@ -289,20 +289,20 @@ class TopicsController < ApplicationController
allowed_groups = topic.allowed_groups
.where('topic_allowed_groups.group_id IN (?)', group_ids).pluck(:id)
allowed_groups.each do |id|
GroupArchivedMessage.where(group_id: id, topic_id: topic.id).destroy_all
if archive
GroupArchivedMessage.archive!(id, topic.id)
group_id = id
GroupArchivedMessage.create!(group_id: id, topic_id: topic.id)
else
GroupArchivedMessage.move_to_inbox!(id, topic.id)
end
end
end
if topic.allowed_users.include?(current_user)
UserArchivedMessage.where(user_id: current_user.id, topic_id: topic.id).destroy_all
if archive
UserArchivedMessage.create!(user_id: current_user.id, topic_id: topic.id)
UserArchivedMessage.archive!(current_user.id, topic.id)
else
UserArchivedMessage.move_to_inbox!(current_user.id, topic.id)
end
end

View File

@ -51,13 +51,16 @@ class UploadsController < ApplicationController
render nothing: true, status: 404
end
MAXIMUM_UPLOAD_SIZE ||= 10.megabytes
DOWNSIZE_RATIO ||= 0.8
def create_upload(type, file, url)
begin
# ensure we have a file
if file.nil?
# API can provide a URL
if url.present? && is_api?
tempfile = FileHelper.download(url, 10.megabytes, "discourse-upload-#{type}") rescue nil
tempfile = FileHelper.download(url, MAXIMUM_UPLOAD_SIZE, "discourse-upload-#{type}") rescue nil
filename = File.basename(URI.parse(url).path)
end
else
@ -69,20 +72,21 @@ class UploadsController < ApplicationController
return { errors: I18n.t("upload.file_missing") } if tempfile.nil?
# allow users to upload (not that) large images that will be automatically reduced to allowed size
uploaded_size = File.size(tempfile.path)
if SiteSetting.max_image_size_kb > 0 && FileHelper.is_image?(filename) && uploaded_size > 0 && uploaded_size < 10.megabytes
attempt = 2
allow_animation = type == "avatar" ? SiteSetting.allow_animated_avatars : SiteSetting.allow_animated_thumbnails
while attempt > 0
downsized_size = File.size(tempfile.path)
break if downsized_size > uploaded_size
break if downsized_size < SiteSetting.max_image_size_kb.kilobytes
image_info = FastImage.new(tempfile.path) rescue nil
w, h = *(image_info.try(:size) || [0, 0])
break if w == 0 || h == 0
dimensions = "#{(w * 0.8).floor}x#{(h * 0.8).floor}"
OptimizedImage.downsize(tempfile.path, tempfile.path, dimensions, filename: filename, allow_animation: allow_animation)
attempt -= 1
if SiteSetting.max_image_size_kb > 0 && FileHelper.is_image?(filename)
uploaded_size = File.size(tempfile.path)
if 0 < uploaded_size && uploaded_size < MAXIMUM_UPLOAD_SIZE && Upload.should_optimize?(tempfile.path)
attempt = 2
allow_animation = type == "avatar" ? SiteSetting.allow_animated_avatars : SiteSetting.allow_animated_thumbnails
while attempt > 0
downsized_size = File.size(tempfile.path)
break if uploaded_size < downsized_size || downsized_size < SiteSetting.max_image_size_kb.kilobytes
image_info = FastImage.new(tempfile.path) rescue nil
w, h = *(image_info.try(:size) || [0, 0])
break if w == 0 || h == 0
dimensions = "#{(w * DOWNSIZE_RATIO).floor}x#{(h * DOWNSIZE_RATIO).floor}"
OptimizedImage.downsize(tempfile.path, tempfile.path, dimensions, filename: filename, allow_animation: allow_animation)
attempt -= 1
end
end
end

View File

@ -28,8 +28,8 @@ module UserNotificationsHelper
logo_url
end
def html_site_link
"<a href='#{Discourse.base_url}'>#{@site_name}</a>"
def html_site_link(color)
"<a href='#{Discourse.base_url}' style='color: ##{color}'>#{@site_name}</a>"
end
def first_paragraph_from(html)

View File

@ -44,7 +44,7 @@ module Jobs
end
def user_archive_export
user_archive_data = Post.includes(:topic => :category).where(user_id: @current_user.id).select('topic_id','post_number','raw','like_count','reply_count','created_at').order('created_at').with_deleted.to_a
user_archive_data = Post.includes(:topic => :category).where(user_id: @current_user.id).select(:topic_id, :post_number, :raw, :like_count, :reply_count, :created_at).order(:created_at).with_deleted.to_a
user_archive_data.map do |user_archive|
get_user_archive_fields(user_archive)
end
@ -57,53 +57,19 @@ module Jobs
if SiteSetting.enable_sso
# SSO enabled
User.includes(:user_stat, :single_sign_on_record, :groups).find_each do |user|
user_info_string = "#{user.id},#{user.name},#{user.username},#{user.email},#{user.title},#{user.created_at},#{user.last_seen_at},#{user.last_posted_at},#{user.last_emailed_at},#{user.trust_level},#{user.approved},#{user.suspended_at},#{user.suspended_till},#{user.blocked},#{user.active},#{user.admin},#{user.moderator},#{user.ip_address},#{user.user_stat.topics_entered},#{user.user_stat.posts_read_count},#{user.user_stat.time_read},#{user.user_stat.topic_count},#{user.user_stat.post_count},#{user.user_stat.likes_given},#{user.user_stat.likes_received}"
# sso
if user.single_sign_on_record
user_info_string << ",#{user.single_sign_on_record.external_id},#{user.single_sign_on_record.external_email},#{user.single_sign_on_record.external_username},#{user.single_sign_on_record.external_name},#{user.single_sign_on_record.external_avatar_url}"
else
user_info_string << ",nil,nil,nil,nil,nil"
end
# custom fields
if user_field_ids.present?
user.user_fields.each do |custom_field|
user_info_string << ",#{custom_field[1]}"
end
end
# group names
group_names = ""
user.groups.each do |group|
group_names << "#{group.name};"
end
user_info_string << ",#{group_names[0..-2]}" unless group_names.blank?
group_names = nil
user_info_string = get_base_user_string(user)
user_info_string = add_single_sign_on(user, user_info_string)
user_info_string = add_custom_fields(user, user_info_string, user_field_ids)
user_info_string = add_group_names(user, user_info_string)
user_array.push(user_info_string.split(","))
user_info_string = nil
end
else
# SSO disabled
User.includes(:user_stat, :groups).find_each do |user|
user_info_string = "#{user.id},#{user.name},#{user.username},#{user.email},#{user.title},#{user.created_at},#{user.last_seen_at},#{user.last_posted_at},#{user.last_emailed_at},#{user.trust_level},#{user.approved},#{user.suspended_at},#{user.suspended_till},#{user.blocked},#{user.active},#{user.admin},#{user.moderator},#{user.ip_address},#{user.user_stat.topics_entered},#{user.user_stat.posts_read_count},#{user.user_stat.time_read},#{user.user_stat.topic_count},#{user.user_stat.post_count},#{user.user_stat.likes_given},#{user.user_stat.likes_received}"
# custom fields
if user_field_ids.present?
user.user_fields.each do |custom_field|
user_info_string << ",#{custom_field[1]}"
end
end
# group names
group_names = ""
user.groups.each do |group|
group_names << "#{group.name};"
end
user_info_string << ",#{group_names[0..-2]}" unless group_names.blank?
group_names = nil
user_info_string = get_base_user_string(user)
user_info_string = add_custom_fields(user, user_info_string, user_field_ids)
user_info_string = add_group_names(user, user_info_string)
user_array.push(user_info_string.split(","))
user_info_string = nil
end
@ -182,6 +148,37 @@ module Jobs
private
def get_base_user_string(user)
"#{user.id},#{user.name},#{user.username},#{user.email},#{user.title},#{user.created_at},#{user.last_seen_at},#{user.last_posted_at},#{user.last_emailed_at},#{user.trust_level},#{user.approved},#{user.suspended_at},#{user.suspended_till},#{user.blocked},#{user.active},#{user.admin},#{user.moderator},#{user.ip_address},#{user.user_stat.topics_entered},#{user.user_stat.posts_read_count},#{user.user_stat.time_read},#{user.user_stat.topic_count},#{user.user_stat.post_count},#{user.user_stat.likes_given},#{user.user_stat.likes_received}"
end
def add_single_sign_on(user, user_info_string)
if user.single_sign_on_record
user_info_string << ",#{user.single_sign_on_record.external_id},#{user.single_sign_on_record.external_email},#{user.single_sign_on_record.external_username},#{user.single_sign_on_record.external_name},#{user.single_sign_on_record.external_avatar_url}"
else
user_info_string << ",nil,nil,nil,nil,nil"
end
user_info_string
end
def add_custom_fields(user, user_info_string, user_field_ids)
if user_field_ids.present?
user.user_fields.each do |custom_field|
user_info_string << ",#{custom_field[1]}"
end
end
user_info_string
end
def add_group_names(user, user_info_string)
group_names = user.groups.each_with_object("") do |group, names|
names << "#{group.name};"
end
user_info_string << ",#{group_names[0..-2]}" unless group_names.blank?
group_names = nil
user_info_string
end
def get_user_archive_fields(user_archive)
user_archive_array = []
topic_data = user_archive.topic

View File

@ -11,7 +11,8 @@ module Jobs
users =
User.activated.not_blocked.not_suspended.real
.where(mailing_list_mode: true)
.joins(:user_option)
.where(user_options: {mailing_list_mode: true})
.where('NOT EXISTS(
SELECT 1
FROM topic_users tu

View File

@ -63,7 +63,7 @@ module Jobs
# Markdown reference - [x]: http://
raw.gsub!(/\[([^\]]+)\]:\s?#{escaped_src}/) { "[#{$1}]: #{url}" }
# Direct link
raw.gsub!(/^#{escaped_src}\s?$/, "<img src='#{url}'>")
raw.gsub!(/^#{escaped_src}(\s?)$/) { "<img src='#{url}'>#{$1}" }
end
rescue => e
Rails.logger.info("Failed to pull hotlinked image: #{src} post:#{post_id}\n" + e.message + "\n" + e.backtrace.join("\n"))

View File

@ -4,7 +4,7 @@ module Jobs
def execute(args)
if user = User.find_by(id: args[:user_id])
user.update_column(:last_redirected_to_top_at, args[:redirected_at])
user.user_option.update_column(:last_redirected_to_top_at, args[:redirected_at])
end
end
end

View File

@ -6,19 +6,20 @@ module Jobs
class UserEmail < Jobs::Base
def execute(args)
notification, post = nil
raise Discourse::InvalidParameters.new(:user_id) unless args[:user_id].present?
raise Discourse::InvalidParameters.new(:type) unless args[:type].present?
post = nil
notification = nil
type = args[:type]
user = User.find_by(id: args[:user_id])
to_address = args[:to_address].presence || user.try(:email).presence || "no_email_found"
set_skip_context(type, args[:user_id], to_address, args[:post_id])
set_skip_context(type, args[:user_id], args[:to_address].presence || user.try(:email).presence || "no_email_found")
return skip(I18n.t("email_log.no_user", user_id: args[:user_id])) unless user
if args[:post_id]
if args[:post_id].present?
post = Post.find_by(id: args[:post_id])
return skip(I18n.t('email_log.post_not_found', post_id: args[:post_id])) unless post.present?
end
@ -27,40 +28,33 @@ module Jobs
notification = Notification.find_by(id: args[:notification_id])
end
message, skip_reason = message_for_email( user,
post,
type,
notification,
args[:notification_type],
args[:notification_data_hash],
args[:email_token],
args[:to_address] )
message, skip_reason = message_for_email(user,
post,
type,
notification,
args[:notification_type],
args[:notification_data_hash],
args[:email_token],
args[:to_address])
if message
Email::Sender.new(message, args[:type], user).send
Email::Sender.new(message, type, user).send
else
skip_reason
end
end
def set_skip_context(type, user_id, to_address)
@skip_context = { type: type, user_id: user_id, to_address: to_address }
def set_skip_context(type, user_id, to_address, post_id)
@skip_context = { type: type, user_id: user_id, to_address: to_address, post_id: post_id }
end
NOTIFICATIONS_SENT_BY_MAILING_LIST ||= Set.new [
Notification.types[:posted],
Notification.types[:replied],
Notification.types[:mentioned],
Notification.types[:group_mentioned],
Notification.types[:quoted],
]
NOTIFICATIONS_SENT_BY_MAILING_LIST ||= Set.new %w{posted replied mentioned group_mentioned quoted}
def message_for_email(user, post, type, notification,
notification_type=nil, notification_data_hash=nil,
email_token=nil, to_address=nil)
notification_type=nil, notification_data_hash=nil,
email_token=nil, to_address=nil)
set_skip_context(type, user.id, to_address || user.email)
set_skip_context(type, user.id, to_address || user.email, post.try(:id))
return skip_message(I18n.t("email_log.anonymous_user")) if user.anonymous?
return skip_message(I18n.t("email_log.suspended_not_pm")) if user.suspended? && type != :user_private_message
@ -68,7 +62,7 @@ module Jobs
return if user.staged && type == :digest
seen_recently = (user.last_seen_at.present? && user.last_seen_at > SiteSetting.email_time_window_mins.minutes.ago)
seen_recently = false if user.email_always || user.staged
seen_recently = false if user.user_option.email_always || user.staged
email_args = {}
@ -81,17 +75,24 @@ module Jobs
end
if notification || notification_type
email_args[:notification_type] ||= notification_type || notification.try(:notification_type)
email_args[:notification_type] ||= notification_type || notification.try(:notification_type)
email_args[:notification_data_hash] ||= notification_data_hash || notification.try(:data_hash)
if user.mailing_list_mode? &&
unless String === email_args[:notification_type]
if Numeric === email_args[:notification_type]
email_args[:notification_type] = Notification.types[email_args[:notification_type]]
end
email_args[:notification_type] = email_args[:notification_type].to_s
end
if user.user_option.mailing_list_mode? &&
!post.topic.private_message? &&
NOTIFICATIONS_SENT_BY_MAILING_LIST.include?(email_args[:notification_type])
# no need to log a reason when the mail was already sent via the mailing list job
return [nil, nil]
end
unless user.email_always?
unless user.user_option.email_always?
if (notification && notification.read?) || (post && post.seen?(user))
return skip_message(I18n.t('email_log.notification_already_read'))
end
@ -112,7 +113,7 @@ module Jobs
# Update the to address if we have a custom one
if message && to_address.present?
message.to = [to_address]
message.to = to_address
end
[message, nil]
@ -141,8 +142,9 @@ module Jobs
email_type: @skip_context[:type],
to_address: @skip_context[:to_address],
user_id: @skip_context[:user_id],
post_id: @skip_context[:post_id],
skipped: true,
skipped_reason: reason,
skipped_reason: "[UserEmail] #{reason}",
)
end

View File

@ -0,0 +1,18 @@
module Jobs
class CleanUpEmailLogs < Jobs::Scheduled
every 1.day
def execute(args)
return if SiteSetting.delete_email_logs_after_days <= 0
threshold = SiteSetting.delete_email_logs_after_days.days.ago
EmailLog.where(reply_key: nil)
.where("created_at < ?", threshold)
.destroy_all
end
end
end

View File

@ -15,11 +15,13 @@ module Jobs
def target_user_ids
# Users who want to receive emails and haven't been emailed in the last day
query = User.real
.where(email_digests: true, active: true, staged: false)
.where(active: true, staged: false)
.joins(:user_option)
.not_suspended
.where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)")
.where("COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * digest_after_days)")
.where("COALESCE(last_seen_at, '2010-01-01') >= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * #{SiteSetting.suppress_digest_email_after_days})")
.where(user_options: {email_digests: true})
.where("COALESCE(last_emailed_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * user_options.digest_after_days)")
.where("COALESCE(last_seen_at, '2010-01-01') <= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * user_options.digest_after_days)")
.where("COALESCE(last_seen_at, '2010-01-01') >= CURRENT_TIMESTAMP - ('1 DAY'::INTERVAL * #{SiteSetting.delete_digest_email_after_days})")
# If the site requires approval, make sure the user is approved
if SiteSetting.must_approve_users?

View File

@ -9,10 +9,16 @@ module Jobs
def execute(args)
if SiteSetting.notify_about_flags_after > 0 &&
PostAction.flagged_posts_count > 0 &&
FlagQuery.flagged_post_actions('active').where('post_actions.created_at < ?', 48.hours.ago).pluck(:id).count > 0
FlagQuery.flagged_post_actions('active').where('post_actions.created_at < ?', SiteSetting.notify_about_flags_after.to_i.hours.ago).pluck(:id).count > 0
message = PendingFlagsMailer.notify
Email::Sender.new(message, :pending_flags_reminder).send
PostCreator.create(
Discourse.system_user,
target_group_names: ["staff"],
archetype: Archetype.private_message,
subtype: TopicSubtype.system_message,
title: I18n.t('flags_reminder.subject_template', { count: PostAction.flagged_posts_count }),
raw: I18n.t('flags_reminder.flags_were_submitted', { count: SiteSetting.notify_about_flags_after })
)
end
end

View File

@ -38,6 +38,7 @@ module Jobs
when Email::Receiver::NoMessageIdError then :email_reject_no_message_id
when Email::Receiver::AutoGeneratedEmailError then :email_reject_auto_generated
when Email::Receiver::InactiveUserError then :email_reject_inactive_user
when Email::Receiver::BlockedUserError then :email_reject_blocked_user
when Email::Receiver::BadDestinationAddress then :email_reject_bad_destination_address
when Email::Receiver::StrangersNotAllowedError then :email_reject_strangers_not_allowed
when Email::Receiver::InsufficientTrustLevelError then :email_reject_insufficient_trust_level

View File

@ -62,6 +62,7 @@ class UserNotifications < ActionMailer::Base
@site_name = SiteSetting.email_prefix.presence || SiteSetting.title
@header_color = ColorScheme.hex_for_name('header_background')
@anchor_color = ColorScheme.hex_for_name('tertiary')
@last_seen_at = short_date(@user.last_seen_at || @user.created_at)
# A list of topics to show the user
@ -110,6 +111,13 @@ class UserNotifications < ActionMailer::Base
notification_email(user, opts)
end
def user_linked(user, opts)
opts[:allow_reply_by_email] = true
opts[:use_site_subject] = true
opts[:show_category_in_subject] = true
notification_email(user, opts)
end
def user_mentioned(user, opts)
opts[:allow_reply_by_email] = true
opts[:use_site_subject] = true
@ -186,15 +194,23 @@ class UserNotifications < ActionMailer::Base
end
def self.get_context_posts(post, topic_user)
user_option = topic_user.try(:user).try(:user_option)
if user_option && (user_option.email_previous_replies == UserOption.previous_replies_type[:never])
return []
end
allowed_post_types = [Post.types[:regular]]
allowed_post_types << Post.types[:whisper] if topic_user.try(:user).try(:staff?)
context_posts = Post.where(topic_id: post.topic_id)
.where("post_number < ?", post.post_number)
.where(user_deleted: false)
.where(hidden: false)
.where(post_type: Topic.visible_post_types)
.where(post_type: allowed_post_types)
.order('created_at desc')
.limit(SiteSetting.email_posts_context)
if topic_user && topic_user.last_emailed_post_number
if topic_user && topic_user.last_emailed_post_number && user_option.try(:email_previous_replies) == UserOption.previous_replies_type[:unless_emailed]
context_posts = context_posts.where("post_number > ?", topic_user.last_emailed_post_number)
end
@ -275,7 +291,7 @@ class UserNotifications < ActionMailer::Base
context_posts = context_posts.to_a
if context_posts.present?
context << "---\n*#{I18n.t('user_notifications.previous_discussion')}*\n"
context << "-- \n*#{I18n.t('user_notifications.previous_discussion')}*\n"
context_posts.each do |cp|
context << email_post_markdown(cp)
end
@ -311,7 +327,7 @@ class UserNotifications < ActionMailer::Base
context: context,
username: username,
add_unsubscribe_link: !user.staged,
add_unsubscribe_via_email_link: user.mailing_list_mode,
add_unsubscribe_via_email_link: user.user_option.mailing_list_mode,
unsubscribe_url: post.topic.unsubscribe_url,
allow_reply_by_email: allow_reply_by_email,
use_site_subject: use_site_subject,

View File

@ -62,7 +62,8 @@ class AdminDashboardData
:failing_emails_check, :default_logo_check, :contact_email_check,
:send_consumer_email_check, :title_check,
:site_description_check, :site_contact_username_check,
:notification_email_check, :subfolder_ends_in_slash_check
:notification_email_check, :subfolder_ends_in_slash_check,
:pop3_polling_configuration
add_problem_check do
sidekiq_check || queue_size_check
@ -205,4 +206,8 @@ class AdminDashboardData
I18n.t('dashboard.subfolder_ends_in_slash') if Discourse.base_uri =~ /\/$/
end
def pop3_polling_configuration
POP3PollingEnabledSettingValidator.new.error_message if SiteSetting.pop3_polling_enabled
end
end

View File

@ -145,16 +145,9 @@ class CategoryList
end
# Remove any empty categories unless we can create them (so we can see the controls)
def prune_empty
if !@guardian.can_create?(Category)
# Remove categories with no featured topics unless we have the ability to edit one
@categories.delete_if do |c|
c.displayable_topics.blank? && c.description.blank?
end
elsif !SiteSetting.allow_uncategorized_topics
# Don't show uncategorized to admins either, if uncategorized topics are not allowed
# and there are none.
unless SiteSetting.allow_uncategorized_topics
# HACK: Don't show uncategorized to anyone if not allowed
@categories.delete_if do |c|
c.uncategorized? && c.displayable_topics.blank?
end

View File

@ -95,7 +95,14 @@ class CategoryUser < ActiveRecord::Base
end
def self.ensure_consistency!
exec_sql("DELETE FROM category_users WHERE user_id NOT IN (SELECT id FROM users)")
exec_sql <<SQL
DELETE FROM category_users
WHERE user_id IN (
SELECT cu.user_id FROM category_users cu
LEFT JOIN users u ON u.id = cu.user_id
WHERE u.id IS NULL
)
SQL
end
private_class_method :apply_default_to_topic, :remove_default_from_topic

View File

@ -18,11 +18,14 @@ class GlobalSetting
def self.database_config
hash = {"adapter" => "postgresql"}
%w{pool timeout socket host port username password}.each do |s|
%w{pool timeout socket host port username password replica_host replica_port}.each do |s|
if val = self.send("db_#{s}")
hash[s] = val
end
end
hash["adapter"] = "postgresql_fallback" if hash["replica_host"]
hostnames = [ hostname ]
hostnames << backup_hostname if backup_hostname.present?

View File

@ -335,6 +335,31 @@ class Group < ActiveRecord::Base
self.find_by(incoming_email: Email.downcase(email))
end
def bulk_add(user_ids)
if user_ids.present?
Group.exec_sql("INSERT INTO group_users
(group_id, user_id, created_at, updated_at)
SELECT #{self.id},
u.id,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM users AS u
WHERE u.id IN (#{user_ids.join(', ')})
AND NOT EXISTS(SELECT 1 FROM group_users AS gu
WHERE gu.user_id = u.id AND
gu.group_id = #{self.id})")
if self.primary_group?
User.where(id: user_ids).update_all(primary_group_id: self.id)
end
if self.title.present?
User.where(id: user_ids).update_all(title: self.title)
end
end
true
end
protected
def name_format_validator

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