Merge branch 'master' into category-reorder

This commit is contained in:
Kane York 2015-09-13 19:07:36 -07:00
commit a5f906db69
81 changed files with 860 additions and 727 deletions
.travis.ymlCONTRIBUTING.mdGemfile.lock
app
config
lib
spec
test/javascripts/lib

View file

@ -41,7 +41,7 @@ cache:
before_install:
- gem install bundler
- npm i -g eslint babel-eslint
- npm i -g eslint@1.3.1 babel-eslint
- eslint app/assets/javascripts
- eslint --ext .es6 app/assets/javascripts
- eslint --ext .es6 test/javascripts

View file

@ -1,129 +1,27 @@
# Contributing to Discourse
## Before You Start
## Important note for Developers
Anyone wishing to contribute to the **[Discourse/Discourse](https://github.com/discourse/discourse)** project **MUST read & sign the [Electronic Discourse Forums Contribution License Agreement](http://www.discourse.org/cla)**. The Discourse team is legally prevented from accepting any pull requests from users who have not signed the CLA first.
Anyone wishing to contribute to the [github.com/discourse/discourse](https://github.com/discourse/discourse) project **must read & sign our [Contributor License Agreement](http://www.discourse.org/cla)**. The Discourse team is legally prevented from accepting any pull requests from users who have not signed the CLA first.
## Reporting Bugs
For more information on
1. Always update to the most recent master release; the bug may already be resolved.
- how to set up your development environment
- first-time project suggestions
- code conventions
- step-by-step guide for GitHub commits
2. Search for similar issues on the [Discourse meta forum][m]; it may already be an identified problem.
**please read our [Discourse Development Contribution Guidelines](https://meta.discourse.org/t/discourse-development-contribution-guidelines/3823)**
3. Make sure you can reproduce your problem on our sandbox at [try.discourse.org](http://try.discourse.org)
## Everything Else
4. If this is a bug or problem that **requires any kind of extended discussion -- open [a topic on meta][m] about it**.
There are many other ways to contribute to Discourse besides code. We've outlined the most common ones below.
5. If possible, submit a Pull Request with a failing test. If you'd rather take matters into your own hands, fix the bug yourself (jump down to the "Contributing (Step-by-step)" section).
- [Reporting Bugs](https://meta.discourse.org/t/how-to-make-bug-reports-for-discourse/33070)
- [Requesting Features](https://meta.discourse.org/t/how-to-request-new-features-for-discourse/32986)
- [Translation](https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882)
- Documentation (TBA)
6. When the bug is fixed, we will do our best to update the Discourse topic.
For anything else, just start a new topic on [Meta](https://meta.discourse.org/) and let us know what you're interested in working on.
## Requesting New Features
1. Do not submit a feature request on GitHub; all feature requests on GitHub will be closed. Instead, visit the **[Discourse meta forum, features category](http://meta.discourse.org/category/feature)**, and search this list for similar feature requests. It's possible somebody has already asked for this feature or provided a pull request that we're still discussing.
2. Provide a clear and detailed explanation of the feature you want and why it's important to add. The feature must apply to a wide array of users of Discourse; for smaller, more targeted "one-off" features, you might consider writing a plugin for Discourse. You may also want to provide us with some advance documentation on the feature, which will help the community to better understand where it will fit.
3. If you're a Rock Star programmer, build the feature yourself (refer to the "Contributing (Step-by-step)" section below).
## Contributing (Step-by-step)
1. Clone the Repo:
git clone git://github.com/discourse/discourse.git
2. Create a new Branch:
cd discourse
git checkout -b new_discourse_branch
> Please keep your code clean: one feature or bug-fix per branch. If you find another bug, you want to fix while being in a new branch, please fix it in a separated branch instead.
3. Code
* Adhere to common conventions you see in the existing code
* Include tests, and ensure they pass
* Search to see if your new functionality has been discussed on [the Discourse meta forum](http://meta.discourse.org), and include updates as appropriate
4. Follow the Coding Conventions
* two spaces, no tabs
* no trailing whitespaces, blank lines should have no spaces
* use spaces around operators, after commas, colons, semicolons, around `{` and before `}`
* no space after `(`, `[` or before `]`, `)`
* use Ruby 1.9 hash syntax: prefer `{ a: 1 }` over `{ :a => 1 }`
* prefer `class << self; def method; end` over `def self.method` for class methods
* prefer `{ ... }` over `do ... end` for single-line blocks, avoid using `{ ... }` for multi-line blocks
* avoid `return` when not required
> However, please note that **pull requests consisting entirely of style changes are not welcome on this project**. Style changes in the context of pull requests that also refactor code, fix bugs, improve functionality *are* welcome.
5. Commit
For every commit please write a short (max 72 characters) summary in the first line followed with a blank line and then more detailed descriptions of the change. Use markdown syntax for simple styling.
**NEVER leave the commit message blank!** Provide a detailed, clear, and complete description of your commit!
6. Update your branch
```
git fetch origin
git rebase origin/master
```
7. Fork
```
git remote add mine git@github.com:<your user name>/discourse.git
```
8. Push to your remote
```
git push mine new_discourse_branch
```
9. Issue a Pull Request
Before submitting a pull-request, clean up the history, go over your commits and squash together minor changes and fixes into the corresponding commits. You can squash commits with the interactive rebase command:
```
git fetch origin
git checkout new_discourse_branch
git rebase origin/master
git rebase -i
< the editor opens and allows you to change the commit history >
< follow the instructions on the bottom of the editor >
git push -f mine new_discourse_branch
```
In order to make a pull request,
* Navigate to the Discourse repository you just pushed to (e.g. https://github.com/your-user-name/discourse)
* Click "Pull Request".
* Write your branch name in the branch field (this is filled with "master" by default)
* Click "Update Commit Range".
* Ensure the changesets you introduced are included in the "Commits" tab.
* Ensure that the "Files Changed" incorporate all of your changes.
* Fill in some details about your potential patch including a meaningful title.
* Click "Send pull request".
Thanks for that -- we'll get to your pull request ASAP, we love pull requests!
10. Responding to Feedback
The Discourse team may recommend adjustments to your code. Part of interacting with a healthy open-source community requires you to be open to learning new techniques and strategies; *don't get discouraged!* Remember: if the Discourse team suggest changes to your code, **they care enough about your work that they want to include it**, and hope that you can assist by implementing those revisions on your own.
> Though we ask you to clean your history and squash commit before submitting a pull-request, please do not change any commits you've submitted already (as other work might be build on top).
## Translations
Translators can do their work in our [Transifex project](https://www.transifex.com/projects/p/discourse-org/). For more information, please see these how-to topics:
* [Contributing a translation to Discourse](https://meta.discourse.org/t/contribute-a-translation-to-discourse/14882)
* [How to add a new language](https://meta.discourse.org/t/how-to-add-a-new-language/14970)
[m]: http://meta.discourse.org
*Thanks for contributing!*

View file

@ -209,7 +209,7 @@ GEM
omniauth-twitter (1.0.1)
multi_json (~> 1.3)
omniauth-oauth (~> 1.0)
onebox (1.5.24)
onebox (1.5.25)
moneta (~> 0.8)
multi_json (~> 1.11)
mustache

View file

@ -1,3 +1,4 @@
import computed from "ember-addons/ember-computed-decorators";
import UploadMixin from "discourse/mixins/upload";
export default Em.Component.extend(UploadMixin, {
@ -5,21 +6,23 @@ export default Em.Component.extend(UploadMixin, {
tagName: "span",
imageIsNotASquare: false,
uploadButtonText: function() {
return this.get("uploading") ? I18n.t("uploading") : I18n.t("user.change_avatar.upload_picture");
}.property("uploading"),
@computed("uploading")
uploadButtonText(uploading) {
return uploading ? I18n.t("uploading") : I18n.t("user.change_avatar.upload_picture");
},
uploadDone(upload) {
this.setProperties({
imageIsNotASquare: upload.width !== upload.height,
uploadedAvatarTemplate: upload.url,
custom_avatar_upload_id: upload.id,
uploadedAvatarId: upload.id,
});
this.sendAction("done");
},
data: function() {
return { user_id: this.get("user_id") };
}.property("user_id")
@computed("user_id")
data(user_id) {
return { user_id };
}
});

View file

@ -7,16 +7,24 @@ export default buildCategoryPanel('security', {
actions: {
editPermissions() {
this.set('editingPermissions', true);
if (!this.get('category.is_special')) {
this.set('editingPermissions', true);
}
},
addPermission(group, id) {
this.get('category').addPermission({group_name: group + "",
permission: Discourse.PermissionType.create({id})});
if (!this.get('category.is_special')) {
this.get('category').addPermission({
group_name: group + "",
permission: Discourse.PermissionType.create({id})
});
}
},
removePermission(permission) {
this.get('category').removePermission(permission);
if (!this.get('category.is_special')) {
this.get('category').removePermission(permission);
}
},
}
});

View file

@ -54,7 +54,7 @@ export default Ember.Component.extend({
}
$panelBody.height('100%');
this.$().css({ left: "auto", top: (menuTop) + "px", height });
this.$().css({ top: menuTop + "px", height });
$('body').removeClass('drop-down-visible');
}

View file

@ -1,4 +1,4 @@
import {searchForTerm, searchContextDescription} from 'discourse/lib/search';
import {searchForTerm, searchContextDescription, isValidSearchTerm } from 'discourse/lib/search';
import DiscourseURL from 'discourse/lib/url';
import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
import showModal from 'discourse/lib/show-modal';
@ -61,8 +61,8 @@ export default Ember.Component.extend({
@observes('searchService.term', 'typeFilter')
newSearchNeeded() {
this.set('noResults', false);
const term = (this.get('searchService.term') || '').trim();
if (term.length >= Discourse.SiteSettings.min_search_term_length) {
const term = this.get('searchService.term')
if (isValidSearchTerm(term)) {
this.set('loading', true);
Ember.run.debounce(this, 'searchTerm', term, this.get('typeFilter'), 400);
} else {
@ -134,7 +134,7 @@ export default Ember.Component.extend({
},
showedSearch() {
$('#search-term').focus();
$('#search-term').focus().select();
},
showSearchHelp() {
@ -154,8 +154,7 @@ export default Ember.Component.extend({
},
keyDown(e) {
const term = this.get('searchService.term');
if (e.which === 13 && term && term.length >= this.siteSettings.min_search_term_length) {
if (e.which === 13 && isValidSearchTerm(this.get('searchService.term'))) {
this.set('visible', false);
this.send('fullSearch');
}

View file

@ -5,5 +5,13 @@ export default TextField.extend({
@computed('searchService.searchContextEnabled')
placeholder: function(searchContextEnabled) {
return searchContextEnabled ? "" : I18n.t('search.title');
}
},
focusIn: function() {
Em.run.later(() => { this.$().select(); });
},
becomeFocused: function() {
if (this.get('hasAutofocus')) this.$().focus();
}.on('didInsertElement')
});

View file

@ -13,7 +13,7 @@ export default Ember.Component.extend(StringBuffer, {
iconsHtml += "<a href=\"" + Discourse.getURL("/users/") + u.get('username_lower') + "\" data-user-card=\"" + u.get('username_lower') + "\">";
iconsHtml += Discourse.Utilities.avatarImg({
size: 'small',
avatarTemplate: u.get('avatarTemplate'),
avatarTemplate: u.get('avatar_template'),
title: u.get('username')
});
iconsHtml += "</a>";

View file

@ -1,21 +1,29 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import computed from "ember-addons/ember-computed-decorators";
import ModalFunctionality from "discourse/mixins/modal-functionality";
export default Ember.Controller.extend(ModalFunctionality, {
uploadedAvatarTemplate: null,
saveDisabled: Em.computed.alias("uploading"),
hasUploadedAvatar: Em.computed.or('uploadedAvatarTemplate', 'custom_avatar_upload_id'),
selectedUploadId: function() {
switch (this.get("selected")) {
case "system": return this.get("system_avatar_upload_id");
case "gravatar": return this.get("gravatar_avatar_upload_id");
default: return this.get("custom_avatar_upload_id");
@computed("selected", "system_avatar_upload_id", "gravatar_avatar_upload_id", "custom_avatar_upload_id")
selectedUploadId(selected, system, gravatar, custom) {
switch (selected) {
case "system": return system;
case "gravatar": return gravatar;
default: return custom;
}
}.property('selected', 'system_avatar_upload_id', 'gravatar_avatar_upload_id', 'custom_avatar_upload_id'),
},
allowImageUpload: function() {
@computed("selected", "system_avatar_template", "gravatar_avatar_template", "custom_avatar_template")
selectedAvatarTemplate(selected, system, gravatar, custom) {
switch (selected) {
case "system": return system;
case "gravatar": return gravatar;
default: return custom;
}
},
@computed()
allowImageUpload() {
return Discourse.Utilities.allowsImages();
}.property(),
},
actions: {
useUploadedAvatar() { this.set("selected", "uploaded"); },
@ -25,8 +33,11 @@ export default Ember.Controller.extend(ModalFunctionality, {
refreshGravatar() {
this.set("gravatarRefreshDisabled", true);
return Discourse
.ajax("/user_avatar/" + this.get("username") + "/refresh_gravatar.json", { method: 'POST' })
.then(result => this.set("gravatar_avatar_upload_id", result.upload_id))
.ajax(`/user_avatar/${this.get("username")}/refresh_gravatar.json`, { method: "POST" })
.then(result => this.setProperties({
gravatar_avatar_template: result.gravatar_avatar_template,
gravatar_upload_id: result.gravatar_upload_id,
}))
.finally(() => this.set("gravatarRefreshDisabled", false));
}
}

View file

@ -3,6 +3,7 @@ import DiscourseURL from 'discourse/lib/url';
import Quote from 'discourse/lib/quote';
import Draft from 'discourse/models/draft';
import Composer from 'discourse/models/composer';
import computed from 'ember-addons/ember-computed-decorators';
function loadDraft(store, opts) {
opts = opts || {};
@ -64,6 +65,11 @@ export default Ember.Controller.extend({
this.set('similarTopics', []);
}.on('init'),
@computed('model.action')
canWhisper(action) {
return this.siteSettings.enable_whispers && action === Composer.REPLY;
},
showWarning: function() {
if (!Discourse.User.currentProp('staff')) { return false; }
@ -132,7 +138,6 @@ export default Ember.Controller.extend({
},
hitEsc() {
const messages = this.get('controllers.composer-messages.model');
if (messages.length) {
messages.popObject();

View file

@ -1,4 +1,4 @@
import { translateResults, searchContextDescription, getSearchKey } from "discourse/lib/search";
import { translateResults, searchContextDescription, getSearchKey, isValidSearchTerm } from "discourse/lib/search";
import showModal from 'discourse/lib/show-modal';
import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
import Category from 'discourse/models/category';
@ -13,6 +13,11 @@ export default Ember.Controller.extend({
context_id: null,
context: null,
@computed('q')
hasAutofocus(q) {
return Em.isEmpty(q);
},
@computed('skip_context', 'context')
searchContextEnabled: {
get(skip,context){
@ -37,7 +42,12 @@ export default Ember.Controller.extend({
@computed('q')
searchActive(q){
return q && q.length > 0;
return isValidSearchTerm(q);
},
@computed('searchTerm')
isNotValidSearchTerm(searchTerm) {
return !isValidSearchTerm(searchTerm);
},
@observes('model')
@ -129,6 +139,7 @@ export default Ember.Controller.extend({
},
search() {
if (this.get("isNotValidSearchTerm")) return;
this.search();
}
}

View file

@ -1,6 +1,7 @@
import { setting } from 'discourse/lib/computed';
import CanCheckEmails from 'discourse/mixins/can-check-emails';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import computed from "ember-addons/ember-computed-decorators";
export default Ember.Controller.extend(CanCheckEmails, {
@ -10,18 +11,18 @@ export default Ember.Controller.extend(CanCheckEmails, {
allowBackgrounds: setting('allow_profile_backgrounds'),
editHistoryVisible: setting('edit_history_visible_to_public'),
selectedCategories: function(){
return [].concat(this.get("model.watchedCategories"),
this.get("model.trackedCategories"),
this.get("model.mutedCategories"));
}.property("model.watchedCategories", "model.trackedCategories", "model.mutedCategories"),
@computed("model.watchedCategories", "model.trackedCategories", "model.mutedCategories")
selectedCategories(watched, tracked, muted) {
return [].concat(watched, tracked, muted);
},
// By default we haven't saved anything
saved: false,
newNameInput: null,
userFields: function() {
@computed("model.user_fields.@each.value")
userFields() {
let siteUserFields = this.site.get('user_fields');
if (!Ember.isEmpty(siteUserFields)) {
const userFields = this.get('model.user_fields');
@ -35,34 +36,37 @@ export default Ember.Controller.extend(CanCheckEmails, {
return Ember.Object.create({ value, field });
});
}
}.property('model.user_fields.@each.value'),
},
cannotDeleteAccount: Em.computed.not('can_delete_account'),
deleteDisabled: Em.computed.or('saving', 'deleting', 'cannotDeleteAccount'),
canEditName: setting('enable_names'),
nameInstructions: function() {
@computed()
nameInstructions() {
return I18n.t(Discourse.SiteSettings.full_name_required ? 'user.name.instructions_required' : 'user.name.instructions');
}.property(),
},
canSelectTitle: function() {
return this.siteSettings.enable_badges && this.get('model.has_title_badges');
}.property('model.badge_count'),
@computed("model.has_title_badges")
canSelectTitle(hasTitleBadges) {
return this.siteSettings.enable_badges && hasTitleBadges;
},
canChangePassword: function() {
@computed()
canChangePassword() {
return !this.siteSettings.enable_sso && this.siteSettings.enable_local_logins;
}.property(),
},
canReceiveDigest: function() {
@computed()
canReceiveDigest() {
return !this.siteSettings.disable_digest_emails;
}.property(),
},
availableLocales: function() {
return this.siteSettings.available_locales.split('|').map( function(s) {
return {name: s, value: s};
});
}.property(),
@computed()
availableLocales() {
return this.siteSettings.available_locales.split('|').map(s => ({ name: s, value: s }));
},
digestFrequencies: [{ name: I18n.t('user.email_digests.daily'), value: 1 },
{ name: I18n.t('user.email_digests.every_three_days'), value: 3 },
@ -86,16 +90,16 @@ export default Ember.Controller.extend(CanCheckEmails, {
{ name: I18n.t('user.new_topic_duration.after_2_weeks'), value: 2 * 7 * 60 * 24 },
{ name: I18n.t('user.new_topic_duration.last_here'), value: -2 }],
saveButtonText: function() {
return this.get('model.isSaving') ? I18n.t('saving') : I18n.t('save');
}.property('model.isSaving'),
@computed("model.isSaving")
saveButtonText(isSaving) {
return isSaving ? I18n.t('saving') : I18n.t('save');
},
passwordProgress: null,
actions: {
save() {
const self = this;
this.set('saved', false);
const model = this.get('model');
@ -113,28 +117,27 @@ export default Ember.Controller.extend(CanCheckEmails, {
// Cook the bio for preview
model.set('name', this.get('newNameInput'));
return model.save().then(function() {
return model.save().then(() => {
if (Discourse.User.currentProp('id') === model.get('id')) {
Discourse.User.currentProp('name', model.get('name'));
}
model.set('bio_cooked', Discourse.Markdown.cook(Discourse.Markdown.sanitize(model.get('bio_raw'))));
self.set('saved', true);
this.set('saved', true);
}).catch(popupAjaxError);
},
changePassword() {
const self = this;
if (!this.get('passwordProgress')) {
this.set('passwordProgress', I18n.t("user.change_password.in_progress"));
return this.get('model').changePassword().then(function() {
return this.get('model').changePassword().then(() => {
// password changed
self.setProperties({
this.setProperties({
changePasswordProgress: false,
passwordProgress: I18n.t("user.change_password.success")
});
}, function() {
}).catch(() => {
// password failed to change
self.setProperties({
this.setProperties({
changePasswordProgress: false,
passwordProgress: I18n.t("user.change_password.error")
});

View file

@ -1,37 +1,29 @@
import registerUnbound from 'discourse/helpers/register-unbound';
import avatarTemplate from 'discourse/lib/avatar-template';
import { longDate, autoUpdatingRelativeAge, number } from 'discourse/lib/formatter';
const safe = Handlebars.SafeString;
Em.Handlebars.helper('bound-avatar', function(user, size, uploadId) {
Em.Handlebars.helper('bound-avatar', (user, size) => {
if (Em.isEmpty(user)) {
return new safe("<div class='avatar-placeholder'></div>");
}
const username = Em.get(user, 'username');
if (arguments.length < 4) { uploadId = Em.get(user, 'uploaded_avatar_id'); }
const avatar = Em.get(user, 'avatar_template') || avatarTemplate(username, uploadId);
const avatar = Em.get(user, 'avatar_template');
return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: avatar }));
}, 'username', 'uploaded_avatar_id', 'avatar_template');
}, 'username', 'avatar_template');
/*
* Used when we only have a template
*/
Em.Handlebars.helper('bound-avatar-template', function(at, size) {
Em.Handlebars.helper('bound-avatar-template', (at, size) => {
return new safe(Discourse.Utilities.avatarImg({ size: size, avatarTemplate: at }));
});
registerUnbound('raw-date', function(dt) {
return longDate(new Date(dt));
});
registerUnbound('raw-date', dt => longDate(new Date(dt)));
registerUnbound('age-with-tooltip', function(dt) {
return new safe(autoUpdatingRelativeAge(new Date(dt), {title: true}));
});
registerUnbound('age-with-tooltip', dt => new safe(autoUpdatingRelativeAge(new Date(dt), {title: true})));
registerUnbound('number', function(orig, params) {
registerUnbound('number', (orig, params) => {
orig = parseInt(orig, 10);
if (isNaN(orig)) { orig = 0; }

View file

@ -1,24 +1,23 @@
import registerUnbound from 'discourse/helpers/register-unbound';
import avatarTemplate from 'discourse/lib/avatar-template';
function renderAvatar(user, options) {
options = options || {};
if (user) {
var username = Em.get(user, 'username');
if (!username) {
if (!options.usernamePath) { return ''; }
username = Em.get(user, options.usernamePath);
}
var title;
const username = Em.get(user, options.usernamePath || 'username');
const avatarTemplate = Em.get(user, options.avatarTemplatePath || 'avatar_template');
if (!username || !avatarTemplate) { return ''; }
let title;
if (!options.ignoreTitle) {
// first try to get a title
title = Em.get(user, 'title');
// if there was no title provided
if (!title) {
// try to retrieve a description
var description = Em.get(user, 'description');
const description = Em.get(user, 'description');
// if a description has been provided
if (description && description.length > 0) {
// preprend the username before the description
@ -27,14 +26,11 @@ function renderAvatar(user, options) {
}
}
// this is simply done to ensure we cache images correctly
var uploadedAvatarId = Em.get(user, 'uploaded_avatar_id') || Em.get(user, 'user.uploaded_avatar_id');
return Discourse.Utilities.avatarImg({
size: options.imageSize,
extraClasses: Em.get(user, 'extras') || options.extraClasses,
title: title || username,
avatarTemplate: avatarTemplate(username, uploadedAvatarId)
avatarTemplate: avatarTemplate
});
} else {
return '';

View file

@ -1,8 +1,10 @@
import interceptClick from 'discourse/lib/intercept-click';
import DiscourseURL from 'discourse/lib/url';
export default {
name: "click-interceptor",
initialize() {
$('#main').on('click.discourse', 'a', interceptClick);
$(window).on('hashchange', () => DiscourseURL.routeTo(document.location.hash));
}
};

View file

@ -1,32 +0,0 @@
import { hashString } from 'discourse/lib/hash';
let _splitAvatars;
function defaultAvatar(username) {
const defaultAvatars = Discourse.SiteSettings.default_avatars;
if (defaultAvatars && defaultAvatars.length) {
_splitAvatars = _splitAvatars || defaultAvatars.split("\n");
if (_splitAvatars.length) {
const hash = hashString(username);
return _splitAvatars[Math.abs(hash) % _splitAvatars.length];
}
}
return Discourse.getURLWithCDN("/letter_avatar/" +
username.toLowerCase() +
"/{size}/" +
Discourse.LetterAvatarVersion + ".png");
}
export default function(username, uploadedAvatarId) {
if (uploadedAvatarId) {
return Discourse.getURLWithCDN("/user_avatar/" +
Discourse.BaseUrl +
"/" +
username.toLowerCase() +
"/{size}/" +
uploadedAvatarId + ".png");
}
return defaultAvatar(username);
}

View file

@ -32,6 +32,7 @@ KeyValueStore.prototype = {
},
remove(key) {
if (!safeLocalStorage) { return; }
return safeLocalStorage.removeItem(this.context + key);
},

View file

@ -106,4 +106,12 @@ const getSearchKey = function(args){
((args.searchContext && args.searchContext.id) || "")
};
export { searchForTerm, searchContextDescription, getSearchKey };
const isValidSearchTerm = function(searchTerm) {
if (searchTerm) {
return searchTerm.trim().length >= Discourse.SiteSettings.min_search_term_length;
} else {
return false;
}
};
export { searchForTerm, searchContextDescription, getSearchKey, isValidSearchTerm };

View file

@ -105,7 +105,7 @@ const DiscourseURL = Ember.Object.createWithMixins({
It contains the logic necessary to route within a topic using replaceState to
keep the history intact.
**/
routeTo: function(path, opts) {
routeTo(path, opts) {
if (Em.isEmpty(path)) { return; }
if (Discourse.get('requiresRefresh')) {
@ -122,6 +122,7 @@ const DiscourseURL = Ember.Object.createWithMixins({
// Scroll to the same page, different anchor
if (path.indexOf('#') === 0) {
this.scrollToId(path);
history.replaceState(undefined, undefined, path);
return;
}

View file

@ -24,6 +24,7 @@ const CLOSED = 'closed',
category: 'categoryId',
topic_id: 'topic.id',
is_warning: 'isWarning',
whisper: 'whisper',
archetype: 'archetypeId',
target_usernames: 'targetUsernames',
typing_duration_msecs: 'typingTime',
@ -557,6 +558,9 @@ const Composer = RestModel.extend({
let addedToStream = false;
const postTypes = this.site.get('post_types');
const postType = this.get('whisper') ? postTypes.whisper : postTypes.regular;
// Build the post object
const createdPost = this.store.createRecord('post', {
imageSizes: opts.imageSizes,
@ -567,9 +571,9 @@ const Composer = RestModel.extend({
username: user.get('username'),
user_id: user.get('id'),
user_title: user.get('title'),
uploaded_avatar_id: user.get('uploaded_avatar_id'),
avatar_template: user.get('avatar_template'),
user_custom_fields: user.get('custom_fields'),
post_type: this.site.get('post_types.regular'),
post_type: postType,
actions_summary: [],
moderator: user.get('moderator'),
admin: user.get('admin'),
@ -587,7 +591,7 @@ const Composer = RestModel.extend({
reply_to_post_number: post.get('post_number'),
reply_to_user: {
username: post.get('username'),
uploaded_avatar_id: post.get('uploaded_avatar_id')
avatar_template: post.get('avatar_template')
}
});
}

View file

@ -1,7 +1,7 @@
import RestModel from 'discourse/models/rest';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import ActionSummary from 'discourse/models/action-summary';
import { url, fmt, propertyEqual } from 'discourse/lib/computed';
import { url, propertyEqual } from 'discourse/lib/computed';
import Quote from 'discourse/lib/quote';
import computed from 'ember-addons/ember-computed-decorators';
@ -77,7 +77,6 @@ const Post = RestModel.extend({
topicOwner: propertyEqual('topic.details.created_by.id', 'user_id'),
hasHistory: Em.computed.gt('version', 1),
postElementId: fmt('post_number', 'post_%@'),
canViewRawEmail: function() {
return this.get("user_id") === Discourse.User.currentProp("id") || Discourse.User.currentProp('staff');

View file

@ -174,8 +174,8 @@ const TopicTrackingState = Discourse.Model.extend({
if (filter === "new") {
list.topics.splice(i, 1);
} else {
list.topics[i].unseen = false;
list.topics[i].dont_sync = true;
list.topics[i].set('unseen', false);
list.topics[i].set('dont_sync', true);
}
}
}

View file

@ -1,5 +1,7 @@
import RestModel from 'discourse/models/rest';
import { url } from 'discourse/lib/computed';
import { on } from 'ember-addons/ember-computed-decorators';
import computed from 'ember-addons/ember-computed-decorators';
const UserActionTypes = {
likes_given: 1,
@ -17,21 +19,22 @@ const UserActionTypes = {
};
const InvertedActionTypes = {};
_.each(UserActionTypes, function (k, v) {
_.each(UserActionTypes, (k, v) => {
InvertedActionTypes[k] = v;
});
const UserAction = RestModel.extend({
_attachCategory: function() {
@on("init")
_attachCategory() {
const categoryId = this.get('category_id');
if (categoryId) {
this.set('category', Discourse.Category.findById(categoryId));
}
}.on('init'),
},
descriptionKey: function() {
const action = this.get('action_type');
@computed("action_type")
descriptionKey(action) {
if (action === null || Discourse.UserAction.TO_SHOW.indexOf(action) >= 0) {
if (this.get('isPM')) {
return this.get('sameUser') ? 'sent_by_you' : 'sent_by_user';
@ -59,34 +62,39 @@ const UserAction = RestModel.extend({
return this.get('targetUser') ? 'user_mentioned_you' : 'user_mentioned_user';
}
}
}.property('action_type'),
},
sameUser: function() {
return this.get('username') === Discourse.User.currentProp('username');
}.property('username'),
@computed("username")
sameUser(username) {
return username === Discourse.User.currentProp('username');
},
targetUser: function() {
return this.get('target_username') === Discourse.User.currentProp('username');
}.property('target_username'),
@computed("target_username")
targetUser(targetUsername) {
return targetUsername === Discourse.User.currentProp('username');
},
presentName: Em.computed.any('name', 'username'),
targetDisplayName: Em.computed.any('target_name', 'target_username'),
actingDisplayName: Em.computed.any('acting_name', 'acting_username'),
targetUserUrl: url('target_username', '/users/%@'),
usernameLower: function() {
return this.get('username').toLowerCase();
}.property('username'),
@computed("username")
usernameLower(username) {
return username.toLowerCase();
},
userUrl: url('usernameLower', '/users/%@'),
postUrl: function() {
@computed()
postUrl() {
return Discourse.Utilities.postUrl(this.get('slug'), this.get('topic_id'), this.get('post_number'));
}.property(),
},
replyUrl: function() {
@computed()
replyUrl() {
return Discourse.Utilities.postUrl(this.get('slug'), this.get('topic_id'), this.get('reply_to_post_number'));
}.property(),
},
replyType: Em.computed.equal('action_type', UserActionTypes.replies),
postType: Em.computed.equal('action_type', UserActionTypes.posts),
@ -99,7 +107,7 @@ const UserAction = RestModel.extend({
postReplyType: Em.computed.or('postType', 'replyType'),
removableBookmark: Em.computed.and('bookmarkType', 'sameUser'),
addChild: function(action) {
addChild(action) {
let groups = this.get("childGroups");
if (!groups) {
groups = {
@ -143,22 +151,21 @@ const UserAction = RestModel.extend({
"childGroups.edits.items", "childGroups.edits.items.@each",
"childGroups.bookmarks.items", "childGroups.bookmarks.items.@each"),
switchToActing: function() {
switchToActing() {
this.setProperties({
username: this.get('acting_username'),
uploaded_avatar_id: this.get('acting_uploaded_avatar_id'),
name: this.get('actingDisplayName')
});
}
});
UserAction.reopenClass({
collapseStream: function(stream) {
collapseStream(stream) {
const uniq = {};
const collapsed = [];
let pos = 0;
stream.forEach(function(item) {
stream.forEach(item => {
const key = "" + item.topic_id + "-" + item.post_number;
const found = uniq[key];
if (found === void 0) {

View file

@ -1,11 +1,11 @@
import { url } from 'discourse/lib/computed';
import RestModel from 'discourse/models/rest';
import avatarTemplate from 'discourse/lib/avatar-template';
import UserStream from 'discourse/models/user-stream';
import UserPostsStream from 'discourse/models/user-posts-stream';
import Singleton from 'discourse/mixins/singleton';
import { longDate } from 'discourse/lib/formatter';
import computed from 'ember-addons/ember-computed-decorators';
import { observes } from 'ember-addons/ember-computed-decorators';
import Badge from 'discourse/models/badge';
import UserBadge from 'discourse/models/user-badge';
@ -18,13 +18,15 @@ const User = RestModel.extend({
hasNotPosted: Em.computed.not("hasPosted"),
canBeDeleted: Em.computed.and("can_be_deleted", "hasNotPosted"),
stream: function() {
@computed()
stream() {
return UserStream.create({ user: this });
}.property(),
},
postsStream: function() {
@computed()
postsStream() {
return UserPostsStream.create({ user: this });
}.property(),
},
staff: Em.computed.or('admin', 'moderator'),
@ -32,27 +34,22 @@ const User = RestModel.extend({
return Discourse.ajax(`/session/${this.get('username')}`, { type: 'DELETE'});
},
searchContext: function() {
@computed("username_lower")
searchContext(username) {
return {
type: 'user',
id: this.get('username_lower'),
id: username,
user: this
};
}.property('username_lower'),
},
/**
This user's display name. Returns the name if possible, otherwise returns the
username.
@property displayName
@type {String}
**/
displayName: function() {
if (Discourse.SiteSettings.enable_names && !Ember.isEmpty(this.get('name'))) {
return this.get('name');
@computed("username", "name")
displayName(username, name) {
if (Discourse.SiteSettings.enable_names && !Ember.isEmpty(name)) {
return name;
}
return this.get('username');
}.property('username', 'name'),
return username;
},
@computed('profile_background')
profileBackground(bgUrl) {
@ -60,38 +57,23 @@ const User = RestModel.extend({
return ('background-image: url(' + Discourse.getURLWithCDN(bgUrl) + ')').htmlSafe();
},
path: function(){
return Discourse.getURL('/users/' + this.get('username_lower'));
@computed()
path() {
// no need to observe, requires a hard refresh to update
}.property(),
return Discourse.getURL(`/users/${this.get('username_lower')}`);
},
/**
Path to this user's administration
@property adminPath
@type {String}
**/
adminPath: url('username_lower', "/admin/users/%@"),
/**
This user's username in lowercase.
@computed("username")
username_lower(username) {
return username.toLowerCase();
},
@property username_lower
@type {String}
**/
username_lower: function() {
return this.get('username').toLowerCase();
}.property('username'),
/**
This user's trust level.
@property trustLevel
@type {Integer}
**/
trustLevel: function() {
return Discourse.Site.currentProp('trustLevels').findProperty('id', parseInt(this.get('trust_level'), 10));
}.property('trust_level'),
@computed("trust_level")
trustLevel(trustLevel) {
return Discourse.Site.currentProp('trustLevels').findProperty('id', parseInt(trustLevel, 10));
},
isBasic: Em.computed.equal('trust_level', 0),
isLeader: Em.computed.equal('trust_level', 3),
@ -100,61 +82,36 @@ const User = RestModel.extend({
isSuspended: Em.computed.equal('suspended', true),
suspended: function() {
return this.get('suspended_till') && moment(this.get('suspended_till')).isAfter();
}.property('suspended_till'),
@computed("suspended_till")
suspended(suspendedTill) {
return suspendedTill && moment(suspendedTill).isAfter();
},
suspendedTillDate: function() {
return longDate(this.get('suspended_till'));
}.property('suspended_till'),
@computed("suspended_till")
suspendedTillDate(suspendedTill) {
return longDate(suspendedTill);
},
/**
Changes this user's username.
@method changeUsername
@param {String} newUsername The user's new username
@returns Result of ajax call
**/
changeUsername: function(newUsername) {
return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/username", {
changeUsername(new_username) {
return Discourse.ajax(`/users/${this.get('username_lower')}/preferences/username`, {
type: 'PUT',
data: { new_username: newUsername }
data: { new_username }
});
},
/**
Changes this user's email address.
@method changeEmail
@param {String} email The user's new email address\
@returns Result of ajax call
**/
changeEmail: function(email) {
return Discourse.ajax("/users/" + this.get('username_lower') + "/preferences/email", {
changeEmail(email) {
return Discourse.ajax(`/users/${this.get('username_lower')}/preferences/email`, {
type: 'PUT',
data: { email: email }
data: { email }
});
},
/**
Returns a copy of this user.
@method copy
@returns {User}
**/
copy: function() {
copy() {
return Discourse.User.create(this.getProperties(Ember.keys(this)));
},
/**
Save's this user's properties over AJAX via a PUT request.
@method save
@returns {Promise} the result of the operation
**/
save: function() {
const self = this,
data = this.getProperties(
save() {
const data = this.getProperties(
'auto_track_topics_after_msecs',
'bio_raw',
'website',
@ -179,10 +136,10 @@ const User = RestModel.extend({
'card_background'
);
['muted','watched','tracked'].forEach(function(s){
var cats = self.get(s + 'Categories').map(function(c){ return c.get('id')});
['muted','watched','tracked'].forEach(s => {
let cats = this.get(s + 'Categories').map(c => c.get('id'));
// HACK: denote lack of categories
if(cats.length === 0) { cats = [-1]; }
if (cats.length === 0) { cats = [-1]; }
data[s + '_category_ids'] = cats;
});
@ -192,26 +149,19 @@ const User = RestModel.extend({
// TODO: We can remove this when migrated fully to rest model.
this.set('isSaving', true);
return Discourse.ajax("/users/" + this.get('username_lower'), {
return Discourse.ajax(`/users/${this.get('username_lower')}`, {
data: data,
type: 'PUT'
}).then(function(result) {
self.set('bio_excerpt', result.user.bio_excerpt);
const userProps = self.getProperties('enable_quoting', 'external_links_in_new_tab', 'dynamic_favicon');
}).then(result => {
this.set('bio_excerpt', result.user.bio_excerpt);
const userProps = this.getProperties('enable_quoting', 'external_links_in_new_tab', 'dynamic_favicon');
Discourse.User.current().setProperties(userProps);
}).finally(() => {
this.set('isSaving', false);
});
},
/**
Changes the password and calls the callback function on AJAX.complete.
@method changePassword
@returns {Promise} the result of the change password operation
**/
changePassword: function() {
changePassword() {
return Discourse.ajax("/session/forgot_password", {
dataType: 'json',
data: { login: this.get('username') },
@ -219,73 +169,63 @@ const User = RestModel.extend({
});
},
/**
Loads a single user action by id.
@method loadUserAction
@param {Integer} id The id of the user action being loaded
@returns A stream of the user's actions containing the action of id
**/
loadUserAction: function(id) {
var self = this,
stream = this.get('stream');
return Discourse.ajax("/user_actions/" + id + ".json", { cache: 'false' }).then(function(result) {
loadUserAction(id) {
const stream = this.get('stream');
return Discourse.ajax(`/user_actions/${id}.json`, { cache: 'false' }).then(result => {
if (result && result.user_action) {
var ua = result.user_action;
const ua = result.user_action;
if ((self.get('stream.filter') || ua.action_type) !== ua.action_type) return;
if (!self.get('stream.filter') && !self.inAllStream(ua)) return;
if ((this.get('stream.filter') || ua.action_type) !== ua.action_type) return;
if (!this.get('stream.filter') && !this.inAllStream(ua)) return;
var action = Discourse.UserAction.collapseStream([Discourse.UserAction.create(ua)]);
const action = Discourse.UserAction.collapseStream([Discourse.UserAction.create(ua)]);
stream.set('itemsLoaded', stream.get('itemsLoaded') + 1);
stream.get('content').insertAt(0, action[0]);
}
});
},
inAllStream: function(ua) {
inAllStream(ua) {
return ua.action_type === Discourse.UserAction.TYPES.posts ||
ua.action_type === Discourse.UserAction.TYPES.topics;
},
// The user's stat count, excluding PMs.
statsCountNonPM: function() {
var self = this;
@computed("statsExcludingPms.@each.count")
statsCountNonPM() {
if (Ember.isEmpty(this.get('statsExcludingPms'))) return 0;
var count = 0;
_.each(this.get('statsExcludingPms'), function(val) {
if (self.inAllStream(val)){
let count = 0;
_.each(this.get('statsExcludingPms'), val => {
if (this.inAllStream(val)) {
count += val.count;
}
});
return count;
}.property('statsExcludingPms.@each.count'),
},
// The user's stats, excluding PMs.
statsExcludingPms: function() {
@computed("stats.@each.isPM")
statsExcludingPms() {
if (Ember.isEmpty(this.get('stats'))) return [];
return this.get('stats').rejectProperty('isPM');
}.property('stats.@each.isPM'),
},
findDetails: function(options) {
var user = this;
findDetails(options) {
const user = this;
return PreloadStore.getAndRemove("user_" + user.get('username'), function() {
return Discourse.ajax("/users/" + user.get('username') + '.json', {data: options});
}).then(function (json) {
return PreloadStore.getAndRemove(`user_${user.get('username')}`, () => {
return Discourse.ajax(`/users/${user.get('username')}.json`, { data: options });
}).then(json => {
if (!Em.isEmpty(json.user.stats)) {
json.user.stats = Discourse.User.groupStats(_.map(json.user.stats,function(s) {
json.user.stats = Discourse.User.groupStats(_.map(json.user.stats, s => {
if (s.count) s.count = parseInt(s.count, 10);
return Discourse.UserActionStat.create(s);
}));
}
if (!Em.isEmpty(json.user.custom_groups)) {
json.user.custom_groups = json.user.custom_groups.map(function (g) {
return Discourse.Group.create(g);
});
json.user.custom_groups = json.user.custom_groups.map(g => Discourse.Group.create(g));
}
if (json.user.invited_by) {
@ -294,12 +234,10 @@ const User = RestModel.extend({
if (!Em.isEmpty(json.user.featured_user_badge_ids)) {
const userBadgesMap = {};
UserBadge.createFromJson(json).forEach(function(userBadge) {
UserBadge.createFromJson(json).forEach(userBadge => {
userBadgesMap[ userBadge.get('id') ] = userBadge;
});
json.user.featured_user_badges = json.user.featured_user_badge_ids.map(function(id) {
return userBadgesMap[id];
});
json.user.featured_user_badges = json.user.featured_user_badge_ids.map(id => userBadgesMap[id]);
}
if (json.user.card_badge) {
@ -311,81 +249,62 @@ const User = RestModel.extend({
});
},
findStaffInfo: function() {
findStaffInfo() {
if (!Discourse.User.currentProp("staff")) { return Ember.RSVP.resolve(null); }
var self = this;
return Discourse.ajax("/users/" + this.get("username_lower") + "/staff-info.json").then(function(info) {
self.setProperties(info);
return Discourse.ajax(`/users/${this.get("username_lower")}/staff-info.json`).then(info => {
this.setProperties(info);
});
},
avatarTemplate: function() {
return avatarTemplate(this.get('username'), this.get('uploaded_avatar_id'));
}.property('uploaded_avatar_id', 'username'),
/*
Change avatar selection
*/
pickAvatar: function(uploadId) {
var self = this;
return Discourse.ajax("/users/" + this.get("username_lower") + "/preferences/avatar/pick", {
pickAvatar(upload_id, type, avatar_template) {
return Discourse.ajax(`/users/${this.get("username_lower")}/preferences/avatar/pick`, {
type: 'PUT',
data: { upload_id: uploadId }
}).then(function(){
self.set('uploaded_avatar_id', uploadId);
});
data: { upload_id, type }
}).then(() => this.setProperties({
avatar_template,
uploaded_avatar_id: upload_id
}));
},
/**
Determines whether the current user is allowed to upload a file.
@method isAllowedToUploadAFile
@param {String} type The type of the upload (image, attachment)
@returns true if the current user is allowed to upload a file
**/
isAllowedToUploadAFile: function(type) {
isAllowedToUploadAFile(type) {
return this.get('staff') ||
this.get('trust_level') > 0 ||
Discourse.SiteSettings['newuser_max_' + type + 's'] > 0;
},
/**
Invite a user to the site
@method createInvite
@param {String} email The email address of the user to invite to the site
@returns {Promise} the result of the server call
**/
createInvite: function(email, groupNames) {
createInvite(email, group_names) {
return Discourse.ajax('/invites', {
type: 'POST',
data: {email: email, group_names: groupNames}
data: { email, group_names }
});
},
generateInviteLink: function(email, groupNames, topicId) {
generateInviteLink(email, group_names, topic_id) {
return Discourse.ajax('/invites/link', {
type: 'POST',
data: {email: email, group_names: groupNames, topic_id: topicId}
data: { email, group_names, topic_id }
});
},
updateMutedCategories: function() {
@observes("muted_category_ids")
updateMutedCategories() {
this.set("mutedCategories", Discourse.Category.findByIds(this.muted_category_ids));
}.observes("muted_category_ids"),
},
updateTrackedCategories: function() {
@observes("tracked_category_ids")
updateTrackedCategories() {
this.set("trackedCategories", Discourse.Category.findByIds(this.tracked_category_ids));
}.observes("tracked_category_ids"),
},
updateWatchedCategories: function() {
@observes("watched_category_ids")
updateWatchedCategories() {
this.set("watchedCategories", Discourse.Category.findByIds(this.watched_category_ids));
}.observes("watched_category_ids"),
},
canDeleteAccount: function() {
return !Discourse.SiteSettings.enable_sso && this.get('can_delete_account') && ((this.get('reply_count')||0) + (this.get('topic_count')||0)) <= 1;
}.property('can_delete_account', 'reply_count', 'topic_count'),
@computed("can_delete_account", "reply_count", "topic_count")
canDeleteAccount(canDeleteAccount, replyCount, topicCount) {
return !Discourse.SiteSettings.enable_sso && canDeleteAccount && ((replyCount || 0) + (topicCount || 0)) <= 1;
},
"delete": function() {
if (this.get('can_delete_account')) {
@ -398,27 +317,26 @@ const User = RestModel.extend({
}
},
dismissBanner: function (bannerKey) {
dismissBanner(bannerKey) {
this.set("dismissed_banner_key", bannerKey);
Discourse.ajax("/users/" + this.get('username'), {
Discourse.ajax(`/users/${this.get('username')}`, {
type: 'PUT',
data: { dismissed_banner_key: bannerKey }
});
},
checkEmail: function () {
var self = this;
return Discourse.ajax("/users/" + this.get("username_lower") + "/emails.json", {
checkEmail() {
return Discourse.ajax(`/users/${this.get("username_lower")}/emails.json`, {
type: "PUT",
data: { context: window.location.pathname }
}).then(function (result) {
}).then(result => {
if (result) {
self.setProperties({
this.setProperties({
email: result.email,
associated_accounts: result.associated_accounts
});
}
}, function () {});
});
}
});
@ -426,14 +344,14 @@ const User = RestModel.extend({
User.reopenClass(Singleton, {
// Find a `Discourse.User` for a given username.
findByUsername: function(username, options) {
findByUsername(username, options) {
const user = User.create({username: username});
return user.findDetails(options);
},
// TODO: Use app.register and junk Singleton
createCurrent: function() {
var userJson = PreloadStore.get('currentUser');
createCurrent() {
const userJson = PreloadStore.get('currentUser');
if (userJson) {
const store = Discourse.__container__.lookup('store:main');
return store.createRecord('user', userJson);
@ -441,56 +359,38 @@ User.reopenClass(Singleton, {
return null;
},
/**
Checks if given username is valid for this email address
@method checkUsername
@param {String} username A username to check
@param {String} email An email address to check
@param {Number} forUserId user id - provide when changing username
**/
checkUsername: function(username, email, forUserId) {
checkUsername(username, email, for_user_id) {
return Discourse.ajax('/users/check_username', {
data: { username: username, email: email, for_user_id: forUserId }
data: { username, email, for_user_id }
});
},
/**
Groups the user's statistics
@method groupStats
@param {Array} stats Given stats
@returns {Object}
**/
groupStats: function(stats) {
var responses = Discourse.UserActionStat.create({
groupStats(stats) {
const responses = Discourse.UserActionStat.create({
count: 0,
action_type: Discourse.UserAction.TYPES.replies
});
stats.filterProperty('isResponse').forEach(function (stat) {
stats.filterProperty('isResponse').forEach(stat => {
responses.set('count', responses.get('count') + stat.get('count'));
});
var result = Em.A();
const result = Em.A();
result.pushObjects(stats.rejectProperty('isResponse'));
var insertAt = 0;
result.forEach(function(item, index){
if(item.action_type === Discourse.UserAction.TYPES.topics || item.action_type === Discourse.UserAction.TYPES.posts){
let insertAt = 0;
result.forEach((item, index) => {
if (item.action_type === Discourse.UserAction.TYPES.topics || item.action_type === Discourse.UserAction.TYPES.posts) {
insertAt = index + 1;
}
});
if(responses.count > 0) {
if (responses.count > 0) {
result.insertAt(insertAt, responses);
}
return(result);
return result;
},
/**
Creates a new account
**/
createAccount: function(attrs) {
createAccount(attrs) {
return Discourse.ajax("/users", {
data: {
name: attrs.accountName,

View file

@ -1,4 +1,4 @@
import { translateResults, getSearchKey } from "discourse/lib/search";
import { translateResults, getSearchKey, isValidSearchTerm } from "discourse/lib/search";
export default Discourse.Route.extend({
queryParams: { q: {}, context_id: {}, context: {} },
@ -23,7 +23,7 @@ export default Discourse.Route.extend({
}
return PreloadStore.getAndRemove("search", function() {
if (params.q && params.q.length > 2) {
if (isValidSearchTerm(params.q)) {
return Discourse.ajax("/search", { data: args });
} else {
return null;

View file

@ -18,50 +18,52 @@ export default RestrictedUserRoute.extend({
showModal('avatar-selector');
// all the properties needed for displaying the avatar selector modal
const controller = this.controllerFor('avatar-selector'),
props = this.modelFor('user').getProperties(
const props = this.modelFor('user').getProperties(
'id',
'email',
'username',
'uploaded_avatar_id',
'avatar_template',
'system_avatar_template',
'gravatar_avatar_template',
'custom_avatar_template',
'system_avatar_upload_id',
'gravatar_avatar_upload_id',
'custom_avatar_upload_id'
);
switch (props.uploaded_avatar_id) {
case props.system_avatar_upload_id:
switch (props.avatar_template) {
case props.system_avatar_template:
props.selected = "system";
break;
case props.gravatar_avatar_upload_id:
case props.gravatar_avatar_template:
props.selected = "gravatar";
break;
default:
props.selected = "uploaded";
}
controller.setProperties(props);
this.controllerFor('avatar-selector').setProperties(props);
},
saveAvatarSelection() {
const user = this.modelFor('user'),
avatarSelector = this.controllerFor('avatar-selector');
controller = this.controllerFor('avatar-selector'),
selectedUploadId = controller.get("selectedUploadId"),
selectedAvatarTemplate = controller.get("selectedAvatarTemplate"),
type = controller.get("selected");
// sends the information to the server if it has changed
if (avatarSelector.get('selectedUploadId') !== user.get('uploaded_avatar_id')) {
user.pickAvatar(avatarSelector.get('selectedUploadId'))
.then(() => {
user.setProperties(avatarSelector.getProperties(
'system_avatar_upload_id',
'gravatar_avatar_upload_id',
'custom_avatar_upload_id'
));
bootbox.alert(I18n.t("user.change_avatar.cache_notice"));
});
}
user.pickAvatar(selectedUploadId, type, selectedAvatarTemplate)
.then(() => {
user.setProperties(controller.getProperties(
'system_avatar_template',
'gravatar_avatar_template',
'custom_avatar_template'
));
bootbox.alert(I18n.t("user.change_avatar.cache_notice"));
});
// saves the data back
avatarSelector.send('closeModal');
controller.send('closeModal');
},
}

View file

@ -1,4 +1,7 @@
<section class='field'>
{{#if category.is_special}}
<p class="warning">{{i18n 'category.special_warning'}}</p>
{{/if}}
<ul class='permission-list'>
{{#each category.permissions as |p|}}
<li>
@ -16,6 +19,8 @@
{{view 'select' class="permission-selector" optionValuePath="content.id" optionLabelPath="content.description" content=category.availablePermissions value=selectedPermission}}
<button {{action "addPermission" selectedGroup selectedPermission}} class="btn btn-small">{{i18n 'category.add_permission'}}</button>
{{else}}
<button {{action "editPermissions"}} class="btn btn-small">{{i18n 'category.edit_permissions'}}</button>
{{#unless category.is_special}}
<button {{action "editPermissions"}} class="btn btn-small">{{i18n 'category.edit_permissions'}}</button>
{{/unless}}
{{/if}}
</section>

View file

@ -23,7 +23,7 @@
{{fa-icon 'times'}} {{i18n "bookmarks.remove"}}
</button>
{{else}}
<a href={{grandChild.userUrl}} data-user-card={{grandChild.username}} class='avatar-link'><div class='avatar-wrapper'>{{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true"}}</div></a>
<a href={{grandChild.userUrl}} data-user-card={{grandChild.username}} class='avatar-link'><div class='avatar-wrapper'>{{avatar grandChild imageSize="tiny" extraClasses="actor" ignoreTitle="true" avatarTemplatePath="acting_avatar_template"}}</div></a>
{{#if grandChild.edit_reason}} &mdash; <span class="edit-reason">{{grandChild.edit_reason}}</span>{{/if}}
{{/if}}
{{/each}}

View file

@ -60,6 +60,16 @@
{{/unless}}
</div>
{{/if}}
{{#if canWhisper}}
<div class='form-element clearfix'>
<label>
{{input type="checkbox" checked=model.whisper tabindex="3"}}
{{i18n "composer.add_whisper"}}
</label>
</div>
{{/if}}
{{plugin-outlet "composer-fields"}}
</div>

View file

@ -1,6 +1,6 @@
<div class="search row clearfix">
{{input type="text" value=searchTerm class="input-xxlarge search no-blur" action="search"}}
{{d-button action="search" icon="search" class="btn-primary"}}
{{search-text-field value=searchTerm class="input-xxlarge search no-blur" action="search" hasAutofocus=hasAutofocus}}
{{d-button action="search" icon="search" class="btn-primary" disabled=isNotValidSearchTerm}}
{{#if canBulkSelect}}
{{#if model.posts}}
{{d-button icon="list" class="bulk-select" title="topics.bulk.toggle" action="toggleBulkSelect"}}

View file

@ -0,0 +1,5 @@
{{#if view.showBadges}}
{{raw "topic-post-badges" unread=topic.unread newPosts=topic.displayNewPosts unseen=topic.unseen url=topic.lastUnreadUrl}}
{{else}}
{{raw "list/posts-count-column" topic=topic tagName="div"}}
{{/if}}

View file

@ -1,5 +1,5 @@
<td class='posters'>
{{#each poster in posters}}
<a href="{{poster.user.path}}" data-user-card="{{poster.user.username}}" class="{{poster.extras}}">{{avatar poster usernamePath="user.username" imageSize="small"}}</a>
<a href="{{poster.user.path}}" data-user-card="{{poster.user.username}}" class="{{poster.extras}}">{{avatar poster avatarTemplatePath="user.avatar_template" usernamePath="user.username" imageSize="small"}}</a>
{{/each}}
</td>

View file

@ -5,13 +5,9 @@
{{#each t in topics}}
<tr {{bind-attr class="t.archived"}}>
<td>
<div class='main-link clearfix'>
<div class='main-link'>
{{topic-status topic=t}}
{{topic-link t}}
{{topic-post-badges unread=t.unread
newPosts=t.new_posts
unseen=t.unseen
url=t.lastUnreadUrl}}
{{#if t.hasExcerpt}}
<div class="topic-excerpt">
@ -25,10 +21,14 @@
</div>
{{/if}}
</div>
<div class='pull-right'>
{{raw "list/post-count-or-badges" topic=t postBadgesEnabled="true"}}
</div>
<div class='clearfix'></div>
<div class="topic-item-stats clearfix">
<div class="pull-right">
{{raw "list/posts-count-column" topic=t tagName="div"}}
{{raw "list/activity-column" topic=t tagName="div" class="num activity last"}}
<a href="{{t.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date t.bumped_at}}}'>{{t.last_poster_username}}</a>
</div>
{{#unless controller.hideCategory}}
<div class='category'>

View file

@ -2,13 +2,10 @@
<div class='main-link'>
{{raw "topic-status" topic=content}}
{{topic-link content}}
{{#if controller.showTopicPostBadges}}
{{raw "topic-post-badges" unread=content.unread newPosts=content.displayNewPosts unseen=content.unseen url=content.lastUnreadUrl}}
{{/if}}
{{raw "list/topic-excerpt" topic=content}}
</div>
<div class='pull-right'>
{{raw "list/posts-count-column" topic=content tagName="div"}}
{{raw "list/post-count-or-badges" topic=content postBadgesEnabled=controller.showTopicPostBadges}}
</div>
<div class="clearfix"></div>
@ -22,8 +19,8 @@
<div class="pull-right">
<div class='num activity last'>
<a href="{{content.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date content.bumped_at}}}'>{{content.last_poster_username}}</a>
{{raw "list/activity-column" topic=content tagName="span" class="age"}}
<a href="{{content.lastPostUrl}}" title='{{i18n 'last_post'}}: {{{raw-date content.bumped_at}}}'>{{content.last_poster_username}}</a>
</div>
</div>
<div class="clearfix"></div>

View file

@ -2,32 +2,27 @@
<div>
<div>
<input type="radio" id="system-avatar" name="avatar" value="system" {{action "useSystem"}}>
<label class="radio" for="system-avatar">{{bound-avatar controller "large" system_avatar_upload_id}} {{{i18n 'user.change_avatar.letter_based'}}}</label>
<label class="radio" for="system-avatar">{{bound-avatar-template system_avatar_template "large"}} {{{i18n 'user.change_avatar.letter_based'}}}</label>
</div>
<div>
<input type="radio" id="gravatar" name="avatar" value="gravatar" {{action "useGravatar"}}>
<label class="radio" for="gravatar">{{bound-avatar controller "large" gravatar_avatar_upload_id}} {{{i18n 'user.change_avatar.gravatar'}}} {{email}}</label>
<label class="radio" for="gravatar">{{bound-avatar-template gravatar_avatar_template "large"}} {{{i18n 'user.change_avatar.gravatar'}}} {{email}}</label>
{{d-button action="refreshGravatar" title="user.change_avatar.refresh_gravatar_title" disabled=gravatarRefreshDisabled icon="refresh"}}
</div>
{{#if allowImageUpload}}
<div>
<input type="radio" id="uploaded_avatar" name="avatar" value="uploaded" {{action "useUploadedAvatar"}}>
<label class="radio" for="uploaded_avatar">
{{#if hasUploadedAvatar}}
{{#if uploadedAvatarTemplate}}
{{bound-avatar-template uploadedAvatarTemplate "large"}}
{{else}}
{{bound-avatar controller "large" custom_avatar_upload_id}}
{{/if}}
{{#if custom_avatar_template}}
{{bound-avatar-template custom_avatar_template "large"}}
{{i18n 'user.change_avatar.uploaded_avatar'}}
{{else}}
{{i18n 'user.change_avatar.uploaded_avatar_empty'}}
{{/if}}
</label>
{{avatar-uploader username=username
user_id=id
uploadedAvatarTemplate=uploadedAvatarTemplate
custom_avatar_upload_id=custom_avatar_upload_id
{{avatar-uploader user_id=id
uploadedAvatarTemplate=custom_avatar_template
uploadedAvatarId=custom_avatar_upload_id
uploading=uploading
done="useUploadedAvatar"}}
</div>
@ -36,6 +31,6 @@
</div>
<div class="modal-footer">
{{d-button action="saveAvatarSelection" class="btn-primary" disabled=saveDisabled label="save"}}
{{d-button action="saveAvatarSelection" class="btn-primary" disabled=uploading label="save"}}
<a {{action "closeModal"}}>{{i18n 'cancel'}}</a>
</div>

View file

@ -8,7 +8,7 @@
{{view 'reply-history' content=replyHistory}}
</div>
<article {{bind-attr class=":boxed via_email" id="postElementId" data-post-id="id" data-user-id="user_id"}}>
<article class="boxed {{if via_email 'via-email'}}" id={{postElementId}} data-post-id={{id}} data-user-id={{user_id}}>
<div class='row'>
<div class='topic-avatar'>
@ -45,15 +45,20 @@
</div>
{{/if}}
{{#if wiki}}
<div class="post-info wiki" title="{{i18n 'post.wiki.about'}}" {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div>
<div class="post-info wiki" title={{i18n 'post.wiki.about'}} {{action "editPost" this}}>{{fa-icon "pencil-square-o"}}</div>
{{/if}}
{{#if via_email}}
{{#if canViewRawEmail}}
<div class="post-info via-email raw-email" title="{{i18n 'post.via_email'}}" {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div>
<div class="post-info via-email raw-email" title={{i18n 'post.via_email'}} {{action "showRawEmail" this}}>{{fa-icon "envelope-o"}}</div>
{{else}}
<div class="post-info via-email" title="{{i18n 'post.via_email'}}">{{fa-icon "envelope-o"}}</div>
<div class="post-info via-email" title={{i18n 'post.via_email'}}>{{fa-icon "envelope-o"}}</div>
{{/if}}
{{/if}}
{{#if view.whisper}}
<div class="post-info whisper" title={{i18n 'post.whisper'}}>{{fa-icon "eye-slash"}}</div>
{{/if}}
{{#if showUserReplyTab}}
<a href {{action "toggleReplyHistory" this target="view"}} class='reply-to-tab'>
{{#if loadingReplyHistory}}

View file

@ -1,3 +1,4 @@
import { on, observes } from "ember-addons/ember-computed-decorators";
import ModalBodyView from "discourse/views/modal-body";
export default ModalBodyView.extend({
@ -6,11 +7,14 @@ export default ModalBodyView.extend({
title: I18n.t('user.change_avatar.title'),
// *HACK* used to select the proper radio button, because {{action}} stops the default behavior
selectedChanged: function() {
@on("didInsertElement")
@observes("controller.selected")
selectedChanged() {
Em.run.next(() => $('input:radio[name="avatar"]').val([this.get('controller.selected')]));
}.observes('controller.selected').on("didInsertElement"),
},
_focusSelectedButton: function() {
@on("didInsertElement")
_focusSelectedButton() {
Em.run.next(() => $('input:radio[value="' + this.get('controller.selected') + '"]').focus());
}.on("didInsertElement")
}
});

View file

@ -1,7 +1,6 @@
import userSearch from 'discourse/lib/user-search';
import afterTransition from 'discourse/lib/after-transition';
import loadScript from 'discourse/lib/load-script';
import avatarTemplate from 'discourse/lib/avatar-template';
import positioningWorkaround from 'discourse/lib/safari-hacks';
import debounce from 'discourse/lib/debounce';
import { linkSeenMentions, fetchUnseenMentions } from 'discourse/lib/link-mentions';
@ -251,10 +250,7 @@ const ComposerView = Ember.View.extend(Ember.Evented, {
if (posts && topicId === self.get('controller.controllers.topic.model.id')) {
const quotedPost = posts.findProperty("post_number", postNumber);
if (quotedPost) {
const username = quotedPost.get('username'),
uploadId = quotedPost.get('uploaded_avatar_id');
return Discourse.Utilities.tinyAvatar(avatarTemplate(username, uploadId));
return Discourse.Utilities.tinyAvatar(quotedPost.get('avatar_template'));
}
}
}

View file

@ -0,0 +1,4 @@
export default Ember.Object.extend({
postCountsPresent: Ember.computed.or('topic.unread', 'topic.displayNewPosts', 'topic.unseen'),
showBadges: Ember.computed.and('postBadgesEnabled', 'postCountsPresent')
});

View file

@ -1,6 +1,8 @@
import ScreenTrack from 'discourse/lib/screen-track';
import { number } from 'discourse/lib/formatter';
import DiscourseURL from 'discourse/lib/url';
import computed from 'ember-addons/ember-computed-decorators';
import { fmt } from 'discourse/lib/computed';
const DAY = 60 * 50 * 1000;
@ -12,10 +14,18 @@ const PostView = Discourse.GroupedView.extend(Ember.Evented, {
'post.deleted:deleted',
'post.topicOwner:topic-owner',
'groupNameClass',
'post.wiki:wiki'],
'post.wiki:wiki',
'whisper'],
post: Ember.computed.alias('content'),
postElementId: fmt('post.post_number', 'post_%@'),
@computed('post.post_type')
whisper(postType) {
return postType === this.site.get('post_types.whisper');
},
templateName: function() {
return (this.get('post.post_type') === this.site.get('post_types.small_action')) ? 'post-small-action' : 'post';
}.property('post.post_type'),

View file

@ -14,7 +14,6 @@
//= require ./discourse/lib/load-script
//= require ./discourse/lib/notification-levels
//= require ./discourse/lib/app-events
//= require ./discourse/lib/avatar-template
//= require ./discourse/lib/url
//= require ./discourse/lib/debounce
//= require ./discourse/lib/quote
@ -41,7 +40,6 @@
//= require ./discourse/lib/autocomplete
//= require ./discourse/lib/after-transition
//= require ./discourse/lib/debounce
//= require ./discourse/lib/avatar-template
//= require ./discourse/lib/safari-hacks
//= require_tree ./discourse/adapters
//= require ./discourse/models/rest

View file

@ -147,7 +147,7 @@ aside.quote {
}
.post-info {
&.wiki, &.via-email {
&.wiki, &.via-email, &.whisper {
margin-right: 5px;
i.fa {
font-size: 1em;
@ -317,3 +317,12 @@ table.md-table {
clear: both;
}
.whisper {
.topic-body {
.cooked {
font-style: italic;
color: dark-light-diff($primary, $secondary, 55%, -40%);
}
}
}

View file

@ -324,8 +324,8 @@
}
.title-input .popup-tip {
width: 300px;
left: 0px;
top: -30px;
margin-top: 8px;
left: 150px;
}
.category-input .popup-tip {
width: 240px;

View file

@ -14,8 +14,8 @@ article.post {
}
.quote .title {
border-left: 5px solid darken(dark-light-diff($primary, $secondary, 90%, -60%), 10%);
background-color: dark-light-diff($primary, $secondary, 90%, -60%);
border-left: 5px solid darken(dark-light-diff($primary, $secondary, 97%, -45%), 10%);
background-color: dark-light-diff($primary, $secondary, 97%, -45%);
padding: 10px 10px 0 12px;
.avatar { margin-right: 7px; }
}
@ -27,8 +27,8 @@ article.post {
blockquote {
padding: 10px 8px 10px 13px;
margin: 0 0 10px 0;
background-color: dark-light-diff($primary, $secondary, 90%, -60%);
border-left: 5px solid darken(dark-light-diff($primary, $secondary, 90%, -60%), 10%);
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;
@ -137,9 +137,3 @@ footer {
float: right;
max-height: 30px;
}
.button {
background-color: #eee;
padding: 5px;
display: inline-block;
}

View file

@ -80,7 +80,17 @@
.badge-notification {
position: relative;
top: -1px;
i {color: $secondary;}
font-size: 1.071em;
padding: 4px 6px 3px 6px;
i {color: $secondary;}
&.new-topic::before {
content: none;
margin-right: 0;
}
&.new-topic {
padding-right: 0;
}
}
.topic-item-stats {
@ -91,7 +101,7 @@
.category a {
max-width: 160px;
}
.num .fa {
.num .fa, a, a:visited {
color: dark-light-choose(scale-color($primary, $lightness: 50%), scale-color($secondary, $lightness: 50%));
}
}
@ -110,6 +120,29 @@
// Category list
// --------------------------------------------------
.categories-list .list-container {
margin-left: -10px; // Extend past the .wrap padding to the edge of the window
}
.category-list-item.category {
// Allow percentage widths on table cells to include their padding
box-sizing: border-box;
*, *:before, *:after {
box-sizing: inherit;
}
.main-link {
width: 80%;
}
.posts {
width: 10%;
}
.age {
width: 10%;
}
}
tr.category-topic-link {
border-top: darken($secondary, 3%) 1px solid;
@ -357,7 +390,7 @@ td .main-link {
padding: 5px 10px 5px 0;
}
}
.topic-list-item {
.topic-list {
.posts-map {
font-size: 1.071em;
padding-top: 2px;

View file

@ -465,6 +465,10 @@ class PostsController < ApplicationController
result[:is_warning] = false
end
if SiteSetting.enable_whispers? && params[:whisper] == "true"
result[:post_type] = Post.types[:whisper]
end
PostRevisor.tracked_topic_fields.each_key do |f|
params.permit(f => [])
result[f] = params[f] if params.has_key?(f)

View file

@ -13,7 +13,10 @@ class UserAvatarsController < ApplicationController
user.create_user_avatar(user_id: user.id) unless user.user_avatar
user.user_avatar.update_gravatar!
render json: { upload_id: user.user_avatar.gravatar_upload_id }
render json: {
gravatar_upload_id: user.user_avatar.gravatar_upload_id,
gravatar_avatar_template: User.avatar_template(user.username, user.user_avatar.gravatar_upload_id)
}
else
raise Discourse::NotFound
end

View file

@ -515,13 +515,13 @@ class UsersController < ApplicationController
results = UserSearch.new(term, topic_id: topic_id, topic_allowed_users: topic_allowed_users, searching_user: current_user).search
user_fields = [:username, :upload_avatar_template, :uploaded_avatar_id]
user_fields = [:username, :upload_avatar_template]
user_fields << :name if SiteSetting.enable_names?
to_render = { users: results.as_json(only: user_fields, methods: :avatar_template) }
to_render = { users: results.as_json(only: user_fields, methods: [:avatar_template]) }
if params[:include_groups] == "true"
to_render[:groups] = Group.search_group(term, current_user).map {|m| {:name=>m.name, :usernames=> m.usernames.split(",")} }
to_render[:groups] = Group.search_group(term, current_user).map { |m| { name: m.name, usernames: m.usernames.split(",") } }
end
render json: to_render
@ -531,13 +531,15 @@ class UsersController < ApplicationController
user = fetch_user_from_params
guardian.ensure_can_edit!(user)
type = params[:type]
upload_id = params[:upload_id]
user.uploaded_avatar_id = upload_id
# ensure we associate the custom avatar properly
if upload_id && user.user_avatar.custom_upload_id != upload_id
if type == "uploaded" || type == "custom"
user.user_avatar.custom_upload_id = upload_id
elsif type == "gravatar"
user.user_avatar.gravatar_upload_id = upload_id
end
user.save!

View file

@ -74,7 +74,7 @@ class Post < ActiveRecord::Base
end
def self.types
@types ||= Enum.new(:regular, :moderator_action, :small_action)
@types ||= Enum.new(:regular, :moderator_action, :small_action, :whisper)
end
def self.cook_methods
@ -96,15 +96,24 @@ class Post < ActiveRecord::Base
end
def publish_change_to_clients!(type)
# special failsafe for posts missing topics
# consistency checks should fix, but message
channel = "/topic/#{topic_id}"
msg = { id: id,
post_number: post_number,
updated_at: Time.now,
type: type }
# special failsafe for posts missing topics consistency checks should fix, but message
# is safe to skip
MessageBus.publish("/topic/#{topic_id}", {
id: id,
post_number: post_number,
updated_at: Time.now,
type: type
}, group_ids: topic.secure_group_ids) if topic
return unless topic
# Whispers should not be published to everyone
if post_type == Post.types[:whisper]
user_ids = User.where('admin or moderator or id = ?', user_id).pluck(:id)
MessageBus.publish(channel, msg, user_ids: user_ids)
else
MessageBus.publish(channel, msg, group_ids: topic.secure_group_ids)
end
end
def trash!(trashed_by=nil)

View file

@ -218,6 +218,13 @@ class Topic < ActiveRecord::Base
end
end
def visible_post_types(viewed_by=nil)
types = Post.types
result = [types[:regular], types[:moderator_action], types[:small_action]]
result << types[:whisper] if viewed_by.try(:staff?)
result
end
def self.top_viewed(max = 10)
Topic.listable_topics.visible.secured.order('views desc').limit(max)
end

View file

@ -50,7 +50,7 @@ class Upload < ActiveRecord::Base
end
# list of image types that will be cropped
CROPPED_IMAGE_TYPES ||= ["avatar", "profile_background", "card_background"]
CROPPED_IMAGE_TYPES ||= %w{avatar profile_background card_background}
# options
# - content_type

View file

@ -454,26 +454,41 @@ class User < ActiveRecord::Base
[((result << 5) - result) + char.ord].pack('L').unpack('l').first
end
avatar_template = split_avatars[hash.abs % split_avatars.size]
split_avatars[hash.abs % split_avatars.size]
end
else
system_avatar_template(username)
end
end
def self.avatar_template(username, uploaded_avatar_id)
username ||= ""
return default_template(username) if !uploaded_avatar_id
hostname = RailsMultisite::ConnectionManagement.current_hostname
UserAvatar.local_avatar_template(hostname, username.downcase, uploaded_avatar_id)
end
def self.system_avatar_template(username)
# TODO it may be worth caching this in a distributed cache, should be benched
if SiteSetting.external_system_avatars_enabled
url = SiteSetting.external_system_avatars_url.dup
url.gsub! "{color}", letter_avatar_color(username.downcase)
url.gsub! "{username}", username
url.gsub! "{first_letter}", username[0].downcase
url
else
"#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
end
end
def self.avatar_template(username,uploaded_avatar_id)
return default_template(username) if !uploaded_avatar_id
def self.letter_avatar_color(username)
username ||= ""
hostname = RailsMultisite::ConnectionManagement.current_hostname
UserAvatar.local_avatar_template(hostname, username.downcase, uploaded_avatar_id)
end
def self.letter_avatar_template(username)
"#{Discourse.base_uri}/letter_avatar/#{username.downcase}/{size}/#{LetterAvatar.version}.png"
color = LetterAvatar::COLORS[Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length]
color.map { |c| c.to_s(16).rjust(2, '0') }.join
end
def avatar_template
self.class.avatar_template(username,uploaded_avatar_id)
self.class.avatar_template(username, uploaded_avatar_id)
end
# The following count methods are somewhat slow - definitely don't use them in a loop.

View file

@ -39,8 +39,7 @@ class UserAvatar < ActiveRecord::Base
end
def self.local_avatar_url(hostname, username, upload_id, size)
version = self.version(upload_id)
"#{Discourse.base_uri}/user_avatar/#{hostname}/#{username}/#{size}/#{version}.png"
self.local_avatar_template(hostname, username, upload_id).gsub("{size}", size.to_s)
end
def self.local_avatar_template(hostname, username, upload_id)
@ -49,8 +48,7 @@ class UserAvatar < ActiveRecord::Base
end
def self.external_avatar_url(user_id, upload_id, size)
version = self.version(upload_id)
"#{Discourse.store.absolute_base_url}/avatars/#{user_id}/#{size}/#{version}.png"
self.external_avatar_template(user_id, upload_id).gsub("{size}", size.to_s)
end
def self.external_avatar_template(user_id, upload_id)

View file

@ -3,7 +3,7 @@ class AdminPostSerializer < ApplicationSerializer
attributes :id,
:created_at,
:post_number,
:name, :username, :avatar_template, :uploaded_avatar_id,
:name, :username, :avatar_template,
:topic_id, :topic_slug, :topic_title,
:category_id,
:excerpt,
@ -29,10 +29,6 @@ class AdminPostSerializer < ApplicationSerializer
object.user.avatar_template
end
def uploaded_avatar_id
object.user.uploaded_avatar_id
end
def topic_slug
topic.slug
end

View file

@ -4,7 +4,6 @@ class BasicPostSerializer < ApplicationSerializer
:name,
:username,
:avatar_template,
:uploaded_avatar_id,
:created_at,
:cooked,
:cooked_hidden
@ -21,13 +20,10 @@ class BasicPostSerializer < ApplicationSerializer
object.user.try(:avatar_template)
end
def uploaded_avatar_id
object.user.try(:uploaded_avatar_id)
end
def cooked_hidden
object.hidden && !scope.is_staff?
end
def include_cooked_hidden?
cooked_hidden
end

View file

@ -1,5 +1,5 @@
class BasicUserSerializer < ApplicationSerializer
attributes :id, :username, :uploaded_avatar_id, :avatar_template
attributes :id, :username, :avatar_template
def include_name?
SiteSetting.enable_names?
@ -9,7 +9,7 @@ class BasicUserSerializer < ApplicationSerializer
if Hash === object
User.avatar_template(user[:username], user[:uploaded_avatar_id])
else
object.avatar_template
user.try(:avatar_template)
end
end

View file

@ -11,6 +11,7 @@ class CategorySerializer < BasicCategorySerializer
:suppress_from_homepage,
:can_delete,
:cannot_delete_reason,
:is_special,
:allow_badges,
:custom_fields
@ -37,6 +38,15 @@ class CategorySerializer < BasicCategorySerializer
true
end
def include_is_special?
[SiteSetting.lounge_category_id, SiteSetting.meta_category_id, SiteSetting.staff_category_id, SiteSetting.uncategorized_category_id]
.include? object.id
end
def is_special
true
end
def include_can_delete?
scope && scope.can_delete?(object)
end

View file

@ -9,10 +9,6 @@ class PostActionUserSerializer < BasicUserSerializer
object.user.username
end
def uploaded_avatar_id
object.user.uploaded_avatar_id
end
def avatar_template
object.user.avatar_template
end

View file

@ -177,8 +177,7 @@ class PostSerializer < BasicPostSerializer
def reply_to_user
{
username: object.reply_to_user.username,
avatar_template: object.reply_to_user.avatar_template,
uploaded_avatar_id: object.reply_to_user.uploaded_avatar_id
avatar_template: object.reply_to_user.avatar_template
}
end

View file

@ -14,8 +14,4 @@ class TopicPostCountSerializer < BasicUserSerializer
object[:post_count]
end
def uploaded_avatar_id
object[:user].uploaded_avatar_id
end
end

View file

@ -26,10 +26,8 @@ class UserActionSerializer < ApplicationSerializer
:action_code,
:edit_reason,
:category_id,
:uploaded_avatar_id,
:closed,
:archived,
:acting_uploaded_avatar_id
:archived
def excerpt
cooked = object.cooked || PrettyText.cook(object.raw)

View file

@ -1,20 +1,3 @@
class UserNameSerializer < ApplicationSerializer
attributes :id, :username, :name, :title, :uploaded_avatar_id, :avatar_template
def include_name?
SiteSetting.enable_names?
end
def avatar_template
if Hash === object
User.avatar_template(user[:username], user[:uploaded_avatar_id])
else
object.avatar_template
end
end
def user
object[:user] || object
end
class UserNameSerializer < BasicUserSerializer
attributes :name, :title
end

View file

@ -93,8 +93,12 @@ class UserSerializer < BasicUserSerializer
:watched_category_ids,
:private_messages_stats,
:disable_jump_reply,
:system_avatar_upload_id,
:system_avatar_template,
:gravatar_avatar_upload_id,
:gravatar_avatar_template,
:custom_avatar_upload_id,
:custom_avatar_template,
:has_title_badges,
:card_image_badge,
:card_image_badge_id,
@ -278,14 +282,32 @@ class UserSerializer < BasicUserSerializer
UserAction.private_messages_stats(object.id, scope)
end
def system_avatar_upload_id
# should be left blank
end
def system_avatar_template
User.system_avatar_template(object.username)
end
def gravatar_avatar_upload_id
object.user_avatar.try(:gravatar_upload_id)
end
def gravatar_avatar_template
return unless gravatar_upload_id = object.user_avatar.try(:gravatar_upload_id)
User.avatar_template(object.username, gravatar_upload_id)
end
def custom_avatar_upload_id
object.user_avatar.try(:custom_upload_id)
end
def custom_avatar_template
return unless custom_upload_id = object.user_avatar.try(:custom_upload_id)
User.avatar_template(object.username, custom_upload_id)
end
def has_title_badges
object.badges.where(allow_title: true).count > 0
end
@ -323,4 +345,5 @@ class UserSerializer < BasicUserSerializer
def pending_count
0
end
end

View file

@ -22,7 +22,7 @@
<span itemprop='name'><%= t.title %></span>
</a>
<%= page_links(t) %>
<% if !@category && t.category %>
<% if (!@category || @category.has_children?) && t.category %>
<span>[<a href='<%= t.category.url %>'><%= t.category.name %></a>]</span>
<% end %>
<span title='<%= t 'posts' %>'>(<a href="<%=t.last_post_url%>"><%= t.posts_count %></a>)</span>

View file

@ -28,8 +28,10 @@ class NoFallbackLocaleList < FallbackLocaleList
end
end
if Rails.env.production?
I18n.fallbacks = FallbackLocaleList.new
else
if Rails.env.development?
I18n.fallbacks = NoFallbackLocaleList.new
else
I18n.fallbacks = FallbackLocaleList.new
I18n.config.missing_interpolation_argument_handler = proc { throw(:exception) }
end

View file

@ -813,6 +813,7 @@ en:
emoji: "Emoji :smile:"
add_warning: "This is an official warning."
add_whisper: "This is a whisper only visible to moderators"
posting_not_on_topic: "Which topic do you want to reply to?"
saving_draft_tip: "saving..."
saved_draft_tip: "saved"
@ -1353,6 +1354,7 @@ en:
yes_value: "Yes, abandon"
via_email: "this post arrived via email"
whisper: "this post is a private whisper for moderators"
wiki:
about: "this post is a wiki; basic users can edit it"
@ -1544,6 +1546,7 @@ en:
change_in_category_topic: "Edit Description"
already_used: 'This color has been used by another category'
security: "Security"
special_warning: "Warning: This category is a pre-seeded category and the security settings cannot be edited. If you do not wish to use this category, delete it instead of repurposing it."
images: "Images"
auto_close_label: "Auto-close topics after:"
auto_close_units: "hours"

View file

@ -94,7 +94,7 @@ en:
min_username_length_exists: "You cannot set the minimum username length above the shortest username."
min_username_length_range: "You cannot set the minimum above the maximum."
max_username_length_exists: "You cannot set the maximum username length below the longest username."
max_username_length_range: "You cannot set the maximum above the minimum."
max_username_length_range: "You cannot set the maximum below the minimum."
default_categories_already_selected: "You cannot select a category used in another list."
activemodel:
@ -880,6 +880,7 @@ en:
email_token_grace_period_hours: "Forgot password / activate account tokens are still valid for a grace period of (n) hours after being redeemed."
enable_badges: "Enable the badge system"
enable_whispers: "Allow users to whisper to moderators"
allow_index_in_robots_txt: "Specify in robots.txt that this site is allowed to be indexed by web search engines."
email_domains_blacklist: "A pipe-delimited list of email domains that users are not allowed to register accounts with. Example: mailinator.com|trashmail.net"
@ -979,6 +980,9 @@ en:
avatar_sizes: "List of automatically generated avatar sizes."
external_system_avatars_enabled: "Use external system avatars service."
external_system_avatars_url: "URL of the external system avatars service. Allowed substitions are {username} {first_letter} {color} {size}"
enable_flash_video_onebox: "Enable embedding of swf and flv (Adobe Flash) links in oneboxes. WARNING: may introduce security risks."
default_invitee_trust_level: "Default trust level (0-4) for invited users."
@ -1199,8 +1203,8 @@ en:
default_email_mailing_list_mode: "Send an email for every new post by default."
default_email_always: "Send an email notification even when the user is active by default."
default_other_new_topic_duration_minutes: "Global default number of minutes a topic is considered new."
default_other_auto_track_topics_after_msecs: "Global default milliseconds before a topic is automatically tracked."
default_other_new_topic_duration_minutes: "Global default condition for which a topic is considered new."
default_other_auto_track_topics_after_msecs: "Global default time before a topic is automatically tracked."
default_other_external_links_in_new_tab: "Open external links in a new tab by default."
default_other_enable_quoting: "Enable quote reply for highlighted text by default."
default_other_dynamic_favicon: "Show new/updated topic count on browser icon by default."
@ -1344,7 +1348,7 @@ en:
unique: "must be unique"
blank: "must be present"
must_begin_with_alphanumeric: "must begin with a letter or number or an underscore"
must_end_with_alphanumeric: "must end with a letter or number"
must_end_with_alphanumeric: "must end with a letter or number or an underscore"
must_not_contain_two_special_chars_in_seq: "must not contain a sequence of 2 or more special chars (.-_)"
must_not_contain_confusing_suffix: "must not contain a confusing suffix like .json or .png etc."
email:

View file

@ -182,6 +182,9 @@ basic:
enable_badges:
client: true
default: true
enable_whispers:
client: true
default: false
login:
invite_only:
@ -418,9 +421,6 @@ posting:
newuser_max_attachments:
client: true
default: 0
uncategorized_category_id:
default: -1
hidden: true
post_excerpt_maxlength: 300
display_name_on_posts:
client: true
@ -572,6 +572,14 @@ files:
avatar_sizes:
default: '20|25|32|45|60|120'
type: list
external_system_avatars_enabled:
default: false
client: true
shadowed_by_global: true
external_system_avatars_url:
default: "https://avatars.discourse.org/letter/{first_letter}/{color}/{size}.png"
client: true
regex: '^https?:\/\/.+[^\/]'
trust:
default_trust_level:
@ -922,14 +930,15 @@ uncategorized:
lounge_category_id:
default: -1
hidden: true
meta_category_id:
default: -1
hidden: true
staff_category_id:
default: -1
hidden: true
uncategorized_category_id:
default: -1
hidden: true
performance_report_topic_id:
default: -1

View file

@ -12,10 +12,7 @@ class AvatarLookup
private
def self.lookup_columns
@lookup_columns ||= [:id,
:email,
:username,
:uploaded_avatar_id]
@lookup_columns ||= %i{id email username uploaded_avatar_id}
end
def users

View file

@ -144,10 +144,13 @@ module PostGuardian
end
def can_see_post?(post)
post.present? &&
(is_admin? ||
((is_moderator? || !post.deleted_at.present?) &&
can_see_topic?(post.topic)))
return false if post.blank?
return true if is_admin?
return false unless can_see_topic?(post.topic)
return false unless post.user == @user || post.topic.visible_post_types(@user).include?(post.post_type)
return false if !is_moderator? && post.deleted_at.present?
true
end
def can_view_edit_history?(post)

View file

@ -191,11 +191,9 @@ class TopicView
# Find the sort order for a post in the topic
def sort_order_for_post_number(post_number)
Post.where(topic_id: @topic.id, post_number: post_number)
.with_deleted
.select(:sort_order)
.first
.try(:sort_order)
posts = Post.where(topic_id: @topic.id, post_number: post_number).with_deleted
posts = filter_post_types(posts)
posts.select(:sort_order).first.try(:sort_order)
end
# Filter to all posts near a particular post number
@ -332,11 +330,22 @@ class TopicView
private
def filter_post_types(posts)
visible_types = @topic.visible_post_types(@user)
if @user.present?
posts.where("user_id = ? OR post_type IN (?)", @user.id, visible_types)
else
posts.where(post_type: visible_types)
end
end
def filter_posts_by_ids(post_ids)
# TODO: Sort might be off
@posts = Post.where(id: post_ids, topic_id: @topic.id)
.includes(:user, :reply_to_user)
.order('sort_order')
@posts = filter_post_types(@posts)
@posts = @posts.with_deleted if @guardian.can_see_deleted_posts?
@posts
end
@ -355,13 +364,13 @@ class TopicView
end
def find_topic(topic_id)
finder = Topic.where(id: topic_id).includes(:category)
finder = finder.with_deleted if @guardian.can_see_deleted_topics?
# with_deleted covered in #check_and_raise_exceptions
finder = Topic.with_deleted.where(id: topic_id).includes(:category)
finder.first
end
def unfiltered_posts
result = @topic.posts
result = filter_post_types(@topic.posts)
result = result.with_deleted if @guardian.can_see_deleted_posts?
result = @topic.posts.where("user_id IS NOT NULL") if @exclude_deleted_users
result

View file

@ -38,6 +38,7 @@ module UserNameSuggester
name = name.gsub(/^[^[:alnum:]]+|\W+$/, "")
.gsub(/\W+/, "_")
.gsub(/^\_+/, '')
.gsub(/[\-_\.]{2,}/, "_")
name
end

View file

@ -437,6 +437,32 @@ describe Guardian do
expect(Guardian.new(user).can_see?(post)).to be_falsey
expect(Guardian.new(admin).can_see?(post)).to be_truthy
end
it 'respects whispers' do
regular_post = Fabricate.build(:post)
whisper_post = Fabricate.build(:post, post_type: Post.types[:whisper])
anon_guardian = Guardian.new
expect(anon_guardian.can_see?(regular_post)).to eq(true)
expect(anon_guardian.can_see?(whisper_post)).to eq(false)
regular_user = Fabricate.build(:user)
regular_guardian = Guardian.new(regular_user)
expect(regular_guardian.can_see?(regular_post)).to eq(true)
expect(regular_guardian.can_see?(whisper_post)).to eq(false)
# can see your own whispers
regular_whisper = Fabricate.build(:post, post_type: Post.types[:whisper], user: regular_user)
expect(regular_guardian.can_see?(regular_whisper)).to eq(true)
mod_guardian = Guardian.new(Fabricate.build(:moderator))
expect(mod_guardian.can_see?(regular_post)).to eq(true)
expect(mod_guardian.can_see?(whisper_post)).to eq(true)
admin_guardian = Guardian.new(Fabricate.build(:admin))
expect(admin_guardian.can_see?(regular_post)).to eq(true)
expect(admin_guardian.can_see?(whisper_post)).to eq(true)
end
end
describe 'a PostRevision' do

View file

@ -13,6 +13,7 @@ describe TopicView do
expect { TopicView.new(1231232, coding_horror) }.to raise_error(Discourse::NotFound)
end
# see also spec/controllers/topics_controller_spec.rb TopicsController::show::permission errors
it "raises an error if the user can't see the topic" do
Guardian.any_instance.expects(:can_see?).with(topic).returns(false)
expect { topic_view }.to raise_error(Discourse::InvalidAccess)
@ -21,7 +22,7 @@ describe TopicView do
it "handles deleted topics" do
admin = Fabricate(:admin)
topic.trash!(admin)
expect { TopicView.new(topic.id, Fabricate(:user)) }.to raise_error(Discourse::NotFound)
expect { TopicView.new(topic.id, Fabricate(:user)) }.to raise_error(Discourse::InvalidAccess)
expect { TopicView.new(topic.id, admin) }.not_to raise_error
end
@ -251,6 +252,23 @@ describe TopicView do
end
context 'whispers' do
it "handles their visibility properly" do
p1 = Fabricate(:post, topic: topic, user: coding_horror)
p2 = Fabricate(:post, topic: topic, user: coding_horror, post_type: Post.types[:whisper])
p3 = Fabricate(:post, topic: topic, user: coding_horror)
ch_posts = TopicView.new(topic.id, coding_horror).posts
expect(ch_posts.map(&:id)).to eq([p1.id, p2.id, p3.id])
anon_posts = TopicView.new(topic.id).posts
expect(anon_posts.map(&:id)).to eq([p1.id, p3.id])
admin_posts = TopicView.new(topic.id, Fabricate(:moderator)).posts
expect(admin_posts.map(&:id)).to eq([p1.id, p2.id, p3.id])
end
end
context '.posts' do
# Create the posts in a different order than the sort_order

View file

@ -75,6 +75,11 @@ describe UserNameSuggester do
expect(UserNameSuggester.suggest("myname.")).to eq('myname')
end
it 'handles usernames with a sequence of 2 or more special chars' do
expect(UserNameSuggester.suggest('Darth__Vader')).to eq('Darth_Vader')
expect(UserNameSuggester.suggest('Darth_-_Vader')).to eq('Darth_Vader')
end
it 'should handle typical facebook usernames' do
expect(UserNameSuggester.suggest('roger.nelson.3344913')).to eq('roger_nelson_33')
end

View file

@ -1,5 +1,23 @@
require 'spec_helper'
def topics_controller_show_gen_perm_tests(expected, ctx)
expected.each do |sym, status|
params = "topic_id: #{sym}.id, slug: #{sym}.slug"
if sym == :nonexist
params = "topic_id: nonexist_topic_id"
end
ctx.instance_eval("
it 'returns #{status} for #{sym}' do
begin
xhr :get, :show, #{params}
expect(response.status).to eq(#{status})
rescue Discourse::NotLoggedIn
expect(302).to eq(#{status})
end
end")
end
end
describe TopicsController do
context 'wordpress' do
@ -554,6 +572,108 @@ describe TopicsController do
end
end
context 'permission errors' do
let(:allowed_user) { Fabricate(:user) }
let(:allowed_group) { Fabricate(:group) }
let(:secure_category) {
c = Fabricate(:category)
c.permissions = [[allowed_group, :full]]
c.save
allowed_user.groups = [allowed_group]
allowed_user.save
c }
let(:normal_topic) { Fabricate(:topic) }
let(:secure_topic) { Fabricate(:topic, category: secure_category) }
let(:private_topic) { Fabricate(:private_message_topic, user: allowed_user) }
let(:deleted_topic) { Fabricate(:deleted_topic) }
let(:nonexist_topic_id) { Topic.last.id + 10000 }
context 'anonymous' do
expected = {
:normal_topic => 200,
:secure_topic => 403,
:private_topic => 302,
:deleted_topic => 403,
:nonexist => 404
}
topics_controller_show_gen_perm_tests(expected, self)
end
context 'anonymous with login required' do
before do
SiteSetting.login_required = true
end
expected = {
:normal_topic => 302,
:secure_topic => 302,
:private_topic => 302,
:deleted_topic => 302,
:nonexist => 302
}
topics_controller_show_gen_perm_tests(expected, self)
end
context 'normal user' do
before do
log_in(:user)
end
expected = {
:normal_topic => 200,
:secure_topic => 403,
:private_topic => 403,
:deleted_topic => 403,
:nonexist => 404
}
topics_controller_show_gen_perm_tests(expected, self)
end
context 'allowed user' do
before do
log_in_user(allowed_user)
end
expected = {
:normal_topic => 200,
:secure_topic => 200,
:private_topic => 200,
:deleted_topic => 403,
:nonexist => 404
}
topics_controller_show_gen_perm_tests(expected, self)
end
context 'moderator' do
before do
log_in(:moderator)
end
expected = {
:normal_topic => 200,
:secure_topic => 403,
:private_topic => 403,
:deleted_topic => 200,
:nonexist => 404
}
topics_controller_show_gen_perm_tests(expected, self)
end
context 'admin' do
before do
log_in(:admin)
end
expected = {
:normal_topic => 200,
:secure_topic => 200,
:private_topic => 200,
:deleted_topic => 200,
:nonexist => 404
}
topics_controller_show_gen_perm_tests(expected, self)
end
end
it 'records a view' do
expect { xhr :get, :show, topic_id: topic.id, slug: topic.slug }.to change(TopicViewItem, :count).by(1)
end

View file

@ -1301,7 +1301,7 @@ describe UsersController do
describe '.pick_avatar' do
it 'raises an error when not logged in' do
expect { xhr :put, :pick_avatar, username: 'asdf', avatar_id: 1}.to raise_error(Discourse::NotLoggedIn)
expect { xhr :put, :pick_avatar, username: 'asdf', avatar_id: 1, type: "custom"}.to raise_error(Discourse::NotLoggedIn)
end
context 'while logged in' do
@ -1310,12 +1310,12 @@ describe UsersController do
it 'raises an error when you don\'t have permission to toggle the avatar' do
another_user = Fabricate(:user)
xhr :put, :pick_avatar, username: another_user.username, upload_id: 1
xhr :put, :pick_avatar, username: another_user.username, upload_id: 1, type: "custom"
expect(response).to be_forbidden
end
it 'it successful' do
xhr :put, :pick_avatar, username: user.username, upload_id: 111
xhr :put, :pick_avatar, username: user.username, upload_id: 111, type: "custom"
expect(user.reload.uploaded_avatar_id).to eq(111)
expect(user.user_avatar.reload.custom_upload_id).to eq(111)
expect(response).to be_success
@ -1326,13 +1326,6 @@ describe UsersController do
expect(response).to be_success
end
it 'returns success' do
xhr :put, :pick_avatar, username: user.username, upload_id: 111
expect(user.reload.uploaded_avatar_id).to eq(111)
expect(response).to be_success
json = ::JSON.parse(response.body)
expect(json['success']).to eq("OK")
end
end
end

View file

@ -11,6 +11,40 @@ describe Topic do
it { is_expected.to rate_limit }
context '#visible_post_types' do
let(:types) { Post.types }
it "returns the appropriate types for anonymous users" do
topic = Fabricate.build(:topic)
post_types = topic.visible_post_types
expect(post_types).to include(types[:regular])
expect(post_types).to include(types[:moderator_action])
expect(post_types).to include(types[:small_action])
expect(post_types).to_not include(types[:whisper])
end
it "returns the appropriate types for regular users" do
topic = Fabricate.build(:topic)
post_types = topic.visible_post_types(Fabricate.build(:user))
expect(post_types).to include(types[:regular])
expect(post_types).to include(types[:moderator_action])
expect(post_types).to include(types[:small_action])
expect(post_types).to_not include(types[:whisper])
end
it "returns the appropriate types for staff users" do
topic = Fabricate.build(:topic)
post_types = topic.visible_post_types(Fabricate.build(:moderator))
expect(post_types).to include(types[:regular])
expect(post_types).to include(types[:moderator_action])
expect(post_types).to include(types[:small_action])
expect(post_types).to include(types[:whisper])
end
end
context 'slug' do
let(:title) { "hello world topic" }
let(:slug) { "hello-world-topic" }

View file

@ -144,7 +144,6 @@ Spork.prefork do
FileUtils.cp("#{Rails.root}/spec/fixtures/images/#{filename}", "#{Rails.root}/tmp/spec/#{filename}")
File.new("#{Rails.root}/tmp/spec/#{filename}")
end
end
Spork.each_run do

View file

@ -1,16 +0,0 @@
import avatarTemplate from 'discourse/lib/avatar-template';
module('lib:avatar-template');
test("avatarTemplate", function(){
var oldCDN = Discourse.CDN;
var oldBase = Discourse.BaseUrl;
Discourse.BaseUrl = "frogs.com";
equal(avatarTemplate("sam", 1), "/user_avatar/frogs.com/sam/{size}/1.png");
Discourse.CDN = "http://awesome.cdn.com";
equal(avatarTemplate("sam", 1), "http://awesome.cdn.com/user_avatar/frogs.com/sam/{size}/1.png");
Discourse.CDN = oldCDN;
Discourse.BaseUrl = oldBase;
});