Update postream to use ES2015 syntax and decorators

This commit is contained in:
Robin Ward 2015-12-01 16:44:43 -05:00
parent 949f51ffe0
commit 1987a35daf

View File

@ -1,5 +1,6 @@
import DiscourseURL from 'discourse/lib/url'; import DiscourseURL from 'discourse/lib/url';
import RestModel from 'discourse/models/rest'; import RestModel from 'discourse/models/rest';
import { default as computed } from 'ember-addons/ember-computed-decorators';
function calcDayDiff(p1, p2) { function calcDayDiff(p1, p2) {
if (!p1) { return; } if (!p1) { return; }
@ -17,83 +18,87 @@ function calcDayDiff(p1, p2) {
} }
const PostStream = RestModel.extend({ const PostStream = RestModel.extend({
loading: Em.computed.or('loadingAbove', 'loadingBelow', 'loadingFilter', 'stagingPost'), loading: Ember.computed.or('loadingAbove', 'loadingBelow', 'loadingFilter', 'stagingPost'),
notLoading: Em.computed.not('loading'), notLoading: Ember.computed.not('loading'),
filteredPostsCount: Em.computed.alias("stream.length"), filteredPostsCount: Ember.computed.alias("stream.length"),
hasPosts: function() { @computed('posts.@each')
hasPosts() {
return this.get('posts.length') > 0; return this.get('posts.length') > 0;
}.property("posts.@each"), },
hasStream: Em.computed.gt('filteredPostsCount', 0), hasStream: Ember.computed.gt('filteredPostsCount', 0),
canAppendMore: Em.computed.and('notLoading', 'hasPosts', 'lastPostNotLoaded'), canAppendMore: Ember.computed.and('notLoading', 'hasPosts', 'lastPostNotLoaded'),
canPrependMore: Em.computed.and('notLoading', 'hasPosts', 'firstPostNotLoaded'), canPrependMore: Ember.computed.and('notLoading', 'hasPosts', 'firstPostNotLoaded'),
firstPostPresent: function() { @computed('hasLoadedData', 'firstPostId', 'posts.@each')
if (!this.get('hasLoadedData')) { return false; } firstPostPresent(hasLoadedData, firstPostId) {
return !!this.get('posts').findProperty('id', this.get('firstPostId')); if (!hasLoadedData) { return false; }
}.property('hasLoadedData', 'posts.@each', 'firstPostId'), return !!this.get('posts').findProperty('id', firstPostId);
},
firstPostNotLoaded: Em.computed.not('firstPostPresent'), firstPostNotLoaded: Ember.computed.not('firstPostPresent'),
firstLoadedPost: function() { @computed('posts.@each')
firstLoadedPost() {
return _.first(this.get('posts')); return _.first(this.get('posts'));
}.property('posts.@each'), },
lastLoadedPost: function() { @computed('posts.@each')
lastLoadedPost() {
return _.last(this.get('posts')); return _.last(this.get('posts'));
}.property('posts.@each'), },
firstPostId: function() { @computed('stream.@each')
firstPostId() {
return this.get('stream')[0]; return this.get('stream')[0];
}.property('stream.@each'), },
lastPostId: function() { @computed('stream.@each')
lastPostId() {
return _.last(this.get('stream')); return _.last(this.get('stream'));
}.property('stream.@each'), },
loadedAllPosts: function() { @computed('hasLoadedData', 'lastPostId', 'posts.@each.id')
if (!this.get('hasLoadedData')) { loadedAllPosts(hasLoadedData, lastPostId) {
return false; if (!hasLoadedData) { return false; }
} if (lastPostId === -1) { return true; }
// if we are staging a post assume all is loaded return !!this.get('posts').findProperty('id', lastPostId);
if (this.get('lastPostId') === -1) { },
return true;
}
return !!this.get('posts').findProperty('id', this.get('lastPostId')); lastPostNotLoaded: Ember.computed.not('loadedAllPosts'),
}.property('hasLoadedData', 'posts.@each.id', 'lastPostId'),
lastPostNotLoaded: Em.computed.not('loadedAllPosts'),
/** /**
Returns a JS Object of current stream filter options. It should match the query Returns a JS Object of current stream filter options. It should match the query
params for the stream. params for the stream.
**/ **/
streamFilters: function() { @computed('summary', 'show_deleted', 'userFilters.[]')
streamFilters(summary, showDeleted) {
const result = {}; const result = {};
if (this.get('summary')) { result.filter = "summary"; } if (summary) { result.filter = "summary"; }
if (this.get('show_deleted')) { result.show_deleted = true; } if (showDeleted) { result.show_deleted = true; }
const userFilters = this.get('userFilters'); const userFilters = this.get('userFilters');
if (!Em.isEmpty(userFilters)) { if (!Ember.isEmpty(userFilters)) {
result.username_filters = userFilters.join(","); result.username_filters = userFilters.join(",");
} }
return result; return result;
}.property('userFilters.[]', 'summary', 'show_deleted'), },
hasNoFilters: function() { @computed('streamFilters.[]', 'topic.posts_count', 'posts.length')
hasNoFilters() {
const streamFilters = this.get('streamFilters'); const streamFilters = this.get('streamFilters');
return !(streamFilters && ((streamFilters.filter === 'summary') || streamFilters.username_filters)); return !(streamFilters && ((streamFilters.filter === 'summary') || streamFilters.username_filters));
}.property('streamFilters.[]', 'topic.posts_count', 'posts.length'), },
/** /**
Returns the window of posts above the current set in the stream, bound to the top of the stream. Returns the window of posts above the current set in the stream, bound to the top of the stream.
This is the collection we'll ask for when scrolling upwards. This is the collection we'll ask for when scrolling upwards.
**/ **/
previousWindow: function() { @computed('posts.@each', 'stream.@each')
previousWindow() {
// If we can't find the last post loaded, bail // If we can't find the last post loaded, bail
const firstPost = _.first(this.get('posts')); const firstPost = _.first(this.get('posts'));
if (!firstPost) { return []; } if (!firstPost) { return []; }
@ -106,16 +111,15 @@ const PostStream = RestModel.extend({
let startIndex = firstIndex - this.get('topic.chunk_size'); let startIndex = firstIndex - this.get('topic.chunk_size');
if (startIndex < 0) { startIndex = 0; } if (startIndex < 0) { startIndex = 0; }
return stream.slice(startIndex, firstIndex); return stream.slice(startIndex, firstIndex);
},
}.property('posts.@each', 'stream.@each'),
/** /**
Returns the window of posts below the current set in the stream, bound by the bottom of the Returns the window of posts below the current set in the stream, bound by the bottom of the
stream. This is the collection we use when scrolling downwards. stream. This is the collection we use when scrolling downwards.
**/ **/
nextWindow: function() { @computed('lastLoadedPost', 'stream.@each')
nextWindow(lastLoadedPost) {
// If we can't find the last post loaded, bail // If we can't find the last post loaded, bail
const lastLoadedPost = this.get('lastLoadedPost');
if (!lastLoadedPost) { return []; } if (!lastLoadedPost) { return []; }
// Find the index of the last post loaded, if not found, bail // Find the index of the last post loaded, if not found, bail
@ -126,7 +130,7 @@ const PostStream = RestModel.extend({
// find our window of posts // find our window of posts
return stream.slice(lastIndex+1, lastIndex + this.get('topic.chunk_size') + 1); return stream.slice(lastIndex+1, lastIndex + this.get('topic.chunk_size') + 1);
}.property('lastLoadedPost', 'stream.@each'), },
cancelFilter() { cancelFilter() {
this.set('summary', false); this.set('summary', false);
@ -138,10 +142,9 @@ const PostStream = RestModel.extend({
this.get('userFilters').clear(); this.get('userFilters').clear();
this.toggleProperty('summary'); this.toggleProperty('summary');
const self = this; return this.refresh().then(() => {
return this.refresh().then(function() { if (this.get('summary')) {
if (self.get('summary')) { this.jumpToSecondVisible();
self.jumpToSecondVisible();
} }
}); });
}, },
@ -172,10 +175,9 @@ const PostStream = RestModel.extend({
userFilters.addObject(username); userFilters.addObject(username);
jump = true; jump = true;
} }
const self = this; return this.refresh().then(() => {
return this.refresh().then(function() {
if (jump) { if (jump) {
self.jumpToSecondVisible(); this.jumpToSecondVisible();
} }
}); });
}, },
@ -189,7 +191,6 @@ const PostStream = RestModel.extend({
opts.nearPost = parseInt(opts.nearPost, 10); opts.nearPost = parseInt(opts.nearPost, 10);
const topic = this.get('topic'); const topic = this.get('topic');
const self = this;
// Do we already have the post in our list of posts? Jump there. // Do we already have the post in our list of posts? Jump there.
if (opts.forceLoad) { if (opts.forceLoad) {
@ -200,25 +201,25 @@ const PostStream = RestModel.extend({
} }
// TODO: if we have all the posts in the filter, don't go to the server for them. // TODO: if we have all the posts in the filter, don't go to the server for them.
self.set('loadingFilter', true); this.set('loadingFilter', true);
opts = _.merge(opts, self.get('streamFilters')); opts = _.merge(opts, this.get('streamFilters'));
// Request a topicView // Request a topicView
return PostStream.loadTopicView(topic.get('id'), opts).then(function (json) { return PostStream.loadTopicView(topic.get('id'), opts).then(json => {
topic.updateFromJson(json); topic.updateFromJson(json);
self.updateFromJson(json.post_stream); this.updateFromJson(json.post_stream);
self.setProperties({ loadingFilter: false, loaded: true }); this.setProperties({ loadingFilter: false, loaded: true });
}).catch(function(result) { }).catch(result => {
self.errorLoading(result); this.errorLoading(result);
throw result; throw result;
}); });
}, },
hasLoadedData: Em.computed.and('hasPosts', 'hasStream'), hasLoadedData: Ember.computed.and('hasPosts', 'hasStream'),
collapsePosts(from, to){ collapsePosts(from, to){
const posts = this.get('posts'); const posts = this.get('posts');
const remove = posts.filter(function(post){ const remove = posts.filter(post => {
const postNumber = post.get('post_number'); const postNumber = post.get('post_number');
return postNumber >= from && postNumber <= to; return postNumber >= from && postNumber <= to;
}); });
@ -228,14 +229,9 @@ const PostStream = RestModel.extend({
// make gap // make gap
this.set('gaps', this.get('gaps') || {before: {}, after: {}}); this.set('gaps', this.get('gaps') || {before: {}, after: {}});
const before = this.get('gaps.before'); const before = this.get('gaps.before');
const post = posts.find(p => p.get('post_number') > to);
const post = posts.find(function(p){ before[post.get('id')] = remove.map(p => p.get('id'));
return p.get('post_number') > to;
});
before[post.get('id')] = remove.map(function(p){
return p.get('id');
});
post.set('hasGap', true); post.set('hasGap', true);
this.get('stream').enumerableContentDidChange(); this.get('stream').enumerableContentDidChange();
@ -245,10 +241,9 @@ const PostStream = RestModel.extend({
// Fill in a gap of posts before a particular post // Fill in a gap of posts before a particular post
fillGapBefore(post, gap) { fillGapBefore(post, gap) {
const postId = post.get('id'), const postId = post.get('id'),
stream = this.get('stream'), stream = this.get('stream'),
idx = stream.indexOf(postId), idx = stream.indexOf(postId),
currentPosts = this.get('posts'), currentPosts = this.get('posts');
self = this;
if (idx !== -1) { if (idx !== -1) {
// Insert the gap at the appropriate place // Insert the gap at the appropriate place
@ -256,16 +251,16 @@ const PostStream = RestModel.extend({
let postIdx = currentPosts.indexOf(post); let postIdx = currentPosts.indexOf(post);
if (postIdx !== -1) { if (postIdx !== -1) {
return this.findPostsByIds(gap).then(function(posts) { return this.findPostsByIds(gap).then(posts => {
posts.forEach(function(p) { posts.forEach(p => {
const stored = self.storePost(p); const stored = this.storePost(p);
if (!currentPosts.contains(stored)) { if (!currentPosts.contains(stored)) {
currentPosts.insertAt(postIdx++, stored); currentPosts.insertAt(postIdx++, stored);
} }
}); });
delete self.get('gaps.before')[postId]; delete this.get('gaps.before')[postId];
self.get('stream').enumerableContentDidChange(); this.get('stream').enumerableContentDidChange();
post.set('hasGap', false); post.set('hasGap', false);
}); });
} }
@ -308,20 +303,16 @@ const PostStream = RestModel.extend({
// Prepend the previous window of posts to the stream. Call it when scrolling upwards. // Prepend the previous window of posts to the stream. Call it when scrolling upwards.
prependMore() { prependMore() {
const postStream = this;
// Make sure we can append more posts // Make sure we can append more posts
if (!postStream.get('canPrependMore')) { return Ember.RSVP.resolve(); } if (!this.get('canPrependMore')) { return Ember.RSVP.resolve(); }
const postIds = postStream.get('previousWindow'); const postIds = this.get('previousWindow');
if (Ember.isEmpty(postIds)) { return Ember.RSVP.resolve(); } if (Ember.isEmpty(postIds)) { return Ember.RSVP.resolve(); }
postStream.set('loadingAbove', true); this.set('loadingAbove', true);
return postStream.findPostsByIds(postIds.reverse()).then(function(posts) { return this.findPostsByIds(postIds.reverse()).then(posts => {
posts.forEach(function(p) { posts.forEach(p => this.prependPost(p));
postStream.prependPost(p); this.set('loadingAbove', false);
});
postStream.set('loadingAbove', false);
}); });
}, },
@ -424,16 +415,14 @@ const PostStream = RestModel.extend({
}, },
removePosts(posts) { removePosts(posts) {
if (Em.isEmpty(posts)) { return; } if (Ember.isEmpty(posts)) { return; }
const postIds = posts.map(function (p) { return p.get('id'); }); const postIds = posts.map(p => p.get('id'));
const identityMap = this.get('postIdentityMap'); const identityMap = this.get('postIdentityMap');
this.get('stream').removeObjects(postIds); this.get('stream').removeObjects(postIds);
this.get('posts').removeObjects(posts); this.get('posts').removeObjects(posts);
postIds.forEach(function(id){ postIds.forEach(id => identityMap.delete(id));
identityMap.delete(id);
});
}, },
// Returns a post from the identity map if it's been inserted. // Returns a post from the identity map if it's been inserted.
@ -472,27 +461,26 @@ const PostStream = RestModel.extend({
} }
}, },
triggerRecoveredPost(postId){ triggerRecoveredPost(postId) {
const self = this, const postIdentityMap = this.get('postIdentityMap');
postIdentityMap = this.get('postIdentityMap'), const existing = postIdentityMap.get(postId);
existing = postIdentityMap.get(postId);
if(existing){ if (existing) {
this.triggerChangedPost(postId, new Date()); this.triggerChangedPost(postId, new Date());
} else { } else {
// need to insert into stream // need to insert into stream
const url = "/posts/" + postId; const url = "/posts/" + postId;
const store = this.store; const store = this.store;
Discourse.ajax(url).then(function(p){ Discourse.ajax(url).then(p => {
const post = store.createRecord('post', p); const post = store.createRecord('post', p);
const stream = self.get("stream"); const stream = this.get("stream");
const posts = self.get("posts"); const posts = this.get("posts");
self.storePost(post); this.storePost(post);
// we need to zip this into the stream // we need to zip this into the stream
let index = 0; let index = 0;
stream.forEach(function(pid){ stream.forEach(pid => {
if (pid < p.id){ if (pid < p.id) {
index+= 1; index+= 1;
} }
}); });
@ -500,17 +488,17 @@ const PostStream = RestModel.extend({
stream.insertAt(index, p.id); stream.insertAt(index, p.id);
index = 0; index = 0;
posts.forEach(function(_post){ posts.forEach(_post => {
if(_post.id < p.id){ if (_post.id < p.id) {
index+= 1; index+= 1;
} }
}); });
if(index < posts.length){ if (index < posts.length) {
posts.insertAt(index, post); posts.insertAt(index, post);
} else { } else {
if(post.post_number < posts[posts.length-1].post_number + 5){ if (post.post_number < posts[posts.length-1].post_number + 5) {
self.appendMore(); this.appendMore();
} }
} }
}); });
@ -518,50 +506,40 @@ const PostStream = RestModel.extend({
}, },
triggerDeletedPost(postId){ triggerDeletedPost(postId){
const self = this, const postIdentityMap = this.get('postIdentityMap');
postIdentityMap = this.get('postIdentityMap'), const existing = postIdentityMap.get(postId);
existing = postIdentityMap.get(postId);
if(existing){ if (existing) {
const url = "/posts/" + postId; const url = "/posts/" + postId;
const store = this.store; const store = this.store;
Discourse.ajax(url).then(
function(p){ Discourse.ajax(url).then(p => {
self.storePost(store.createRecord('post', p)); this.storePost(store.createRecord('post', p));
}, }).catch(() => {
function(){ this.removePosts([existing]);
self.removePosts([existing]); });
});
} }
}, },
triggerChangedPost(postId, updatedAt) { triggerChangedPost(postId, updatedAt) {
if (!postId) { return; } if (!postId) { return; }
const postIdentityMap = this.get('postIdentityMap'), const postIdentityMap = this.get('postIdentityMap');
existing = postIdentityMap.get(postId), const existing = postIdentityMap.get(postId);
self = this;
if (existing && existing.updated_at !== updatedAt) { if (existing && existing.updated_at !== updatedAt) {
const url = "/posts/" + postId; const url = "/posts/" + postId;
const store = this.store; const store = this.store;
Discourse.ajax(url).then(function(p){ Discourse.ajax(url).then(p => this.storePost(store.createRecord('post', p)));
self.storePost(store.createRecord('post', p));
});
} }
}, },
// Returns the "thread" of posts in the history of a post. // Returns the "thread" of posts in the history of a post.
findReplyHistory(post) { findReplyHistory(post) {
const postStream = this, const url = `/posts/${post.get('id')}/reply-history.json?max_replies=${Discourse.SiteSettings.max_reply_history}`;
url = "/posts/" + post.get('id') + "/reply-history.json?max_replies=" + Discourse.SiteSettings.max_reply_history;
const store = this.store; const store = this.store;
return Discourse.ajax(url).then(function(result) { return Discourse.ajax(url).then(result => {
return result.map(function (p) { return result.map(p => this.storePost(store.createRecord('post', p)));
return postStream.storePost(store.createRecord('post', p)); }).then(replyHistory => {
});
}).then(function (replyHistory) {
post.set('replyHistory', replyHistory); post.set('replyHistory', replyHistory);
}); });
}, },
@ -575,7 +553,7 @@ const PostStream = RestModel.extend({
if (!this.get('hasPosts')) { return; } if (!this.get('hasPosts')) { return; }
let closest = null; let closest = null;
this.get('posts').forEach(function (p) { this.get('posts').forEach(p => {
if (!closest) { if (!closest) {
closest = p; closest = p;
return; return;
@ -589,20 +567,14 @@ const PostStream = RestModel.extend({
return closest; return closest;
}, },
/** // Get the index of a post in the stream. (Use this for the topic progress bar.)
Get the index of a post in the stream. (Use this for the topic progress bar.)
@param post the post to get the index of
@returns {Number} 1-starting index of the post, or 0 if not found
@see PostStream.progressIndexOfPostId
**/
progressIndexOfPost(post) { progressIndexOfPost(post) {
return this.progressIndexOfPostId(post.get('id')); return this.progressIndexOfPostId(post.get('id'));
}, },
// Get the index in the stream of a post id. (Use this for the topic progress bar.) // Get the index in the stream of a post id. (Use this for the topic progress bar.)
progressIndexOfPostId(post_id) { progressIndexOfPostId(postId) {
return this.get('stream').indexOf(post_id) + 1; return this.get('stream').indexOf(postId) + 1;
}, },
/** /**
@ -614,7 +586,7 @@ const PostStream = RestModel.extend({
if (!this.get('hasPosts')) { return; } if (!this.get('hasPosts')) { return; }
let closest = null; let closest = null;
this.get('posts').forEach(function (p) { this.get('posts').forEach(p => {
if (closest === postNumber) { return; } if (closest === postNumber) { return; }
if (!closest) { closest = p.get('post_number'); } if (!closest) { closest = p.get('post_number'); }
@ -661,9 +633,7 @@ const PostStream = RestModel.extend({
if (postStreamData) { if (postStreamData) {
// Load posts if present // Load posts if present
const store = this.store; const store = this.store;
postStreamData.posts.forEach(function(p) { postStreamData.posts.forEach(p => postStream.appendPost(store.createRecord('post', p)));
postStream.appendPost(store.createRecord('post', p));
});
delete postStreamData.posts; delete postStreamData.posts;
// Update our attributes // Update our attributes
@ -677,10 +647,10 @@ const PostStream = RestModel.extend({
than you supplied if the post has already been loaded. than you supplied if the post has already been loaded.
**/ **/
storePost(post) { storePost(post) {
// Calling `Em.get(undefined` raises an error // Calling `Ember.get(undefined` raises an error
if (!post) { return; } if (!post) { return; }
const postId = Em.get(post, 'id'); const postId = Ember.get(post, 'id');
if (postId) { if (postId) {
const postIdentityMap = this.get('postIdentityMap'), const postIdentityMap = this.get('postIdentityMap'),
existing = postIdentityMap.get(post.get('id')); existing = postIdentityMap.get(post.get('id'));
@ -708,49 +678,42 @@ const PostStream = RestModel.extend({
identity map and need to load. identity map and need to load.
**/ **/
listUnloadedIds(postIds) { listUnloadedIds(postIds) {
const unloaded = Em.A(), const unloaded = [];
postIdentityMap = this.get('postIdentityMap'); const postIdentityMap = this.get('postIdentityMap');
postIds.forEach(function(p) { postIds.forEach(p => {
if (!postIdentityMap.has(p)) { unloaded.pushObject(p); } if (!postIdentityMap.has(p)) { unloaded.pushObject(p); }
}); });
return unloaded; return unloaded;
}, },
findPostsByIds(postIds) { findPostsByIds(postIds) {
const unloaded = this.listUnloadedIds(postIds), const unloaded = this.listUnloadedIds(postIds);
postIdentityMap = this.get('postIdentityMap'); const postIdentityMap = this.get('postIdentityMap');
// Load our unloaded posts by id // Load our unloaded posts by id
return this.loadIntoIdentityMap(unloaded).then(function() { return this.loadIntoIdentityMap(unloaded).then(() => {
return postIds.map(function (p) { return postIds.map(p => postIdentityMap.get(p)).compact();
return postIdentityMap.get(p);
}).compact();
}); });
}, },
loadIntoIdentityMap(postIds) { loadIntoIdentityMap(postIds) {
// If we don't want any posts, return a promise that resolves right away if (Ember.isEmpty(postIds)) { return Ember.RSVP.resolve([]); }
if (Em.isEmpty(postIds)) {
return Ember.RSVP.resolve([]);
}
const url = "/t/" + this.get('topic.id') + "/posts.json"; const url = "/t/" + this.get('topic.id') + "/posts.json";
const data = { post_ids: postIds }; const data = { post_ids: postIds };
const store = this.store; const store = this.store;
return Discourse.ajax(url, {data: data}).then(result => { return Discourse.ajax(url, {data}).then(result => {
const posts = Em.get(result, "post_stream.posts"); const posts = Ember.get(result, "post_stream.posts");
if (posts) { if (posts) {
posts.forEach(p => this.storePost(store.createRecord('post', p))); posts.forEach(p => this.storePost(store.createRecord('post', p)));
} }
}); });
}, },
indexOf(post) { indexOf(post) {
return this.get('stream').indexOf(post.get('id')); return this.get('stream').indexOf(post.get('id'));
}, },
/** /**
Handles an error loading a topic based on a HTTP status code. Updates Handles an error loading a topic based on a HTTP status code. Updates
the text to the correct values. the text to the correct values.
@ -787,14 +750,13 @@ const PostStream = RestModel.extend({
PostStream.reopenClass({ PostStream.reopenClass({
create() { create() {
const postStream = this._super.apply(this, arguments); const postStream = this._super.apply(this, arguments);
postStream.setProperties({ postStream.setProperties({
posts: [], posts: [],
stream: [], stream: [],
userFilters: [], userFilters: [],
postIdentityMap: Em.Map.create(), postIdentityMap: Ember.Map.create(),
summary: false, summary: false,
loaded: false, loaded: false,
loadingAbove: false, loadingAbove: false,
@ -815,12 +777,10 @@ PostStream.reopenClass({
delete opts.__type; delete opts.__type;
delete opts.store; delete opts.store;
return PreloadStore.getAndRemove("topic_" + topicId, function() { return PreloadStore.getAndRemove("topic_" + topicId, () => {
return Discourse.ajax(url + ".json", {data: opts}); return Discourse.ajax(url + ".json", {data: opts});
}); });
} }
}); });
export default PostStream; export default PostStream;