mirror of
https://github.com/codeninjasllc/discourse.git
synced 2024-11-27 17:46:05 -05:00
Refactor + Fix: Wasn't correctly loading activity streams. Code is a lot more Ember-y now.
This commit is contained in:
parent
89a617f0c6
commit
0f296cd42b
15 changed files with 198 additions and 187 deletions
|
@ -177,23 +177,6 @@ Discourse.User = Discourse.Model.extend({
|
|||
});
|
||||
},
|
||||
|
||||
/**
|
||||
Filters out this user's stream of user actions by a given filter
|
||||
|
||||
@method filterStream
|
||||
@param {String} filter
|
||||
**/
|
||||
filterStream: function(filter) {
|
||||
if (Discourse.UserAction.statGroups[filter]) {
|
||||
filter = Discourse.UserAction.statGroups[filter].join(",");
|
||||
}
|
||||
|
||||
this.set('streamFilter', filter);
|
||||
this.set('stream', Em.A());
|
||||
this.set('totalItems', 0);
|
||||
return this.loadMoreUserActions();
|
||||
},
|
||||
|
||||
/**
|
||||
Loads a single user action by id.
|
||||
|
||||
|
@ -207,44 +190,9 @@ Discourse.User = Discourse.Model.extend({
|
|||
return Discourse.ajax("/user_actions/" + id + ".json", { cache: 'false' }).then(function(result) {
|
||||
if (result) {
|
||||
if ((user.get('streamFilter') || result.action_type) !== result.action_type) return;
|
||||
|
||||
var action = Em.A();
|
||||
action.pushObject(Discourse.UserAction.create(result));
|
||||
action = Discourse.UserAction.collapseStream(action);
|
||||
|
||||
user.set('totalItems', user.get('totalItems') + 1);
|
||||
|
||||
return stream.insertAt(0, action[0]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
Loads more user actions, and then calls a callback if defined.
|
||||
|
||||
@method loadMoreUserActions
|
||||
@returns {Promise} the content of the user actions
|
||||
**/
|
||||
loadMoreUserActions: function() {
|
||||
var user = this;
|
||||
var stream = user.get('stream');
|
||||
if (!stream) return;
|
||||
|
||||
var url = Discourse.getURL("/user_actions?offset=") + this.get('totalItems') + "&user_id=" + (this.get("id"));
|
||||
if (this.get('streamFilter')) {
|
||||
url += "&filter=" + (this.get('streamFilter'));
|
||||
}
|
||||
|
||||
return Discourse.ajax(url, { cache: 'false' }).then( function(result) {
|
||||
if (result && result.user_actions && result.user_actions.each) {
|
||||
var copy = Em.A();
|
||||
result.user_actions.each(function(i) {
|
||||
return copy.pushObject(Discourse.UserAction.create(i));
|
||||
});
|
||||
copy = Discourse.UserAction.collapseStream(copy);
|
||||
stream.pushObjects(copy);
|
||||
user.set('stream', stream);
|
||||
user.set('totalItems', user.get('totalItems') + result.user_actions.length);
|
||||
var action = Discourse.UserAction.collapseStream([Discourse.UserAction.create(result)]);
|
||||
stream.set('itemsLoaded', user.get('itemsLoaded') + 1);
|
||||
stream.insertAt(0, action[0]);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
@ -284,33 +232,36 @@ Discourse.User = Discourse.Model.extend({
|
|||
return this.get('stats').filterProperty('isPM');
|
||||
}.property('stats.@each.isPM'),
|
||||
|
||||
/**
|
||||
Load extra details for the user
|
||||
|
||||
@method loadDetails
|
||||
**/
|
||||
loadDetails: function() {
|
||||
|
||||
this.set('loading', true);
|
||||
|
||||
// Check the preload store first
|
||||
findDetails: function() {
|
||||
var user = this;
|
||||
var username = user.get('username');
|
||||
|
||||
return PreloadStore.getAndRemove("user_" + username, function() {
|
||||
return Discourse.ajax("/users/" + username + '.json');
|
||||
return PreloadStore.getAndRemove("user_" + user.get('username'), function() {
|
||||
return Discourse.ajax("/users/" + user.get('username') + '.json');
|
||||
}).then(function (json) {
|
||||
|
||||
// Create a user from the resulting JSON
|
||||
json.user.stats = Discourse.User.groupStats(json.user.stats.map(function(s) {
|
||||
if (s.count) s.count = parseInt(s.count, 10);
|
||||
return Discourse.UserActionStat.create(s);
|
||||
}));
|
||||
|
||||
user.setProperties(json.user);
|
||||
user.set('loading', false);
|
||||
return user;
|
||||
});
|
||||
},
|
||||
|
||||
findStream: function(filter) {
|
||||
if (Discourse.UserAction.statGroups[filter]) {
|
||||
filter = Discourse.UserAction.statGroups[filter].join(",");
|
||||
}
|
||||
|
||||
var stream = Discourse.UserStream.create({
|
||||
totalItems: 0,
|
||||
content: [],
|
||||
filter: filter,
|
||||
user: this
|
||||
});
|
||||
|
||||
stream.findItems();
|
||||
return stream;
|
||||
}
|
||||
|
||||
});
|
||||
|
|
39
app/assets/javascripts/discourse/models/user_stream.js
Normal file
39
app/assets/javascripts/discourse/models/user_stream.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
/**
|
||||
Represents a user's stream
|
||||
|
||||
@class UserStream
|
||||
@extends Discourse.Model
|
||||
@namespace Discourse
|
||||
@module Discourse
|
||||
**/
|
||||
Discourse.UserStream = Discourse.Model.extend({
|
||||
|
||||
filterChanged: function() {
|
||||
this.setProperties({
|
||||
content: Em.A(),
|
||||
itemsLoaded: 0
|
||||
});
|
||||
this.findItems();
|
||||
}.observes('filter'),
|
||||
|
||||
findItems: function() {
|
||||
var url = Discourse.getURL("/user_actions?offset=") + this.get('itemsLoaded') + "&username=" + (this.get('user.username_lower'));
|
||||
if (this.get('filter')) {
|
||||
url += "&filter=" + (this.get('filter'));
|
||||
}
|
||||
|
||||
var stream = this;
|
||||
return Discourse.ajax(url, {cache: 'false'}).then( function(result) {
|
||||
if (result && result.user_actions && result.user_actions.each) {
|
||||
var copy = Em.A();
|
||||
result.user_actions.each(function(i) {
|
||||
return copy.pushObject(Discourse.UserAction.create(i));
|
||||
});
|
||||
copy = Discourse.UserAction.collapseStream(copy);
|
||||
stream.get('content').pushObjects(copy);
|
||||
stream.set('itemsLoaded', stream.get('itemsLoaded') + result.user_actions.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
|
@ -11,9 +11,11 @@ Discourse.Route = Em.Route.extend({
|
|||
/**
|
||||
Called every time we enter a route on Discourse.
|
||||
|
||||
@method enter
|
||||
@method activate
|
||||
**/
|
||||
enter: function(router, context) {
|
||||
activate: function(router, context) {
|
||||
this._super();
|
||||
|
||||
// Close mini profiler
|
||||
$('.profiler-results .profiler-result').remove();
|
||||
|
||||
|
|
|
@ -9,15 +9,11 @@
|
|||
Discourse.UserActivityRoute = Discourse.Route.extend({
|
||||
|
||||
model: function() {
|
||||
return this.modelFor('user');
|
||||
return this.modelFor('user').findStream();
|
||||
},
|
||||
|
||||
renderTemplate: function() {
|
||||
this.render({ into: 'user', outlet: 'userOutlet' });
|
||||
},
|
||||
|
||||
setupController: function(controller, user) {
|
||||
user.filterStream(null);
|
||||
}
|
||||
|
||||
});
|
||||
|
|
|
@ -9,16 +9,14 @@
|
|||
Discourse.UserPrivateMessagesRoute = Discourse.RestrictedUserRoute.extend({
|
||||
|
||||
model: function() {
|
||||
return this.modelFor('user');
|
||||
return this.modelFor('user').findStream(Discourse.UserAction.GOT_PRIVATE_MESSAGE);
|
||||
},
|
||||
|
||||
renderTemplate: function() {
|
||||
this.render({ into: 'user', outlet: 'userOutlet' });
|
||||
},
|
||||
|
||||
setupController: function(controller, user) {
|
||||
user.filterStream(Discourse.UserAction.GOT_PRIVATE_MESSAGE);
|
||||
|
||||
setupController: function(controller, stream) {
|
||||
var composerController = this.controllerFor('composer');
|
||||
Discourse.Draft.get('new_private_message').then(function(data) {
|
||||
if (data.draft) {
|
||||
|
@ -32,6 +30,7 @@ Discourse.UserPrivateMessagesRoute = Discourse.RestrictedUserRoute.extend({
|
|||
});
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
|
|
@ -9,11 +9,29 @@
|
|||
Discourse.UserRoute = Discourse.Route.extend({
|
||||
|
||||
model: function(params) {
|
||||
return Discourse.User.create({username: params.username}).loadDetails();
|
||||
return Discourse.User.create({username: params.username});
|
||||
},
|
||||
|
||||
serialize: function(params) {
|
||||
return { username: Em.get(params, 'username').toLowerCase() };
|
||||
},
|
||||
|
||||
setupController: function(controller, user) {
|
||||
user.findDetails();
|
||||
},
|
||||
|
||||
activate: function() {
|
||||
this._super();
|
||||
var user = this.modelFor('user');
|
||||
Discourse.MessageBus.subscribe("/users/" + user.get('username_lower'), function(data) {
|
||||
user.loadUserAction(data);
|
||||
});
|
||||
},
|
||||
|
||||
deactivate: function() {
|
||||
this._super();
|
||||
Discourse.MessageBus.unsubscribe("/users/" + this.modelFor('user').get('username_lower'));
|
||||
}
|
||||
|
||||
|
||||
});
|
||||
|
|
|
@ -1,53 +1,54 @@
|
|||
<div id='user-info'>
|
||||
<nav class='buttons'>
|
||||
{{#if can_edit}}
|
||||
{{#linkTo "preferences" class="btn"}}{{i18n user.edit}}{{/linkTo}}
|
||||
{{/if}}
|
||||
<br/>
|
||||
{{#if can_send_private_message_to_user}}
|
||||
<button class='btn create' {{action composePrivateMessage}}>
|
||||
<i class='icon icon-envelope-alt'></i>
|
||||
{{i18n user.private_message}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</nav>
|
||||
<div class='clearfix'></div>
|
||||
{{#with user}}
|
||||
<div id='user-info'>
|
||||
<nav class='buttons'>
|
||||
{{#if can_edit}}
|
||||
{{#linkTo "preferences" class="btn"}}{{i18n user.edit}}{{/linkTo}}
|
||||
{{/if}}
|
||||
<br/>
|
||||
{{#if can_send_private_message_to_user}}
|
||||
<button class='btn create' {{action composePrivateMessage}}>
|
||||
<i class='icon icon-envelope-alt'></i>
|
||||
{{i18n user.private_message}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</nav>
|
||||
<div class='clearfix'></div>
|
||||
|
||||
<ul class='action-list nav-stacked side-nav'>
|
||||
{{view Discourse.ActivityFilterView countBinding="statsCountNonPM"}}
|
||||
{{#each statsExcludingPms}}
|
||||
{{view Discourse.ActivityFilterView contentBinding="this"}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class='show'>
|
||||
<dl>
|
||||
{{#if hasWebsite}}
|
||||
<dt>{{i18n user.website}}:</dt><dd><a {{bindAttr href="website"}} target="_blank">{{websiteName}}</a></dd>
|
||||
{{/if}}
|
||||
<dt>{{i18n user.created}}:</dt><dd>{{date created_at}}</dd>
|
||||
{{#if last_posted_at}}
|
||||
<dt>{{i18n user.last_posted}}:</dt><dd>{{date last_posted_at}}</dd>
|
||||
{{/if}}
|
||||
{{#if last_seen_at}}
|
||||
<dt>{{i18n user.last_seen}}:</dt><dd>{{date last_seen_at}}</dd>
|
||||
{{/if}}
|
||||
{{#if invited_by}}
|
||||
<dt>{{i18n user.invited_by}}:</dt><dd>{{#linkTo user.activity invited_by}}{{invited_by.username}}{{/linkTo}}</dd>
|
||||
{{/if}}
|
||||
{{#if email}}
|
||||
<dt>{{i18n user.email.title}}:</dt><dd {{bindAttr title="email"}}>{{email}}</dd>
|
||||
{{/if}}
|
||||
<dt>{{i18n user.trust_level}}:</dt><dd>{{trustLevel.name}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{{#if can_edit}}
|
||||
<div style='margin-top: 10px'>
|
||||
<button class='btn' data-not-implemented='true' disabled title="{{i18n not_implemented}}">{{i18n user.download_archive}}</button>
|
||||
<ul class='action-list nav-stacked side-nav'>
|
||||
{{view Discourse.ActivityFilterView countBinding="statsCountNonPM"}}
|
||||
{{#each statsExcludingPms}}
|
||||
{{view Discourse.ActivityFilterView contentBinding="this"}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
<div class='show'>
|
||||
<dl>
|
||||
{{#if hasWebsite}}
|
||||
<dt>{{i18n user.website}}:</dt><dd><a {{bindAttr href="website"}} target="_blank">{{websiteName}}</a></dd>
|
||||
{{/if}}
|
||||
<dt>{{i18n user.created}}:</dt><dd>{{date created_at}}</dd>
|
||||
{{#if last_posted_at}}
|
||||
<dt>{{i18n user.last_posted}}:</dt><dd>{{date last_posted_at}}</dd>
|
||||
{{/if}}
|
||||
{{#if last_seen_at}}
|
||||
<dt>{{i18n user.last_seen}}:</dt><dd>{{date last_seen_at}}</dd>
|
||||
{{/if}}
|
||||
{{#if invited_by}}
|
||||
<dt>{{i18n user.invited_by}}:</dt><dd>{{#linkTo user.activity invited_by}}{{invited_by.username}}{{/linkTo}}</dd>
|
||||
{{/if}}
|
||||
{{#if email}}
|
||||
<dt>{{i18n user.email.title}}:</dt><dd {{bindAttr title="email"}}>{{email}}</dd>
|
||||
{{/if}}
|
||||
<dt>{{i18n user.trust_level}}:</dt><dd>{{trustLevel.name}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if can_edit}}
|
||||
<div style='margin-top: 10px'>
|
||||
<button class='btn' data-not-implemented='true' disabled title="{{i18n not_implemented}}">{{i18n user.download_archive}}</button>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{{/with}}
|
||||
|
||||
{{view Discourse.UserStreamView streamBinding="stream"}}
|
||||
{{view Discourse.UserStreamView streamBinding="model"}}
|
||||
|
|
|
@ -1,23 +1,24 @@
|
|||
<div id='user-info'>
|
||||
<nav class='buttons'>
|
||||
{{#if can_edit}}
|
||||
{{#linkTo "preferences" class="btn"}}{{i18n user.edit}}{{/linkTo}}
|
||||
{{/if}}
|
||||
<br/>
|
||||
{{#if can_send_private_message_to_user}}
|
||||
<button class='btn create' {{action composePrivateMessage}}>
|
||||
<i class='icon icon-plus'></i>
|
||||
{{i18n user.private_message}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</nav>
|
||||
<div class='clearfix'></div>
|
||||
|
||||
<ul class='action-list nav-stacked side-nav'>
|
||||
{{#each statsPmsOnly}}
|
||||
{{view Discourse.ActivityFilterView contentBinding="this"}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{#with user}}
|
||||
<nav class='buttons'>
|
||||
{{#if can_edit}}
|
||||
{{#linkTo "preferences" class="btn"}}{{i18n user.edit}}{{/linkTo}}
|
||||
{{/if}}
|
||||
<br/>
|
||||
{{#if can_send_private_message_to_user}}
|
||||
<button class='btn create' {{action composePrivateMessage}}>
|
||||
<i class='icon icon-plus'></i>
|
||||
{{i18n user.private_message}}
|
||||
</button>
|
||||
{{/if}}
|
||||
</nav>
|
||||
<div class='clearfix'></div>
|
||||
|
||||
<ul class='action-list nav-stacked side-nav'>
|
||||
{{#each statsPmsOnly}}
|
||||
{{view Discourse.ActivityFilterView contentBinding="this"}}
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{/with}}
|
||||
</div>
|
||||
{{view Discourse.UserStreamView streamBinding="stream"}}
|
||||
{{view Discourse.UserStreamView streamBinding="model"}}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<div id='user-stream'>
|
||||
{{#collection contentBinding="stream" itemClass="item"}}
|
||||
{{#with view.content}}
|
||||
{{#each view.stream.content}}
|
||||
<div class='item'>
|
||||
<div class='clearfix info'>
|
||||
<a href="{{unbound userUrl}}" class='avatar-link'><div class='avatar-wrapper'>{{avatar this imageSize="large" extraClasses="actor" ignoreTitle="true"}}</div></a>
|
||||
<span class='time'>{{date path="created_at" leaveAgo="true"}}</span>
|
||||
|
@ -20,7 +20,8 @@
|
|||
{{/each}}
|
||||
</div>
|
||||
{{/each}}
|
||||
{{/with}}
|
||||
{{/collection}}
|
||||
</div>
|
||||
{{/each}}
|
||||
|
||||
</div>
|
||||
<div id="user-stream-bottom"></div>
|
||||
|
|
|
@ -10,6 +10,8 @@ Discourse.ActivityFilterView = Discourse.View.extend({
|
|||
tagName: 'li',
|
||||
classNameBindings: ['active'],
|
||||
|
||||
stream: Em.computed.alias('controller.content'),
|
||||
|
||||
countChanged: function(){
|
||||
this.rerender();
|
||||
}.observes('count'),
|
||||
|
@ -17,11 +19,11 @@ Discourse.ActivityFilterView = Discourse.View.extend({
|
|||
active: function() {
|
||||
var content = this.get('content');
|
||||
if (content) {
|
||||
return parseInt(this.get('controller.content.streamFilter'), 10) === parseInt(Em.get(content, 'action_type'), 10);
|
||||
return parseInt(this.get('stream.filter'), 10) === parseInt(Em.get(content, 'action_type'), 10);
|
||||
} else {
|
||||
return this.blank('controller.content.streamFilter');
|
||||
return this.blank('stream.filter');
|
||||
}
|
||||
}.property('controller.content.streamFilter', 'content.action_type'),
|
||||
}.property('stream.filter', 'content.action_type'),
|
||||
|
||||
render: function(buffer) {
|
||||
var content = this.get('content');
|
||||
|
@ -40,7 +42,7 @@ Discourse.ActivityFilterView = Discourse.View.extend({
|
|||
},
|
||||
|
||||
click: function() {
|
||||
this.get('controller.content').filterStream(this.get('content.action_type'));
|
||||
this.set('stream.filter', this.get('content.action_type'));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
|
|
@ -9,8 +9,6 @@
|
|||
**/
|
||||
Discourse.UserStreamView = Discourse.View.extend(Discourse.Scrolling, {
|
||||
templateName: 'user/stream',
|
||||
currentUserBinding: 'Discourse.currentUser',
|
||||
userBinding: 'controller.content',
|
||||
|
||||
scrolled: function(e) {
|
||||
|
||||
|
@ -23,13 +21,16 @@ Discourse.UserStreamView = Discourse.View.extend(Discourse.Scrolling, {
|
|||
var docViewTop = $(window).scrollTop();
|
||||
var windowHeight = $(window).height();
|
||||
var docViewBottom = docViewTop + windowHeight;
|
||||
this.set('loading', true);
|
||||
|
||||
if (position.top < docViewBottom) {
|
||||
$userStreamBottom.data('loading', true);
|
||||
this.set('loading', true);
|
||||
|
||||
var userStreamView = this;
|
||||
this.get('controller.content').loadMoreUserActions().then(function() {
|
||||
var user = this.get('stream.user');
|
||||
var stream = this.get('stream');
|
||||
|
||||
stream.findItems().then(function() {
|
||||
userStreamView.set('loading', false);
|
||||
Em.run.schedule('afterRender', function() {
|
||||
$userStreamBottom.data('loading', null);
|
||||
|
@ -39,15 +40,10 @@ Discourse.UserStreamView = Discourse.View.extend(Discourse.Scrolling, {
|
|||
},
|
||||
|
||||
willDestroyElement: function() {
|
||||
Discourse.MessageBus.unsubscribe("/users/" + (this.get('user.username').toLowerCase()));
|
||||
this.unbindScrolling();
|
||||
},
|
||||
|
||||
didInsertElement: function() {
|
||||
var userSteamView = this;
|
||||
Discourse.MessageBus.subscribe("/users/" + (this.get('user.username').toLowerCase()), function(data) {
|
||||
userSteamView.get('user').loadUserAction(data);
|
||||
});
|
||||
this.bindScrolling();
|
||||
}
|
||||
|
||||
|
|
|
@ -175,6 +175,19 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
end
|
||||
|
||||
|
||||
def fetch_user_from_params
|
||||
username_lower = params[:username].downcase
|
||||
username_lower.gsub!(/\.json$/, '')
|
||||
|
||||
user = User.where(username_lower: username_lower).first
|
||||
raise Discourse::NotFound.new if user.blank?
|
||||
|
||||
guardian.ensure_can_see!(user)
|
||||
user
|
||||
end
|
||||
|
||||
|
||||
private
|
||||
|
||||
def render_json_error(obj)
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
class UserActionsController < ApplicationController
|
||||
def index
|
||||
requires_parameters(:user_id)
|
||||
requires_parameters(:username)
|
||||
per_chunk = 60
|
||||
|
||||
user = fetch_user_from_params
|
||||
|
||||
opts = {
|
||||
user_id: params[:user_id].to_i,
|
||||
offset: params[:offset],
|
||||
user_id: user.id,
|
||||
offset: params[:offset].to_i,
|
||||
limit: per_chunk,
|
||||
action_types: (params[:filter] || "").split(",").map(&:to_i),
|
||||
guardian: guardian,
|
||||
|
@ -29,4 +31,5 @@ class UserActionsController < ApplicationController
|
|||
# todo
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
|
|
|
@ -348,17 +348,6 @@ class UsersController < ApplicationController
|
|||
'3019774c067cc2b'
|
||||
end
|
||||
|
||||
def fetch_user_from_params
|
||||
username_lower = params[:username].downcase
|
||||
username_lower.gsub!(/\.json$/, '')
|
||||
|
||||
user = User.where(username_lower: username_lower).first
|
||||
raise Discourse::NotFound.new if user.blank?
|
||||
|
||||
guardian.ensure_can_see!(user)
|
||||
user
|
||||
end
|
||||
|
||||
def honeypot_or_challenge_fails?(params)
|
||||
params[:password_confirmation] != honeypot_value ||
|
||||
params[:challenge] != challenge_value.try(:reverse)
|
||||
|
|
|
@ -62,12 +62,12 @@ class UserSerializer < BasicUserSerializer
|
|||
scope.can_send_private_message?(object)
|
||||
end
|
||||
|
||||
def stats
|
||||
UserAction.stats(object.id, scope)
|
||||
end
|
||||
|
||||
def can_edit
|
||||
scope.can_edit?(object)
|
||||
end
|
||||
|
||||
def stats
|
||||
UserAction.stats(object.id, scope)
|
||||
end
|
||||
|
||||
end
|
||||
|
|
Loading…
Reference in a new issue