Merge pull request #1 from discourse/master

Syncing from original
This commit is contained in:
Mittineague 2016-06-15 13:31:46 -04:00 committed by GitHub
commit bd509f5550
447 changed files with 13719 additions and 5760 deletions

1
.gitignore vendored
View File

@ -106,3 +106,4 @@ config/version.rb
bundler_stubs/*
vendor/bundle/*
*.db

View File

@ -45,6 +45,7 @@ before_install:
- eslint app/assets/javascripts
- eslint --ext .es6 app/assets/javascripts
- eslint --ext .es6 test/javascripts
- eslint --ext .es6 plugins/**/assets/javascripts
- eslint test/javascripts
before_script:

View File

@ -61,7 +61,7 @@ gem 'fast_xs'
gem 'fast_xor'
# while we sort out https://github.com/sdsykes/fastimage/pull/46
gem 'discourse_fastimage', require: 'fastimage'
gem 'discourse_fastimage', '2.0.2', require: 'fastimage'
gem 'aws-sdk', require: false
gem 'excon', require: false
gem 'unf', require: false
@ -74,7 +74,7 @@ gem 'email_reply_trimmer', '0.1.3'
gem 'image_optim', '0.20.2'
gem 'multi_json'
gem 'mustache'
gem 'nokogiri', '1.6.8.rc3'
gem 'nokogiri'
gem 'omniauth'
gem 'omniauth-openid'
gem 'openid-redis-store'
@ -125,7 +125,7 @@ group :test do
end
group :test, :development do
gem 'rspec', '~> 3.2.0'
gem 'rspec'
gem 'mock_redis'
gem 'listen', '0.7.3', require: false
gem 'certified', require: false

View File

@ -73,7 +73,7 @@ GEM
diff-lcs (1.2.5)
discourse-qunit-rails (0.0.9)
railties
discourse_fastimage (2.0.0)
discourse_fastimage (2.0.2)
docile (1.1.5)
domain_name (0.5.25)
unf (>= 0.0.5, < 1.0.0)
@ -174,7 +174,7 @@ GEM
multipart-post (2.0.0)
mustache (1.0.3)
netrc (0.11.0)
nokogiri (1.6.8.rc3)
nokogiri (1.6.8)
mini_portile2 (~> 2.1.0)
pkg-config (~> 1.1.7)
nokogumbo (1.4.7)
@ -294,33 +294,33 @@ GEM
netrc (~> 0.7)
rinku (2.0.0)
rmmseg-cpp (0.2.9)
rspec (3.2.0)
rspec-core (~> 3.2.0)
rspec-expectations (~> 3.2.0)
rspec-mocks (~> 3.2.0)
rspec-core (3.2.3)
rspec-support (~> 3.2.0)
rspec-expectations (3.2.1)
rspec (3.4.0)
rspec-core (~> 3.4.0)
rspec-expectations (~> 3.4.0)
rspec-mocks (~> 3.4.0)
rspec-core (3.4.4)
rspec-support (~> 3.4.0)
rspec-expectations (3.4.0)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.2.0)
rspec-support (~> 3.4.0)
rspec-given (3.7.1)
given_core (= 3.7.1)
rspec (>= 2.14.0)
rspec-html-matchers (0.7.0)
nokogiri (~> 1)
rspec (~> 3)
rspec-mocks (3.2.1)
rspec-mocks (3.4.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.2.0)
rspec-rails (3.2.3)
rspec-support (~> 3.4.0)
rspec-rails (3.4.2)
actionpack (>= 3.0, < 4.3)
activesupport (>= 3.0, < 4.3)
railties (>= 3.0, < 4.3)
rspec-core (~> 3.2.0)
rspec-expectations (~> 3.2.0)
rspec-mocks (~> 3.2.0)
rspec-support (~> 3.2.0)
rspec-support (3.2.2)
rspec-core (~> 3.4.0)
rspec-expectations (~> 3.4.0)
rspec-mocks (~> 3.4.0)
rspec-support (~> 3.4.0)
rspec-support (3.4.1)
rtlit (0.0.5)
ruby-openid (2.7.0)
ruby-readability (0.7.0)
@ -410,7 +410,7 @@ DEPENDENCIES
byebug
certified
discourse-qunit-rails
discourse_fastimage
discourse_fastimage (= 2.0.2)
email_reply_trimmer (= 0.1.3)
ember-rails (= 0.18.5)
ember-source (= 1.12.2)
@ -444,7 +444,7 @@ DEPENDENCIES
mock_redis
multi_json
mustache
nokogiri (= 1.6.8.rc3)
nokogiri
oj
omniauth
omniauth-facebook
@ -475,7 +475,7 @@ DEPENDENCIES
rest-client
rinku
rmmseg-cpp
rspec (~> 3.2.0)
rspec
rspec-given
rspec-html-matchers
rspec-rails
@ -500,4 +500,4 @@ DEPENDENCIES
unicorn
BUNDLED WITH
1.12.3
1.12.5

6
Vagrantfile vendored
View File

@ -3,8 +3,8 @@
# See https://github.com/discourse/discourse/blob/master/docs/VAGRANT.md
#
Vagrant.configure("2") do |config|
config.vm.box = 'discourse/discourse-1.3.0'
config.vm.box_url = "http://discourse-vms.s3.amazonaws.com/discourse-1.3.0.box"
config.vm.box = 'discourse-16.04'
config.vm.box_url = "https://www.dropbox.com/s/2132770g1e05c6d/discourse.box?dl=1"
# Make this VM reachable on the host network as well, so that other
# VM's running other browsers can access our dev server.
@ -43,6 +43,6 @@ Vagrant.configure("2") do |config|
config.vm.network :forwarded_port, guest: 1080, host: 4080 # Mailcatcher
nfs_setting = RUBY_PLATFORM =~ /darwin/ || RUBY_PLATFORM =~ /linux/
config.vm.synced_folder ".", "/vagrant", id: "vagrant-root", :nfs => nfs_setting
config.vm.synced_folder ".", "/vagrant", id: "vagrant-root"
end

View File

@ -65,7 +65,11 @@ export default Ember.Component.extend({
const $elem = this.$();
const minimumResultsForSearch = this.capabilities.isIOS ? -1 : 5;
$elem.select2({formatResult: this.comboTemplate, minimumResultsForSearch, width: 'resolve'});
$elem.select2({
formatResult: this.comboTemplate, minimumResultsForSearch,
width: 'resolve',
allowClear: true
});
const castInteger = this.get('castInteger');
$elem.on("change", e => {

View File

@ -82,7 +82,7 @@ export default Ember.Component.extend({
}
this._bindUploadTarget();
this.appEvents.trigger('composer:opened');
this.appEvents.trigger('composer:will-open');
},
@computed('composer.reply', 'composer.replyLength', 'composer.missingReplyCharacters', 'composer.minimumPostLength', 'lastValidatedAt')
@ -341,6 +341,7 @@ export default Ember.Component.extend({
@on('willDestroyElement')
_composerClosed() {
this.appEvents.trigger('composer:will-close');
Ember.run.next(() => {
$('#main-outlet').css('padding-bottom', 0);
// need to wait a bit for the "slide down" transition of the composer
@ -361,7 +362,7 @@ export default Ember.Component.extend({
this._resetUpload(true);
},
showOptions() {
showOptions(toolbarEvent) {
// long term we want some smart positioning algorithm in popup-menu
// the problem is that positioning in a fixed panel is a nightmare
// cause offsetParent can end up returning a fixed element and then
@ -387,9 +388,8 @@ export default Ember.Component.extend({
left = replyWidth - popupWidth - 40;
}
this.sendAction('showOptions', { position: "absolute",
left: left,
top: top });
this.sendAction('showOptions', toolbarEvent,
{ position: "absolute", left, top });
},
showUploadModal(toolbarEvent) {
@ -419,7 +419,7 @@ export default Ember.Component.extend({
sendAction: 'showUploadModal'
});
if (this.get('canWhisper')) {
if (this.get("popupMenuOptions").some(option => option.condition)) {
toolbar.addButton({
id: 'options',
group: 'extras',
@ -458,6 +458,7 @@ export default Ember.Component.extend({
// Paint oneboxes
$('a.onebox', $preview).each((i, e) => Discourse.Onebox.load(e, refresh));
this.trigger('previewRefreshed', $preview);
this.sendAction('afterRefresh', $preview);
},
}
});

View File

@ -0,0 +1,21 @@
import computed from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
classNameBindings: [':composer-popup', ':hidden', 'message.extraClass'],
@computed('message.templateName')
defaultLayout(templateName) {
return this.container.lookup(`template:composer/${templateName}`);
},
didInsertElement() {
this._super();
this.$().show();
},
actions: {
closeMessage() {
this.sendAction('closeMessage', this.get('message'));
}
}
});

View File

@ -0,0 +1,162 @@
import LinkLookup from 'discourse/lib/link-lookup';
export default Ember.Component.extend({
classNameBindings: [':composer-popup-container', 'hidden'],
checkedMessages: false,
messages: null,
messagesByTemplate: null,
queuedForTyping: null,
_lastSimilaritySearch: null,
_similarTopicsMessage: null,
similarTopics: null,
hidden: Ember.computed.not('composer.viewOpen'),
didInsertElement() {
this._super();
this.reset();
this.appEvents.on('composer:typed-reply', this, this._typedReply);
this.appEvents.on('composer:opened', this, this._findMessages);
this.appEvents.on('composer:find-similar', this, this._findSimilar);
this.appEvents.on('composer-messages:close', this, this._closeTop);
this.appEvents.on('composer-messages:create', this, this._create);
},
willDestroyElement() {
this.appEvents.off('composer:typed-reply', this, this._typedReply);
this.appEvents.off('composer:opened', this, this._findMessages);
this.appEvents.off('composer:find-similar', this, this._findSimilar);
this.appEvents.off('composer-messages:close', this, this._closeTop);
this.appEvents.off('composer-messages:create', this, this._create);
},
_closeTop() {
const messages = this.get('messages');
messages.popObject();
this.set('messageCount', messages.get('length'));
},
_removeMessage(message) {
const messages = this.get('messages');
messages.removeObject(message);
this.set('messageCount', messages.get('length'));
},
actions: {
closeMessage(message) {
this._removeMessage(message);
},
hideMessage(message) {
this._removeMessage(message);
// kind of hacky but the visibility depends on this
this.get('messagesByTemplate')[message.get('templateName')] = undefined;
},
popup(message) {
const messagesByTemplate = this.get('messagesByTemplate');
const templateName = message.get('templateName');
if (!messagesByTemplate[templateName]) {
const messages = this.get('messages');
messages.pushObject(message);
this.set('messageCount', messages.get('length'));
messagesByTemplate[templateName] = message;
}
}
},
// Resets all active messages.
// For example if composing a new post.
reset() {
if (this.isDestroying || this.isDestroyed) { return; }
this.setProperties({
messages: [],
messagesByTemplate: {},
queuedForTyping: [],
checkedMessages: false,
similarTopics: [],
});
},
// Called after the user has typed a reply.
// Some messages only get shown after being typed.
_typedReply() {
if (this.isDestroying || this.isDestroyed) { return; }
this.get('queuedForTyping').forEach(msg => this.send("popup", msg));
},
_create(info) {
this.reset();
this.send('popup', Ember.Object.create(info));
},
_findSimilar() {
const composer = this.get('composer');
// We don't care about similar topics unless creating a topic
if (!composer.get('creatingTopic')) { return; }
const origBody = composer.get('reply') || '';
const title = composer.get('title') || '';
// Ensure the fields are of the minimum length
if (origBody.length < Discourse.SiteSettings.min_body_similar_length) { return; }
if (title.length < Discourse.SiteSettings.min_title_similar_length) { return; }
// TODO pass the 200 in from somewhere
const body = origBody.substr(0, 200);
// Don't search over and over
const concat = title + body;
if (concat === this._lastSimilaritySearch) { return; }
this._lastSimilaritySearch = concat;
const similarTopics = this.get('similarTopics');
const message = this._similarTopicsMessage || composer.store.createRecord('composer-message', {
id: 'similar_topics',
templateName: 'similar-topics',
extraClass: 'similar-topics'
});
this._similarTopicsMessage = message;
composer.store.find('similar-topic', {title, raw: body}).then(newTopics => {
similarTopics.clear();
similarTopics.pushObjects(newTopics.get('content'));
if (similarTopics.get('length') > 0) {
message.set('similarTopics', similarTopics);
this.send('popup', message);
} else if (message) {
this.send('hideMessage', message);
}
});
},
// Figure out if there are any messages that should be displayed above the composer.
_findMessages() {
if (this.get('checkedMessages')) { return; }
const composer = this.get('composer');
const args = { composer_action: composer.get('action') };
const topicId = composer.get('topic.id');
const postId = composer.get('post.id');
if (topicId) { args.topic_id = topicId; }
if (postId) { args.post_id = postId; }
const queuedForTyping = this.get('queuedForTyping');
composer.store.find('composer-message', args).then(messages => {
// Checking composer messages on replies can give us a list of links to check for
// duplicates
if (messages.extras && messages.extras.duplicate_lookup) {
this.sendAction('addLinkLookup', new LinkLookup(messages.extras.duplicate_lookup));
}
this.set('checkedMessages', true);
messages.forEach(msg => msg.wait_for_typing ? queuedForTyping.addObject(msg) : this.send('popup', msg));
});
}
});

View File

@ -561,5 +561,4 @@ export default Ember.Component.extend({
});
}
}
});

View File

@ -0,0 +1,4 @@
import { buildCategoryPanel } from 'discourse/components/edit-category-panel';
export default buildCategoryPanel('tags', {
});

View File

@ -16,11 +16,14 @@ export default Ember.Component.extend({
_widgetClass: null,
_renderCallback: null,
_childEvents: null,
_dispatched: null,
init() {
this._super();
const name = this.get('widget');
(this.get('delegated') || []).forEach(m => this.set(m, m));
this._widgetClass = queryRegistry(name) || this.container.lookupFactory(`widget:${name}`);
if (!this._widgetClass) {
@ -29,6 +32,7 @@ export default Ember.Component.extend({
this._childEvents = [];
this._connected = [];
this._dispatched = [];
},
didInsertElement() {
@ -50,7 +54,10 @@ export default Ember.Component.extend({
},
willDestroyElement() {
this._childEvents.forEach(evt => this.appEvents.off(evt));
this._dispatched.forEach(evt => {
const [eventName, caller] = evt;
this.appEvents.off(eventName, caller);
});
Ember.run.cancel(this._timeout);
},
@ -71,9 +78,10 @@ export default Ember.Component.extend({
dispatch(eventName, key) {
this._childEvents.push(eventName);
this.appEvents.on(eventName, refreshArg => {
this.eventDispatched(eventName, key, refreshArg);
});
const caller = refreshArg => this.eventDispatched(eventName, key, refreshArg);
this._dispatched.push([eventName, caller]);
this.appEvents.on(eventName, caller);
},
queueRerender(callback) {
@ -93,7 +101,6 @@ export default Ember.Component.extend({
if (!this._widgetClass) { return; }
const t0 = new Date().getTime();
const args = this.get('args') || this.buildArgs();
const opts = { model: this.get('model') };
const newTree = new this._widgetClass(args, this.container, opts);
@ -117,8 +124,6 @@ export default Ember.Component.extend({
if (this.profileWidget) {
console.log(new Date().getTime() - t0);
}
}
}
});

View File

@ -1,73 +1,49 @@
import DropdownButton from 'discourse/components/dropdown-button';
import NotificationLevels from 'discourse/lib/notification-levels';
import { all, buttonDetails } from 'discourse/lib/notification-levels';
import { iconHTML } from 'discourse/helpers/fa-icon';
import computed from 'ember-addons/ember-computed-decorators';
const NotificationsButton = DropdownButton.extend({
export default DropdownButton.extend({
classNames: ['notification-options'],
title: '',
buttonIncludesText: true,
activeItem: Em.computed.alias('notificationLevel'),
i18nPrefix: '',
i18nPostfix: '',
watchingClasses: 'fa fa-exclamation-circle watching',
trackingClasses: 'fa fa-circle tracking',
mutedClasses: 'fa fa-times-circle muted',
regularClasses: 'fa fa-circle-o regular',
options: function() {
return [['WATCHING', 'watching', this.watchingClasses],
['TRACKING', 'tracking', this.trackingClasses],
['REGULAR', 'regular', this.regularClasses],
['MUTED', 'muted', this.mutedClasses]];
}.property(),
@computed
dropDownContent() {
const prefix = this.get('i18nPrefix');
const postfix = this.get('i18nPostfix');
dropDownContent: function() {
const contents = [],
prefix = this.get('i18nPrefix'),
postfix = this.get('i18nPostfix');
_.each(this.get('options'), function(pair) {
if (postfix === '_pm' && pair[1] === 'regular') { return; }
contents.push({
id: NotificationLevels[pair[0]],
title: I18n.t(prefix + '.' + pair[1] + postfix + '.title'),
description: I18n.t(prefix + '.' + pair[1] + postfix + '.description'),
styleClasses: pair[2]
});
return all.map(l => {
const start = `${prefix}.${l.key}${postfix}`;
return {
id: l.id,
title: I18n.t(`${start}.title`),
description: I18n.t(`${start}.description`),
styleClasses: `${l.key} fa fa-${l.icon}`
};
});
},
return contents;
}.property(),
@computed('notificationLevel')
text(notificationLevel) {
const details = buttonDetails(notificationLevel);
const { key } = details;
const icon = iconHTML(details.icon, { class: key });
text: function() {
const self = this,
prefix = this.get('i18nPrefix'),
postfix = this.get('i18nPostfix');
const key = (function() {
switch (this.get('notificationLevel')) {
case NotificationLevels.WATCHING: return 'watching';
case NotificationLevels.TRACKING: return 'tracking';
case NotificationLevels.MUTED: return 'muted';
default: return 'regular';
}
}).call(this);
const icon = (function() {
switch (key) {
case 'watching': return '<i class="' + self.watchingClasses + '"></i>&nbsp;';
case 'tracking': return '<i class="' + self.trackingClasses + '"></i>&nbsp;';
case 'muted': return '<i class="' + self.mutedClasses + '"></i>&nbsp;';
default: return '<i class="' + self.regularClasses + '"></i>&nbsp;';
}
})();
return icon + ( this.get('buttonIncludesText') ? I18n.t(prefix + '.' + key + postfix + ".title") : '') + "<span class='caret'></span>";
}.property('notificationLevel'),
if (this.get('buttonIncludesText')) {
const prefix = this.get('i18nPrefix');
const postfix = this.get('i18nPostfix');
const text = I18n.t(`${prefix}.${key}${postfix}.title`);
return `${icon}&nbsp;${text}<span class='caret'></span>`;
} else {
return `${icon}&nbsp;<span class='caret'></span>`;
}
},
clicked(/* id */) {
// sub-class needs to implement this
}
});
export default NotificationsButton;
export { NotificationLevels };

View File

@ -3,11 +3,12 @@ import { keyDirty } from 'discourse/widgets/widget';
import MountWidget from 'discourse/components/mount-widget';
import { cloak, uncloak } from 'discourse/widgets/post-stream';
import { isWorkaroundActive } from 'discourse/lib/safari-hacks';
import offsetCalculator from 'discourse/lib/offset-calculator';
function findTopView($posts, viewportTop, min, max) {
if (max < min) { return min; }
while(max>min){
while (max > min) {
const mid = Math.floor((min + max) / 2);
const $post = $($posts[mid]);
const viewBottom = $post.position().top + $post.height();
@ -26,8 +27,11 @@ export default MountWidget.extend({
widget: 'post-stream',
_topVisible: null,
_bottomVisible: null,
_currentPost: null,
_currentVisible: null,
_currentPercent: null,
args: Ember.computed(function() {
buildArgs() {
return this.getProperties('posts',
'canCreatePost',
'multiSelect',
@ -35,7 +39,7 @@ export default MountWidget.extend({
'selectedQuery',
'selectedPostsCount',
'searchService');
}).volatile(),
},
beforePatch() {
const $body = $(document);
@ -66,7 +70,7 @@ export default MountWidget.extend({
const onscreen = [];
const nearby = [];
let windowTop = $w.scrollTop();
const windowTop = $w.scrollTop();
const $posts = this.$('.onscreen-post, .cloaked-post');
const viewportTop = windowTop - slack;
@ -79,7 +83,18 @@ export default MountWidget.extend({
if (windowBottom > bodyHeight) { windowBottom = bodyHeight; }
if (viewportBottom > bodyHeight) { viewportBottom = bodyHeight; }
let currentPost = null;
let percent = null;
const offset = offsetCalculator();
const topCheck = Math.ceil(windowTop + offset);
// uncomment to debug the eyeline
// $('.debug-eyeline').css({ height: '1px', width: '100%', backgroundColor: 'blue', position: 'absolute', top: `${topCheck}px` });
let allAbove = true;
let bottomView = topView;
let lastBottom = 0;
while (bottomView < $posts.length) {
const post = $posts[bottomView];
const $post = $(post);
@ -87,18 +102,34 @@ export default MountWidget.extend({
if (!$post) { break; }
const viewTop = $post.offset().top;
const viewBottom = viewTop + $post.height() + 100;
const postHeight = $post.height();
const viewBottom = Math.ceil(viewTop + postHeight);
allAbove = allAbove && (viewTop < topCheck);
if (viewTop > viewportBottom) { break; }
if (viewBottom > windowTop && viewTop <= windowBottom) {
if (viewBottom >= windowTop && viewTop <= windowBottom) {
onscreen.push(bottomView);
}
nearby.push(bottomView);
if ((currentPost === null) &&
((viewTop <= topCheck && viewBottom >= topCheck) ||
(lastBottom <= topCheck && viewTop >= topCheck))) {
percent = (topCheck - viewTop) / postHeight;
currentPost = bottomView;
}
lastBottom = viewBottom;
nearby.push(bottomView);
bottomView++;
}
if (allAbove) {
if (percent === null) { percent = 1.0; }
if (currentPost === null) { currentPost = bottomView - 1; }
}
const posts = this.posts;
const refresh = cb => this.queueRerender(cb);
if (onscreen.length) {
@ -131,9 +162,28 @@ export default MountWidget.extend({
this._bottomVisible = last;
this.sendAction('bottomVisibleChanged', { post: last, refresh });
}
const changedPost = this._currentPost !== currentPost;
if (changedPost) {
this._currentPost = currentPost;
const post = posts.objectAt(currentPost);
this.sendAction('currentPostChanged', { post });
}
if (percent !== null) {
if (percent > 1.0) { percent = 1.0; }
if (changedPost || (this._currentPercent !== percent)) {
this._currentPercent = percent;
this.sendAction('currentPostScrolled', { percent });
}
}
} else {
this._topVisible = null;
this._bottomVisible = null;
this._currentPost = null;
this._currentPercent = null;
}
const onscreenPostNumbers = [];

View File

@ -1,17 +0,0 @@
import DButton from 'discourse/components/d-button';
export default DButton.extend({
click() {
const $target = this.$(),
position = $target.position(),
width = $target.innerWidth(),
loc = {
position: this.get('position') || "fixed",
left: position.left + width,
top: position.top
};
this.appEvents.trigger("popup-menu:open", loc);
this.sendAction("action");
}
});

View File

@ -1,5 +1,6 @@
import MountWidget from 'discourse/components/mount-widget';
import { observes } from 'ember-addons/ember-computed-decorators';
import Docking from 'discourse/mixins/docking';
const _flagProperties = [];
function addFlagProperty(prop) {
@ -8,45 +9,37 @@ function addFlagProperty(prop) {
const PANEL_BODY_MARGIN = 30;
const SiteHeaderComponent = MountWidget.extend({
const SiteHeaderComponent = MountWidget.extend(Docking, {
widget: 'header',
docAt: null,
dockedHeader: null,
_topic: null,
// profileWidget: true,
// classNameBindings: ['editingTopic'],
@observes('currentUser.unread_notifications', 'currentUser.unread_private_messages')
_notificationsChanged() {
this.queueRerender();
},
examineDockHeader() {
dockCheck(info) {
if (this.docAt === null) {
const outlet = $('#main-outlet');
if (!(outlet && outlet.length === 1)) return;
this.docAt = outlet.offset().top;
}
const $body = $('body');
// Check the dock after the current run loop. While rendering,
// it's much slower to calculate `outlet.offset()`
Ember.run.next(() => {
if (this.docAt === null) {
const outlet = $('#main-outlet');
if (!(outlet && outlet.length === 1)) return;
this.docAt = outlet.offset().top;
const offset = info.offset();
if (offset >= this.docAt) {
if (!this.dockedHeader) {
$body.addClass('docked');
this.dockedHeader = true;
}
const offset = window.pageYOffset || $('html').scrollTop();
if (offset >= this.docAt) {
if (!this.dockedHeader) {
$body.addClass('docked');
this.dockedHeader = true;
}
} else {
if (this.dockedHeader) {
$body.removeClass('docked');
this.dockedHeader = false;
}
} else {
if (this.dockedHeader) {
$body.removeClass('docked');
this.dockedHeader = false;
}
});
}
},
setTopic(topic) {
@ -56,8 +49,6 @@ const SiteHeaderComponent = MountWidget.extend({
didInsertElement() {
this._super();
$(window).bind('scroll.discourse-dock', () => this.examineDockHeader());
$(document).bind('touchmove.discourse-dock', () => this.examineDockHeader());
$(window).on('resize.discourse-menu-panel', () => this.afterRender());
this.appEvents.on('header:show-topic', topic => this.setTopic(topic));
@ -72,16 +63,11 @@ const SiteHeaderComponent = MountWidget.extend({
this.eventDispatched('dom:clean', 'header');
}
});
this.examineDockHeader();
},
willDestroyElement() {
this._super();
$(window).unbind('scroll.discourse-dock');
$(document).unbind('touchmove.discourse-dock');
$('body').off('keydown.header');
this.appEvents.off('notifications:changed');
$(window).off('resize.discourse-menu-panel');
this.appEvents.off('header:show-topic');

View File

@ -6,9 +6,9 @@ function formatTag(t) {
export default Ember.TextField.extend({
classNameBindings: [':tag-chooser'],
attributeBindings: ['tabIndex'],
attributeBindings: ['tabIndex', 'placeholderKey', 'categoryId'],
_setupTags: function() {
_initValue: function() {
const tags = this.get('tags') || [];
this.set('value', tags.join(", "));
}.on('init'),
@ -18,16 +18,37 @@ export default Ember.TextField.extend({
this.set('tags', tags);
}.observes('value'),
_tagsChanged: function() {
const $tagChooser = this.$(),
val = this.get('value');
if ($tagChooser && val !== this.get('tags')) {
if (this.get('tags')) {
const data = this.get('tags').map((t) => {return {id: t, text: t};});
$tagChooser.select2('data', data);
} else {
$tagChooser.select2('data', []);
}
}
}.observes('tags'),
_initializeTags: function() {
const site = this.site,
self = this,
filterRegexp = new RegExp(this.site.tags_filter_regexp, "g");
var limit = this.siteSettings.max_tags_per_topic;
if (this.get('unlimitedTagCount')) {
limit = null;
} else if (this.get('limit')) {
limit = parseInt(this.get('limit'));
}
this.$().select2({
tags: true,
placeholder: I18n.t('tagging.choose_for_topic'),
placeholder: I18n.t(this.get('placeholderKey') || 'tagging.choose_for_topic'),
maximumInputLength: this.siteSettings.max_tag_length,
maximumSelectionSize: this.siteSettings.max_tags_per_topic,
maximumSelectionSize: limit,
initSelection(element, callback) {
const data = [];
@ -65,7 +86,7 @@ export default Ember.TextField.extend({
list.push(item);
},
formatSelection: function (data) {
return data ? renderTag(this.text(data)) : undefined;
return data ? renderTag(this.text(data)) : undefined;
},
formatSelectionCssClass: function(){
return "discourse-tag-select2";
@ -78,7 +99,16 @@ export default Ember.TextField.extend({
url: Discourse.getURL("/tags/filter/search"),
dataType: 'json',
data: function (term) {
return { q: term, limit: self.siteSettings.max_tag_search_results };
const d = {
q: term,
limit: self.siteSettings.max_tag_search_results,
categoryId: self.get('categoryId'),
selected_tags: self.get('tags')
};
if (!self.get('everyTag')) {
d.filterForInput = true;
}
return d;
},
results: function (data) {
if (self.siteSettings.tags_sort_alphabetically) {

View File

@ -0,0 +1,84 @@
function renderTagGroup(tag) {
return "<a class='discourse-tag'>" + Handlebars.Utils.escapeExpression(tag.text ? tag.text : tag) + "</a>";
};
export default Ember.TextField.extend({
classNameBindings: [':tag-chooser'],
attributeBindings: ['tabIndex', 'placeholderKey', 'categoryId'],
_initValue: function() {
const names = this.get('tagGroups') || [];
this.set('value', names.join(", "));
}.on('init'),
_valueChanged: function() {
const names = this.get('value').split(',').map(v => v.trim()).reject(v => v.length === 0).uniq();
this.set('tagGroups', names);
}.observes('value'),
_tagGroupsChanged: function() {
const $chooser = this.$(),
val = this.get('value');
if ($chooser && val !== this.get('tagGroups')) {
if (this.get('tagGroups')) {
const data = this.get('tagGroups').map((t) => {return {id: t, text: t};});
$chooser.select2('data', data);
} else {
$chooser.select2('data', []);
}
}
}.observes('tagGroups'),
_initializeChooser: function() {
const self = this;
this.$().select2({
tags: true,
placeholder: this.get('placeholderKey') ? I18n.t(this.get('placeholderKey')) : null,
initSelection(element, callback) {
const data = [];
function splitVal(string, separator) {
var val, i, l;
if (string === null || string.length < 1) return [];
val = string.split(separator);
for (i = 0, l = val.length; i < l; i = i + 1) val[i] = $.trim(val[i]);
return val;
}
$(splitVal(element.val(), ",")).each(function () {
data.push({ id: this, text: this });
});
callback(data);
},
formatSelection: function (data) {
return data ? renderTagGroup(this.text(data)) : undefined;
},
formatSelectionCssClass: function(){
return "discourse-tag-select2";
},
formatResult: renderTagGroup,
multiple: true,
ajax: {
quietMillis: 200,
cache: true,
url: Discourse.getURL("/tag_groups/filter/search"),
dataType: 'json',
data: function (term) {
return { q: term, limit: self.siteSettings.max_tag_search_results };
},
results: function (data) {
data.results = data.results.sort(function(a,b) { return a.text > b.text; });
return data;
}
},
});
}.on('didInsertElement'),
_destroyChooser: function() {
this.$().select2('destroy');
}.on('willDestroyElement')
});

View File

@ -0,0 +1,21 @@
export default Ember.Component.extend({
classNameBindings: [':tag-list', 'categoryClass'],
sortedTags: Ember.computed.sort('tags', 'sortProperties'),
title: function() {
if (this.get('titleKey')) { return I18n.t(this.get('titleKey')); }
}.property('titleKey'),
category: function() {
if (this.get('categoryId')) {
return Discourse.Category.findById(this.get('categoryId'));
}
}.property('categoryId'),
categoryClass: function() {
if (this.get('category')) {
return "tag-list-" + this.get('category.fullSlug');
}
}.property('category')
});

View File

@ -0,0 +1,29 @@
import { iconHTML } from 'discourse/helpers/fa-icon';
import DropdownButton from 'discourse/components/dropdown-button';
import computed from "ember-addons/ember-computed-decorators";
export default DropdownButton.extend({
buttonExtraClasses: 'no-text',
title: '',
text: iconHTML('bars') + ' ' + iconHTML('caret-down'),
classNames: ['tags-admin-menu'],
@computed()
dropDownContent() {
const items = [
{ id: 'manageGroups',
title: I18n.t('tagging.manage_groups'),
description: I18n.t('tagging.manage_groups_description'),
styleClasses: 'fa fa-wrench' }
];
return items;
},
actionNames: {
manageGroups: 'showTagGroups'
},
clicked(id) {
this.sendAction('actionNames.' + id);
}
});

View File

@ -0,0 +1,10 @@
import MountWidget from 'discourse/components/mount-widget';
export default MountWidget.extend({
tagName: 'span',
widget: "topic-admin-menu-button",
buildArgs() {
return this.getProperties('topic', 'fixed', 'openUpwards');
}
});

View File

@ -1,16 +1,15 @@
import ContainerView from 'discourse/views/container';
import { on } from 'ember-addons/ember-computed-decorators';
export default ContainerView.extend({
elementId: 'topic-footer-buttons',
@on('init')
createButtons() {
const topic = this.get('topic');
const currentUser = this.get('controller.currentUser');
init() {
this._super();
if (this.currentUser) {
const viewArgs = this.getProperties('topic', 'topicDelegated');
viewArgs.currentUser = this.currentUser;
if (currentUser) {
const viewArgs = { topic, currentUser };
this.attachViewWithArgs(viewArgs, 'topic-footer-main-buttons');
this.attachViewWithArgs(viewArgs, 'pinned-button');
this.attachViewWithArgs(viewArgs, 'topic-notifications-button');

View File

@ -0,0 +1,43 @@
export default Ember.Component.extend({
composerOpen: null,
classNameBindings: ['composerOpen'],
showTimeline: null,
info: null,
_checkSize() {
const renderTimeline = $(window).width() > 960;
this.set('info', { renderTimeline, showTimeline: renderTimeline && !this.get('composerOpen') });
},
composerOpened() {
this.set('composerOpen', true);
this._checkSize();
},
composerClosed() {
this.set('composerOpen', false);
this._checkSize();
},
didInsertElement() {
this._super();
if (!this.site.mobileView) {
$(window).on('resize.discourse-topic-navigation', () => this._checkSize());
this.appEvents.on('composer:will-open', this, this.composerOpened);
this.appEvents.on('composer:will-close', this, this.composerClosed);
this._checkSize();
} else {
this.set('info', null);
}
},
willDestroyElement() {
this._super();
if (!this.site.mobileView) {
$(window).off('resize.discourse-topic-navigation');
this.appEvents.off('composer:will-open', this, this.composerOpened);
this.appEvents.off('composer:will-close', this, this.composerClosed);
}
}
});

View File

@ -1,16 +1,15 @@
import NotificationsButton from 'discourse/components/notifications-button';
import MountWidget from 'discourse/components/mount-widget';
import { observes } from 'ember-addons/ember-computed-decorators';
export default NotificationsButton.extend({
longDescription: Em.computed.alias('topic.details.notificationReasonText'),
hidden: Em.computed.alias('topic.deleted'),
notificationLevel: Em.computed.alias('topic.details.notification_level'),
i18nPrefix: 'topic.notifications',
export default MountWidget.extend({
widget: 'topic-notifications-button',
i18nPostfix: function() {
return this.get('topic.isPrivateMessage') ? '_pm' : '';
}.property('topic.isPrivateMessage'),
buildArgs() {
return { topic: this.get('topic'), appendReason: true, showFullTitle: true };
},
clicked(id) {
this.get('topic.details').updateNotifications(id);
@observes('topic.details.notification_level')
_triggerRerender() {
this.queueRerender();
}
});

View File

@ -0,0 +1,219 @@
import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
export default Ember.Component.extend({
elementId: 'topic-progress-wrapper',
classNameBindings: ['docked', 'hidden'],
expanded: false,
toPostIndex: null,
docked: false,
progressPosition: null,
postStream: Ember.computed.alias('topic.postStream'),
userWantsToJump: null,
_streamPercentage: null,
init() {
this._super();
(this.get('delegated') || []).forEach(m => this.set(m, m));
},
@computed('userWantsToJump', 'showTimeline')
hidden(userWantsToJump, showTimeline) {
return !userWantsToJump && showTimeline;
},
@observes('hidden')
visibilityChanged() {
if (!this.get('hidden')) {
this._updateBar();
}
},
keyboardTrigger(kbdEvent) {
if (kbdEvent.type === 'jump') {
this.set('expanded', true);
this.set('userWantsToJump', true);
Ember.run.scheduleOnce('afterRender', () => this.$('.jump-form input').focus());
}
},
@computed('progressPosition')
jumpTopDisabled(progressPosition) {
return progressPosition <= 3;
},
@computed('postStream.filteredPostsCount', 'topic.highest_post_number', 'progressPosition')
jumpBottomDisabled(filteredPostsCount, highestPostNumber, progressPosition) {
return progressPosition >= filteredPostsCount || progressPosition >= highestPostNumber;
},
@computed('postStream.loaded', 'topic.currentPost', 'postStream.filteredPostsCount')
hideProgress(loaded, currentPost, filteredPostsCount) {
return (!loaded) || (!currentPost) || (filteredPostsCount < 2);
},
@computed('postStream.filteredPostsCount')
hugeNumberOfPosts(filteredPostsCount) {
return filteredPostsCount >= this.siteSettings.short_progress_text_threshold;
},
@computed('hugeNumberOfPosts', 'topic.highest_post_number')
jumpToBottomTitle(hugeNumberOfPosts, highestPostNumber) {
if (hugeNumberOfPosts) {
return I18n.t('topic.progress.jump_bottom_with_number', { post_number: highestPostNumber });
} else {
return I18n.t('topic.progress.jump_bottom');
}
},
@observes('postStream.stream.[]')
_updateBar() {
Ember.run.scheduleOnce('afterRender', this, this._updateProgressBar);
},
_topicScrolled(event) {
this.set('progressPosition', event.postIndex);
this._streamPercentage = event.percent;
this._updateBar();
},
didInsertElement() {
this._super();
this.appEvents.on('composer:will-open', this, this._dock)
.on("composer:resized", this, this._dock)
.on('composer:closed', this, this._dock)
.on("topic:scrolled", this, this._dock)
.on('topic:current-post-scrolled', this, this._topicScrolled)
.on('topic-progress:keyboard-trigger', this, this.keyboardTrigger);
Ember.run.scheduleOnce('afterRender', this, this._updateProgressBar);
},
willDestroyElement() {
this._super();
this.appEvents.off('composer:will-open', this, this._dock)
.off("composer:resized", this, this._dock)
.off('composer:closed', this, this._dock)
.off('topic:scrolled', this, this._dock)
.off('topic:current-post-scrolled', this, this._topicScrolled)
.off('topic-progress:keyboard-trigger');
},
_updateProgressBar() {
if (this.isDestroyed || this.isDestroying || this.get('hidden')) { return; }
const $topicProgress = this.$('#topic-progress');
// speeds up stuff, bypass jquery slowness and extra checks
if (!this._totalWidth) {
this._totalWidth = $topicProgress[0].offsetWidth;
}
const totalWidth = this._totalWidth;
const progressWidth = (this._streamPercentage || 0) * totalWidth;
const borderSize = (progressWidth === totalWidth) ? "0px" : "1px";
const $bg = $topicProgress.find('.bg');
if ($bg.length === 0) {
const style = `border-right-width: ${borderSize}; width: ${progressWidth}px`;
$topicProgress.append(`<div class='bg' style="${style}">&nbsp;</div>`);
} else {
$bg.css("border-right-width", borderSize).width(progressWidth);
}
},
_dock() {
const maximumOffset = $('#topic-footer-buttons').offset(),
composerHeight = $('#reply-control').height() || 0,
$topicProgressWrapper = this.$(),
offset = window.pageYOffset || $('html').scrollTop(),
topicProgressHeight = $('#topic-progress').height();
let isDocked = false;
if (maximumOffset) {
const threshold = maximumOffset.top;
const windowHeight = $(window).height();
isDocked = offset >= threshold - windowHeight + topicProgressHeight + composerHeight;
}
const dockPos = $(document).height() - $('#topic-bottom').offset().top;
if (composerHeight > 0) {
if (isDocked) {
$topicProgressWrapper.css('bottom', dockPos);
} else {
const height = composerHeight + "px";
if ($topicProgressWrapper.css('bottom') !== height) {
$topicProgressWrapper.css('bottom', height);
}
}
} else {
$topicProgressWrapper.css('bottom', isDocked ? dockPos : '');
}
this.set('docked', isDocked);
},
click(e) {
if ($(e.target).parents('#topic-progress').length) {
this.send('toggleExpansion');
}
},
keyDown(e) {
if (this.get('expanded')) {
if (e.keyCode === 13) {
this.$('input').blur();
this.send('jumpPost');
} else if (e.keyCode === 27) {
this.send('toggleExpansion');
this.set('userWantsToJump', false);
}
}
},
actions: {
toggleExpansion(opts) {
this.toggleProperty('expanded');
if (this.get('expanded')) {
this.set('userWantsToJump', false);
this.set('toPostIndex', this.get('progressPosition'));
if(opts && opts.highlight){
// TODO: somehow move to view?
Em.run.next(function(){
$('.jump-form input').select().focus();
});
}
if (!this.site.mobileView && !this.capabilities.isIOS) {
Ember.run.schedule('afterRender', () => this.$('input').focus());
}
}
},
jumpPost() {
let postIndex = parseInt(this.get('toPostIndex'), 10);
// Validate the post index first
if (isNaN(postIndex) || postIndex < 1) {
postIndex = 1;
}
if (postIndex > this.get('postStream.filteredPostsCount')) {
postIndex = this.get('postStream.filteredPostsCount');
}
this.set('toPostIndex', postIndex);
this._beforeJump();
this.sendAction('jumpToIndex', postIndex);
},
jumpTop() {
this._beforeJump();
this.sendAction('jumpTop');
},
jumpBottom() {
this._beforeJump();
this.sendAction('jumpBottom');
}
},
_beforeJump() {
this.set('expanded', false);
this.set('userWantsToJump', false);
}
});

View File

@ -0,0 +1,59 @@
import MountWidget from 'discourse/components/mount-widget';
import Docking from 'discourse/mixins/docking';
import { observes } from 'ember-addons/ember-computed-decorators';
const FIXED_POS = 85;
export default MountWidget.extend(Docking, {
widget: 'topic-timeline-container',
dockBottom: null,
dockAt: null,
buildArgs() {
return { topic: this.get('topic'),
topicTrackingState: this.topicTrackingState,
enteredIndex: this.get('enteredIndex'),
dockAt: this.dockAt,
top: this.dockAt || FIXED_POS,
dockBottom: this.dockBottom };
},
@observes('topic.highest_post_number', 'loading')
newPostAdded() {
this.queueRerender(() => this.queueDockCheck());
},
dockCheck(info) {
const mainOffset = $('#main').offset();
const offsetTop = mainOffset ? mainOffset.top : 0;
const topicTop = $('.container.posts').offset().top - offsetTop;
const topicBottom = $('#topic-bottom').offset().top;
const $timeline = this.$('.timeline-container');
const timelineHeight = $timeline.height() || 400;
const footerHeight = $('.timeline-footer-controls').outerHeight(true) || 0;
const prev = this.dockAt;
const posTop = FIXED_POS + info.offset();
const pos = posTop + timelineHeight;
this.dockBottom = false;
if (posTop < topicTop) {
this.dockAt = topicTop;
} else if (pos > topicBottom + footerHeight) {
this.dockAt = (topicBottom - timelineHeight) + footerHeight;
this.dockBottom = true;
if (this.dockAt < 0) { this.dockAt = 0; }
} else {
this.dockAt = null;
}
if (this.dockAt !== prev) {
this.queueRerender();
}
},
didInsertElement() {
this._super();
this.dispatch('topic:current-post-scrolled', 'timeline-scrollarea');
}
});

View File

@ -1,81 +0,0 @@
// A controller for displaying messages as the user composes a message.
export default Ember.ArrayController.extend({
needs: ['composer'],
// Whether we've checked our messages
checkedMessages: false,
_init: function() {
this.reset();
}.on("init"),
actions: {
closeMessage(message) {
this.removeObject(message);
},
hideMessage(message) {
this.removeObject(message);
// kind of hacky but the visibility depends on this
this.get('messagesByTemplate')[message.get('templateName')] = undefined;
},
popup(message) {
let messagesByTemplate = this.get('messagesByTemplate');
const templateName = message.get('templateName');
if (!messagesByTemplate[templateName]) {
this.pushObject(message);
messagesByTemplate[templateName] = message;
}
}
},
// Resets all active messages.
// For example if composing a new post.
reset() {
this.clear();
this.setProperties({
messagesByTemplate: {},
queuedForTyping: [],
checkedMessages: false
});
},
// Called after the user has typed a reply.
// Some messages only get shown after being typed.
typedReply() {
this.get('queuedForTyping').forEach(msg => this.send("popup", msg));
},
groupsMentioned(groups) {
// reset existing messages, this should always win it is critical
this.reset();
groups.forEach(group => {
const msg = I18n.t('composer.group_mentioned', {
group: "@" + group.name,
count: group.user_count,
group_link: Discourse.getURL(`/group/${group.name}/members`)
});
this.send("popup",
Em.Object.create({
templateName: 'composer/group-mentioned',
body: msg})
);
});
},
// Figure out if there are any messages that should be displayed above the composer.
queryFor(composer) {
if (this.get('checkedMessages')) { return; }
const self = this;
var queuedForTyping = self.get('queuedForTyping');
Discourse.ComposerMessage.find(composer).then(messages => {
self.set('checkedMessages', true);
messages.forEach(msg => msg.wait_for_typing ? queuedForTyping.addObject(msg) : self.send("popup", msg));
});
}
});

View File

@ -3,6 +3,7 @@ import Quote from 'discourse/lib/quote';
import Draft from 'discourse/models/draft';
import Composer from 'discourse/models/composer';
import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
import { relativeAge } from 'discourse/lib/formatter';
function loadDraft(store, opts) {
opts = opts || {};
@ -41,22 +42,39 @@ function loadDraft(store, opts) {
}
}
export default Ember.Controller.extend({
needs: ['modal', 'topic', 'composer-messages', 'application'],
const _popupMenuOptionsCallbacks = [];
export function addPopupMenuOptionsCallback(callback) {
_popupMenuOptionsCallbacks.push(callback);
}
export default Ember.Controller.extend({
needs: ['modal', 'topic', 'application'],
replyAsNewTopicDraft: Em.computed.equal('model.draftKey', Composer.REPLY_AS_NEW_TOPIC_KEY),
checkedMessages: false,
messageCount: null,
showEditReason: false,
editReason: null,
scopedCategoryId: null,
similarTopics: null,
similarTopicsMessage: null,
lastSimilaritySearch: null,
optionsVisible: false,
lastValidatedAt: null,
isUploading: false,
topic: null,
linkLookup: null,
init() {
this._super();
addPopupMenuOptionsCallback(function() {
return {
action: 'toggleWhisper',
icon: 'eye-slash',
label: 'composer.toggle_whisper',
condition: "canWhisper"
};
});
},
showToolbar: Em.computed({
get(){
const keyValueStore = this.container.lookup('key-value-store:main');
@ -79,10 +97,6 @@ export default Ember.Controller.extend({
topicModel: Ember.computed.alias('controllers.topic.model'),
_initializeSimilar: function() {
this.set('similarTopics', []);
}.on('init'),
@computed('model.canEditTitle', 'model.creatingPrivateMessage')
canEditTags(canEditTitle, creatingPrivateMessage) {
return !this.site.mobileView &&
@ -97,6 +111,23 @@ export default Ember.Controller.extend({
return currentUser && currentUser.get('staff') && this.siteSettings.enable_whispers && action === Composer.REPLY;
},
@computed("model.composeState")
popupMenuOptions(composeState) {
if (composeState === 'open') {
return _popupMenuOptionsCallbacks.map(callback => {
let option = callback();
if (option.condition) {
option.condition = this.get(option.condition);
} else {
option.condition = true;
}
return option;
});
}
},
showWarning: function() {
if (!Discourse.User.currentProp('staff')) { return false; }
@ -111,6 +142,45 @@ export default Ember.Controller.extend({
}.property('model.creatingPrivateMessage', 'model.targetUsernames'),
actions: {
addLinkLookup(linkLookup) {
this.set('linkLookup', linkLookup);
},
afterRefresh($preview) {
const topic = this.get('model.topic');
const linkLookup = this.get('linkLookup');
if (!topic || !linkLookup) { return; }
// Don't check if there's only one post
if (topic.get('posts_count') === 1) { return; }
const post = this.get('model.post');
if (post && post.get('user_id') !== this.currentUser.id) { return; }
const $links = $('a[href]', $preview);
$links.each((idx, l) => {
const href = $(l).prop('href');
if (href && href.length) {
const [warn, info] = linkLookup.check(post, href);
if (warn) {
const body = I18n.t('composer.duplicate_link', {
domain: info.domain,
username: info.username,
post_url: topic.urlForPostNumber(info.post_number),
ago: relativeAge(moment(info.posted_at).toDate(), { format: 'medium' })
});
this.appEvents.trigger('composer-messages:create', {
extraClass: 'custom-body',
templateName: 'custom-body',
body
});
return false;
}
}
return true;
});
},
toggleWhisper() {
this.toggleProperty('model.whisper');
@ -120,7 +190,8 @@ export default Ember.Controller.extend({
this.toggleProperty('showToolbar');
},
showOptions(loc) {
showOptions(toolbarEvent, loc) {
this.set('toolbarEvent', toolbarEvent);
this.appEvents.trigger('popup-menu:open', loc);
this.set('optionsVisible', true);
},
@ -184,9 +255,8 @@ export default Ember.Controller.extend({
},
hitEsc() {
const messages = this.get('controllers.composer-messages.model');
if (messages.length) {
messages.popObject();
if ((this.get('messageCount') || 0) > 0) {
this.appEvents.trigger('composer-messages:close');
return;
}
@ -203,7 +273,18 @@ export default Ember.Controller.extend({
groupsMentioned(groups) {
if (!this.get('model.creatingPrivateMessage') && !this.get('model.topic.isPrivateMessage')) {
this.get('controllers.composer-messages').groupsMentioned(groups);
groups.forEach(group => {
const body = I18n.t('composer.group_mentioned', {
group: "@" + group.name,
count: group.user_count,
group_link: Discourse.getURL(`/group/${group.name}/members`)
});
this.appEvents.trigger('composer-messages:create', {
extraClass: 'custom-body',
templateName: 'custom-body',
body
});
});
}
}
@ -344,7 +425,7 @@ export default Ember.Controller.extend({
}).catch(function(error) {
composer.set('disableDrafts', false);
self.appEvents.one('composer:opened', () => bootbox.alert(error));
self.appEvents.one('composer:will-open', () => bootbox.alert(error));
});
if (this.get('controllers.application.currentRouteName').split('.')[0] === 'topic' &&
@ -360,61 +441,14 @@ export default Ember.Controller.extend({
return promise;
},
// Checks to see if a reply has been typed.
// This is signaled by a keyUp event in a view.
// Notify the composer messages controller that a reply has been typed. Some
// messages only appear after typing.
checkReplyLength() {
if (!Ember.isEmpty('model.reply')) {
// Notify the composer messages controller that a reply has been typed. Some
// messages only appear after typing.
this.get('controllers.composer-messages').typedReply();
this.appEvents.trigger('composer:typed-reply');
}
},
// Fired after a user stops typing.
// Considers whether to check for similar topics based on the current composer state.
findSimilarTopics() {
// We don't care about similar topics unless creating a topic
if (!this.get('model.creatingTopic')) { return; }
let body = this.get('model.reply') || '';
const title = this.get('model.title') || '';
// Ensure the fields are of the minimum length
if (body.length < Discourse.SiteSettings.min_body_similar_length) { return; }
if (title.length < Discourse.SiteSettings.min_title_similar_length) { return; }
// TODO pass the 200 in from somewhere
body = body.substr(0, 200);
// Done search over and over
if ((title + body) === this.get('lastSimilaritySearch')) { return; }
this.set('lastSimilaritySearch', title + body);
const messageController = this.get('controllers.composer-messages'),
similarTopics = this.get('similarTopics');
let message = this.get('similarTopicsMessage');
if (!message) {
message = Discourse.ComposerMessage.create({
templateName: 'composer/similar-topics',
extraClass: 'similar-topics'
});
this.set('similarTopicsMessage', message);
}
this.store.find('similar-topic', {title, raw: body}).then(function(newTopics) {
similarTopics.clear();
similarTopics.pushObjects(newTopics.get('content'));
if (similarTopics.get('length') > 0) {
message.set('similarTopics', similarTopics);
messageController.send("popup", message);
} else if (message) {
messageController.send("hideMessage", message);
}
});
},
/**
Open the composer view
@ -439,13 +473,10 @@ export default Ember.Controller.extend({
this.set('scopedCategoryId', opts.categoryId);
}
const composerMessages = this.get('controllers.composer-messages'),
self = this;
const self = this;
let composerModel = this.get('model');
this.setProperties({ showEditReason: false, editReason: null });
composerMessages.reset();
// If we want a different draft than the current composer, close it and clear our model.
if (composerModel &&
@ -493,6 +524,8 @@ export default Ember.Controller.extend({
// Given a potential instance and options, set the model for this composer.
_setModel(composerModel, opts) {
this.set('linkLookup', null);
if (opts.draft) {
composerModel = loadDraft(this.store, opts);
if (composerModel) {
@ -532,11 +565,13 @@ export default Ember.Controller.extend({
}
}
if (opts.topicTags && !this.site.mobileView && this.site.get('can_tag_topics')) {
this.set('model.tags', opts.topicTags.split(","));
}
if (opts.topicBody) {
this.set('model.reply', opts.topicBody);
}
this.get('controllers.composer-messages').queryFor(composerModel);
},
// View a new reply we've made

View File

@ -1,27 +1,50 @@
import { fmt } from 'discourse/lib/computed';
import { popupAjaxError } from 'discourse/lib/ajax-error';
import computed from 'ember-addons/ember-computed-decorators';
import Group from 'discourse/models/group';
export default Ember.ArrayController.extend({
needs: ['group'],
export default Ember.Controller.extend({
loading: false,
emptyText: fmt('type', 'groups.empty.%@'),
limit: null,
offset: null,
@computed('model.owners.[]')
isOwner(owners) {
if (this.get('currentUser.admin')) {
return true;
}
const currentUserId = this.get('currentUser.id');
if (currentUserId) {
return !!owners.findBy('id', currentUserId);
}
},
actions: {
loadMore() {
removeMember(user) {
this.get('model').removeMember(user);
},
if (this.get('loading')) { return; }
this.set('loading', true);
const posts = this.get('model');
if (posts && posts.length) {
const beforePostId = posts[posts.length-1].get('id');
const group = this.get('controllers.group.model');
const opts = { beforePostId, type: this.get('type') };
group.findPosts(opts).then(newPosts => {
posts.addObjects(newPosts);
this.set('loading', false);
});
addMembers() {
const usernames = this.get('usernames');
if (usernames && usernames.length > 0) {
this.get('model').addMembers(usernames).then(() => this.set('usernames', [])).catch(popupAjaxError);
}
},
loadMore() {
if (this.get("loading")) { return; }
if (this.get("model.members.length") >= this.get("model.user_count")) { return; }
this.set("loading", true);
Group.loadMembers(this.get("model.name"), this.get("model.members.length"), this.get("limit")).then(result => {
this.get("model.members").addObjects(result.members.map(member => Discourse.User.create(member)));
this.setProperties({
loading: false,
user_count: result.meta.total,
limit: result.meta.limit,
offset: Math.min(result.meta.offset + result.meta.limit, result.meta.total)
});
});
}
}
});

View File

@ -0,0 +1,26 @@
import { fmt } from 'discourse/lib/computed';
export default Ember.ArrayController.extend({
needs: ['group'],
loading: false,
emptyText: fmt('type', 'groups.empty.%@'),
actions: {
loadMore() {
if (this.get('loading')) { return; }
this.set('loading', true);
const posts = this.get('model');
if (posts && posts.length) {
const beforePostId = posts[posts.length-1].get('id');
const group = this.get('controllers.group.model');
const opts = { beforePostId, type: this.get('type') };
group.findPosts(opts).then(newPosts => {
posts.addObjects(newPosts);
this.set('loading', false);
});
}
}
}
});

View File

@ -15,7 +15,7 @@ var Tab = Em.Object.extend({
export default Ember.Controller.extend({
counts: null,
showing: 'posts',
showing: 'members',
@observes('counts')
countsChanged() {
@ -35,10 +35,10 @@ export default Ember.Controller.extend({
},
tabs: [
Tab.create({ name: 'posts', active: true, 'location': 'group.index' }),
Tab.create({ name: 'members', active: true, 'location': 'group.index' }),
Tab.create({ name: 'posts' }),
Tab.create({ name: 'topics' }),
Tab.create({ name: 'mentions' }),
Tab.create({ name: 'members' }),
Tab.create({ name: 'messages' }),
]
});

View File

@ -1,50 +0,0 @@
import { popupAjaxError } from 'discourse/lib/ajax-error';
import computed from 'ember-addons/ember-computed-decorators';
import Group from 'discourse/models/group';
export default Ember.Controller.extend({
loading: false,
limit: null,
offset: null,
@computed('model.owners.[]')
isOwner(owners) {
if (this.get('currentUser.admin')) {
return true;
}
const currentUserId = this.get('currentUser.id');
if (currentUserId) {
return !!owners.findBy('id', currentUserId);
}
},
actions: {
removeMember(user) {
this.get('model').removeMember(user);
},
addMembers() {
const usernames = this.get('usernames');
if (usernames && usernames.length > 0) {
this.get('model').addMembers(usernames).then(() => this.set('usernames', [])).catch(popupAjaxError);
}
},
loadMore() {
if (this.get("loading")) { return; }
if (this.get("model.members.length") >= this.get("model.user_count")) { return; }
this.set("loading", true);
Group.loadMembers(this.get("model.name"), this.get("model.members.length"), this.get("limit")).then(result => {
this.get("model.members").addObjects(result.members.map(member => Discourse.User.create(member)));
this.setProperties({
loading: false,
user_count: result.meta.total,
limit: result.meta.limit,
offset: Math.min(result.meta.offset + result.meta.limit, result.meta.total)
});
});
}
}
});

View File

@ -6,6 +6,8 @@ export default Ember.Controller.extend(ModalFunctionality, {
// If this isn't defined, it will proxy to the user model on the preferences
// page which is wrong.
emailOrUsername: null,
hasCustomMessage: false,
customMessage: null,
inviteIcon: "envelope",
isAdmin: function(){
@ -27,6 +29,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
}.property('isAdmin', 'emailOrUsername', 'invitingToTopic', 'isPrivateTopic', 'model.groupNames', 'model.saving'),
disabledCopyLink: function() {
if (this.get('hasCustomMessage')) return true;
if (this.get('model.saving')) return true;
if (Ember.isEmpty(this.get('emailOrUsername'))) return true;
const emailOrUsername = this.get('emailOrUsername').trim();
@ -37,7 +40,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
// when inviting to private topic via email, group name must be specified
if (this.get('isPrivateTopic') && Ember.isEmpty(this.get('model.groupNames')) && Discourse.Utilities.emailValid(emailOrUsername)) return true;
return false;
}.property('emailOrUsername', 'model.saving', 'isPrivateTopic', 'model.groupNames'),
}.property('emailOrUsername', 'model.saving', 'isPrivateTopic', 'model.groupNames', 'hasCustomMessage'),
buttonTitle: function() {
return this.get('model.saving') ? 'topic.inviting' : 'topic.invite_reply.action';
@ -71,6 +74,10 @@ export default Ember.Controller.extend(ModalFunctionality, {
return this.get('isAdmin') && (Discourse.Utilities.emailValid(this.get('emailOrUsername')) || this.get('isPrivateTopic') || !this.get('invitingToTopic')) && !Discourse.SiteSettings.enable_sso && Discourse.SiteSettings.enable_local_logins && !this.get('isMessage');
}.property('isAdmin', 'emailOrUsername', 'isPrivateTopic', 'isMessage', 'invitingToTopic'),
showCustomMessage: function() {
return (this.get('model') === this.currentUser || Discourse.Utilities.emailValid(this.get('emailOrUsername')));
}.property('emailOrUsername'),
// Instructional text for the modal.
inviteInstructions: function() {
if (Discourse.SiteSettings.enable_sso || !Discourse.SiteSettings.enable_local_logins) {
@ -102,11 +109,8 @@ export default Ember.Controller.extend(ModalFunctionality, {
}
}.property('isMessage', 'invitingToTopic', 'emailOrUsername'),
// Instructional text for the group selection.
groupInstructions: function() {
return this.get('isPrivateTopic') ?
I18n.t('topic.automatically_add_to_groups_required') :
I18n.t('topic.automatically_add_to_groups_optional');
showGroupsClass: function() {
return this.get('isPrivateTopic') ? 'required' : 'optional';
}.property('isPrivateTopic'),
groupFinder(term) {
@ -136,9 +140,15 @@ export default Ember.Controller.extend(ModalFunctionality, {
'topic.invite_private.email_or_username_placeholder';
}.property(),
customMessagePlaceholder: function() {
return I18n.t('invite.custom_message_placeholder');
}.property(),
// Reset the modal to allow a new user to be invited.
reset() {
this.set('emailOrUsername', null);
this.set('hasCustomMessage', false);
this.set('customMessage', null);
this.get('model').setProperties({
groupNames: null,
error: false,
@ -147,7 +157,6 @@ export default Ember.Controller.extend(ModalFunctionality, {
inviteLink: null
});
},
actions: {
createInvite() {
@ -162,7 +171,7 @@ export default Ember.Controller.extend(ModalFunctionality, {
model.setProperties({ saving: true, error: false });
return this.get('model').createInvite(this.get('emailOrUsername').trim(), groupNames).then(result => {
return this.get('model').createInvite(this.get('emailOrUsername').trim(), groupNames, this.get('customMessage')).then(result => {
model.setProperties({ saving: false, finished: true });
if (!this.get('invitingToTopic')) {
Invite.findInvitedBy(this.currentUser, userInvitedController.get('filter')).then(invite_model => {
@ -213,6 +222,19 @@ export default Ember.Controller.extend(ModalFunctionality, {
}
model.setProperties({ saving: false, error: true });
});
},
showCustomMessageBox() {
this.toggleProperty('hasCustomMessage');
if (this.get('hasCustomMessage')) {
if (this.get('model') === this.currentUser) {
this.set('customMessage', I18n.t('invite.custom_message_template_forum'));
} else {
this.set('customMessage', I18n.t('invite.custom_message_template_topic'));
}
} else {
this.set('customMessage', null);
}
}
}

View File

@ -130,21 +130,6 @@ export default Ember.Controller.extend(CanCheckEmails, {
actions: {
checkMailingList(){
Em.run.next(()=>{
const postsPerDay = this.get('model.mailing_list_posts_per_day');
if (!postsPerDay || postsPerDay < 2) {
return true;
}
bootbox.confirm(I18n.t("user.enable_mailing_list", {count: postsPerDay}), I18n.t("no_value"), I18n.t("yes_value"), (success) => {
if (!success) {
this.set('model.user_option.mailing_list_mode', false);
}
});
});
},
save() {
this.set('saved', false);

View File

@ -1,5 +1,6 @@
import ModalFunctionality from 'discourse/mixins/modal-functionality';
import BufferedContent from 'discourse/mixins/buffered-content';
import { extractError } from 'discourse/lib/ajax-error';
export default Ember.Controller.extend(ModalFunctionality, BufferedContent, {
@ -14,11 +15,15 @@ export default Ember.Controller.extend(ModalFunctionality, BufferedContent, {
performRename() {
const tag = this.get('model'),
self = this;
tag.update({ id: this.get('buffered.id') }).then(function() {
tag.update({ id: this.get('buffered.id') }).then(function(result) {
self.send('closeModal');
self.transitionToRoute('tags.show', tag.get('id'));
}).catch(function() {
self.flash(I18n.t('generic_error'), 'error');
if (result.responseJson.tag) {
self.transitionToRoute('tags.show', result.responseJson.tag.id);
} else {
self.flash(extractError(result.responseJson.errors[0]), 'error');
}
}).catch(function(error) {
self.flash(extractError(error), 'error');
});
}
}

View File

@ -6,6 +6,7 @@ export default Ember.Controller.extend({
needs: ['topic'],
title: Ember.computed.alias('controllers.topic.model.title'),
canReplyAsNewTopic: Ember.computed.alias('controllers.topic.model.details.can_reply_as_new_topic'),
@computed('type', 'postNumber')
shareTitle(type, postNumber) {
@ -29,6 +30,15 @@ export default Ember.Controller.extend({
return false;
},
replyAsNewTopic() {
const topicController = this.get("controllers.topic");
const postStream = topicController.get("model.postStream");
const postId = postStream.findPostIdForPostNumber(this.get("postNumber"));
const post = postStream.findLoadedPost(postId);
topicController.send("replyAsNewTopic", post);
this.send("close");
},
share(source) {
var url = source.generateUrl(this.get('link'), this.get('title'));
if (source.shouldOpenInPopup) {

View File

@ -0,0 +1,27 @@
export default Ember.Controller.extend({
needs: ['tagGroups'],
actions: {
save() {
this.get('model').save();
},
destroy() {
const self = this;
return bootbox.confirm(
I18n.t("tagging.groups.confirm_delete"),
I18n.t("no_value"),
I18n.t("yes_value"),
function(destroy) {
if (destroy) {
const c = self.controllerFor('tagGroups');
return self.get('model').destroy().then(function() {
c.removeObject(self.get('model'));
self.transitionToRoute('tagGroups');
});
}
}
);
}
}
});

View File

@ -0,0 +1,18 @@
export default Ember.ArrayController.extend({
actions: {
selectTagGroup: function(tagGroup) {
if (this.get('selectedItem')) { this.get('selectedItem').set('selected', false); }
this.set('selectedItem', tagGroup);
tagGroup.set('selected', true);
tagGroup.set('savingStatus', null);
this.transitionToRoute('tagGroups.show', tagGroup);
},
newTagGroup: function() {
const newTagGroup = this.store.createRecord('tag-group');
newTagGroup.set('name', I18n.t('tagging.groups.new_name'));
this.pushObject(newTagGroup);
this.send('selectTagGroup', newTagGroup);
}
}
});

View File

@ -1,7 +1,7 @@
export default Ember.Controller.extend({
sortProperties: ['count:desc', 'id'],
sortedTags: Ember.computed.sort('model', 'sortProperties'),
canAdminTags: Ember.computed.alias("currentUser.staff"),
actions: {
sortByCount() {

View File

@ -1,111 +0,0 @@
import DiscourseURL from 'discourse/lib/url';
export default Ember.Controller.extend({
needs: ['topic'],
progressPosition: null,
expanded: false,
toPostIndex: null,
actions: {
toggleExpansion(opts) {
this.toggleProperty('expanded');
if (this.get('expanded')) {
this.set('toPostIndex', this.get('progressPosition'));
if(opts && opts.highlight){
// TODO: somehow move to view?
Em.run.next(function(){
$('.jump-form input').select().focus();
});
}
}
},
jumpPost() {
var postIndex = parseInt(this.get('toPostIndex'), 10);
// Validate the post index first
if (isNaN(postIndex) || postIndex < 1) {
postIndex = 1;
}
if (postIndex > this.get('model.postStream.filteredPostsCount')) {
postIndex = this.get('model.postStream.filteredPostsCount');
}
this.set('toPostIndex', postIndex);
var stream = this.get('model.postStream'),
postId = stream.findPostIdForPostNumber(postIndex);
if (!postId) {
Em.Logger.warn("jump-post code broken - requested an index outside the stream array");
return;
}
var post = stream.findLoadedPost(postId);
if (post) {
this.jumpTo(this.get('model').urlForPostNumber(post.get('post_number')));
} else {
var self = this;
// need to load it
stream.findPostsByIds([postId]).then(function(arr) {
post = arr[0];
self.jumpTo(self.get('model').urlForPostNumber(post.get('post_number')));
});
}
},
jumpTop() {
this.jumpTo(this.get('model.firstPostUrl'));
},
jumpBottom() {
this.jumpTo(this.get('model.lastPostUrl'));
}
},
// Route and close the expansion
jumpTo(url) {
this.set('expanded', false);
DiscourseURL.routeTo(url);
},
streamPercentage: function() {
if (!this.get('model.postStream.loaded')) { return 0; }
if (this.get('model.postStream.highest_post_number') === 0) { return 0; }
var perc = this.get('progressPosition') / this.get('model.postStream.filteredPostsCount');
return (perc > 1.0) ? 1.0 : perc;
}.property('model.postStream.loaded', 'progressPosition', 'model.postStream.filteredPostsCount'),
jumpTopDisabled: function() {
return this.get('progressPosition') <= 3;
}.property('progressPosition'),
filteredPostCountChanged: function(){
if(this.get('model.postStream.filteredPostsCount') < this.get('progressPosition')){
this.set('progressPosition', this.get('model.postStream.filteredPostsCount'));
}
}.observes('model.postStream.filteredPostsCount'),
jumpBottomDisabled: function() {
return this.get('progressPosition') >= this.get('model.postStream.filteredPostsCount') ||
this.get('progressPosition') >= this.get('model.highest_post_number');
}.property('model.postStream.filteredPostsCount', 'model.highest_post_number', 'progressPosition'),
hideProgress: function() {
if (!this.get('model.postStream.loaded')) return true;
if (!this.get('model.currentPost')) return true;
if (this.get('model.postStream.filteredPostsCount') < 2) return true;
return false;
}.property('model.postStream.loaded', 'model.currentPost', 'model.postStream.filteredPostsCount'),
hugeNumberOfPosts: function() {
return (this.get('model.postStream.filteredPostsCount') >= Discourse.SiteSettings.short_progress_text_threshold);
}.property('model.highest_post_number'),
jumpToBottomTitle: function() {
if (this.get('hugeNumberOfPosts')) {
return I18n.t('topic.progress.jump_bottom_with_number', {post_number: this.get('model.highest_post_number')});
} else {
return I18n.t('topic.progress.jump_bottom');
}
}.property('hugeNumberOfPosts', 'model.highest_post_number')
});

View File

@ -10,20 +10,38 @@ import DiscourseURL from 'discourse/lib/url';
import { categoryBadgeHTML } from 'discourse/helpers/category-link';
export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
needs: ['modal', 'composer', 'quote-button', 'topic-progress', 'application'],
needs: ['modal', 'composer', 'quote-button', 'application'],
multiSelect: false,
allPostsSelected: false,
editingTopic: false,
selectedPosts: null,
selectedReplies: null,
queryParams: ['filter', 'username_filters', 'show_deleted'],
loadedAllPosts: Em.computed.or('model.postStream.loadedAllPosts', 'model.postStream.loadingLastPost'),
loadedAllPosts: Ember.computed.or('model.postStream.loadedAllPosts', 'model.postStream.loadingLastPost'),
enteredAt: null,
enteredIndex: null,
retrying: false,
adminMenuVisible: false,
userTriggeredProgress: null,
_progressIndex: null,
showRecover: Em.computed.and('model.deleted', 'model.details.can_recover'),
isFeatured: Em.computed.or("model.pinned_at", "model.isBanner"),
topicDelegated: [
'toggleMultiSelect',
'deleteTopic',
'recoverTopic',
'toggleClosed',
'showAutoClose',
'showFeatureTopic',
'showChangeTimestamp',
'toggleArchived',
'toggleVisibility',
'convertToPublicTopic',
'convertToPrivateMessage',
'jumpTop',
'jumpToPost',
'jumpToIndex',
'jumpBottom',
'replyToPost'
],
_titleChanged: function() {
const title = this.get('model.title');
@ -176,19 +194,39 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
return this.get('model.postStream').fillGapAfter(args.post, args.gap);
},
currentPostChanged(event) {
const { post } = event;
if (!post) { return; }
const postNumber = post.get('post_number');
const topic = this.get('model');
topic.set('currentPost', postNumber);
if (postNumber > (topic.get('last_read_post_number') || 0)) {
topic.set('last_read_post_id', post.get('id'));
topic.set('last_read_post_number', postNumber);
}
this.send('postChangedRoute', postNumber);
this._progressIndex = topic.get('postStream').progressIndexOfPost(post);
},
currentPostScrolled(event) {
const total = this.get('model.postStream.filteredPostsCount');
const percent = (parseFloat(this._progressIndex + event.percent - 1) / total);
this.appEvents.trigger('topic:current-post-scrolled', {
postIndex: this._progressIndex,
percent: Math.max(Math.min(percent, 1.0), 0.0)
});
},
// Called the the topmost visible post on the page changes.
topVisibleChanged(event) {
const { post, refresh } = event;
if (!post) { return; }
const postStream = this.get('model.postStream');
const firstLoadedPost = postStream.get('posts.firstObject');
const currentPostNumber = post.get('post_number');
this.set('model.currentPost', currentPostNumber);
this.send('postChangedRoute', currentPostNumber);
if (post.get('post_number') === 1) { return; }
if (firstLoadedPost && firstLoadedPost === post) {
@ -196,15 +234,13 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}
},
// Called the the bottommost visible post on the page changes.
// Called the the bottommost visible post on the page changes.
bottomVisibleChanged(event) {
const { post, refresh } = event;
const postStream = this.get('model.postStream');
const lastLoadedPost = postStream.get('posts.lastObject');
this.set('controllers.topic-progress.progressPosition', postStream.progressIndexOfPost(post));
if (lastLoadedPost && lastLoadedPost === post && postStream.get('canAppendMore')) {
postStream.appendMore().then(() => refresh());
// show loading stuff
@ -220,14 +256,6 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
return this.get('model.details').removeAllowedUser(user);
},
showTopicAdminMenu() {
this.set('adminMenuVisible', true);
},
hideTopicAdminMenu() {
this.set('adminMenuVisible', false);
},
deleteTopic() {
this.deleteTopic();
},
@ -378,8 +406,20 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}
},
jumpToIndex(index) {
this._jumpToPostId(this.get('model.postStream.stream')[index-1]);
},
jumpToPost(postNumber) {
this._jumpToPostId(this.get('model.postStream').findPostIdForPostNumber(postNumber));
},
jumpTop() {
this.get('controllers.topic-progress').send('jumpTop');
DiscourseURL.routeTo(this.get('model.firstPostUrl'), { skipIfOnScreen: false });
},
jumpBottom() {
DiscourseURL.routeTo(this.get('model.lastPostUrl'), { skipIfOnScreen: false });
},
selectAll() {
@ -546,10 +586,10 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
},
replyAsNewTopic(post) {
const composerController = this.get('controllers.composer'),
quoteController = this.get('controllers.quote-button'),
quotedText = Quote.build(quoteController.get('post'), quoteController.get('buffer')),
self = this;
const composerController = this.get('controllers.composer');
const quoteController = this.get('controllers.quote-button');
post = post || quoteController.get('post');
const quotedText = Quote.build(post, quoteController.get('buffer'));
quoteController.deselectText();
@ -561,7 +601,7 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
return Em.isEmpty(quotedText) ? "" : quotedText;
}).then(q => {
const postUrl = `${location.protocol}//${location.host}${post.get('url')}`;
const postLink = `[${Handlebars.escapeExpression(self.get('model.title'))}](${postUrl})`;
const postLink = `[${Handlebars.escapeExpression(this.get('model.title'))}](${postUrl})`;
composerController.get('model').prependText(`${I18n.t("post.continue_discussion", { postLink })}\n\n${q}`, {new_line: true});
});
},
@ -609,6 +649,25 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
}
},
_jumpToPostId(postId) {
if (!postId) {
Ember.Logger.warn("jump-post code broken - requested an index outside the stream array");
return;
}
const topic = this.get('model');
const postStream = topic.get('postStream');
const post = postStream.findLoadedPost(postId);
if (post) {
DiscourseURL.routeTo(topic.urlForPostNumber(post.get('post_number')));
} else {
// need to load it
postStream.findPostsByIds([postId]).then(arr => {
DiscourseURL.routeTo(topic.urlForPostNumber(arr[0].get('post_number')));
});
}
},
togglePinnedState() {
this.send('togglePinnedForUser');
},
@ -792,27 +851,23 @@ export default Ember.Controller.extend(SelectedPostsCount, BufferedContent, {
const postStream = topic.get("postStream");
if (topic.get('id') === topicId) {
// TODO identity map for postNumber
postStream.get('posts').forEach(post => {
if (!post.read && postNumbers.indexOf(post.post_number) !== -1) {
post.set('read', true);
this.appEvents.trigger('post-stream:refresh', { id: post.id });
this.appEvents.trigger('post-stream:refresh', { id: post.get('id') });
}
});
const max = _.max(postNumbers);
if (max > topic.get("last_read_post_number")) {
topic.set("last_read_post_number", max);
}
if (this.siteSettings.automatically_unpin_topics &&
this.currentUser &&
this.currentUser.automatically_unpin_topics) {
// automatically unpin topics when the user reaches the bottom
const max = _.max(postNumbers);
if (topic.get("pinned") && max >= topic.get("highest_post_number")) {
Em.run.next(() => topic.clearPin());
}
}
}
},

View File

@ -29,6 +29,11 @@ export default Ember.Controller.extend({
linkWebsite: Em.computed.not('user.isBasic'),
hasLocationOrWebsite: Em.computed.or('user.location', 'user.website_name'),
@computed('user.name')
nameFirst(name) {
return !this.get('siteSettings.prioritize_username_in_ux') && name && name.trim().length > 0;
},
@computed('user.user_fields.@each.value')
publicUserFields() {
const siteUserFields = this.site.get('user_fields');

View File

@ -1,5 +1,6 @@
import Invite from 'discourse/models/invite';
import debounce from 'discourse/lib/debounce';
import { popupAjaxError } from 'discourse/lib/ajax-error';
// This controller handles actions related to a user's invitations
export default Ember.Controller.extend({
@ -10,6 +11,7 @@ export default Ember.Controller.extend({
invitesCount: null,
canLoadMore: true,
invitesLoading: false,
reinvitedAll: false,
init: function() {
this._super();
@ -32,6 +34,10 @@ export default Ember.Controller.extend({
inviteRedeemed: Em.computed.equal('filter', 'redeemed'),
showReinviteAllButton: function() {
return (this.get('filter') === "pending" && this.get('model').invites.length > 4 && this.currentUser.get('staff'));
}.property('filter'),
/**
Can the currently logged in user invite users to the site
@ -87,6 +93,13 @@ export default Ember.Controller.extend({
return false;
},
reinviteAll() {
const self = this;
Invite.reinviteAll().then(function() {
self.set('reinvitedAll', true);
}).catch(popupAjaxError);
},
loadMore() {
var self = this;
var model = self.get('model');

View File

@ -41,7 +41,12 @@ export default Ember.Controller.extend(CanCheckEmails, {
return viewingSelf || staff;
},
@computed("content.badge_count")
@computed('model.name')
nameFirst(name) {
return !this.get('siteSettings.prioritize_username_in_ux') && name && name.trim().length > 0;
},
@computed("model.badge_count")
showBadges(badgeCount) {
return Discourse.SiteSettings.enable_badges && badgeCount > 0;
},

View File

@ -0,0 +1,13 @@
export default {
name: "auth-complete",
after: "inject-objects",
initialize() {
if (window.location.search.indexOf('authComplete=true') !== -1) {
const lastAuthResult = localStorage.getItem('lastAuthResult');
if (lastAuthResult) {
Ember.run.next(() => Discourse.authenticationComplete(JSON.parse(lastAuthResult)));
}
}
}
};

View File

@ -1,4 +1,5 @@
import loadScript from 'discourse/lib/load-script';
import DiscourseURL from 'discourse/lib/url';
// Use the message bus for live reloading of components for faster development.
export default {
@ -30,6 +31,11 @@ export default {
});
});
// Useful to export this for debugging purposes
if (Discourse.Environment === 'development' && !Ember.testing) {
window.DiscourseURL = DiscourseURL;
}
// Observe file changes
messageBus.subscribe("/file-change", function(data) {
if (Handlebars.compile && !Ember.TEMPLATES.empty) {

View File

@ -55,7 +55,12 @@ function shortDateNoYear(date) {
return moment(date).format(I18n.t("dates.tiny.date_month"));
}
function tinyDateYear(date) {
// Suppress year if it's this year
export function smartShortDate(date, withYear=tinyDateYear) {
return (date.getFullYear() === new Date().getFullYear()) ? shortDateNoYear(date) : withYear(date);
}
export function tinyDateYear(date) {
return moment(date).format(I18n.t("dates.tiny.date_year"));
}
@ -120,47 +125,46 @@ export function autoUpdatingRelativeAge(date,options) {
return "<span class='relative-date" + append + "' data-time='" + date.getTime() + "' data-format='" + format + "'>" + relAge + "</span>";
}
function wrapAgo(dateStr) {
return I18n.t("dates.wrap_ago", { date: dateStr });
}
function relativeAgeTiny(date){
function relativeAgeTiny(date, ageOpts) {
const format = "tiny";
const distance = Math.round((new Date() - date) / 1000);
const distanceInMinutes = Math.round(distance / 60.0);
let formatted;
const t = function(key,opts){
return I18n.t("dates." + format + "." + key, opts);
const t = function(key, opts) {
const result = I18n.t("dates." + format + "." + key, opts);
return (ageOpts && ageOpts.addAgo) ? wrapAgo(result) : result;
};
switch(true){
case(distanceInMinutes < 1):
formatted = t("less_than_x_minutes", {count: 1});
break;
case(distanceInMinutes >= 1 && distanceInMinutes <= 44):
formatted = t("x_minutes", {count: distanceInMinutes});
break;
case(distanceInMinutes >= 45 && distanceInMinutes <= 89):
formatted = t("about_x_hours", {count: 1});
break;
case(distanceInMinutes >= 90 && distanceInMinutes <= 1409):
formatted = t("about_x_hours", {count: Math.round(distanceInMinutes / 60.0)});
break;
case(Discourse.SiteSettings.relative_date_duration === 0 && distanceInMinutes <= 525599):
formatted = shortDateNoYear(date);
break;
case(distanceInMinutes >= 1410 && distanceInMinutes <= 2519):
formatted = t("x_days", {count: 1});
break;
case(distanceInMinutes >= 2520 && distanceInMinutes <= ((Discourse.SiteSettings.relative_date_duration||14) * 1440)):
formatted = t("x_days", {count: Math.round(distanceInMinutes / 1440.0)});
break;
default:
if(date.getFullYear() === new Date().getFullYear()) {
switch(true) {
case(distanceInMinutes < 1):
formatted = t("less_than_x_minutes", {count: 1});
break;
case(distanceInMinutes >= 1 && distanceInMinutes <= 44):
formatted = t("x_minutes", {count: distanceInMinutes});
break;
case(distanceInMinutes >= 45 && distanceInMinutes <= 89):
formatted = t("about_x_hours", {count: 1});
break;
case(distanceInMinutes >= 90 && distanceInMinutes <= 1409):
formatted = t("about_x_hours", {count: Math.round(distanceInMinutes / 60.0)});
break;
case(Discourse.SiteSettings.relative_date_duration === 0 && distanceInMinutes <= 525599):
formatted = shortDateNoYear(date);
} else {
formatted = tinyDateYear(date);
}
break;
break;
case(distanceInMinutes >= 1410 && distanceInMinutes <= 2519):
formatted = t("x_days", {count: 1});
break;
case(distanceInMinutes >= 2520 && distanceInMinutes <= ((Discourse.SiteSettings.relative_date_duration||14) * 1440)):
formatted = t("x_days", {count: Math.round(distanceInMinutes / 1440.0)});
break;
default:
formatted = (ageOpts.defaultFormat || smartShortDate)(date);
break;
}
return formatted;
@ -199,7 +203,7 @@ function relativeAgeMediumSpan(distance, leaveAgo) {
formatted = t("x_days", {count: Math.round((distanceInMinutes - 720.0) / 1440.0)});
break;
}
return formatted || '&mdash';
return formatted || '&mdash;';
}
function relativeAgeMedium(date, options) {
@ -219,11 +223,7 @@ function relativeAgeMedium(date, options) {
if (distance < oneMinuteAgo) {
displayDate = I18n.t("now");
} else if (distance > fiveDaysAgo) {
if ((new Date()).getFullYear() !== date.getFullYear()) {
displayDate = shortDate(date);
} else {
displayDate = shortDateNoYear(date);
}
displayDate = smartShortDate(date, shortDate);
} else {
displayDate = relativeAgeMediumSpan(distance, leaveAgo);
}
@ -239,7 +239,7 @@ export function relativeAge(date, options) {
options = options || {};
const format = options.format || "tiny";
if(format === "tiny") {
if (format === "tiny") {
return relativeAgeTiny(date, options);
} else if (format === "medium") {
return relativeAgeMedium(date, options);

View File

@ -1,5 +1,6 @@
import DiscourseURL from 'discourse/lib/url';
import Composer from 'discourse/models/composer';
import { scrollTopFor } from 'discourse/lib/offset-calculator';
const bindings = {
'!': {postAction: 'showFlags'},
@ -116,7 +117,7 @@ export default {
_jumpTo(direction) {
if ($('.container.posts').length) {
this.container.lookup('controller:topic-progress').send(direction);
this.container.lookup('controller:topic').send(direction);
}
},
@ -159,7 +160,7 @@ export default {
},
toggleProgress() {
this.container.lookup('controller:topic-progress').send('toggleExpansion', {highlight: true});
this.appEvents.trigger('topic-progress:keyboard-trigger', { type: 'jump' });
},
toggleSearch(event) {
@ -298,12 +299,19 @@ export default {
if ($article.is('.topic-post')) {
$('a.tabLoc', $article).focus();
}
this._scrollToPost($article);
this._scrollList($article, direction);
} else {
this._scrollList($article, direction);
}
}
},
_scrollToPost($article) {
const pos = $article.offset();
$(window).scrollTop(Math.ceil(pos.top - scrollTopFor(pos.top)));
},
_scrollList($article) {
// Try to keep the article on screen
const pos = $article.offset();

View File

@ -0,0 +1,29 @@
const _warned = {};
const NO_RESULT = [false, null];
export default class LinkLookup {
constructor(links) {
this._links = links;
}
check(post, href) {
if (_warned[href]) { return NO_RESULT; }
const normalized = href.replace(/^https?:\/\//, '').replace(/\/$/, '');
if (_warned[normalized]) { return NO_RESULT; }
const linkInfo = this._links[normalized];
if (linkInfo) {
// Skip edits to the same post
if (post && post.get('post_number') === linkInfo.post_number) { return NO_RESULT; }
_warned[href] = true;
_warned[normalized] = true;
return [true, linkInfo];
}
return NO_RESULT;
}
};

View File

@ -1,3 +1,5 @@
import { scrollTopFor } from 'discourse/lib/offset-calculator';
// Dear traveller, you are entering a zone where we are at war with the browser
// the browser is insisting on positioning scrollTop per the location it was in
// the past, we are insisting on it being where we want it to be
@ -16,75 +18,66 @@
// 1. onbeforeunload ensure we are scrolled to the right spot
// 2. give up on the scrollbar and implement it ourselves (something that will happen)
(function (exports) {
const SCROLL_EVENTS = "scroll.lock-on touchmove.lock-on mousedown.lock-on wheel.lock-on DOMMouseScroll.lock-on mousewheel.lock-on keyup.lock-on";
var scrollEvents = "scroll.lock-on touchmove.lock-on mousedown.lock-on wheel.lock-on DOMMouseScroll.lock-on mousewheel.lock-on keyup.lock-on";
function within(threshold, x, y) {
return Math.abs(x-y) < threshold;
}
var LockOn = function(selector, options) {
export default class LockOn {
constructor(selector, options) {
this.selector = selector;
this.options = options || {};
};
LockOn.prototype.elementTop = function() {
var offsetCalculator = this.options.offsetCalculator,
selected = $(this.selector);
this.offsetTop = null;
}
elementTop() {
const selected = $(this.selector);
if (selected && selected.offset && selected.offset()) {
return selected.offset().top - (offsetCalculator ? offsetCalculator() : 0);
const result = selected.offset().top;
return result - Math.round(scrollTopFor(result));
}
};
}
LockOn.prototype.lock = function() {
var self = this,
previousTop = this.elementTop(),
startedAt = new Date().getTime(),
i = 0;
clearLock(interval) {
$('body,html').off(SCROLL_EVENTS);
clearInterval(interval);
}
lock() {
let previousTop = this.elementTop();
const startedAt = new Date().getTime();
$(window).scrollTop(previousTop);
var within = function(threshold,x,y) {
return Math.abs(x-y) < threshold;
};
let i = 0;
var interval = setInterval(function() {
const interval = setInterval(() => {
i = i + 1;
var top = self.elementTop(),
scrollTop = $(window).scrollTop();
let top = this.elementTop();
const scrollTop = $(window).scrollTop();
if (typeof(top) === "undefined") {
$('body,html').off(scrollEvents);
clearInterval(interval);
return;
if (typeof(top) === "undefined" || isNaN(top)) {
return this.clearLock(interval);
}
if (!within(4, top, previousTop) || !within(4, scrollTop, top)) {
$(window).scrollTop(top);
// animating = true;
// $('html,body').animate({scrollTop: parseInt(top,10)+'px'}, 200, 'swing', function(){
// animating = false;
// });
previousTop = top;
}
// We commit suicide after 3s just to clean up
var nowTime = new Date().getTime();
const nowTime = new Date().getTime();
if (nowTime - startedAt > 1000) {
$('body,html').off(scrollEvents);
clearInterval(interval);
return this.clearLock(interval);
}
}, 50);
$('body,html').off(scrollEvents).on(scrollEvents, function(e){
$('body,html').off(SCROLL_EVENTS).on(SCROLL_EVENTS, e => {
if ( e.which > 0 || e.type === "mousedown" || e.type === "mousewheel" || e.type === "touchmove") {
$('body,html').off(scrollEvents);
clearInterval(interval);
this.clearLock(interval);
}
});
};
exports.LockOn = LockOn;
})(window);
}
}

View File

@ -1,6 +1,24 @@
export default {
const NotificationLevels = {
WATCHING: 3,
TRACKING: 2,
REGULAR: 1,
MUTED: 0
};
export default NotificationLevels;
export function buttonDetails(level) {
switch(level) {
case NotificationLevels.WATCHING:
return { id: NotificationLevels.WATCHING, key: 'watching', icon: 'exclamation-circle' };
case NotificationLevels.TRACKING:
return { id: NotificationLevels.TRACKING, key: 'tracking', icon: 'circle' };
case NotificationLevels.MUTED:
return { id: NotificationLevels.MUTED, key: 'muted', icon: 'times-circle' };
default:
return { id: NotificationLevels.REGULAR, key: 'regular', icon: 'circle-o' };
}
}
export const all = [ NotificationLevels.WATCHING,
NotificationLevels.TRACKING,
NotificationLevels.MUTED,
NotificationLevels.DEFAULT ].map(buttonDetails);

View File

@ -0,0 +1,47 @@
// TODO: This is quite ugly but seems reasonably fast? Maybe refactor
// this out before we merge into stable.
export function scrollTopFor(y) {
let off = 0;
for (let i=0; i<3; i++) {
off = offsetCalculator(y - off);
}
return off;
}
export default function offsetCalculator(y) {
const $header = $('header');
const $title = $('#topic-title');
const rawWinHeight = $(window).height();
const windowHeight = rawWinHeight - $title.height();
const eyeTarget = (windowHeight / 10);
const headerHeight = $header.outerHeight(true);
const expectedOffset = $title.height() - $header.find('.contents').height() + (eyeTarget * 2);
const ideal = headerHeight + ((expectedOffset < 0) ? 0 : expectedOffset);
const $container = $('.posts-wrapper');
const topPos = $container.offset().top;
const scrollTop = y || $(window).scrollTop();
const docHeight = $(document).height();
const scrollPercent = (scrollTop / (docHeight-rawWinHeight));
let inter = topPos - scrollTop + ($container.height() * scrollPercent);
if (inter < headerHeight + eyeTarget) {
inter = headerHeight + eyeTarget;
}
if (inter > ideal) {
const bottom = $('#topic-bottom').offset().top;
const switchPos = bottom - rawWinHeight;
if (scrollTop > switchPos) {
const p = Math.max(Math.min((scrollTop + inter - switchPos) / rawWinHeight, 1.0), 0.0);
return ((1 - p) * ideal) + (p * inter);
} else {
return ideal;
}
}
return inter;
}

View File

@ -10,6 +10,7 @@ import { onPageChange } from 'discourse/lib/page-tracker';
import { preventCloak } from 'discourse/widgets/post-stream';
import { h } from 'virtual-dom';
import { addFlagProperty } from 'discourse/components/site-header';
import { addPopupMenuOptionsCallback } from 'discourse/controllers/composer';
class PluginApi {
constructor(version, container) {
@ -224,6 +225,26 @@ class PluginApi {
addToolbarCallback(callback);
}
/**
* Add a new button in the options popup menu.
*
* Example:
*
* ```
* api.addToolbarPopupMenuOptionsCallback(function(controller) {
* return {
* action: 'toggleWhisper',
* icon: 'eye-slash',
* label: 'composer.toggle_whisper',
* condition: "canWhisper"
* };
* });
* ```
**/
addToolbarPopupMenuOptionsCallback(callback) {
addPopupMenuOptionsCallback(callback);
}
/**
* A hook that is called when the post stream is removed from the DOM.
* This advanced hook should be used if you end up wiring up any

View File

@ -1,4 +1,6 @@
/*global LockOn:true*/
import offsetCalculator from 'discourse/lib/offset-calculator';
import LockOn from 'discourse/lib/lock-on';
let _jumpScheduled = false;
const rewrites = [];
@ -14,14 +16,6 @@ const DiscourseURL = Ember.Object.extend({
// Jumps to a particular post in the stream
jumpToPost(postNumber, opts) {
const holderId = `#post_${postNumber}`;
const offset = () => {
const $header = $('header');
const $title = $('#topic-title');
const windowHeight = $(window).height() - $title.height();
const expectedOffset = $title.height() - $header.find('.contents').height() + (windowHeight / 5);
return $header.outerHeight(true) + ((expectedOffset < 0) ? 0 : expectedOffset);
};
Em.run.schedule('afterRender', () => {
if (postNumber === 1) {
@ -29,15 +23,14 @@ const DiscourseURL = Ember.Object.extend({
return;
}
const lockon = new LockOn(holderId, {offsetCalculator: offset});
const lockon = new LockOn(holderId);
const holder = $(holderId);
if (holder.length > 0 && opts && opts.skipIfOnScreen){
// if we are on screen skip
const elementTop = lockon.elementTop(),
scrollTop = $(window).scrollTop(),
windowHeight = $(window).height()-offset(),
height = holder.height();
const elementTop = lockon.elementTop();
const scrollTop = $(window).scrollTop();
const windowHeight = $(window).height() - offsetCalculator();
const height = holder.height();
if (elementTop > scrollTop && (elementTop + height) < (scrollTop + windowHeight)) {
return;
@ -107,6 +100,8 @@ const DiscourseURL = Ember.Object.extend({
keep the history intact.
**/
routeTo(path, opts) {
opts = opts || {};
if (Em.isEmpty(path)) { return; }
if (Discourse.get('requiresRefresh')) {
@ -150,12 +145,12 @@ const DiscourseURL = Ember.Object.extend({
rewrites.forEach(rw => path = path.replace(rw.regexp, rw.replacement));
if (this.navigatedToPost(oldPath, path)) { return; }
if (this.navigatedToPost(oldPath, path, opts)) { return; }
// Schedule a DOM cleanup event
Em.run.scheduleOnce('afterRender', Discourse.Route, 'cleanDOM');
// TODO: Extract into rules we can inject into the URL handler
if (this.navigatedToHome(oldPath, path)) { return; }
if (this.navigatedToHome(oldPath, path, opts)) { return; }
if (oldPath === path) {
// If navigating to the same path send an app event. Views can watch it
@ -166,11 +161,11 @@ const DiscourseURL = Ember.Object.extend({
return this.handleURL(path, opts);
},
rewrite: function(regexp, replacement) {
rewrites.push({ regexp: regexp, replacement: replacement });
rewrite(regexp, replacement) {
rewrites.push({ regexp, replacement });
},
redirectTo: function(url) {
redirectTo(url) {
window.location = Discourse.getURL(url);
},
@ -193,17 +188,11 @@ const DiscourseURL = Ember.Object.extend({
},
/**
@private
If the URL is in the topic form, /t/something/:topic_id/:post_number
then we want to apply some special logic. If the post_number changes within the
same topic, use replaceState and instruct our controller to load more posts.
@method navigatedToPost
@param {String} oldPath the previous path we were on
@param {String} path the path we're navigating to
**/
navigatedToPost(oldPath, path) {
navigatedToPost(oldPath, path, routeOpts) {
const newMatches = this.TOPIC_REGEXP.exec(path);
const newTopicId = newMatches ? newMatches[2] : null;
@ -232,14 +221,9 @@ const DiscourseURL = Ember.Object.extend({
enteredAt: new Date().getTime().toString()
});
const closestPost = postStream.closestPostForPostNumber(closest);
const progress = postStream.progressIndexOfPost(closestPost);
const progressController = container.lookup('controller:topic-progress');
progressController.set('progressPosition', progress);
this.appEvents.trigger('post:highlight', closest);
}).then(() => {
DiscourseURL.jumpToPost(closest, {skipIfOnScreen: true});
DiscourseURL.jumpToPost(closest, {skipIfOnScreen: routeOpts.skipIfOnScreen});
});
// Abort routing, we have replaced our state.

View File

@ -91,7 +91,7 @@ export default function userSearch(options) {
return new Ember.RSVP.Promise(function(resolve) {
// TODO site setting for allowed regex in username
if (term.match(/[^a-zA-Z0-9_\.\-]/)) {
if (term.match(/[^\w\.\-]/)) {
resolve([]);
return;
}

View File

@ -0,0 +1,6 @@
export default Ember.Mixin.create({
init() {
this._super();
(this.get('delegated') || []).forEach(m => this.set(m, m));
},
});

View File

@ -0,0 +1,38 @@
const helper = {
offset() {
const mainOffset = $('#main').offset();
const offsetTop = mainOffset ? mainOffset.top : 0;
return (window.pageYOffset || $('html').scrollTop()) - offsetTop;
}
};
export default Ember.Mixin.create({
queueDockCheck: null,
init() {
this._super();
this.queueDockCheck = () => {
Ember.run.debounce(this, this.safeDockCheck, 5);
};
},
safeDockCheck() {
if (this.isDestroyed || this.isDestroying) { return; }
this.dockCheck(helper);
},
didInsertElement() {
this._super();
$(window).bind('scroll.discourse-dock', this.queueDockCheck);
$(document).bind('touchmove.discourse-dock', this.queueDockCheck);
this.dockCheck(helper);
},
willDestroyElement() {
this._super();
$(window).unbind('scroll.discourse-dock', this.queueDockCheck);
$(document).unbind('touchmove.discourse-dock', this.queueDockCheck);
}
});

View File

@ -12,13 +12,14 @@ export default Ember.Mixin.create({
});
},
openComposerWithTopicParams(controller, topicTitle, topicBody, topicCategoryId, topicCategory) {
openComposerWithTopicParams(controller, topicTitle, topicBody, topicCategoryId, topicCategory, topicTags) {
this.controllerFor('composer').open({
action: Composer.CREATE_TOPIC,
topicTitle,
topicBody,
topicCategoryId,
topicCategory,
topicTags,
draftKey: controller.get('model.draft_key'),
draftSequence: controller.get('model.draft_sequence')
});

View File

@ -86,7 +86,9 @@ const Category = RestModel.extend({
allow_badges: this.get('allow_badges'),
custom_fields: this.get('custom_fields'),
topic_template: this.get('topic_template'),
suppress_from_homepage: this.get('suppress_from_homepage')
suppress_from_homepage: this.get('suppress_from_homepage'),
allowed_tags: this.get('allowed_tags'),
allowed_tag_groups: this.get('allowed_tag_groups')
},
type: this.get('id') ? 'PUT' : 'POST'
});

View File

@ -1,35 +0,0 @@
/**
Represents a pop up message displayed over the composer
@class ComposerMessage
@extends Ember.Object
@namespace Discourse
@module Discourse
**/
Discourse.ComposerMessage = Em.Object.extend({});
Discourse.ComposerMessage.reopenClass({
/**
Look for composer messages given the current composing settings.
@method find
@param {Discourse.Composer} composer The current composer
@returns {Discourse.ComposerMessage} the composer message to display (or null)
**/
find: function(composer) {
var data = { composerAction: composer.get('action') },
topicId = composer.get('topic.id'),
postId = composer.get('post.id');
if (topicId) { data.topic_id = topicId; }
if (postId) { data.post_id = postId; }
return Discourse.ajax('/composer-messages', { data: data }).then(function (messages) {
return messages.map(function (message) {
return Discourse.ComposerMessage.create(message);
});
});
}
});

View File

@ -1,3 +1,5 @@
import { popupAjaxError } from 'discourse/lib/ajax-error';
const Invite = Discourse.Model.extend({
rescind() {
@ -9,11 +11,13 @@ const Invite = Discourse.Model.extend({
},
reinvite() {
Discourse.ajax('/invites/reinvite', {
const self = this;
return Discourse.ajax('/invites/reinvite', {
type: 'POST',
data: { email: this.get('email') }
});
this.set('reinvited', true);
}).then(function() {
self.set('reinvited', true);
}).catch(popupAjaxError);
}
});
@ -48,6 +52,10 @@ Invite.reopenClass({
findInvitedCount(user) {
if (!user) { return Em.RSVP.resolve(); }
return Discourse.ajax("/users/" + user.get('username_lower') + "/invited_count.json").then(result => Em.Object.create(result.counts));
},
reinviteAll() {
return Discourse.ajax('/invites/reinvite-all', { type: 'POST' });
}
});

View File

@ -16,6 +16,7 @@ export default RestModel.extend({
loadingFilter: null,
stagingPost: null,
postsWithPlaceholders: null,
timelineLookup: null,
init() {
this._identityMap = {};
@ -33,6 +34,7 @@ export default RestModel.extend({
loadingBelow: false,
loadingFilter: false,
stagingPost: false,
timelineLookup: []
});
},
@ -217,7 +219,7 @@ export default RestModel.extend({
// Request a topicView
return loadTopicView(topic, opts).then(json => {
this.updateFromJson(json.post_stream);
this.setProperties({ loadingFilter: false, loaded: true });
this.setProperties({ loadingFilter: false, timelineLookup: json.timeline_lookup, loaded: true });
}).catch(result => {
this.errorLoading(result);
throw result;
@ -612,6 +614,27 @@ export default RestModel.extend({
return closest;
},
closestDaysAgoFor(postNumber) {
const timelineLookup = this.get('timelineLookup') || [];
let low = 0, high = timelineLookup.length - 1;
while (low <= high) {
const mid = Math.floor(low + ((high - low) / 2));
const midValue = timelineLookup[mid][0];
if (midValue > postNumber) {
high = mid - 1;
} else if (midValue < postNumber) {
low = mid + 1;
} else {
return timelineLookup[mid][1];
}
}
const val = timelineLookup[high] || timelineLookup[low];
if (val) { return val[1]; }
},
// Find a postId for a postNumber, respecting gaps
findPostIdForPostNumber(postNumber) {
const stream = this.get('stream'),
@ -673,6 +696,7 @@ export default RestModel.extend({
const postNumber = post.get('post_number');
if (postNumber && postNumber > (this.get('topic.highest_post_number') || 0)) {
this.set('topic.highest_post_number', postNumber);
this.set('topic.last_posted_at', post.get('created_at'));
}
if (existing) {

View File

@ -0,0 +1,40 @@
import RestModel from 'discourse/models/rest';
import computed from 'ember-addons/ember-computed-decorators';
const TagGroup = RestModel.extend({
@computed('name', 'tag_names')
disableSave() {
return Ember.isEmpty(this.get('name')) || Ember.isEmpty(this.get('tag_names')) || this.get('saving');
},
save() {
var url = "/tag_groups",
self = this;
if (this.get('id')) {
url = "/tag_groups/" + this.get('id');
}
this.set('savingStatus', I18n.t('saving'));
this.set('saving', true);
return Discourse.ajax(url, {
data: {
name: this.get('name'),
tag_names: this.get('tag_names'),
parent_tag_name: this.get('parent_tag_name') ? this.get('parent_tag_name') : undefined,
one_per_topic: this.get('one_per_topic')
},
type: this.get('id') ? 'PUT' : 'POST'
}).then(function(result) {
if(result.id) { self.set('id', result.id); }
self.set('savingStatus', I18n.t('saved'));
self.set('saving', false);
});
},
destroy() {
return Discourse.ajax("/tag_groups/" + this.get('id'), {type: "DELETE"});
}
});
export default TagGroup;

View File

@ -2,6 +2,7 @@
A model representing a Topic's details that aren't always present, such as a list of participants.
When showing topics in lists and such this information should not be required.
**/
import NotificationLevels from 'discourse/lib/notification-levels';
import RestModel from 'discourse/models/rest';
const TopicDetails = RestModel.extend({
@ -35,20 +36,21 @@ const TopicDetails = RestModel.extend({
},
notificationReasonText: function() {
var level = this.get('notification_level');
if(typeof level !== 'number'){
level = 1;
let level = this.get('notification_level');
if (typeof level !== 'number') { level = 1; }
let localeString = `topic.notifications.reasons.${level}`;
if (typeof this.get('notifications_reason_id') === 'number') {
const tmp = localeString + "_" + this.get('notifications_reason_id');
// some sane protection for missing translations of edge cases
if (I18n.lookup(tmp)) { localeString = tmp; }
}
var localeString = "topic.notifications.reasons." + level;
if (typeof this.get('notifications_reason_id') === 'number') {
var tmp = localeString + "_" + this.get('notifications_reason_id');
// some sane protection for missing translations of edge cases
if(I18n.lookup(tmp)){
localeString = tmp;
}
if (Discourse.User.currentProp('mailing_list_mode') && level > NotificationLevels.MUTED) {
return I18n.t("topic.notifications.reasons.mailing_list_mode");
} else {
return I18n.t(localeString, { username: Discourse.User.currentProp('username_lower') });
}
return I18n.t(localeString, { username: Discourse.User.currentProp('username_lower') });
}.property('notification_level', 'notifications_reason_id'),

View File

@ -309,10 +309,10 @@ const Topic = RestModel.extend({
});
},
createInvite(emailOrUsername, groupNames) {
createInvite(user, group_names, custom_message) {
return Discourse.ajax("/t/" + this.get('id') + "/invite", {
type: 'POST',
data: { user: emailOrUsername, group_names: groupNames }
data: { user, group_names, custom_message }
});
},

View File

@ -94,6 +94,8 @@ const User = RestModel.extend({
mutedTopicsPath: url('/latest?state=muted'),
watchingTopicsPath: url('/latest?state=watching'),
@computed("username")
username_lower(username) {
return username.toLowerCase();
@ -321,10 +323,10 @@ const User = RestModel.extend({
Discourse.SiteSettings['newuser_max_' + type + 's'] > 0;
},
createInvite(email, group_names) {
createInvite(email, group_names, custom_message) {
return Discourse.ajax('/invites', {
type: 'POST',
data: { email, group_names }
data: { email, group_names, custom_message }
});
},

View File

@ -49,9 +49,10 @@ export default function() {
});
this.resource('group', { path: '/groups/:name' }, function() {
this.route('members');
this.route('posts');
this.route('topics');
this.route('mentions');
this.route('members');
this.route('messages');
});
@ -130,4 +131,8 @@ export default function() {
this.route('showParentCategory' + filter.capitalize(), {path: '/c/:parent_category/:category/:tag_id/l/' + filter});
});
});
this.resource('tagGroups', {path: '/tag_groups'}, function() {
this.route('show', {path: '/:id'});
});
}

View File

@ -166,8 +166,8 @@ const ApplicationRoute = Discourse.Route.extend(OpenComposer, {
this.render(w, {into: 'modal/topic-bulk-actions', outlet: 'bulkOutlet', controller: factory ? controllerName : 'topic-bulk-actions'});
},
createNewTopicViaParams(title, body, category_id, category) {
this.openComposerWithTopicParams(this.controllerFor('discovery/topics'), title, body, category_id, category);
createNewTopicViaParams(title, body, category_id, category, tags) {
this.openComposerWithTopicParams(this.controllerFor('discovery/topics'), title, body, category_id, category, tags);
},
createNewMessageViaParams(username, title, body) {

View File

@ -1,24 +1,11 @@
export function buildIndex(type) {
return Discourse.Route.extend({
type,
export default Discourse.Route.extend({
model() {
return this.modelFor("group");
},
model() {
return this.modelFor("group").findPosts({ type });
},
setupController(controller, model) {
this.controllerFor('group-index').setProperties({ model, type });
this.controllerFor("group").set("showing", type);
},
renderTemplate() {
this.render('group-index');
},
actions: {
didTransition() { return true; }
}
});
}
export default buildIndex('posts');
setupController(controller, model) {
this.controllerFor("group").set("showing", "members");
controller.set("model", model);
model.findMembers();
}
});

View File

@ -1,11 +1,5 @@
export default Discourse.Route.extend({
model() {
return this.modelFor("group");
},
setupController(controller, model) {
this.controllerFor("group").set("showing", "members");
controller.set("model", model);
model.findMembers();
beforeModel: function() {
this.transitionTo("group.index");
}
});

View File

@ -1,3 +1,3 @@
import { buildIndex } from 'discourse/routes/group-index';
import { buildGroupPage } from 'discourse/routes/group-posts';
export default buildIndex('mentions');
export default buildGroupPage('mentions');

View File

@ -1,3 +1,3 @@
import { buildIndex } from 'discourse/routes/group-index';
import { buildGroupPage } from 'discourse/routes/group-posts';
export default buildIndex('messages');
export default buildGroupPage('messages');

View File

@ -0,0 +1,24 @@
export function buildGroupPage(type) {
return Discourse.Route.extend({
type,
model() {
return this.modelFor("group").findPosts({ type });
},
setupController(controller, model) {
this.controllerFor('group-posts').setProperties({ model, type });
this.controllerFor("group").set("showing", type);
},
renderTemplate() {
this.render('group-posts');
},
actions: {
didTransition() { return true; }
}
});
}
export default buildGroupPage('posts');

View File

@ -1,3 +1,3 @@
import { buildIndex } from 'discourse/routes/group-index';
import { buildGroupPage } from 'discourse/routes/group-posts';
export default buildIndex('topics');
export default buildGroupPage('topics');

View File

@ -7,7 +7,7 @@ export default Discourse.Route.extend({
if (self.controllerFor('navigation/default').get('canCreateTopic')) {
// User can create topic
Ember.run.next(function() {
e.send('createNewTopicViaParams', transition.queryParams.title, transition.queryParams.body, transition.queryParams.category_id, transition.queryParams.category);
e.send('createNewTopicViaParams', transition.queryParams.title, transition.queryParams.body, transition.queryParams.category_id, transition.queryParams.category, transition.queryParams.tags);
});
}
});

View File

@ -0,0 +1,5 @@
export default Discourse.Route.extend({
model(params) {
return this.store.find('tagGroup', params.id);
}
});

View File

@ -0,0 +1,9 @@
export default Discourse.Route.extend({
model() {
return this.store.findAll('tagGroup');
},
titleToken() {
return I18n.t("tagging.groups.title");
},
});

View File

@ -18,6 +18,11 @@ export default Discourse.Route.extend({
didTransition() {
this.controllerFor("application").set("showFooter", true);
return true;
},
showTagGroups() {
this.transitionTo('tagGroups');
return true;
}
}
});

View File

@ -15,7 +15,6 @@ export default Discourse.Route.extend({
topic = this.modelFor('topic'),
postStream = topic.get('postStream'),
topicController = this.controllerFor('topic'),
topicProgressController = this.controllerFor('topic-progress'),
composerController = this.controllerFor('composer');
// I sincerely hope no topic gets this many posts
@ -28,20 +27,15 @@ export default Discourse.Route.extend({
// we need better handling and logging for this condition.
// The post we requested might not exist. Let's find the closest post
const closestPost = postStream.closestPostForPostNumber(params.nearPost || 1),
closest = closestPost.get('post_number'),
progress = postStream.progressIndexOfPost(closestPost);
const closestPost = postStream.closestPostForPostNumber(params.nearPost || 1);
const closest = closestPost.get('post_number');
topicController.setProperties({
'model.currentPost': closest,
enteredIndex: postStream.get('stream').indexOf(closestPost.get('id')),
enteredAt: new Date().getTime().toString(),
});
topicProgressController.setProperties({
progressPosition: progress,
expanded: false
});
// Highlight our post after the next render
Ember.run.scheduleOnce('afterRender', function() {
self.appEvents.trigger('post:highlight', closest);

View File

@ -210,8 +210,6 @@ const TopicRoute = Discourse.Route.extend({
this.topicTrackingState.trackIncoming('all');
controller.subscribe();
this.controllerFor('topic-progress').set('model', model);
// We reset screen tracking every time a topic is entered
this.screenTrack.start(model.get('id'), controller);
}

View File

@ -1,5 +1,5 @@
import { on, observes } from 'ember-addons/ember-computed-decorators';
import computed from 'ember-addons/ember-computed-decorators';
import { default as computed, on, observes } from 'ember-addons/ember-computed-decorators';
import { autoUpdatingRelativeAge } from 'discourse/lib/formatter';
const LOGS_NOTICE_KEY = "logs-notice-text";
@ -28,6 +28,7 @@ const LogsNotice = Ember.Object.extend({
this.set('text',
I18n.t(`logs_error_rate_notice.${translationKey}`, {
relativeAge: autoUpdatingRelativeAge(new Date),
timestamp: moment().format("YYYY-MM-DD H:mm:ss"),
siteSettingRate: I18n.t('logs_error_rate_notice.rate', { count: siteSettingLimit, duration: duration }),
rate: I18n.t('logs_error_rate_notice.rate', { count: rate, duration: duration }),

View File

@ -0,0 +1,3 @@
{{#each messages as |message|}}
{{composer-message message=message closeMessage="closeMessage"}}
{{/each}}

View File

@ -0,0 +1,7 @@
<section class="field">
<p>{{i18n 'category.tags_allowed_tags'}}</p>
{{tag-chooser placeholderKey="category.tags_placeholder" tags=category.allowed_tags}}
<p>{{i18n 'category.tags_allowed_tag_groups'}}</p>
{{tag-group-chooser placeholderKey="category.tag_groups_placeholder" tagGroups=category.allowed_tag_groups}}
</section>

View File

@ -0,0 +1,13 @@
{{#if title}}
<h3>{{title}}</h3>
{{/if}}
{{#if category}}
{{category-title-link category=category}}
{{/if}}
{{#each sortedTags as |tag|}}
<div class='tag-box'>
{{discourse-tag tag.id}} <span class='tag-count'>x {{tag.count}}</span>
</div>
{{/each}}
<div class="clearfix" />
<hr/>

View File

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

View File

@ -0,0 +1,28 @@
{{#unless hidden}}
{{#if expanded}}
<nav id='topic-progress-expanded'>
{{d-button action="jumpTop"
disabled=jumpTopDisabled
class="full no-text"
icon="caret-up"
label="topic.progress.go_top"}}
<div class='jump-form'>
{{input value=toPostIndex}}
{{d-button action="jumpPost" label="topic.progress.go"}}
</div>
{{d-button action="jumpBottom"
disabled=jumpBottomDisabled
class="full no-text jump-bottom"
icon="caret-down"
label="topic.progress.go_bottom"}}
</nav>
{{/if}}
<nav id='topic-progress' title="{{i18n 'topic.progress.title'}}" class="{{if hideProgress 'hidden'}}">
<div class='nums'>
<h4>{{progressPosition}}</h4><span class="{{if hugeNumberOfPosts 'hidden'}}">
<span>/</span>
<h4>{{postStream.filteredPostsCount}}</h4></span>
</div>
<i class="fa {{unless expanded 'fa-sort'}}"></i>
</nav>
{{/unless}}

View File

@ -3,15 +3,19 @@
{{#if currentUser.staff}}
{{#popup-menu visible=optionsVisible hide="hideOptions" title="composer.options"}}
<li>
{{d-button action="toggleWhisper" icon="eye-slash" label="composer.toggle_whisper"}}
</li>
{{#each popupMenuOptions as |option|}}
{{#if option.condition}}
<li>
{{d-button action=option.action icon=option.icon label=option.label}}
</li>
{{/if}}
{{/each}}
{{/popup-menu}}
{{/if}}
{{render "composer-messages"}}
<div class='control'>
{{composer-messages composer=model messageCount=messageCount addLinkLookup="addLinkLookup"}}
<div class='control'>
{{#if site.mobileView}}
<a href class='toggle-toolbar' {{action "toggleToolbar" bubbles=false}}></a>
{{/if}}
@ -86,19 +90,21 @@
composer=model
lastValidatedAt=lastValidatedAt
canWhisper=canWhisper
popupMenuOptions=popupMenuOptions
draftStatus=model.draftStatus
isUploading=isUploading
groupsMentioned="groupsMentioned"
importQuote="importQuote"
showOptions="showOptions"
showToolbar=showToolbar
showUploadSelector="showUploadSelector"}}
showUploadSelector="showUploadSelector"
afterRefresh="afterRefresh"}}
{{#if currentUser}}
<div class='submit-panel'>
{{plugin-outlet "composer-fields-below"}}
{{#if canEditTags}}
{{tag-chooser tags=model.tags tabIndex="4"}}
{{tag-chooser tags=model.tags tabIndex="4" categoryId=model.categoryId}}
{{/if}}
<button {{action "save"}} tabindex="5" class="btn btn-primary create {{if disableSubmit 'disabled'}}" title="{{i18n 'composer.title'}}">{{{model.saveIcon}}}{{model.saveText}}</button>
<a href {{action "cancel"}} class='cancel' tabindex="6">{{i18n 'cancel'}}</a>

View File

@ -0,0 +1,2 @@
<a href {{action "closeMessage"}} class='close'>{{fa-icon "close"}}</a>
<p>{{{message.body}}}</p>

View File

@ -1,2 +1,2 @@
<a href {{action "closeMessage" this}} class='close'><i class='fa fa-times'></i></a>
{{{body}}}
<a href {{action "closeMessage"}} class='close'>{{fa-icon "times"}}</a>
{{{message.body}}}

View File

@ -1,2 +1,2 @@
<a href {{action "closeMessage" this}} class='close'><i class='fa fa-close'></i></a>
{{{body}}}
<a href {{action "closeMessage"}} class='close'>{{fa-icon "close"}}</a>
{{{message.body}}}

View File

@ -1,6 +1,6 @@
<a href {{action "closeMessage" this}} class='close'>{{fa-icon "close"}}</a>
<a href {{action "closeMessage"}} class='close'>{{fa-icon "close"}}</a>
<h3>{{i18n 'composer.similar_topics'}}</h3>
<ul class='topics'>
{{mount-widget widget="search-result-topic" args=(as-hash results=similarTopics)}}
{{mount-widget widget="search-result-topic" args=(as-hash results=message.similarTopics)}}
</ul>

View File

@ -1 +1,45 @@
{{group-post-stream posts=model emptyText=emptyText loadMore="loadMore"}}
{{#if model}}
{{#if isOwner}}
<div class='clearfix'>
<form id='add-user-to-group' autocomplete="off">
{{user-selector usernames=usernames placeholderKey="groups.selector_placeholder" id="user-search-selector" name="usernames"}}
{{d-button action="addMembers" class="add" icon="plus" label="groups.add"}}
</form>
</div>
{{/if}}
{{#load-more selector=".group-members tr" action="loadMore"}}
<table class='group-members'>
<tr>
<th colspan="2">{{i18n 'last_post'}}</th>
<th>{{i18n 'last_seen'}}</th>
{{#if isOwner}}
<th></th>
{{/if}}
</tr>
{{#each model.members as |m|}}
<tr>
<td class='avatar'>
{{user-info user=m}}
{{#if m.owner}}<span class='is-owner'>{{i18n "groups.owner"}}</span>{{/if}}
</td>
<td>
<span class="text">{{bound-date m.last_posted_at}}</span>
</td>
<td>
<span class="text">{{bound-date m.last_seen_at}}</span>
</td>
{{#if isOwner}}
<td class='remove-user'>
{{#unless m.owner}}
<a class="remove-link" {{action "removeMember" m}}><i class="fa fa-times"></i></a>
{{/unless}}
</td>
{{/if}}
</tr>
{{/each}}
</table>
{{/load-more}}
{{else}}
<div>{{i18n "groups.empty.users"}}</div>
{{/if}}

View File

@ -1,45 +0,0 @@
{{#if model}}
{{#if isOwner}}
<div class='clearfix'>
<form id='add-user-to-group' autocomplete="off">
{{user-selector usernames=usernames placeholderKey="groups.selector_placeholder" id="user-search-selector" name="usernames"}}
{{d-button action="addMembers" class="add" icon="plus" label="groups.add"}}
</form>
</div>
{{/if}}
{{#load-more selector=".group-members tr" action="loadMore"}}
<table class='group-members'>
<tr>
<th colspan="2">{{i18n 'last_post'}}</th>
<th>{{i18n 'last_seen'}}</th>
{{#if isOwner}}
<th></th>
{{/if}}
</tr>
{{#each model.members as |m|}}
<tr>
<td class='avatar'>
{{user-info user=m}}
{{#if m.owner}}<span class='is-owner'>{{i18n "groups.owner"}}</span>{{/if}}
</td>
<td>
<span class="text">{{bound-date m.last_posted_at}}</span>
</td>
<td>
<span class="text">{{bound-date m.last_seen_at}}</span>
</td>
{{#if isOwner}}
<td class='remove-user'>
{{#unless m.owner}}
<a class="remove-link" {{action "removeMember" m}}><i class="fa fa-times"></i></a>
{{/unless}}
</td>
{{/if}}
</tr>
{{/each}}
</table>
{{/load-more}}
{{else}}
<div>{{i18n "groups.empty.users"}}</div>
{{/if}}

View File

@ -0,0 +1 @@
{{group-post-stream posts=model emptyText=emptyText loadMore="loadMore"}}

View File

@ -25,7 +25,7 @@
<tr class="input">
<td class="label"><label for='new-account-username'>{{i18n 'user.username.title'}}</label></td>
<td>
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength}}
{{input value=accountUsername id="new-account-username" name="username" maxlength=maxUsernameLength autocomplete="off"}}
&nbsp;{{input-tip validation=usernameValidation id="username-validation"}}
</td>
</tr>

View File

@ -7,6 +7,9 @@
{{edit-category-tab panels=panels selectedTab=selectedTab tab="settings"}}
{{edit-category-tab panels=panels selectedTab=selectedTab tab="images"}}
{{edit-category-tab panels=panels selectedTab=selectedTab tab="topic-template"}}
{{#if siteSettings.tagging_enabled}}
{{edit-category-tab panels=panels selectedTab=selectedTab tab="tags"}}
{{/if}}
</ul>
<div class="modal-body">

View File

@ -19,9 +19,15 @@
{{text-field value=emailOrUsername placeholderKey="topic.invite_reply.email_placeholder"}}
{{/if}}
{{#if showGroups}}
<label>{{{groupInstructions}}}</label>
<label><span class={{showGroupsClass}}>{{i18n 'topic.automatically_add_to_groups'}}</span></label>
{{group-selector groupFinder=groupFinder groupNames=model.groupNames placeholderKey="topic.invite_private.group_name"}}
{{/if}}
{{#if showCustomMessage}}
<br><label><span class='optional'>{{i18n 'invite.custom_message'}}</span> <a {{action "showCustomMessageBox"}}>{{i18n 'invite.custom_message_link'}}</a>.</label>
{{#if hasCustomMessage}}{{textarea value=customMessage placeholder=customMessagePlaceholder}}{{/if}}
{{/if}}
{{/if}}
</div>
<div class="modal-footer">

View File

@ -14,6 +14,12 @@
{{share-source source=s title=title action="share"}}
{{/each}}
{{#if canReplyAsNewTopic}}
<div class='reply-as-new-topic'>
<a href {{action "replyAsNewTopic"}} aria-label='{{i18n 'post.reply_as_new_topic'}}' title='{{i18n 'post.reply_as_new_topic'}}'>{{fa-icon "plus"}}{{i18n 'topic.create'}}</a>
</div>
{{/if}}
<div class='link'>
<a href {{action "close"}} aria-label='{{i18n 'share.close'}}' title='{{i18n 'share.close'}}'>{{fa-icon "close"}}</a>
</div>

View File

@ -0,0 +1,3 @@
<div class="tag-group-content">
<p class="about">{{i18n 'tagging.groups.about'}}</p>
</div>

View File

@ -0,0 +1,25 @@
<div class="tag-group-content">
<h1>{{text-field value=model.name}}</h1>
<br/>
<section class="group-tags-list">
<label>{{i18n 'tagging.groups.tags_label'}}</label><br/>
{{tag-chooser tags=model.tag_names everyTag="true" unlimitedTagCount="true"}}
</section>
<section class="parent-tag-section">
<label>{{i18n 'tagging.groups.parent_tag_label'}}</label>
{{tag-chooser tags=model.parent_tag_name everyTag="true" limit="1" placeholderKey="tagging.groups.parent_tag_placeholder"}}
<span class="description">{{i18n 'tagging.groups.parent_tag_description'}}</span>
</section>
<section class="group-one-per-topic">
<label>
{{input type="checkbox" checked=model.one_per_topic name="onepertopic"}}
{{i18n 'tagging.groups.one_per_topic_label'}}
</label>
</section>
<button {{action "save"}} disabled={{model.disableSave}} class='btn'>{{i18n 'tagging.groups.save'}}</button>
<button {{action "destroy"}} disabled={{model.disableSave}} class='btn btn-danger'><i class="fa fa-trash-o"></i> {{i18n 'tagging.groups.delete'}}</button>
<span class="saving {{unless model.savingStatus 'hidden'}}">{{model.savingStatus}}</span>
</div>

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