Merge branch 'master' into vdom

This commit is contained in:
Sam 2016-02-15 19:29:59 +11:00
commit 1dc168a7e6
19 changed files with 240 additions and 89 deletions

View File

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

View File

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

View File

@ -61,6 +61,10 @@ export default Ember.Component.extend({
_markRead: function(){ _markRead: function(){
this.$('a').click(() => { this.$('a').click(() => {
this.set('notification.read', true); this.set('notification.read', true);
Discourse.setTransientHeader("Discourse-Clear-Notifications", this.get('notification.id'));
if (document && document.cookie) {
document.cookie = `cn=${this.get('notification.id')}; expires=Fri, 31 Dec 9999 23:59:59 GMT`;
}
return true; return true;
}); });
}.on('didInsertElement'), }.on('didInsertElement'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -130,15 +130,29 @@ class Notification < ActiveRecord::Base
.to_a .to_a
if notifications.present? if notifications.present?
notifications += user
.notifications
.order('notifications.created_at DESC')
.where(read: false, notification_type: Notification.types[:private_message])
.joins(:topic)
.where('notifications.id < ?', notifications.last.id)
.limit(count)
notifications.sort do |x,y| ids = Notification.exec_sql("
SELECT n.id FROM notifications n
WHERE
n.notification_type = 6 AND
n.user_id = #{user.id.to_i} AND
NOT read
ORDER BY n.id ASC
LIMIT #{count.to_i}
").values.map do |x,_|
x.to_i
end
if ids.length > 0
notifications += user
.notifications
.order('notifications.created_at DESC')
.where(id: ids)
.joins(:topic)
.limit(count)
end
notifications.uniq(&:id).sort do |x,y|
if x.unread_pm? && !y.unread_pm? if x.unread_pm? && !y.unread_pm?
-1 -1
elsif y.unread_pm? && !x.unread_pm? elsif y.unread_pm? && !x.unread_pm?

View File

@ -293,15 +293,6 @@ class User < ActiveRecord::Base
def saw_notification_id(notification_id) def saw_notification_id(notification_id)
User.where("id = ? and seen_notification_id < ?", id, notification_id) User.where("id = ? and seen_notification_id < ?", id, notification_id)
.update_all ["seen_notification_id = ?", notification_id] .update_all ["seen_notification_id = ?", notification_id]
# some notifications are considered read once seen
Notification.where('user_id = ? AND NOT read AND notification_type IN (?)', id, [
Notification.types[:granted_badge],
Notification.types[:invitee_accepted],
Notification.types[:group_message_summary],
Notification.types[:liked]
])
.update_all ["read = ?", true]
end end
def publish_notifications_state def publish_notifications_state
@ -310,11 +301,43 @@ class User < ActiveRecord::Base
notification = notifications.visible.order('notifications.id desc').first notification = notifications.visible.order('notifications.id desc').first
json = NotificationSerializer.new(notification).as_json if notification json = NotificationSerializer.new(notification).as_json if notification
sql = "
SELECT * FROM (
SELECT n.id, n.read FROM notifications n
LEFT JOIN topics t ON n.topic_id = t.id
WHERE
t.deleted_at IS NULL AND
n.notification_type = :type AND
n.user_id = :user_id AND
NOT read
ORDER BY n.id DESC
LIMIT 20
) AS x
UNION ALL
SELECT * FROM (
SELECT n.id, n.read FROM notifications n
LEFT JOIN topics t ON n.topic_id = t.id
WHERE
t.deleted_at IS NULL AND
(n.notification_type <> :type OR read) AND
n.user_id = :user_id
ORDER BY n.id DESC
LIMIT 20
) AS y
"
recent = User.exec_sql(sql, user_id: id,
type: Notification.types[:private_message]).values.map do |id, read|
[id.to_i, read == 't'.freeze]
end
MessageBus.publish("/notification/#{id}", MessageBus.publish("/notification/#{id}",
{unread_notifications: unread_notifications, {unread_notifications: unread_notifications,
unread_private_messages: unread_private_messages, unread_private_messages: unread_private_messages,
total_unread_notifications: total_unread_notifications, total_unread_notifications: total_unread_notifications,
last_notification: json last_notification: json,
recent: recent
}, },
user_ids: [id] # only publish the notification to this user user_ids: [id] # only publish the notification to this user
) )

View File

@ -3,6 +3,12 @@ class UserArchivedMessage < ActiveRecord::Base
belongs_to :topic belongs_to :topic
def self.move_to_inbox!(user_id, topic_id) def self.move_to_inbox!(user_id, topic_id)
return if (TopicUser.where(
user_id: user_id,
topic_id: topic_id,
notification_level: TopicUser.notification_levels[:muted]
).exists?)
UserArchivedMessage.where(user_id: user_id, topic_id: topic_id).destroy_all UserArchivedMessage.where(user_id: user_id, topic_id: topic_id).destroy_all
MessageBus.publish("/topic/#{topic_id}", {type: "move_to_inbox"}, user_ids: [user_id]) MessageBus.publish("/topic/#{topic_id}", {type: "move_to_inbox"}, user_ids: [user_id])
end end

View File

@ -1287,7 +1287,7 @@ en:
moved_post: "%{display_username} moved your post to %{link}" moved_post: "%{display_username} moved your post to %{link}"
private_message: "%{display_username} sent you a message: %{link}" private_message: "%{display_username} sent you a message: %{link}"
invited_to_private_message: "%{display_username} invited you to a message: %{link}" invited_to_private_message: "%{display_username} invited you to a message: %{link}"
invited_to_topic: "%{display_username} invited you to a topic: %{link}" invited_to_topic: "%{display_username} invited you to %{link}"
invitee_accepted: "%{display_username} accepted your invitation" invitee_accepted: "%{display_username} accepted your invitation"
linked: "%{display_username} linked you in %{link}" linked: "%{display_username} linked you in %{link}"
granted_badge: "You earned %{link}" granted_badge: "You earned %{link}"
@ -2041,7 +2041,7 @@ en:
Please visit this link to view the message: %{base_url}%{url} Please visit this link to view the message: %{base_url}%{url}
user_invited_to_topic: user_invited_to_topic:
subject_template: "[%{site_name}] %{username} invited you to a topic '%{topic_title}'" subject_template: "[%{site_name}] %{username} invited you to '%{topic_title}'"
text_body_template: | text_body_template: |
%{username} invited you to a discussion %{username} invited you to a discussion

View File

@ -0,0 +1,6 @@
class AddUnreadPmIndexToNotifications < ActiveRecord::Migration
def change
# create index idxtmp on notifications(user_id, id) where notification_type = 6 AND NOT read
add_index :notifications, [:user_id, :id], unique: true, where: 'notification_type = 6 AND NOT read'
end
end

View File

@ -85,6 +85,35 @@ describe TopicsController do
end end
describe 'clear_notifications' do
it 'correctly clears notifications if specified via cookie' do
notification = Fabricate(:notification)
log_in_user(notification.user)
request.cookies['cn'] = "2828,100,#{notification.id}"
get :show, {topic_id: 100}
expect(response.cookies['cn']).to eq nil
notification.reload
expect(notification.read).to eq true
end
it 'correctly clears notifications if specified via header' do
notification = Fabricate(:notification)
log_in_user(notification.user)
request.headers['Discourse-Clear-Notifications'] = "2828,100,#{notification.id}"
get :show, {topic_id: 100}
notification.reload
expect(notification.read).to eq true
end
end
describe 'set_locale' do describe 'set_locale' do
it 'sets the one the user prefers' do it 'sets the one the user prefers' do
SiteSetting.stubs(:allow_user_locale).returns(true) SiteSetting.stubs(:allow_user_locale).returns(true)

View File

@ -0,0 +1,21 @@
require 'rails_helper'
describe UserArchivedMessage do
it 'Does not move archived muted messages back to inbox' do
user = Fabricate(:admin)
user2 = Fabricate(:admin)
topic = create_post(user: user,
skip_validations: true,
target_usernames: [user2.username,user.username].join(","),
archetype: Archetype.private_message).topic
UserArchivedMessage.archive!(user.id, topic.id)
expect(topic.message_archived?(user)).to eq(true)
TopicUser.change(user.id, topic.id, notification_level: TopicUser.notification_levels[:muted])
UserArchivedMessage.move_to_inbox!(user.id, topic.id)
expect(topic.message_archived?(user)).to eq(true)
end
end