diff --git a/app/assets/javascripts/discourse/controllers/composer.js.es6 b/app/assets/javascripts/discourse/controllers/composer.js.es6 index 8ad012dd2..c4f1ac21b 100644 --- a/app/assets/javascripts/discourse/controllers/composer.js.es6 +++ b/app/assets/javascripts/discourse/controllers/composer.js.es6 @@ -413,7 +413,7 @@ export default Ember.ObjectController.extend(Presence, { } // we need a draft sequence for the composer to work - if (opts.draftSequence === void 0) { + if (opts.draftSequence === undefined) { return Discourse.Draft.get(opts.draftKey).then(function(data) { opts.draftSequence = data.draft_sequence; opts.draft = data.draft; diff --git a/app/assets/javascripts/discourse/models/composer.js.es6 b/app/assets/javascripts/discourse/models/composer.js.es6 index 49838fdf7..edde5df3c 100644 --- a/app/assets/javascripts/discourse/models/composer.js.es6 +++ b/app/assets/javascripts/discourse/models/composer.js.es6 @@ -22,7 +22,9 @@ const CLOSED = 'closed', topic_id: 'topic.id', is_warning: 'isWarning', archetype: 'archetypeId', - target_usernames: 'targetUsernames' + target_usernames: 'targetUsernames', + typing_duration_msecs: 'typingTime', + composer_open_duration_msecs: 'composerTime' }, _edit_topic_serializer = { @@ -52,6 +54,31 @@ const Composer = RestModel.extend({ viewOpen: Em.computed.equal('composeState', OPEN), viewDraft: Em.computed.equal('composeState', DRAFT), + composeStateChanged: function() { + var oldOpen = this.get('composerOpened'); + + if (this.get('composeState') === OPEN) { + this.set('composerOpened', oldOpen || new Date()); + } else { + if (oldOpen) { + var oldTotal = this.get('composerTotalOpened') || 0; + this.set('composerTotalOpened', oldTotal + (new Date() - oldOpen)); + } + this.set('composerOpened', null); + } + }.observes('composeState'), + + composerTime: function() { + var total = this.get('composerTotalOpened') || 0; + + var oldOpen = this.get('composerOpened'); + if (oldOpen) { + total += (new Date() - oldOpen); + } + + return total; + }.property().volatile(), + archetype: function() { return this.get('archetypes').findProperty('id', this.get('archetypeId')); }.property('archetypeId'), @@ -60,6 +87,12 @@ const Composer = RestModel.extend({ return this.set('metaData', Em.Object.create()); }.observes('archetype'), + // view detected user is typing + typing: _.throttle(function(){ + var typingTime = this.get("typingTime") || 0; + this.set("typingTime", typingTime + 100); + }, 100, {leading: false, trailing: true}), + editingFirstPost: Em.computed.and('editingPost', 'post.firstPost'), canEditTitle: Em.computed.or('creatingTopic', 'creatingPrivateMessage', 'editingFirstPost'), canCategorize: Em.computed.and('canEditTitle', 'notCreatingPrivateMessage'), @@ -349,7 +382,9 @@ const Composer = RestModel.extend({ composeState: opts.composerState || OPEN, action: opts.action, topic: opts.topic, - targetUsernames: opts.usernames + targetUsernames: opts.usernames, + composerTotalOpened: opts.composerTime, + typingTime: opts.typingTime }); if (opts.post) { @@ -420,7 +455,10 @@ const Composer = RestModel.extend({ post: null, title: null, editReason: null, - stagedPost: false + stagedPost: false, + typingTime: 0, + composerOpened: null, + composerTotalOpened: 0 }); }, @@ -502,7 +540,9 @@ const Composer = RestModel.extend({ admin: user.get('admin'), yours: true, read: true, - wiki: false + wiki: false, + typingTime: this.get('typingTime'), + composerTime: this.get('composerTime') }); this.serialize(_create_serializer, createdPost); @@ -603,13 +643,20 @@ const Composer = RestModel.extend({ postId: this.get('post.id'), archetypeId: this.get('archetypeId'), metaData: this.get('metaData'), - usernames: this.get('targetUsernames') + usernames: this.get('targetUsernames'), + composerTime: this.get('composerTime'), + typingTime: this.get('typingTime') }; this.set('draftStatus', I18n.t('composer.saving_draft_tip')); const composer = this; + if (this._clearingStatus) { + Em.run.cancel(this._clearingStatus); + this._clearingStatus = null; + } + // try to save the draft return Discourse.Draft.save(this.get('draftKey'), this.get('draftSequence'), data) .then(function() { @@ -617,7 +664,20 @@ const Composer = RestModel.extend({ }).catch(function() { composer.set('draftStatus', I18n.t('composer.drafts_offline')); }); - } + }, + + dataChanged: function(){ + const draftStatus = this.get('draftStatus'); + const self = this; + + if (draftStatus && !this._clearingStatus) { + + this._clearingStatus = Em.run.later(this, function(){ + self.set('draftStatus', null); + self._clearingStatus = null; + }, 1000); + } + }.observes('title','reply') }); @@ -657,7 +717,9 @@ Composer.reopenClass({ metaData: draft.metaData, usernames: draft.usernames, draft: true, - composerState: DRAFT + composerState: DRAFT, + composerTime: draft.composerTime, + typingTime: draft.typingTime }); } }, diff --git a/app/assets/javascripts/discourse/views/composer.js.es6 b/app/assets/javascripts/discourse/views/composer.js.es6 index 36a477793..9e8b53849 100644 --- a/app/assets/javascripts/discourse/views/composer.js.es6 +++ b/app/assets/javascripts/discourse/views/composer.js.es6 @@ -85,6 +85,8 @@ const ComposerView = Discourse.View.extend(Ember.Evented, { const controller = this.get('controller'); controller.checkReplyLength(); + this.get('controller.model').typing(); + const lastKeyUp = new Date(); this.set('lastKeyUp', lastKeyUp); diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 0d301b1bb..d594d3d71 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -403,7 +403,6 @@ class PostsController < ApplicationController # Awful hack, but you can't seem to remove the `default_scope` when joining # So instead I grab the topics separately topic_ids = posts.dup.pluck(:topic_id) - secured_category_ids = guardian.secure_category_ids topics = Topic.where(id: topic_ids).with_deleted.where.not(archetype: 'private_message') topics = topics.secured(guardian) @@ -422,7 +421,9 @@ class PostsController < ApplicationController :category, :target_usernames, :reply_to_post_number, - :auto_track + :auto_track, + :typing_duration_msecs, + :composer_open_duration_msecs ] # param munging for WordPress diff --git a/app/models/draft.rb b/app/models/draft.rb index 2a209fd0f..27378ea05 100644 --- a/app/models/draft.rb +++ b/app/models/draft.rb @@ -7,7 +7,11 @@ class Draft < ActiveRecord::Base d = find_draft(user,key) if d return if d.sequence > sequence - d.update_columns(data: data, sequence: sequence) + exec_sql("UPDATE drafts + SET data = :data, + sequence = :sequence, + revisions = revisions + 1 + WHERE id = :id", id: d.id, sequence: sequence, data: data) else Draft.create!(user_id: user.id, draft_key: key, data: data, sequence: sequence) end diff --git a/app/models/post.rb b/app/models/post.rb index 7dfe6c3e5..00523e187 100644 --- a/app/models/post.rb +++ b/app/models/post.rb @@ -37,6 +37,7 @@ class Post < ActiveRecord::Base has_many :uploads, through: :post_uploads has_one :post_search_data + has_one :post_stat has_many :post_details diff --git a/app/models/post_stat.rb b/app/models/post_stat.rb new file mode 100644 index 000000000..b9b97acb2 --- /dev/null +++ b/app/models/post_stat.rb @@ -0,0 +1,3 @@ +class PostStat < ActiveRecord::Base + belongs_to :post +end diff --git a/db/migrate/20150802233112_add_post_stats.rb b/db/migrate/20150802233112_add_post_stats.rb new file mode 100644 index 000000000..0ff42664d --- /dev/null +++ b/db/migrate/20150802233112_add_post_stats.rb @@ -0,0 +1,16 @@ +class AddPostStats < ActiveRecord::Migration + def change + + add_column :drafts, :revisions, :int, null: false, default: 1 + + create_table :post_stats do |t| + t.integer :post_id + t.integer :drafts_saved + t.integer :typing_duration_msecs + t.integer :composer_open_duration_msecs + t.timestamps + end + + add_index :post_stats, [:post_id] + end +end diff --git a/lib/post_creator.rb b/lib/post_creator.rb index 1f1753861..0bd6d9e54 100644 --- a/lib/post_creator.rb +++ b/lib/post_creator.rb @@ -113,6 +113,7 @@ class PostCreator def create if valid? transaction do + build_post_stats create_topic save_post extract_links @@ -146,6 +147,14 @@ class PostCreator @post end + def self.track_post_stats + Rails.env != "test".freeze || @track_post_stats + end + + def self.track_post_stats=(val) + @track_post_stats = val + end + def self.create(user, opts) PostCreator.new(user, opts).create end @@ -172,6 +181,23 @@ class PostCreator protected + def build_post_stats + if PostCreator.track_post_stats + draft_key = @topic ? "topic_#{@topic.id}" : "new_topic" + + sequence = DraftSequence.current(@user, draft_key) + revisions = Draft.where(sequence: sequence, + user_id: @user.id, + draft_key: draft_key).pluck(:revisions).first || 0 + + @post.build_post_stat( + drafts_saved: revisions, + typing_duration_msecs: @opts[:typing_duration_msecs] || 0, + composer_open_duration_msecs: @opts[:composer_open_duration_msecs] || 0 + ) + end + end + def trigger_after_events(post) DiscourseEvent.trigger(:topic_created, post.topic, @opts, @user) unless @opts[:topic_id] DiscourseEvent.trigger(:post_created, post, @opts, @user) diff --git a/spec/components/post_creator_spec.rb b/spec/components/post_creator_spec.rb index 6a5feef97..4d58fb9fa 100644 --- a/spec/components/post_creator_spec.rb +++ b/spec/components/post_creator_spec.rb @@ -213,6 +213,21 @@ describe PostCreator do }.to_not change { topic.excerpt } end + it 'creates post stats' do + + Draft.set(user, 'new_topic', 0, "test") + Draft.set(user, 'new_topic', 0, "test1") + + begin + PostCreator.track_post_stats = true + post = creator.create + expect(post.post_stat.typing_duration_msecs).to eq(0) + expect(post.post_stat.drafts_saved).to eq(2) + ensure + PostCreator.track_post_stats = false + end + end + describe "topic's auto close" do it "doesn't update topic's auto close when it's not based on last post" do diff --git a/spec/models/draft_spec.rb b/spec/models/draft_spec.rb index f0c418e28..16746ad4e 100644 --- a/spec/models/draft_spec.rb +++ b/spec/models/draft_spec.rb @@ -109,6 +109,12 @@ describe Draft do expect(Draft.get(p.user, p.topic.draft_key, s)).to eq nil end - it 'increases the sequence number when a post is revised' + it 'increases revision each time you set' do + u = User.first + Draft.set(u, 'new_topic', 0, 'hello') + Draft.set(u, 'new_topic', 0, 'goodbye') + + expect(Draft.find_draft(u, 'new_topic').revisions).to eq(2) + end end end