Initial release of Discourse

This commit is contained in:
Robin Ward
2013-02-05 14:16:51 -05:00
commit 21b5628528
2932 changed files with 143949 additions and 0 deletions

View File

@@ -0,0 +1 @@
//= require_tree ./admin

View File

@@ -0,0 +1,18 @@
window.Discourse.AdminCustomizeController = Ember.Controller.extend
newCustomization: ->
item = Discourse.SiteCustomization.create(name: 'New Style')
@get('content').pushObject(item)
@set('content.selectedItem', item)
selectStyle: (style)-> @set('content.selectedItem', style)
save: -> @get('content.selectedItem').save()
delete: ->
bootbox.confirm Em.String.i18n("admin.customize.delete_confirm"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) =>
if result
selected = @get('content.selectedItem')
selected.delete()
@set('content.selectedItem', null)
@get('content').removeObject(selected)

View File

@@ -0,0 +1,17 @@
window.Discourse.AdminEmailLogsController = Ember.ArrayController.extend Discourse.Presence,
sendTestEmailDisabled: (->
@blank('testEmailAddress')
).property('testEmailAddress')
sendTestEmail: ->
@set('sentTestEmail', false)
$.ajax
url: '/admin/email_logs/test',
type: 'POST'
data:
email_address: @get('testEmailAddress')
success: =>
@set('sentTestEmail', true)
false

View File

@@ -0,0 +1,16 @@
window.Discourse.AdminFlagsController = Ember.Controller.extend
clearFlags: (item) ->
item.clearFlags().then (=>
@content.removeObject(item)
), (->
bootbox.alert("something went wrong")
)
adminOldFlagsView: (->
@query == 'old'
).property('query')
adminActiveFlagsView: (->
@query == 'active'
).property('query')

View File

@@ -0,0 +1,30 @@
window.Discourse.AdminSiteSettingsController = Ember.ArrayController.extend Discourse.Presence,
filter: null
onlyOverridden: false
filteredContent: (->
return null unless @present('content')
filter = @get('filter').toLowerCase() if @get('filter')
@get('content').filter (item, index, enumerable) =>
return false if @get('onlyOverridden') and !item.get('overridden')
if filter
return true if item.get('setting').toLowerCase().indexOf(filter) > -1
return true if item.get('description').toLowerCase().indexOf(filter) > -1
return true if item.get('value').toLowerCase().indexOf(filter) > -1
return false
else
true
).property('filter', 'content.@each', 'onlyOverridden')
resetDefault: (setting) ->
setting.set('value', setting.get('default'))
setting.save()
save: (setting) -> setting.save()
cancel: (setting) -> setting.resetValue()

View File

@@ -0,0 +1,45 @@
window.Discourse.AdminUsersListController = Ember.ArrayController.extend Discourse.Presence,
username: null
query: null
selectAll: false
content: null
selectAllChanged: (->
@get('content').each (user) => user.set('selected', @get('selectAll'))
).observes('selectAll')
filterUsers: Discourse.debounce(->
@refreshUsers()
,250).observes('username')
orderChanged: (->
@refreshUsers()
).observes('query')
showApproval: (->
return false unless Discourse.SiteSettings.must_approve_users
return true if @get('query') is 'new'
return true if @get('query') is 'pending'
).property('query')
selectedCount: (->
return 0 if @blank('content')
@get('content').filterProperty('selected').length
).property('content.@each.selected')
hasSelection: (->
@get('selectedCount') > 0
).property('selectedCount')
refreshUsers: ->
@set 'content', Discourse.AdminUser.findAll(@get('query'), @get('username'))
show: (term) ->
if @get('query') == term
@refreshUsers()
else
@set('query', term)
approveUsers: ->
Discourse.AdminUser.bulkApprove(@get('content').filterProperty('selected'))

View File

@@ -0,0 +1,122 @@
window.Discourse.AdminUser = Discourse.Model.extend
# Revoke the user's admin access
revokeAdmin: ->
@set('admin',false)
@set('can_grant_admin',true)
@set('can_revoke_admin',false)
$.ajax "/admin/users/#{@get('id')}/revoke_admin", type: 'PUT'
grantAdmin: ->
@set('admin',true)
@set('can_grant_admin',false)
@set('can_revoke_admin',true)
$.ajax "/admin/users/#{@get('id')}/grant_admin", type: 'PUT'
refreshBrowsers: ->
$.ajax "/admin/users/#{@get('id')}/refresh_browsers",
type: 'POST'
bootbox.alert("Message sent to all clients!")
approve: ->
@set('can_approve', false)
@set('approved', true)
@set('approved_by', Discourse.get('currentUser'))
$.ajax "/admin/users/#{@get('id')}/approve", type: 'PUT'
username_lower:(->
@get('username').toLowerCase()
).property('username')
trustLevel: (->
Discourse.get('site.trust_levels').findProperty('id', @get('trust_level'))
).property('trust_level')
canBan: ( ->
!@admin && !@moderator
).property('admin','moderator')
banDuration: (->
banned_at = Date.create(@banned_at)
banned_till = Date.create(@banned_till)
"#{banned_at.short()} - #{banned_till.short()}"
).property('banned_till', 'banned_at')
ban: ->
debugger
if duration = parseInt(window.prompt(Em.String.i18n('admin.user.ban_duration')))
if duration > 0
$.ajax "/admin/users/#{@id}/ban",
type: 'PUT'
data:
duration: duration
success: ->
window.location.reload()
return
error: (e) =>
error = Em.String.i18n('admin.user.ban_failed', error: "http: #{e.status} - #{e.body}")
bootbox.alert error
return
unban: ->
$.ajax "/admin/users/#{@id}/unban",
type: 'PUT'
success: ->
window.location.reload()
return
error: (e) =>
error = Em.String.i18n('admin.user.unban_failed', error: "http: #{e.status} - #{e.body}")
bootbox.alert error
return
impersonate: ->
$.ajax "/admin/impersonate"
type: 'POST'
data:
username_or_email: @get('username')
success: ->
document.location = "/"
error: (e) =>
@set('loading', false)
if e.status == 404
bootbox.alert Em.String.i18n('admin.impersonate.not_found')
else
bootbox.alert Em.String.i18n('admin.impersonate.invalid')
window.Discourse.AdminUser.reopenClass
create: (result) ->
result = @_super(result)
result
bulkApprove: (users) ->
users.each (user) ->
user.set('approved', true)
user.set('can_approve', false)
user.set('selected', false)
$.ajax "/admin/users/approve-bulk",
type: 'PUT'
data: {users: users.map (u) -> u.id}
find: (username)->
promise = new RSVP.Promise()
$.ajax
url: "/admin/users/#{username}"
success: (result) -> promise.resolve(Discourse.AdminUser.create(result))
promise
findAll: (query, filter)->
result = Em.A()
$.ajax
url: "/admin/users/list/#{query}.json"
data: {filter: filter}
success: (users) ->
users.each (u) -> result.pushObject(Discourse.AdminUser.create(u))
result

View File

@@ -0,0 +1,17 @@
window.Discourse.EmailLog = Discourse.Model.extend({})
window.Discourse.EmailLog.reopenClass
create: (attrs) ->
attrs.user = Discourse.AdminUser.create(attrs.user) if attrs.user
@_super(attrs)
findAll: (filter)->
result = Em.A()
$.ajax
url: "/admin/email_logs.json"
data: {filter: filter}
success: (logs) ->
logs.each (log) -> result.pushObject(Discourse.EmailLog.create(log))
result

View File

@@ -0,0 +1,62 @@
window.Discourse.FlaggedPost = Discourse.Post.extend
flaggers: (->
r = []
@post_actions.each (a)=>
r.push(@userLookup[a.user_id])
r
).property()
messages: (->
r = []
@post_actions.each (a)=>
if a.message
r.push
user: @userLookup[a.user_id]
message: a.message
r
).property()
lastFlagged: (->
@post_actions[0].created_at
).property()
user: (->
@userLookup[@user_id]
).property()
topicHidden: (->
@get('topic_visible') == 'f'
).property('topic_hidden')
clearFlags: ->
promise = new RSVP.Promise()
$.ajax "/admin/flags/clear/#{@id}",
type: 'POST'
cache: false
success: ->
promise.resolve()
error: (e)->
promise.reject()
promise
hiddenClass: (->
"hidden-post" if @get('hidden') == "t"
).property()
window.Discourse.FlaggedPost.reopenClass
findAll: (filter) ->
result = Em.A()
$.ajax
url: "/admin/flags/#{filter}.json"
success: (data) ->
userLookup = {}
data.users.each (u) -> userLookup[u.id] = Discourse.User.create(u)
data.posts.each (p) ->
f = Discourse.FlaggedPost.create(p)
f.userLookup = userLookup
result.pushObject(f)
result

View File

@@ -0,0 +1,78 @@
window.Discourse.SiteCustomization = Discourse.Model.extend
init: ->
@_super()
@startTrackingChanges()
trackedProperties: ['enabled','name', 'stylesheet', 'header', 'override_default_style']
description: (->
"#{@.name}#{if @.enabled then ' (*)' else ''}"
).property('selected', 'name')
changed: (->
return false unless @.originals
@trackedProperties.any (p)=>
@.originals[p] != @get(p)
).property('override_default_style','enabled','name', 'stylesheet', 'header', 'originals') # TODO figure out how to call with apply
startTrackingChanges: ->
@set('originals',{})
@trackedProperties.each (p)=>
@.originals[p] = @get(p)
true
previewUrl: (->
"/?preview-style=#{@get('key')}"
).property('key')
disableSave:(->
!@get('changed')
).property('changed')
save: ->
@startTrackingChanges()
data =
name: @name
enabled: @enabled
stylesheet: @stylesheet
header: @header
override_default_style: @override_default_style
$.ajax
url: "/admin/site_customizations#{if @id then '/' + @id else ''}"
data:
site_customization: data
type: if @id then 'PUT' else 'POST'
delete: ->
return unless @id
$.ajax
url: "/admin/site_customizations/#{ @id }"
type: 'DELETE'
SiteCustomizations = Ember.ArrayProxy.extend
selectedItemChanged: (->
selected = @get('selectedItem')
@get('content').each (i)->
i.set('selected', selected == i)
).observes('selectedItem')
Discourse.SiteCustomization.reopenClass
findAll: ->
content = SiteCustomizations.create
content: []
loading: true
$.ajax
url: "/admin/site_customizations"
dataType: "json"
success: (data)=>
data?.site_customizations.each (c)->
item = Discourse.SiteCustomization.create(c)
content.pushObject(item)
content.set('loading',false)
content

View File

@@ -0,0 +1,42 @@
window.Discourse.SiteSetting = Discourse.Model.extend Discourse.Presence,
# Whether a property is short.
short: (->
return true if @blank('value')
return @get('value').toString().length < 80
).property('value')
# Whether the site setting has changed
dirty: (->
@get('originalValue') != @get('value')
).property('originalValue', 'value')
overridden: (->
val = @get('value')
defaultVal = @get('default')
return val.toString() != defaultVal.toString() if (val and defaultVal)
return val != defaultVal
).property('value')
resetValue: ->
@set('value', @get('originalValue'))
save: ->
# Update the setting
$.ajax "/admin/site_settings/#{@get('setting')}",
data:
value: @get('value')
type: 'PUT'
success: => @set('originalValue', @get('value'))
window.Discourse.SiteSetting.reopenClass
findAll: ->
result = Em.A()
$.get "/admin/site_settings", (settings) ->
settings.each (s) ->
s.originalValue = s.value
result.pushObject(Discourse.SiteSetting.create(s))
result

View File

@@ -0,0 +1,2 @@
Discourse.AdminCustomizeRoute = Discourse.Route.extend
model: -> Discourse.SiteCustomization.findAll()

View File

@@ -0,0 +1,2 @@
Discourse.AdminEmailLogsRoute = Discourse.Route.extend
model: -> Discourse.EmailLog.findAll()

View File

@@ -0,0 +1,6 @@
Discourse.AdminFlagsActiveRoute = Discourse.Route.extend
model: -> Discourse.FlaggedPost.findAll('active')
setupController: (controller, model) ->
c = @controllerFor('adminFlags')
c.set('content', model)
c.set('query', 'active')

View File

@@ -0,0 +1,6 @@
Discourse.AdminFlagsOldRoute = Discourse.Route.extend
model: -> Discourse.FlaggedPost.findAll('old')
setupController: (controller, model) ->
c = @controllerFor('adminFlags')
c.set('content', model)
c.set('query', 'old')

View File

@@ -0,0 +1,17 @@
Discourse.buildRoutes ->
@resource 'admin', path: '/admin', ->
@route 'dashboard', path: '/'
@route 'site_settings', path: '/site_settings'
@route 'email_logs', path: '/email_logs'
@route 'customize', path: '/customize'
@resource 'adminFlags', path: '/flags', ->
@route 'active', path: '/active'
@route 'old', path: '/old'
@resource 'adminUsers', path: '/users', ->
@resource 'adminUser', path: '/:username'
@resource 'adminUsersList', path: '/list', ->
@route 'active', path: '/active'
@route 'new', path: '/new'
@route 'pending', path: '/pending'

View File

@@ -0,0 +1,2 @@
Discourse.AdminSiteSettingsRoute = Discourse.Route.extend
model: -> Discourse.SiteSetting.findAll()

View File

@@ -0,0 +1,2 @@
Discourse.AdminUserRoute = Discourse.Route.extend
model: (params) -> Discourse.AdminUser.find(params.username)

View File

@@ -0,0 +1,2 @@
Discourse.AdminUsersListActiveRoute = Discourse.Route.extend
setupController: (c) -> @controllerFor('adminUsersList').show('active')

View File

@@ -0,0 +1,2 @@
Discourse.AdminUsersListNewRoute = Discourse.Route.extend
setupController: (c) -> @controllerFor('adminUsersList').show('new')

View File

@@ -0,0 +1,2 @@
Discourse.AdminUsersListNewRoute = Discourse.Route.extend
setupController: (c) -> @controllerFor('adminUsersList').show('pending')

View File

@@ -0,0 +1,23 @@
<div class="container">
<div class="row">
<div class="full-width">
<ul class="nav nav-pills">
<li>{{#linkTo 'admin.dashboard'}}{{i18n admin.dashboard}}{{/linkTo}}</li>
<li>{{#linkTo 'admin.site_settings'}}{{i18n admin.site_settings.title}}{{/linkTo}}</li>
<li>{{#linkTo 'adminUsersList.active'}}{{i18n admin.users.title}}{{/linkTo}}</li>
<li>{{#linkTo 'admin.email_logs'}}{{i18n admin.email_logs.title}}{{/linkTo}}</li>
<li>{{#linkTo 'adminFlags.active'}}{{i18n admin.flags.title}}{{/linkTo}}</li>
<li>{{#linkTo 'admin.customize'}}{{i18n admin.customize.title}}{{/linkTo}}</li>
</ul>
<div class='boxed white admin-content'>
<div class='admin-contents'>
{{outlet}}
</div>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,56 @@
<div class='list'>
<div class='well'>
<ul class='nav nav-list'>
{{#each view.content}}
<li {{bindAttr class="this.selected:active"}}><a {{action selectStyle this target="controller"}}>{{this.description}}</a></li>
{{/each}}
</ul>
</div>
<button {{action newCustomization target="controller"}} class='btn btn-primary'>New</button>
</div>
{{#if content.selectedItem}}
<div class='current-style'>
<div class='admin-controls'>
<ul class="nav nav-pills">
<li {{bindAttr class="view.stylesheetActive:active"}}>
<a {{action selectStylesheet href="true" target="view"}}>{{i18n admin.customize.css}}</a>
</li>
<li {{bindAttr class="view.headerActive:active"}}>
<a {{action selectHeader href="true" target="view"}}>{{i18n admin.customize.header}}</a>
</li>
</ul>
</div>
{{#with content.selectedItem}}
{{view Ember.TextField class="style-name" valueBinding="name"}}
{{#if view.headerActive}}
{{view Discourse.AceEditorView contentBinding="header" mode="html"}}
{{/if}}
{{#if view.stylesheetActive}}
{{view Discourse.AceEditorView contentBinding="stylesheet" mode="css"}}
{{/if}}
{{/with}}
<br>
<div class='status-actions'>
<span>{{i18n admin.customize.override_default}} {{view Ember.Checkbox checkedBinding="content.selectedItem.override_default_style"}}</span>
<span>{{i18n admin.customize.enabled}} {{view Ember.Checkbox checkedBinding="content.selectedItem.enabled"}}</span>
{{#unless content.selectedItem.changed}}
<a class='preview-link' {{bindAttr href="content.selectedItem.previewUrl"}} target='_blank'>{{i18n admin.customize.preview}}</a>
|
<a href="/?preview-style=" target='_blank'>{{i18n admin.customize.undo_preview}}</a><br>
{{/unless}}
</div>
<div class='buttons'>
<button {{action save target="controller"}} {{bindAttr disabled="content.selectedItem.disableSave"}} class='btn btn-primary'>{{i18n admin.customize.save}}</button>
<a {{action delete target="controller"}} class='delete-link'>{{i18n admin.customize.delete}}</a>
<span class='saving'>{{content.savingStatus}}</span>
</div>
</div>
{{/if}}
<div class='clearfix'></div>

View File

@@ -0,0 +1,4 @@
<h3>Welcome to the admin section.</h3>
<p>Not much to see here right now. Why not try the Site Settings?</p>

View File

@@ -0,0 +1,37 @@
<div class='admin-controls'>
<div class='span5 controls'>
{{view Discourse.TextField valueBinding="controller.testEmailAddress" placeholderKey="admin.email_logs.test_email_address"}}
</div>
<div class='span10 controls'>
<button class='btn' {{action sendTestEmail target="controller"}} {{bindAttr disabled="sendTestEmailDisabled"}}>{{i18n admin.email_logs.send_test}}</button>
{{#if controller.sentTestEmail}}<span class='result-message'>{{i18n admin.email_logs.sent_test}}</span>{{/if}}
</div>
</div>
<table class='table'>
<tr>
<th>{{i18n admin.email_logs.sent_at}}</th>
<th>{{i18n user.title}}</th>
<th>{{i18n admin.email_logs.to_address}}</th>
<th>{{i18n admin.email_logs.email_type}}</th>
</tr>
{{#if controller.content.length}}
{{#group}}
{{#collection contentBinding="controller.content" tagName="tbody" itemTagName="tr"}}
<td>{{date view.content.created_at}}</td>
<td>
{{#if view.content.user}}
<a href="/admin/users/{{unbound view.content.user.username_lower}}">{{avatar view.content.user imageSize="tiny"}}</a>
<a href="/admin/users/{{unbound view.content.user.username_lower}}">{{view.content.user.username}}</a>
{{else}}
&mdash;
{{/if}}
</td>
<td><a href='mailto:{{unbound view.content.to_address}}'>{{view.content.to_address}}</a></td>
<td>{{view.content.email_type}}</td>
{{/collection}}
{{/group}}
{{/if}}
</table>

View File

@@ -0,0 +1,49 @@
<div class='admin-controls'>
<div class='span15'>
<ul class="nav nav-pills">
<li>{{#linkTo adminFlags.active}}{{i18n admin.flags.active}}{{/linkTo}}</li>
<li>{{#linkTo adminFlags.old}}{{i18n admin.flags.old}}{{/linkTo}}</li>
</ul>
</div>
</div>
<table class='admin-flags'>
<thead>
<tr>
<th class='user'></th>
<th class='excerpt'></th>
<th class='flaggers'>Flag by</th>
<th class='last-flagged'></th>
<th class='action'></th>
</tr>
</thead>
<tbody>
{{#each content}}
<tr {{bindAttr class="hiddenClass"}}>
<td class='user'>{{avatar user imageSize="small"}}</td>
<td class='excerpt'>{{#if topicHidden}}<i title='this topic is invisible' class='icon icon-eye-close'></i> {{/if}}<h3><a href='{{unbound url}}'>{{title}}</a></h3><br>{{{excerpt}}}
</td>
<td class='flaggers'>{{#each flaggers}}{{avatar this imageSize="small"}}{{/each}}</td>
<td class='last-flagged'>{{date lastFlagged}}</td>
<td class='action'>
{{#if controller.adminActiveFlagsView}}
<button title='dismiss all flags on this post (will unhide hidden posts)' class='btn' {{action clearFlags this}}>Clear Flags</button>
{{/if}}
</td>
</tr>
{{#each messages}}
<tr>
<td></td>
<td class='message'>
<div>{{avatar user imageSize="small"}} {{message}}</div>
</td>
<td></td>
<td></td>
<td></td>
</tr>
{{/each}}
{{/each}}
</tbody>
</table>

View File

@@ -0,0 +1,34 @@
<div class='admin-controls'>
<div class='span15 search controls'>
<label>
{{view Ember.Checkbox checkedBinding="controller.onlyOverridden"}}
{{i18n admin.site_settings.show_overriden}}
</label>
</div>
<div class='span5 controls'>
{{view Discourse.TextField valueBinding="controller.filter" placeholderKey="type_to_filter"}}
</div>
</div>
{{#collection contentBinding="filteredContent" classNames="form-horizontal settings" itemClass="row setting"}}
{{#with view.content}}
<div class='span4 offset1'>
{{unbound setting}}
</div>
<div {{bindAttr class=":span11 overridden:overridden"}}>
{{view Ember.TextField valueBinding="value" classNames="input-xxlarge"}}
<div class='desc'>{{unbound description}}</div>
</div>
{{#if dirty}}
<div class='span3'>
<button class='btn ok' {{action save this target="controller"}}><i class='icon-ok'></i></button>
<button class='btn cancel' {{action cancel this target="controller"}}><i class='icon-remove'></i></button
</div>
{{else}}
{{#if overridden}}
<button class='btn' href='#' {{action resetDefault this target="controller"}}>{{i18n admin.site_settings.reset}}</button>
{{/if}}
{{/if}}
{{/with}}
{{/collection}}

View File

@@ -0,0 +1,168 @@
<section class='details'>
<h1>{{i18n admin.user.basics}}</h1>
<div class='display-row'>
<div class='field'>{{i18n user.username.title}}</div>
<div class='value'>{{content.username}}</div>
<div class='controls'>
<a href="/users/{{unbound content.username_lower}}" class='btn'>
<i class='icon icon-user'></i>
{{i18n admin.user.show_public_profile}}
</a>
{{#if content.can_impersonate}}
<button class='btn' {{action impersonate target="content"}}>
<i class='icon icon-screenshot'></i>
{{i18n admin.user.impersonate}}
</button>
{{/if}}
</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n user.email.title}}</div>
<div class='value'><a href="mailto:{{unbound content.email}}">{{content.email}}</a></div>
</div>
<div class='display-row' style='height: 50px'>
<div class='field'>{{i18n user.avatar.title}}</div>
<div class='value'>{{avatar content imageSize="large"}}</div>
</div>
<div class='display-row' style='height: 50px'>
<div class='field'>{{i18n user.ip_address.title}}</div>
<div class='value'>{{content.ip_address}}</div>
<div class='controls'>
<button class='btn' {{action refreshBrowsers target="content"}}>
{{i18n admin.user.refresh_browsers}}
</button>
</div>
</div>
</section>
<section class='details'>
<h1>{{i18n admin.user.permissions}}</h1>
<div class='display-row'>
<div class='field'>{{i18n admin.users.approved}}</div>
<div class='value'>
{{#if content.approved}}
{{i18n admin.user.approved_by}}
<a href="/admin/users/{{unbound content.approved_by.username_lower}}">{{avatar approved_by imageSize="small"}}</a>
<a href="/admin/users/{{unbound username_lower}}">{{content.approved_by.username}}</a>
{{else}}
{{i18n no_value}}
{{/if}}
</div>
<div class='controls'>
{{#if content.can_approve}}
<button class='btn' {{action approve target="content"}}>
<i class='icon icon-ok'></i>
{{i18n admin.user.approve}}
</button>
{{/if}}
</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.admin}}</div>
<div class='value'>{{content.admin}}</div>
<div class='controls'>
{{#if content.can_revoke_admin}}
<button class='btn' {{action revokeAdmin target="content"}}>
<i class='icon icon-trophy'></i>
{{i18n admin.user.revoke_admin}}
</button>
{{/if}}
{{#if content.can_grant_admin}}
<button class='btn' {{action grantAdmin target="content"}}>
<i class='icon icon-trophy'></i>
{{i18n admin.user.grant_admin}}
</button>
{{/if}}
</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.moderator}}</div>
<div class='value'>{{content.moderator}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n trust_level}}</div>
<div class='value'>{{content.trustLevel.name}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.banned}}</div>
<div class='value'>{{content.is_banned}}</div>
<div class='controls'>
{{#if content.is_banned}}
{{#if content.canBan}}
<button class='btn' {{action unban target="content"}}>
<i class='icon icon-screenshot'></i>
{{i18n admin.user.unban}}
</button>
{{content.banDuration}}
{{/if}}
{{else}}
{{#if content.canBan}}
<button class='btn' {{action ban target="content"}}>
<i class='icon icon-screenshot'></i>
{{i18n admin.user.ban}}
</button>
{{/if}}
{{/if}}
</div>
</div>
</section>
<section class='details'>
<h1>{{i18n admin.user.activity}}</h1>
<div class='display-row'>
<div class='field'>{{i18n created}}</div>
<div class='value'>{{{content.created_at_age}}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.users.last_emailed}}</div>
<div class='value'>{{{content.last_emailed_age}}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n last_seen}}</div>
<div class='value'>{{{content.last_seen_age}}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.like_count}}</div>
<div class='value'>{{content.like_count}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.topics_entered}}</div>
<div class='value'>{{content.topics_entered}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.post_count}}</div>
<div class='value'>{{content.post_count}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.posts_read_count}}</div>
<div class='value'>{{content.posts_read_count}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.flags_given_count}}</div>
<div class='value'>{{content.flags_given_count}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.flags_received_count}}</div>
<div class='value'>{{content.flags_received_count}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.private_topics_count}}</div>
<div class='value'>{{content.private_topics_count}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n admin.user.time_read}}</div>
<div class='value'>{{{content.time_read}}}</div>
</div>
<div class='display-row'>
<div class='field'>{{i18n user.invited.days_visited}}</div>
<div class='value'>{{{content.days_visited}}}</div>
</div>
</section>

View File

@@ -0,0 +1,82 @@
<div class='admin-controls'>
<div class='span15'>
<ul class="nav nav-pills">
<li>{{#linkTo adminUsersList.active}}{{i18n admin.users.active}}{{/linkTo}}</li>
<li>{{#linkTo adminUsersList.new}}{{i18n admin.users.new}}{{/linkTo}}</li>
{{#if Discourse.SiteSettings.must_approve_users}}
<li>{{#linkTo adminUsersList.pending}}{{i18n admin.users.pending}}{{/linkTo}}</li>
{{/if}}
</ul>
</div>
<div class='span5 username controls'>
{{view Discourse.TextField valueBinding="controller.username" placeholderKey="username"}}
</div>
</div>
{{#if hasSelection}}
<div id='selected-controls'>
<button {{action approveUsers target="controller"}} class='btn'>{{countI18n admin.users.approved_selected countBinding="selectedCount"}}</button>
</div>
{{/if}}
{{#if content.length}}
<table class='table'>
<tr>
{{#if showApproval}}
<th>{{view Ember.Checkbox checkedBinding="selectAll"}}</th>
{{/if}}
<th>&nbsp;</th>
<th>{{i18n username}}</th>
<th>{{i18n email}}</th>
<th>{{i18n admin.users.last_emailed}}</th>
<th>{{i18n last_seen}}</th>
<th>{{i18n admin.user.topics_entered}}</th>
<th>{{i18n admin.user.posts_read_count}}</th>
<th>{{i18n admin.user.time_read}}</th>
<th>{{i18n created}}</th>
{{#if showApproval}}
<th>{{i18n admin.users.approved}}</th>
{{/if}}
<th>&nbsp;</th>
</tr>
{{#each content}}
<tr {{bindAttr class="selected"}}>
{{#if controller.showApproval}}
<td>
{{#if can_approve}}
{{view Ember.Checkbox checkedBinding="selected"}}
{{/if}}
</td>
{{/if}}
<td>
<a href="/admin/users/{{unbound username_lower}}">{{avatar this imageSize="small"}}</a>
</td>
<td><a href="/admin/users/{{unbound username_lower}}">{{unbound username}}</a></td>
<td>{{unbound email}}</td>
<td>{{{unbound last_emailed_age}}}</td>
<td>{{{unbound last_seen_age}}}</td>
<td>{{{unbound topics_entered}}}</td>
<td>{{{unbound posts_read_count}}}</td>
<td>{{{unbound time_read}}}</td>
<td>{{{unbound created_at_age}}}</td>
{{#if controller.showApproval}}
<td>
{{#if approved}}
{{i18n yes_value}}
{{else}}
{{i18n no_value}}
{{/if}}
</td>
{{/if}}
<td>{{#if admin}}<i class="icon-trophy"></i>{{/if}}<td>
</tr>
{{/each}}
</table>
{{else}}
<div class='admin-loading'>{{i18n loading}}</div>
{{/if}}

View File

@@ -0,0 +1,7 @@
//= depend_on 'en.yml'
<% SimplesIdeias::I18n.assert_usable_configuration! %>
<% admin = SimplesIdeias::I18n.translation_segments['app/assets/javascripts/i18n/admin.en.js']
admin[:en][:js] = admin[:en].delete(:admin_js)
%>
$.extend(true, I18n.translations, <%= admin.to_json %>);

View File

@@ -0,0 +1,42 @@
Discourse.AceEditorView = window.Discourse.View.extend
mode: 'css'
classNames: ['ace-wrapper']
contentChanged:(->
if @editor && !@skipContentChangeEvent
@editor.getSession().setValue(@get('content'))
).observes('content')
render: (buffer) ->
buffer.push("<div class='ace'>")
buffer.push(Handlebars.Utils.escapeExpression(@get('content'))) if @get('content')
buffer.push("</div>")
willDestroyElement: ->
if @editor
@editor.destroy()
@editor = null
didInsertElement: ->
initAce = =>
@editor = ace.edit(@$('.ace')[0])
@editor.setTheme("ace/theme/chrome")
@editor.setShowPrintMargin(false)
@editor.getSession().setMode("ace/mode/#{@get('mode')}")
@editor.on "change", (e)=>
# amending stuff as you type seems a bit out of scope for now - can revisit after launch
# changes = @get('changes')
# unless changes
# changes = []
# @set('changes', changes)
# changes.push e.data
@skipContentChangeEvent = true
@set('content', @editor.getSession().getValue())
@skipContentChangeEvent = false
if window.ace
initAce()
else
$LAB.script('http://d1n0x3qji82z53.cloudfront.net/src-min-noconflict/ace.js').wait initAce

View File

@@ -0,0 +1,33 @@
Discourse.AdminCustomizeView = window.Discourse.View.extend
templateName: 'admin/templates/customize'
classNames: ['customize']
contentBinding: 'controller.content'
init: ->
@_super()
@set('selected', 'stylesheet')
headerActive: (->
@get('selected') == 'header'
).property('selected')
stylesheetActive: (->
@get('selected') == 'stylesheet'
).property('selected')
selectHeader: ->
@set('selected', 'header')
selectStylesheet: ->
@set('selected', 'stylesheet')
didInsertElement: ->
Mousetrap.bindGlobal ['meta+s', 'ctrl+s'], =>
@get('controller').save()
return false
willDestroyElement: ->
Mousetrap.unbindGlobal('meta+s','ctrl+s')

View File

@@ -0,0 +1,2 @@
Discourse.AdminDashboardView = window.Discourse.View.extend
templateName: 'admin/templates/dashboard'

View File

@@ -0,0 +1,2 @@
Discourse.AdminEmailLogsView = window.Discourse.View.extend
templateName: 'admin/templates/email_logs'

View File

@@ -0,0 +1,3 @@
Discourse.AdminFlagsView = window.Discourse.View.extend
templateName: 'admin/templates/flags'

View File

@@ -0,0 +1,2 @@
Discourse.AdminSiteSettingsView = window.Discourse.View.extend
templateName: 'admin/templates/site_settings'

View File

@@ -0,0 +1,2 @@
Discourse.AdminUserView = window.Discourse.View.extend
templateName: 'admin/templates/user'

View File

@@ -0,0 +1,2 @@
Discourse.AdminUsersListView = window.Discourse.View.extend
templateName: 'admin/templates/users_list'

View File

@@ -0,0 +1,2 @@
Discourse.AdminView = window.Discourse.View.extend
templateName: 'admin/templates/admin'

View File

@@ -0,0 +1,51 @@
// This is a manifest file that'll be compiled into including all the files listed below.
// Add new JavaScript/Coffee code in separate files in this directory and they'll automatically
// be included in the compiled file accessible from http://example.com/assets/application.js
// It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the
// the compiled file.
//
//= require ./env
// probe framework first
//= require ./discourse/components/probes.js
// Externals we need to load first
//= require ./external/jquery-1.8.2.js
//= require ./external/jquery.ui.widget.js
//= require ./external/handlebars-1.0.rc.2.js
//= require ./external/ember.js
// Pagedown customizations
//= require ./pagedown_custom.js
// The rest of the externals
//= require_tree ./external
//= require i18n
//= require discourse/translations
//= require ./discourse/helpers/i18n_helpers
//= require ./discourse
// Stuff we need to load first
//= require_tree ./discourse/mixins
//= require ./discourse/components/debounce
//= require ./discourse/views/view
//= require ./discourse/controllers/controller
//= require ./discourse/views/modal/modal_body_view
//= require ./discourse/models/model
//= require ./discourse/routes/discourse_route
//= require_tree ./discourse/controllers
//= require_tree ./discourse/components
//= require_tree ./discourse/models
//= require_tree ./discourse/views
//= require_tree ./discourse/helpers
//= require_tree ./discourse/templates
//= require_tree ./discourse/routes
<%
# Include javascripts
DiscoursePluginRegistry.javascripts.each do |js|
require_asset(js)
end
%>

View File

@@ -0,0 +1,272 @@
window.Discourse = Ember.Application.createWithMixins
rootElement: '#main'
# Data we want to remember for a short period
transient: Em.Object.create()
hasFocus: true
scrolling: false
# The highest seen post number by topic
highestSeenByTopic: {}
logoSmall: (->
logo = Discourse.SiteSettings.logo_small_url
if logo && logo.length > 1
"<img src='#{logo}' width='33' height='33'>"
else
"<i class='icon-home'></i>"
).property()
titleChanged: (->
title = ""
title += "#{@get('title')} - " if @get('title')
title += Discourse.SiteSettings.title
$('title').text(title)
title = ("(*) " + title) if !@get('hasFocus') && @get('notify')
# chrome bug workaround see: http://stackoverflow.com/questions/2952384/changing-the-window-title-when-focussing-the-window-doesnt-work-in-chrome
window.setTimeout (->
document.title = "."
document.title = title
return), 200
return
).observes('title', 'hasFocus', 'notify')
currentUserChanged: (->
bus = Discourse.MessageBus
# We don't want to receive any previous user notidications
bus.unsubscribe "/notification"
bus.callbackInterval = Discourse.SiteSettings.anon_polling_interval
bus.enableLongPolling = false
user = @get('currentUser')
if user
bus.callbackInterval = Discourse.SiteSettings.polling_interval
bus.enableLongPolling = true
if user.admin
bus.subscribe "/flagged_counts", (data) ->
user.set('site_flagged_posts_count', data.total)
bus.subscribe "/notification", ((data) ->
user.set('unread_notifications', data.unread_notifications)
user.set('unread_private_messages', data.unread_private_messages)), user.notification_channel_position
).observes('currentUser')
notifyTitle: ->
@set('notify', true)
# Browser aware replaceState
replaceState: (path) ->
if window.history && window.history.pushState && window.history.replaceState && !navigator.userAgent.match(/((iPod|iPhone|iPad).+\bOS\s+[1-4]|WebApps\/.+CFNetwork)/)
history.replaceState({path: path}, null, path) unless window.location.pathname is path
openComposer: (opts) ->
# TODO, remove container link
Discourse.__container__.lookup('controller:composer')?.open(opts)
# Like router.route, but allow full urls rather than relative ones
# HERE BE HACKS - uses the ember container for now until we can do this nicer.
routeTo: (path) ->
path = path.replace(/https?\:\/\/[^\/]+/, '')
# If we're in the same topic, don't push the state
topicRegexp = /\/t\/([^\/]+)\/(\d+)\/?(\d+)?/
newMatches = topicRegexp.exec(path);
if newTopicId = newMatches?[2]
oldMatches = topicRegexp.exec(window.location.pathname);
if (oldTopicId = oldMatches?[2]) && (oldTopicId is newTopicId)
Discourse.replaceState(path)
topicController = Discourse.__container__.lookup('controller:topic')
opts = {trackVisit: false}
opts.nearPost = newMatches[3] if newMatches[3]
topicController.get('content').loadPosts(opts)
return
# Be wary of looking up the router. In this case, we have links in our
# HTML, say form compiled markdown posts, that need to be routed.
router = Discourse.__container__.lookup('router:main')
router.router.updateURL(path)
router.handleURL(path)
# Scroll to the top if we're not replacing state
# The classes of buttons to show on a post
postButtons: (->
Discourse.SiteSettings.post_menu.split("|").map (i) -> "#{i.replace(/\+/, '').capitalize()}"
).property('Discourse.SiteSettings.post_menu')
bindDOMEvents: ->
$html = $('html')
# Add the discourse touch event
hasTouch = false
hasTouch = true if $html.hasClass('touch')
hasTouch = true if (Modernizr.prefixed("MaxTouchPoints", navigator) > 1)
if hasTouch
$html.addClass('discourse-touch')
@touch = true
@hasTouch = true
else
$html.addClass('discourse-no-touch')
@touch = false
$('#main').on 'click.discourse', '[data-not-implemented=true]', (e) =>
e.preventDefault()
alert Em.String.i18n('not_implemented')
false
$('#main').on 'click.discourse', 'a', (e) =>
return if (e.isDefaultPrevented() || e.metaKey || e.ctrlKey)
$currentTarget = $(e.currentTarget)
href = $currentTarget.attr('href')
return if href is undefined
return if href is '#'
return if $currentTarget.attr('target')
return if $currentTarget.data('auto-route')
return if href.indexOf("mailto:") is 0
if href.match(/^http[s]?:\/\//i) && !href.match new RegExp("^http:\\/\\/" + window.location.hostname,"i")
return
e.preventDefault()
@routeTo(href)
false
$(window).focus( =>
@set('hasFocus',true)
@set('notify',false)
).blur( =>
@set('hasFocus',false)
)
logout: ->
username = @get('currentUser.username')
Discourse.KeyValueStore.abandonLocal()
$.ajax "/session/#{username}",
type: 'DELETE'
success: (result) =>
# To keep lots of our variables unbound, we can handle a redirect on logging out.
window.location.reload()
# fancy probes in ember
insertProbes: ->
return unless console?
topLevel = (fn,name) ->
window.probes.measure fn,
name: name
before: (data,owner, args) ->
if owner
window.probes.clear()
after: (data, owner, args) ->
if owner && data.time > 10
f = (name,data) ->
"#{name} - #{data.count} calls #{(data.time + 0.0).toFixed(2)}ms" if data && data.count
if console && console.group
console.group(f(name, data))
else
console.log("")
console.log(f(name,data))
ary = []
for n,v of window.probes
continue if n == name || v.time < 1
ary.push(k: n, v: v)
ary.sortBy((item) -> if item.v && item.v.time then -item.v.time else 0).each (item)->
console.log output if output = f("#{item.k}", item.v)
console?.groupEnd?()
window.probes.clear()
Ember.View.prototype.renderToBuffer = window.probes.measure Ember.View.prototype.renderToBuffer, "renderToBuffer"
Discourse.routeTo = topLevel(Discourse.routeTo, "Discourse.routeTo")
Ember.run.end = topLevel(Ember.run.end, "Ember.run.end")
return
authenticationComplete: (options)->
# TODO, how to dispatch this to the view without the container?
loginView = Discourse.__container__.lookup('controller:modal').get('currentView')
loginView.authenticationComplete(options)
buildRoutes: (builder) ->
oldBuilder = Discourse.routeBuilder
Discourse.routeBuilder = ->
oldBuilder.call(@) if oldBuilder
builder.call(@)
start: ->
@bindDOMEvents()
Discourse.SiteSettings = PreloadStore.getStatic('siteSettings')
Discourse.MessageBus.start()
Discourse.KeyValueStore.init("discourse_", Discourse.MessageBus)
Discourse.insertProbes()
# subscribe to any site customizations that are loaded
$('link.custom-css').each ->
split = @href.split("/")
id = split[split.length-1].split(".css")[0]
stylesheet = @
Discourse.MessageBus.subscribe "/file-change/#{id}", (data)=>
$(stylesheet).data('orig', stylesheet.href) unless $(stylesheet).data('orig')
orig = $(stylesheet).data('orig')
sp = orig.split(".css?")
stylesheet.href = sp[0] + ".css?" + data
$('header.custom').each ->
header = $(this)
Discourse.MessageBus.subscribe "/header-change/#{$(@).data('key')}", (data)->
header.html(data)
# possibly move this to dev only
Discourse.MessageBus.subscribe "/file-change", (data)->
Ember.TEMPLATES["empty"] = Handlebars.compile("")
data.each (me)->
if me == "refresh"
document.location.reload(true)
else if me.name.substr(-10) == "handlebars"
js = me.name.replace(".handlebars","").replace("app/assets/javascripts","/assets")
$LAB.script(js + "?hash=" + me.hash).wait ->
templateName = js.replace(".js","").replace("/assets/","")
$.each Ember.View.views, ->
if(@get('templateName')==templateName)
@set('templateName','empty')
@rerender()
Em.run.next =>
@set('templateName', templateName)
@rerender()
else
$('link').each ->
if @.href.match(me.name) and me.hash
$(@).data('orig', @.href) unless $(@).data('orig')
@.href = $(@).data('orig') + "&hash=" + me.hash
window.Discourse.Router = Discourse.Router.reopen(location: 'discourse_location')
# since we have no jquery-rails these days, hook up csrf token
csrf_token = $('meta[name=csrf-token]').attr('content')
$.ajaxPrefilter (options,originalOptions,xhr) ->
unless options.crossDomain
xhr.setRequestHeader('X-CSRF-Token', csrf_token)
return

View File

@@ -0,0 +1,255 @@
( ($) ->
template = null
$.fn.autocomplete = (options)->
return if @.length == 0
if options && options.cancel && @.data("closeAutocomplete")
@.data("closeAutocomplete")()
return this
alert "only supporting one matcher at the moment" unless @.length == 1
autocompleteOptions = null
selectedOption = null
completeStart = null
completeEnd = null
me = @
div = null
# input is handled differently
isInput = @[0].tagName == "INPUT"
inputSelectedItems = []
addInputSelectedItem = (item) ->
transformed = options.transformComplete(item) if options.transformComplete
d = $("<div class='item'><span>#{transformed || item}<a href='#'><i class='icon-remove'></i></a></span></div>")
prev = me.parent().find('.item:last')
if prev.length == 0
me.parent().prepend(d)
else
prev.after(d)
inputSelectedItems.push(item)
if options.onChangeItems
options.onChangeItems(inputSelectedItems)
d.find('a').click ->
closeAutocomplete()
inputSelectedItems.splice($.inArray(item),1)
$(this).parent().parent().remove()
if options.onChangeItems
options.onChangeItems(inputSelectedItems)
if isInput
width = @.width()
height = @.height()
wrap = @.wrap("<div class='ac-wrap clearfix'/>").parent()
wrap.width(width)
@.width(80)
@.attr('name', @.attr('name') + "-renamed")
vals = @.val().split(",")
vals.each (x)->
unless x == ""
x = options.reverseTransform(x) if options.reverseTransform
addInputSelectedItem(x)
@.val("")
completeStart = 0
wrap.click =>
@.focus()
true
markSelected = ->
links = div.find('li a')
links.removeClass('selected')
$(links[selectedOption]).addClass('selected')
renderAutocomplete = ->
div.hide().remove() if div
return if autocompleteOptions.length == 0
div = $(options.template(options: autocompleteOptions))
ul = div.find('ul')
selectedOption = 0
markSelected()
ul.find('li').click ->
selectedOption = ul.find('li').index(this)
completeTerm(autocompleteOptions[selectedOption])
pos = null
if isInput
pos =
left: 0
top: 0
else
pos = me.caretPosition(pos: completeStart, key: options.key)
div.css(left: "-1000px")
me.parent().append(div)
mePos = me.position()
borderTop = parseInt(me.css('border-top-width')) || 0
div.css
position: 'absolute',
top: (mePos.top + pos.top - div.height() + borderTop) + 'px',
left: (mePos.left + pos.left + 27) + 'px'
updateAutoComplete = (r)->
return if completeStart == null
autocompleteOptions = r
if !r || r.length == 0
closeAutocomplete()
else
renderAutocomplete()
closeAutocomplete = ->
div.hide().remove() if div
div = null
completeStart = null
autocompleteOptions = null
# chain to allow multiples
oldClose = me.data("closeAutocomplete")
me.data "closeAutocomplete", ->
oldClose() if oldClose
closeAutocomplete()
completeTerm = (term) ->
if term
if isInput
me.val("")
addInputSelectedItem(term)
else
term = options.transformComplete(term) if options.transformComplete
text = me.val()
text = text.substring(0, completeStart) + (options.key || "") + term + ' ' + text.substring(completeEnd+1, text.length)
me.val(text)
Discourse.Utilities.setCaretPosition(me[0], completeStart + 1 + term.length)
closeAutocomplete()
$(@).keypress (e) ->
if !options.key
return
# keep hunting backwards till you hit a
if e.which == options.key.charCodeAt(0)
caretPosition = Discourse.Utilities.caretPosition(me[0])
prevChar = me.val().charAt(caretPosition-1)
if !prevChar || /\s/.test(prevChar)
completeStart = completeEnd = caretPosition
term = ""
options.dataSource term, updateAutoComplete
return
$(@).keydown (e) ->
completeStart = 0 if !options.key
return if e.which == 16
if completeStart == null && e.which == 8 && options.key #backspace
c = Discourse.Utilities.caretPosition(me[0])
next = me[0].value[c]
nextIsGood = next == undefined || /\s/.test(next)
c-=1
initial = c
prevIsGood = true
while prevIsGood && c >= 0
c -=1
prev = me[0].value[c]
stopFound = prev == options.key
if stopFound
prev = me[0].value[c-1]
if !prev || /\s/.test(prev)
completeStart = c
caretPosition = completeEnd = initial
term = me[0].value.substring(c+1, initial)
options.dataSource term, updateAutoComplete
return true
prevIsGood = /[a-zA-Z\.]/.test(prev)
if e.which == 27 # esc key
if completeStart != null
closeAutocomplete()
return false
return true
if (completeStart != null)
caretPosition = Discourse.Utilities.caretPosition(me[0])
# If we've backspaced past the beginning, cancel unless no key
if caretPosition <= completeStart && options.key
closeAutocomplete()
return false
# Keyboard codes! So 80's.
switch e.which
when 13, 39, 9 # enter, tab or right arrow completes
return true unless autocompleteOptions
if selectedOption >= 0 and userToComplete = autocompleteOptions[selectedOption]
completeTerm(userToComplete)
else
# We're cancelling it, really.
return true
closeAutocomplete()
return false
when 38 # up arrow
selectedOption = selectedOption - 1
selectedOption = 0 if selectedOption < 0
markSelected()
return false
when 40 # down arrow
total = autocompleteOptions.length
selectedOption = selectedOption + 1
selectedOption = total - 1 if selectedOption >= total
selectedOption = 0 if selectedOption < 0
markSelected()
return false
else
# otherwise they're typing - let's search for it!
completeEnd = caretPosition
caretPosition-- if (e.which == 8)
if caretPosition < 0
closeAutocomplete()
if isInput
i = wrap.find('a:last')
i.click() if i
return false
term = me.val().substring(completeStart+(if options.key then 1 else 0), caretPosition)
if (e.which > 48 && e.which < 90)
term += String.fromCharCode(e.which)
else
term += "," unless e.which == 8 # backspace
options.dataSource term, updateAutoComplete
return true
)(jQuery)

View File

@@ -0,0 +1,130 @@
Discourse.BBCode =
QUOTE_REGEXP: /\[quote=([^\]]*)\]([\s\S]*?)\[\/quote\]/im
# Define our replacers
replacers:
base:
withoutArgs:
"ol": (_, content) -> "<ol>#{content}</ol>"
"li": (_, content) -> "<li>#{content}</li>"
"ul": (_, content) -> "<ul>#{content}</ul>"
"code": (_, content) -> "<pre>#{content}</pre>"
"url": (_, url) -> "<a href=\"#{url}\">#{url}</a>"
"email": (_, address) -> "<a href=\"mailto:#{address}\">#{address}</a>"
"img": (_, src) -> "<img src=\"#{src}\">"
withArgs:
"url": (_, href, title) -> "<a href=\"#{href}\">#{title}</a>"
"email": (_, address, title) -> "<a href=\"mailto:#{address}\">#{title}</a>"
"color": (_, color, content) ->
return content unless /^(\#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?)|(aqua|black|blue|fuchsia|gray|green|lime|maroon|navy|olive|purple|red|silver|teal|white|yellow)$/.test(color)
"<span style=\"color: #{color}\">#{content}</span>"
# For HTML emails
email:
withoutArgs:
"b": (_, content) -> "<b>#{content}</b>"
"i": (_, content) -> "<i>#{content}</i>"
"u": (_, content) -> "<u>#{content}</u>"
"s": (_, content) -> "<s>#{content}</s>"
"spoiler": (_, content) -> "<span style='background-color: #000'>#{content}</span>"
withArgs:
"size": (_, size, content) -> "<span style=\"font-size: #{size}px\">#{content}</span>"
# For sane environments that support CSS
default:
withoutArgs:
"b": (_, content) -> "<span class='bbcode-b'>#{content}</span>"
"i": (_, content) -> "<span class='bbcode-i'>#{content}</span>"
"u": (_, content) -> "<span class='bbcode-u'>#{content}</span>"
"s": (_, content) -> "<span class='bbcode-s'>#{content}</span>"
"spoiler": (_, content) -> "<span class=\"spoiler\">#{content}</span>"
withArgs:
"size": (_, size, content) -> "<span class=\"bbcode-size-#{size}\">#{content}</span>"
# Apply a particular set of replacers
apply: (text, environment) ->
replacer = Discourse.BBCode.parsedReplacers()[environment]
replacer.forEach (r) -> text = text.replace r.regexp, r.fn
text
parsedReplacers: ->
return @parsed if @parsed
result = {}
Object.keys Discourse.BBCode.replacers, (name, rules) ->
parsed = result[name] = []
Object.keys Object.merge(Discourse.BBCode.replacers.base.withoutArgs, rules.withoutArgs), (tag, val) ->
parsed.push(regexp: RegExp("\\[#{tag}\\]([\\s\\S]*?)\\[\\/#{tag}\\]", "igm"), fn: val)
Object.keys Object.merge(Discourse.BBCode.replacers.base.withArgs, rules.withArgs), (tag, val) ->
parsed.push(regexp: RegExp("\\[#{tag}=?(.+?)\\\]([\\s\\S]*?)\\[\\/#{tag}\\]", "igm"), fn: val)
@parsed = result
@parsed
buildQuoteBBCode: (post, contents="") ->
sansQuotes = contents.replace(@QUOTE_REGEXP, '').trim()
return "" if sansQuotes.length == 0
# Strip the HTML from cooked
tmp = document.createElement('div')
tmp.innerHTML = post.get('cooked')
stripped = tmp.textContent||tmp.innerText
# Let's remove any non alphanumeric characters as a kind of hash. Yes it's
# not accurate but it should work almost every time we need it to. It would be unlikely
# that the user would quote another post that matches in exactly this way.
stripped_hashed = stripped.replace(/[^a-zA-Z0-9]/g, '')
contents_hashed = contents.replace(/[^a-zA-Z0-9]/g, '')
result = "[quote=\"#{post.get('username')}, post:#{post.get('post_number')}, topic:#{post.get('topic_id')}"
# If the quote is the full message, attribute it as such
if stripped_hashed == contents_hashed
result += ", full:true"
result += "\"]#{sansQuotes}[/quote]\n\n"
formatQuote: (text, opts) ->
# Replace quotes with appropriate markup
while matches = @QUOTE_REGEXP.exec(text)
paramsString = matches[1]
paramsString = paramsString.replace(/\"/g, '')
paramsSplit = paramsString.split(/\, */)
params=[]
paramsSplit.each (p, i) ->
if i > 0
assignment = p.split(':')
if assignment[0] and assignment[1]
params.push(key: assignment[0], value: assignment[1].trim())
username = paramsSplit[0]
# Arguments for formatting
args =
username: username
params: params
quote: matches[2].trim()
avatarImg: opts.lookupAvatar(username) if opts.lookupAvatar
templateName = 'quote'
templateName = "quote_#{opts.environment}" if opts?.environment
text = text.replace(matches[0], "</p>" + HANDLEBARS_TEMPLATES[templateName](args) + "<p>")
text
format: (text, opts) ->
text = Discourse.BBCode.apply(text, opts?.environment || 'default')
# Add quotes
text = Discourse.BBCode.formatQuote(text, opts)
text

View File

@@ -0,0 +1,101 @@
# caret position in textarea ... very hacky ... sorry
(($) ->
# http://stackoverflow.com/questions/263743/how-to-get-caret-position-in-textarea
getCaret = (el) ->
if el.selectionStart
return el.selectionStart
else if document.selection
el.focus()
r = document.selection.createRange()
return 0 if r is null
re = el.createTextRange()
rc = re.duplicate()
re.moveToBookmark r.getBookmark()
rc.setEndPoint "EndToStart", re
return rc.text.length
0
clone = null
$.fn.caretPosition = (options) ->
clone.remove() if clone
span = $("#pos span")
textarea = $(this)
getStyles = (el, prop) ->
if el.currentStyle
el.currentStyle
else
document.defaultView.getComputedStyle el, ""
styles = getStyles(textarea[0])
clone = $("<div><p></p></div>").appendTo("body")
p = clone.find("p")
clone.width textarea.width()
clone.height textarea.height()
important = (prop) ->
styles.getPropertyValue(prop)
clone.css
border: "1px solid black"
padding: important("padding")
resize: important("resize")
"max-height": textarea.height() + "px"
"overflow-y": "auto"
"word-wrap": "break-word"
position: "absolute"
left: "-7000px"
p.css
margin: 0
padding: 0
"word-wrap": "break-word"
"letter-spacing": important("letter-spacing")
"font-family": important("font-family")
"font-size": important("font-size")
"line-height": important("line-height")
before = undefined
after = undefined
pos = if options && options.pos then options.pos else getCaret(textarea[0])
val = textarea.val().replace("\r", "")
if (options && options.key)
val = val.substring(0,pos) + options.key + val.substring(pos)
before = pos - 1
after = pos
insertSpaceAfterBefore = false
# if before and after are \n insert a space
insertSpaceAfterBefore = true if val[before] is "\n" and val[after] is "\n"
guard = (v) ->
buf = v.replace(/</g,"&lt;")
buf = buf.replace(/>/g,"&gt;")
buf = buf.replace(/[ ]/g, "&#x200b;&nbsp;&#x200b;")
buf.replace(/\n/g,"<br />")
makeCursor = (pos, klass, color) ->
l = val.substring(pos, pos + 1)
return "<br>" if l is "\n"
"<span class='" + klass + "' style='background-color:" + color + "; margin:0; padding: 0'>" + guard(l) + "</span>"
html = ""
if before >= 0
html += guard(val.substring(0, pos - 1)) + makeCursor(before, "before", "#d0ffff")
html += makeCursor(0, "post-before", "#d0ffff") if insertSpaceAfterBefore
if after >= 0
html += makeCursor(after, "after", "#ffd0ff")
html += guard(val.substring(after + 1)) if after - 1 < val.length
p.html html
clone.scrollTop textarea.scrollTop()
letter = p.find("span:first")
pos = letter.offset()
pos.left = pos.left + letter.width() if letter.hasClass("before")
pPos = p.offset()
#clone.hide().remove()
left: pos.left - pPos.left
top: (pos.top - pPos.top) - clone.scrollTop()
) jQuery

View File

@@ -0,0 +1,64 @@
# We use this object to keep track of click counts.
window.Discourse.ClickTrack =
# Pass the event of the click here and we'll do the magic!
trackClick: (e) ->
$a = $(e.currentTarget)
e.preventDefault()
# We don't track clicks on quote back buttons
return true if $a.hasClass('back') or $a.hasClass('quote-other-topic')
# Remove the href, put it as a data attribute
unless $a.data('href')
$a.addClass('no-href')
$a.data('href', $a.attr('href'))
$a.attr('href', null)
# Don't route to this URL
$a.data('auto-route', true)
href = $a.data('href')
$article = $a.closest('article')
postId = $article.data('post-id')
topicId = $('#topic').data('topic-id')
userId = $a.data('user-id')
userId = $article.data('user-id') unless userId
ownLink = userId and (userId is Discourse.get('currentUser.id'))
# Build a Redirect URL
trackingUrl = "/clicks/track?url=" + encodeURIComponent(href)
trackingUrl += "&post_id=" + encodeURI(postId) if postId and (not $a.data('ignore-post-id'))
trackingUrl += "&topic_id=" + encodeURI(topicId) if topicId
# Update badge clicks unless it's our own
unless ownLink
$badge = $('span.badge', $a)
if $badge.length == 1
count = parseInt($badge.html())
$badge.html(count + 1)
# If they right clicked, change the destination href
if e.which is 3
destination = if Discourse.SiteSettings.track_external_right_clicks then trackingUrl else href
$a.attr('href', destination)
return true
# if they want to open in a new tab, do an AJAX request
if (e.metaKey || e.ctrlKey || e.which is 2)
$.get "/clicks/track", url: href, post_id: postId, topic_id: topicId, redirect: false
window.open(href, '_blank')
return false
# If we're on the same site, use the router and track via AJAX
if href.indexOf(window.location.origin) == 0
$.get "/clicks/track", url: href, post_id: postId, topic_id: topicId, redirect: false
Discourse.routeTo(href)
return false
# Otherwise, use a custom URL with a redirect
window.location = trackingUrl
false

View File

@@ -0,0 +1,20 @@
window.Discourse.debounce = (func, wait, trickle) ->
timeout = null
return ->
context = @
args = arguments
later = ->
timeout = null
func.apply(context, args)
if timeout != null && trickle
# already queued, let it through
return
if typeof wait == "function"
currentWait = wait()
else
currentWait = wait
clearTimeout(timeout) if timeout
timeout = setTimeout(later, currentWait)

View File

@@ -0,0 +1,7 @@
Discourse.TextField = Ember.TextField.extend
attributeBindings: ['autocorrect', 'autocapitalize']
placeholder: (->
Em.String.i18n(@get('placeholderKey'))
).property('placeholderKey')

View File

@@ -0,0 +1,61 @@
#based off text area resizer by Ryan O'Dell : http://plugins.jquery.com/misc/textarea.js
(($) ->
div = undefined
originalPos = undefined
originalDivHeight = undefined
lastMousePos = 0
min = 230
grip = undefined
wrappedEndDrag = undefined
wrappedPerformDrag = undefined
startDrag = (e,opts) ->
div = $(e.data.el)
div.addClass('clear-transitions')
div.blur()
lastMousePos = mousePosition(e).y
originalPos = lastMousePos
originalDivHeight = div.height()
wrappedPerformDrag = ( ->
(e) -> performDrag(e,opts)
)()
wrappedEndDrag = ( ->
(e) -> endDrag(e,opts)
)()
$(document).mousemove(wrappedPerformDrag).mouseup wrappedEndDrag
false
performDrag = (e,opts) ->
thisMousePos = mousePosition(e).y
size = originalDivHeight + (originalPos - thisMousePos)
lastMousePos = thisMousePos
size = Math.max(min, size)
div.height size + "px"
endDrag e,opts if size < min
false
endDrag = (e,opts) ->
$(document).unbind("mousemove", wrappedPerformDrag).unbind "mouseup", wrappedEndDrag
div.removeClass('clear-transitions')
div.focus()
opts.resize() if opts.resize
div = null
mousePosition = (e) ->
x: e.clientX + document.documentElement.scrollLeft
y: e.clientY + document.documentElement.scrollTop
$.fn.DivResizer = (opts) ->
@each ->
div = $(this)
return if (div.hasClass("processed"))
div.addClass("processed")
staticOffset = null
start = ->
(e) -> startDrag(e,opts)
grippie = div.prepend("<div class='grippie'></div>").find('.grippie').bind("mousedown",
el: this
, start())
) jQuery

View File

@@ -0,0 +1,64 @@
#
# Track visible elements on the screen
#
# You can register for triggers on:
# focusChanged: -> the top element we're focusing on
# seenElement: -> if we've seen the element
#
class Discourse.Eyeline
constructor: (@selector) ->
# Call this whenever we want to consider what is currently being seen by the browser
update: ->
docViewTop = $(window).scrollTop()
windowHeight = $(window).height()
docViewBottom = docViewTop + windowHeight
documentHeight = $(document).height()
$elements = $(@selector)
atBottom = false
if bottomOffset = $elements.last().offset()
atBottom = (bottomOffset.top <= docViewBottom) and (bottomOffset.top >= docViewTop)
# Whether we've seen any elements in this search
foundElement = false
$results = $(@selector)
$results.each (i, elem) =>
$elem = $(elem)
elemTop = $elem.offset().top
elemBottom = elemTop + $elem.height()
markSeen = false
# It's seen if...
# ...the element is vertically within the top and botom
markSeen = true if ((elemTop <= docViewBottom) and (elemTop >= docViewTop))
# ...the element top is above the top and the bottom is below the bottom (large elements)
markSeen = true if ((elemTop <= docViewTop) and (elemBottom >= docViewBottom))
# ...we're at the bottom and the bottom of the element is visible (large bottom elements)
markSeen = true if atBottom and (elemBottom >= docViewTop)
return true unless markSeen
# If you hit the bottom we mark all the elements as seen. Otherwise, just the first one
unless atBottom
@trigger('saw', detail: $elem)
@trigger('sawTop', detail: $elem) if i == 0
return false
@trigger('sawTop', detail: $elem) if i == 0
@trigger('sawBottom', detail: $elem) if i == ($results.length - 1)
# Call this when we know aren't loading any more elements. Mark the rest
# as seen
flushRest: ->
$(@selector).each (i, elem) =>
$elem = $(elem)
@trigger('saw', detail: $elem)
RSVP.EventTarget.mixin(Discourse.Eyeline.prototype)

View File

@@ -0,0 +1,33 @@
# key value store
#
window.Discourse.KeyValueStore = (->
initialized = false
context = ""
init: (ctx,messageBus) ->
initialized = true
context = ctx
abandonLocal: ->
return unless localStorage && initialized
i=localStorage.length-1
while i >= 0
k = localStorage.key(i)
localStorage.removeItem(k) if k.substring(0, context.length) == context
i--
return true
remove: (key)->
localStorage.removeItem(context + key)
set: (opts)->
return false unless localStorage && initialized
localStorage[context + opts["key"]] = opts["value"]
get: (key)->
return null unless localStorage
localStorage[context + key]
)()

View File

@@ -0,0 +1,114 @@
window.Discourse.MessageBus = ( ->
# http://stackoverflow.com/questions/105034/how-to-create-a-guid-uuid-in-javascript
uniqueId = -> 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace /[xy]/g, (c)->
r = Math.random()*16 | 0
v = if c == 'x' then r else (r&0x3|0x8)
v.toString(16)
clientId = uniqueId()
responseCallbacks = {}
callbacks = []
queue = []
interval = null
failCount = 0
isHidden = ->
if document.hidden != undefined
document.hidden
else if document.webkitHidden != undefined
document.webkitHidden
else if document.msHidden != undefined
document.msHidden
else if document.mozHidden != undefined
document.mozHidden
else
# fallback to problamatic window.focus
!Discourse.get('hasFocus')
enableLongPolling: true
callbackInterval: 60000
maxPollInterval: (3 * 60 * 1000)
callbacks: callbacks
clientId: clientId
#TODO
stop:
false
# Start polling
start: (opts={})->
poll = =>
if callbacks.length == 0
setTimeout poll, 500
return
data = {}
callbacks.each (c)->
data[c.channel] = if c.last_id == undefined then -1 else c.last_id
gotData = false
@longPoll = $.ajax "/message-bus/#{clientId}/poll?#{if isHidden() || !@enableLongPolling then "dlp=t" else ""}",
data: data
cache: false
dataType: 'json'
type: 'POST'
headers:
'X-SILENCE-LOGGER': 'true'
success: (messages) =>
failCount = 0
messages.each (message) =>
gotData = true
callbacks.each (callback) ->
if callback.channel == message.channel
callback.last_id = message.message_id
callback.func(message.data)
if message["channel"] == "/__status"
callback.last_id = message.data[callback.channel] if message.data[callback.channel] != undefined
return
error:
failCount += 1
complete: =>
if gotData
setTimeout poll, 100
else
interval = @callbackInterval
if failCount > 2
interval = interval * failCount
else if isHidden()
# slowning down stuff a lot when hidden
# we will need to add a lot of fine tuning here
interval = interval * 4
if interval > @maxPollInterval
interval = @maxPollInterval
setTimeout poll, interval
@longPoll = null
return
poll()
return
# Subscribe to a channel
subscribe: (channel,func,lastId)->
callbacks.push {channel:channel, func:func, last_id: lastId}
@longPoll.abort() if @longPoll
# Unsubscribe from a channel
unsubscribe: (channel) ->
# TODO proper globbing
if channel.endsWith("*")
channel = channel.substr(0, channel.length-1)
glob = true
callbacks = callbacks.filter (callback) ->
if glob
callback.channel.substr(0, channel.length) != channel
else
callback.channel != channel
@longPoll.abort() if @longPoll
)()

View File

@@ -0,0 +1,24 @@
window.Discourse.PagedownEditor = Ember.ContainerView.extend
elementId: 'pagedown-editor'
init: ->
@_super()
# Add a button bar
@pushObject Em.View.create(elementId: 'wmd-button-bar')
@pushObject Em.TextArea.create(valueBinding: 'parentView.value', elementId: 'wmd-input')
@pushObject Em.View.createWithMixins Discourse.Presence,
elementId: 'wmd-preview',
classNameBindings: [':preview', 'hidden']
hidden: (->
@blank('parentView.value')
).property('parentView.value')
didInsertElement: ->
$wmdInput = $('#wmd-input')
$wmdInput.data('init', true)
@editor = new Markdown.Editor(Discourse.Utilities.markdownConverter())
@editor.run()

View File

@@ -0,0 +1,122 @@
/*
* JavaScript probing framework by Sam Saffron
* MIT license
*
*
* Examples:
*
someFunction = window.probes.measure(someFunction, {
name: "somename" // or function(args) { return "name"; },
before: function(data, owner, args) {
// if owner is true, we are not in a recursive function call.
//
// data contains the bucker of data already measuer
// data.count >= 0
// data.time is the total time measured till now
//
// arguments contains the original arguments sent to the function
},
after: function(data, owner, args) {
// same format as before
}
});
// minimal
someFunction = window.probes.measure(someFunction, "someFunction");
*
*
* */
(function(){
var measure, clear;
clear = function() {
window.probes = {
clear: clear,
measure: measure
};
};
measure = function(fn,options) {
// start is outside so we measure time around recursive calls properly
var start = null, nameParam, before, after;
if (!options) {
options = {};
}
if (typeof options === "string") {
nameParam = options;
}
else
{
nameParam = options["name"];
if (nameParam === "measure" || nameParam == "clear") {
throw Error("can not be called measure or clear");
}
if (!nameParam)
{
throw Error("you must specify the name option measure(fn, {name: 'some name'})");
}
before = options["before"];
after = options["after"];
}
var now = (function(){
var perf = window.performance || {};
var time = perf.now || perf.mozNow || perf.webkitNow || perf.msNow || perf.oNow;
return time ? time.bind(perf) : function() { return new Date().getTime(); };
})();
return function() {
var name = nameParam;
if (typeof name == "function"){
name = nameParam(arguments);
}
var p = window.probes[name];
var owner = start === null;
if (before) {
// would like to avoid try catch so its optimised properly by chrome
before(p, owner, arguments);
}
if (p === undefined) {
window.probes[name] = {count: 0, time: 0, currentTime: 0};
p = window.probes[name];
}
var callStart;
if (owner) {
start = now();
callStart = start;
}
else if(after)
{
callStart = now();
}
var r = fn.apply(this, arguments);
if (owner && start) {
p.time += now() - start;
start = null;
}
p.count += 1;
if (after) {
p.currentTime = now() - callStart;
// would like to avoid try catch so its optimised properly by chrome
after(p, owner, arguments);
}
return r;
}
}
clear();
})();

View File

@@ -0,0 +1,128 @@
# We use this class to track how long posts in a topic are on the screen.
# This could be a potentially awesome metric to keep track of.
window.Discourse.ScreenTrack = Ember.Object.extend
# Don't send events if we haven't scrolled in a long time
PAUSE_UNLESS_SCROLLED: 1000*60*3
# After 6 minutes stop tracking read position on post
MAX_TRACKING_TIME: 1000*60*6
totalTimings: {}
# Elements to track
timings: {}
topicTime: 0
cancelled: false
track: (elementId, postNumber) ->
@timings["##{elementId}"] =
time: 0
postNumber: postNumber
guessedSeen: (postNumber) ->
@highestSeen = postNumber if postNumber > (@highestSeen || 0)
# Reset our timers
reset: ->
@lastTick = new Date().getTime()
@lastFlush = 0
@cancelled = false
# Start tracking
start: ->
@reset()
@lastScrolled = new Date().getTime()
@interval = setInterval =>
@tick()
, 1000
# Cancel and eject any tracking we have buffered
cancel: ->
@cancelled = true
@timings = {}
@topicTime = 0
clearInterval(@interval)
@interval = null
# Stop tracking and flush buffered read records
stop: ->
clearInterval(@interval)
@interval = null
@flush()
scrolled: ->
@lastScrolled = new Date().getTime()
flush: ->
return if @cancelled
# We don't log anything unless we're logged in
return unless Discourse.get('currentUser')
newTimings = {}
Object.values @timings, (timing) =>
@totalTimings[timing.postNumber] ||= 0
if timing.time > 0 and @totalTimings[timing.postNumber] < @MAX_TRACKING_TIME
@totalTimings[timing.postNumber] += timing.time
newTimings[timing.postNumber] = timing.time
timing.time = 0
topicId = @get('topic_id')
highestSeenByTopic = Discourse.get('highestSeenByTopic')
if (highestSeenByTopic[topicId] || 0) < @highestSeen
highestSeenByTopic[topicId] = @highestSeen
unless Object.isEmpty(newTimings)
$.ajax '/topics/timings'
data:
timings: newTimings
topic_time: @topicTime
highest_seen: @highestSeen
topic_id: topicId
cache: false
type: 'POST'
headers:
'X-SILENCE-LOGGER': 'true'
@topicTime = 0
@lastFlush = 0
tick: ->
# If the user hasn't scrolled the browser in a long time, stop tracking time read
sinceScrolled = new Date().getTime() - @lastScrolled
if sinceScrolled > @PAUSE_UNLESS_SCROLLED
@reset()
return
diff = new Date().getTime() - @lastTick
@lastFlush += diff
@lastTick = new Date().getTime()
@flush() if @lastFlush > (Discourse.SiteSettings.flush_timings_secs * 1000)
# Don't track timings if we're not in focus
return unless Discourse.get("hasFocus")
@topicTime += diff
docViewTop = $(window).scrollTop() + $('header').height()
docViewBottom = docViewTop + $(window).height()
Object.keys @timings, (id) =>
$element = $(id)
if ($element.length == 1)
elemTop = $element.offset().top
elemBottom = elemTop + $element.height()
# If part of the element is on the screen, increase the counter
if (docViewTop <= elemTop <= docViewBottom) or (docViewTop <= elemBottom <= docViewBottom)
timing = @timings[id]
timing.time = timing.time + diff

View File

@@ -0,0 +1,8 @@
# Helper object for syntax highlighting. Uses highlight.js which is loaded
# on demand.
window.Discourse.SyntaxHighlighting =
apply: ($elem) ->
$('pre code[class]', $elem).each (i, e) =>
$LAB.script("/javascripts/highlight-handlebars.pack.js").wait ->
hljs.highlightBlock(e)

View File

@@ -0,0 +1,25 @@
# CSS transitions are a PITA, often we need to queue some js after a transition, this helper ensures
# it happens after the transition
#
# SO: http://stackoverflow.com/questions/9943435/css3-animation-end-techniques
dummy = document.createElement("div")
eventNameHash =
webkit: "webkitTransitionEnd"
Moz: "transitionend"
O: "oTransitionEnd"
ms: "MSTransitionEnd"
transitionEnd = (_getTransitionEndEventName = ->
retValue = "transitionend"
Object.keys(eventNameHash).some (vendor) ->
if vendor + "TransitionProperty" of dummy.style
retValue = eventNameHash[vendor]
true
retValue
)()
window.Discourse.TransitionHelper =
after: (element, callback) ->
$(element).on(transitionEnd, callback)

View File

@@ -0,0 +1,51 @@
cache = {}
cacheTopicId = null
cacheTime = null
doSearch = (term,topicId,success)->
$.ajax
url: '/users/search/users'
dataType: 'JSON'
data: {term: term, topic_id: topicId}
success: (r)->
cache[term] = r
cacheTime = new Date()
success(r)
debouncedSearch = Discourse.debounce(doSearch, 200)
window.Discourse.UserSearch =
search: (options) ->
term = options.term || ""
callback = options.callback
exclude = options.exclude || []
topicId = options.topicId
limit = options.limit || 5
throw "missing callback" unless callback
#TODO site setting for allowed regex in username ?
if term.match(/[^a-zA-Z0-9\_\.]/)
callback([])
return true
cache = {} if (new Date() - cacheTime) > 30000
cache = {} if cacheTopicId != topicId
cacheTopicId = topicId
success = (r)->
result = []
r.users.each (u)->
result.push(u) if exclude.indexOf(u.username) == -1
return false if result.length > limit
true
callback(result)
if cache[term]
success(cache[term])
else
debouncedSearch(term, topicId, success)
true

View File

@@ -0,0 +1,165 @@
baseUrl = null
site = null
Discourse.Utilities =
translateSize: (size)->
switch size
when 'tiny' then size=20
when 'small' then size=25
when 'medium' then size=32
when 'large' then size=45
return size
# Create a badge like category link
categoryLink: (category) ->
return "" unless category
slug = Em.get(category, 'slug')
color = Em.get(category, 'color')
name = Em.get(category, 'name')
"<a href=\"/category/#{slug}\" class=\"badge-category excerptable\" data-excerpt-size=\"medium\" style=\"background-color: ##{color}\">#{name}</a>"
avatarUrl: (username, size, template)->
return "" unless username
size = Discourse.Utilities.translateSize(size)
rawSize = (size * (window.devicePixelRatio || 1)).toFixed()
return template.replace(/\{size\}/g, rawSize) if template
"/users/#{username.toLowerCase()}/avatar/#{rawSize}?__ws=#{encodeURIComponent(Discourse.BaseUrl || "")}"
avatarImg: (options)->
size = Discourse.Utilities.translateSize(options.size)
title = options.title || ""
extraClasses = options.extraClasses || ""
url = Discourse.Utilities.avatarUrl(options.username, options.size, options.avatarTemplate)
"<img width='#{size}' height='#{size}' src='#{url}' class='avatar #{extraClasses || ""}' title='#{Handlebars.Utils.escapeExpression(title || "")}'>"
postUrl: (slug, topicId, postNumber)->
url = "/t/"
url += slug + "/" if slug
url += topicId
url += "/#{postNumber}" if postNumber > 1
url
emailValid: (email)->
# see: http://stackoverflow.com/questions/46155/validate-email-address-in-javascript
re = /^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
re.test(email)
selectedText: ->
t = ''
if window.getSelection
t = window.getSelection().toString()
else if document.getSelection
t = document.getSelection().toString()
else if document.selection
t = document.selection.createRange().text
String(t).trim()
# Determine the position of the caret in an element
caretPosition: (el) ->
return el.selectionStart if el.selectionStart
if document.selection
el.focus()
r = document.selection.createRange()
return 0 if r == null
re = el.createTextRange()
rc = re.duplicate()
re.moveToBookmark(r.getBookmark())
rc.setEndPoint('EndToStart', re)
return rc.text.length
return 0
# Set the caret's position
setCaretPosition: (ctrl, pos) ->
if(ctrl.setSelectionRange)
ctrl.focus()
ctrl.setSelectionRange(pos,pos)
return
if (ctrl.createTextRange)
range = ctrl.createTextRange()
range.collapse(true)
range.moveEnd('character', pos)
range.moveStart('character', pos)
range.select()
markdownConverter: (opts)->
converter = new Markdown.Converter()
mentionLookup = opts.mentionLookup if opts
mentionLookup = mentionLookup || Discourse.Mention.lookupCache
# Before cooking callbacks
converter.hooks.chain "preConversion", (text) =>
@trigger 'beforeCook', detail: text, opts: opts
@textResult || text
# Support autolinking of www.something.com
converter.hooks.chain "preConversion", (text) ->
text.replace /(^|[\s\n])(www\.[a-z\.\-\_\(\)\/\?\=\%0-9]+)/gim, (full, _, rest) ->
" <a href=\"http://#{rest}\">#{rest}</a>"
# newline prediction in trivial cases
unless Discourse.SiteSettings.traditional_markdown_linebreaks
converter.hooks.chain "preConversion", (text) ->
result = text.replace /(^[\w\<][^\n]*\n+)/gim, (t) ->
return t if t.match /\n{2}/gim
t = t.replace "\n"," \n"
# github style fenced code
converter.hooks.chain "preConversion", (text) ->
result = text.replace /^`{3}(?:(.*$)\n)?([\s\S]*?)^`{3}/gm, (wholeMatch,m1,m2) ->
escaped = Handlebars.Utils.escapeExpression(m2)
"<pre><code class='#{m1 || 'lang-auto'}'>#{escaped}</code></pre>"
converter.hooks.chain "postConversion", (text) ->
return "" unless text
# don't to mention voodoo in pres
text = text.replace /<pre>([\s\S]*@[\s\S]*)<\/pre>/gi, (wholeMatch, inner) ->
"<pre>#{inner.replace(/@/g, '&#64;')}</pre>"
# Add @mentions of names
text = text.replace(/([\s\t>,:'|";\]])(@[A-Za-z0-9_-|\.]*[A-Za-z0-9_-|]+)(?=[\s\t<\!:|;',"\?\.])/g, (x,pre,name) ->
if mentionLookup(name.substr(1))
"#{pre}<a href='/users/#{name.substr(1).toLowerCase()}' class='mention'>#{name}</a>"
else
"#{pre}<span class='mention'>#{name}</span>")
# a primitive attempt at oneboxing, this regex gives me much eye sores
text = text.replace /(<li>)?((<p>|<br>)[\s\n\r]*)(<a href=["]([^"]+)[^>]*)>([^<]+<\/a>[\s\n\r]*(?=<\/p>|<br>))/gi, ->
# We don't onebox items in a list
return arguments[0] if arguments[1]
url = arguments[5]
onebox = Discourse.Onebox.lookupCache(url) if Discourse && Discourse.Onebox
if onebox and !onebox.isBlank()
return arguments[2] + onebox
else
return arguments[2] + arguments[4] + " class=\"onebox\" target=\"_blank\">" + arguments[6]
converter.hooks.chain "postConversion", (text) =>
Discourse.BBCode.format(text, opts)
converter
# Takes raw input and cooks it to display nicely (mostly markdown)
cook: (raw, opts) ->
# Make sure we've got a string
return "" unless raw
return "" unless raw.length > 0
@converter = @markdownConverter(opts)
@converter.makeHtml(raw)
RSVP.EventTarget.mixin(Discourse.Utilities)

View File

@@ -0,0 +1,6 @@
window.Discourse.ApplicationController = Ember.Controller.extend
needs: ['modal']
showLogin: ->
@get('controllers.modal')?.show(Discourse.LoginView.create())

View File

@@ -0,0 +1,173 @@
window.Discourse.ComposerController = Ember.Controller.extend Discourse.Presence,
needs: ['modal', 'topic']
togglePreview: ->
@get('content').togglePreview()
# Import a quote from the post
importQuote: ->
@get('content').importQuote()
appendText: (text) ->
c = @get('content')
c.appendText(text) if c
save: ->
composer = @get('content')
composer.set('disableDrafts', true)
composer.save(imageSizes: @get('view').imageSizes())
.then (opts) =>
opts = opts || {}
@close()
Discourse.routeTo(opts.post.get('url'))
, (error) =>
composer.set('disableDrafts', false)
bootbox.alert error
saveDraft: ->
model = @get('content')
model.saveDraft() if model
# Open the reply view
#
# opts:
# action - The action we're performing: edit, reply or createTopic
# post - The post we're replying to, if present
# topic - The topic we're replying to, if present
# quote - If we're opening a reply from a quote, the quote we're making
#
open: (opts={}) ->
opts.promise = promise = opts.promise || new RSVP.Promise
unless opts.draftKey
alert("composer was opened without a draft key")
throw "composer opened without a proper draft key"
# ensure we have a view now, without it transitions are going to be messed
view = @get('view')
unless view
view = Discourse.ComposerView.create
controller: @
view.appendTo($('#main'))
@set('view', view)
# the next runloop is too soon, need to get the control rendered and then
# we need to change stuff, otherwise css animations don't kick in
Em.run.next =>
Em.run.next =>
@open(opts)
return promise
composer = @get('content')
if composer && opts.draftKey != composer.draftKey && composer.composeState == Discourse.Composer.DRAFT
@close()
composer = null
if composer && !opts.tested && composer.wouldLoseChanges()
if composer.composeState == Discourse.Composer.DRAFT && composer.draftKey == opts.draftKey && composer.action == opts.action
composer.set('composeState', Discourse.Composer.OPEN)
promise.resolve()
return promise
else
opts.tested = true
@cancel(( => @open(opts) ),( => promise.reject())) unless opts.ignoreIfChanged
return promise
# we need a draft sequence, without it drafts are bust
if opts.draftSequence == undefined
Discourse.Draft.get(opts.draftKey).then (data)=>
opts.draftSequence = data.draft_sequence
opts.draft = data.draft
@open(opts)
return promise
if opts.draft
composer = Discourse.Composer.loadDraft(opts.draftKey, opts.draftSequence, opts.draft)
composer?.set('topic', opts.topic)
composer = composer || Discourse.Composer.open(opts)
@set('content', composer)
@set('view.content', composer)
promise.resolve()
return promise
wouldLoseChanges: ->
composer = @get('content')
composer && composer.wouldLoseChanges()
# View a new reply we've made
viewNewReply: ->
Discourse.routeTo(@get('createdPost.url'))
@close()
false
destroyDraft: ->
key = @get('content.draftKey')
Discourse.Draft.clear(key, @get('content.draftSequence')) if key
cancel: (success, fail) ->
if @get('content.hasMetaData') || ((@get('content.reply') || "") != (@get('content.originalText') || ""))
bootbox.confirm Em.String.i18n("post.abandon"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) =>
if result
@destroyDraft()
@close()
success() if typeof success == "function"
else
fail() if typeof fail == "function"
else
# it is possible there is some sort of crazy draft with no body ... just give up on it
@destroyDraft()
@close()
success() if typeof success == "function"
return
click: ->
if @get('content.composeState') == Discourse.Composer.DRAFT
@set('content.composeState', Discourse.Composer.OPEN)
false
shrink: ->
if @get('content.reply') == @get('content.originalText') then @close() else @collapse()
collapse: ->
@saveDraft()
@set('content.composeState', Discourse.Composer.DRAFT)
close: ->
@set('content', null)
@set('view.content', null)
closeIfCollapsed: ->
if @get('content.composeState') == Discourse.Composer.DRAFT
@close()
closeAutocomplete: ->
$('#wmd-input').autocomplete(cancel: true)
# Toggle the reply view
toggle: ->
@closeAutocomplete()
switch @get('content.composeState')
when Discourse.Composer.OPEN
if @blank('content.reply') and @blank('content.title') then @close() else @shrink()
when Discourse.Composer.DRAFT
@set('content.composeState', Discourse.Composer.OPEN)
when Discourse.Composer.SAVING
@close()
false
# ESC key hit
hitEsc: ->
@shrink() if @get('content.composeState') == @OPEN
showOptions: ->
@get('controllers.modal')?.show(Discourse.ArchetypeOptionsModalView.create(archetype: @get('content.archetype'), metaData: @get('content.metaData')))

View File

@@ -0,0 +1 @@
Discourse.Controller = Ember.Controller.extend(Discourse.Presence)

View File

@@ -0,0 +1,7 @@
Discourse.HeaderController = Ember.Controller.extend Discourse.Presence,
topic: null
showExtraInfo: false
toggleStar: ->
@get('topic')?.toggleStar()
false

View File

@@ -0,0 +1,21 @@
Discourse.ListCategoriesController = Ember.ObjectController.extend Discourse.Presence,
needs: ['modal']
categoriesEven: (->
return Em.A() if @blank('categories')
@get('categories').filter (item, index) -> (index % 2) == 0
).property('categories.@each')
categoriesOdd: (->
return Em.A() if @blank('categories')
@get('categories').filter (item, index) -> (index % 2) == 1
).property('categories.@each')
editCategory: (category) ->
@get('controllers.modal').show(Discourse.EditCategoryView.create(category: category))
false
canEdit: (->
u = Discourse.get('currentUser')
u && u.admin
).property()

View File

@@ -0,0 +1,73 @@
Discourse.ListController = Ember.Controller.extend Discourse.Presence,
currentUserBinding: 'Discourse.currentUser'
categoriesBinding: 'Discourse.site.categories'
categoryBinding: 'topicList.category'
canCreateCategory: false
canCreateTopic: false
needs: ['composer', 'modal', 'listTopics']
availableNavItems: (->
summary = @get('filterSummary')
loggedOn = !!Discourse.get('currentUser')
hasCategories = !!@get('categories')
Discourse.SiteSettings.top_menu.split("|").map((i)->
Discourse.NavItem.fromText i,
loggedOn: loggedOn
hasCategories: hasCategories
countSummary: summary
).filter((i)-> i != null)
).property('filterSummary')
load: (filterMode) ->
@set('loading', true)
if filterMode == 'categories'
return Ember.Deferred.promise (deferred) =>
Discourse.CategoryList.list(filterMode).then (items) =>
@set('loading', false)
@set('filterMode', filterMode)
@set('categoryMode', true)
deferred.resolve(items)
else
current = (@get('availableNavItems').filter (f)=> f.name == filterMode)[0]
current = Discourse.NavItem.create(name: filterMode) unless current
return Ember.Deferred.promise (deferred) =>
Discourse.TopicList.list(current).then (items) =>
@set('filterSummary', items.filter_summary)
@set('filterMode', filterMode)
@set('loading', false)
deferred.resolve(items)
# Put in the appropriate page title based on our view
updateTitle: (->
if @get('filterMode') == 'categories'
Discourse.set('title', Em.String.i18n('categories_list'))
else
if @present('category')
Discourse.set('title', "#{@get('category.name').capitalize()} #{Em.String.i18n('topic.list')}")
else
Discourse.set('title', Em.String.i18n('topic.list'))
).observes('filterMode', 'category')
# Create topic button
createTopic: ->
topicList = @get('controllers.listTopics.content')
return unless topicList
@get('controllers.composer').open
categoryName: @get('category.name')
action: Discourse.Composer.CREATE_TOPIC
draftKey: topicList.get('draft_key')
draftSequence: topicList.get('draft_sequence')
createCategory: ->
@get('controllers.modal')?.show(Discourse.EditCategoryView.create())
Discourse.ListController.reopenClass(filters: ['popular','favorited','read','unread','new','posted'])

View File

@@ -0,0 +1,53 @@
Discourse.ListTopicsController = Ember.ObjectController.extend
needs: ['list','composer']
# If we're changing our channel
previousChannel: null
filterModeChanged: (->
# Unsubscribe from a previous channel if necessary
if previousChannel = @get('previousChannel')
Discourse.MessageBus.unsubscribe "/#{previousChannel}"
@set('previousChannel', null)
filterMode = @get('controllers.list.filterMode')
return unless filterMode
channel = filterMode
Discourse.MessageBus.subscribe "/#{channel}", (data) =>
@get('content').insert(data)
@set('previousChannel', channel)
).observes('controllers.list.filterMode')
draftLoaded: (->
draft = @get('content.draft')
if(draft)
@get('controllers.composer').open
draft: draft
draftKey: @get('content.draft_key'),
draftSequence: @get('content.draft_sequence')
ignoreIfChanged: true
).observes('content.draft')
# Star a topic
toggleStar: (topic) ->
topic.toggleStar()
false
observer: (->
@set('filterMode', @get('controllser.list.filterMode'))
).observes('controller.list.filterMode')
# Show newly inserted topics
showInserted: (e) ->
# Move inserted into topics
@get('content.topics').unshiftObjects @get('content.inserted')
# Clear inserted
@set('content.inserted', Em.A())
false

View File

@@ -0,0 +1,3 @@
Discourse.ModalController = Ember.Controller.extend Discourse.Presence,
show: (view) -> @set('currentView', view)

View File

@@ -0,0 +1,54 @@
Discourse.PreferencesController = Ember.ObjectController.extend Discourse.Presence,
# By default we haven't saved anything
saved: false
saveDisabled: (->
return true if @get('saving')
return true if @blank('content.name')
return true if @blank('content.email')
false
).property('saving', 'content.name', 'content.email')
digestFrequencies: (->
freqs = Em.A()
freqs.addObject(name: Em.String.i18n('user.email_digests.daily'), value: 1)
freqs.addObject(name: Em.String.i18n('user.email_digests.weekly'), value: 7)
freqs.addObject(name: Em.String.i18n('user.email_digests.bi_weekly'), value: 14)
freqs
).property()
autoTrackDurations: (->
freqs = Em.A()
freqs.addObject(name: Em.String.i18n('user.auto_track_options.never'), value: -1)
freqs.addObject(name: Em.String.i18n('user.auto_track_options.always'), value: 0)
freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_seconds', count: 30), value: 30000)
freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 1), value: 60000)
freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 2), value: 120000)
freqs.addObject(name: Em.String.i18n('user.auto_track_options.after_n_minutes', count: 5), value: 300000)
freqs
).property()
save: ->
@set('saving', true)
# Cook the bio for preview
@get('content').save (result) =>
@set('saving', false)
if result
@set('content.bio_cooked', Discourse.Utilities.cook(@get('content.bio_raw')))
@set('saved', true)
else
alert 'failed'
saveButtonText: (->
return Em.String.i18n('saving') if @get('saving')
return Em.String.i18n('save')
).property('saving')
changePassword: ->
unless @get('passwordProgress')
@set('passwordProgress','(generating email)')
@get('content').changePassword (message)=>
@set('changePasswordProgress', false)
@set('passwordProgress', "(#{message})")

View File

@@ -0,0 +1,35 @@
Discourse.PreferencesEmailController = Ember.ObjectController.extend Discourse.Presence,
taken: false
saving: false
error: false
success: false
saveDisabled: (->
return true if @get('saving')
return true if @blank('newEmail')
return true if @get('taken')
return true if @get('unchanged')
).property('newEmail', 'taken', 'unchanged', 'saving')
unchanged: (->
@get('newEmail') == @get('content.email')
).property('newEmail', 'content.email')
initializeEmail: (->
@set('newEmail', @get('content.email'))
).observes('content.email')
saveButtonText: (->
return Em.String.i18n("saving") if @get('saving')
Em.String.i18n("user.change_email.action")
).property('saving')
changeEmail: ->
@set('saving', true)
@get('content').changeEmail(@get('newEmail')).then =>
@set('success', true)
, =>
# Error
@set('error', true)
@set('saving', false)

View File

@@ -0,0 +1,40 @@
Discourse.PreferencesUsernameController = Ember.ObjectController.extend Discourse.Presence,
taken: false
saving: false
error: false
saveDisabled: (->
return true if @get('saving')
return true if @blank('newUsername')
return true if @get('taken')
return true if @get('unchanged')
).property('newUsername', 'taken', 'unchanged', 'saving')
unchanged: (->
@get('newUsername') == @get('content.username')
).property('newUsername', 'content.username')
checkTaken: (->
@set('taken', false)
return if @blank('newUsername')
return if @get('unchanged')
Discourse.User.checkUsername(@get('newUsername')).then (result) =>
@set('taken', true) unless result.available
).observes('newUsername')
saveButtonText: (->
return Em.String.i18n("saving") if @get('saving')
Em.String.i18n("user.change_username.action")
).property('saving')
changeUsername: ->
bootbox.confirm Em.String.i18n("user.change_username.confirm"), Em.String.i18n("no_value"), Em.String.i18n("yes_value"), (result) =>
if result
@set('saving', true)
@get('content').changeUsername(@get('newUsername')).then =>
window.location = "/users/#{@get('newUsername').toLowerCase()}/preferences"
, =>
# Error
@set('error', true)
@set('saving', false)

View File

@@ -0,0 +1,70 @@
Discourse.QuoteButtonController = Discourse.Controller.extend
needs: ['topic', 'composer']
started: null
# If the buffer is cleared, clear out other state (post)
bufferChanged: (->
@set('post', null) if @blank('buffer')
).observes('buffer')
mouseDown: (e) ->
@started = [e.pageX, e.pageY]
mouseUp: (e) ->
if @started[1] > e.pageY
@started = [e.pageX, e.pageY]
selectText: (e) ->
return unless Discourse.get('currentUser')
return unless @get('controllers.topic.content.can_create_post')
selectedText = Discourse.Utilities.selectedText()
return if @get('buffer') == selectedText
return if @get('lastSelected') == selectedText
@set('post', e.context)
@set('buffer', selectedText)
top = e.pageY + 5
left = e.pageX + 5
$quoteButton = $('.quote-button')
if @started
top = @started[1] - 50
left = ((left - @started[0]) / 2) + @started[0] - ($quoteButton.width() / 2)
$quoteButton.css(top: top, left: left)
@started = null
false
quoteText: (e) ->
e.stopPropagation()
post = @get('post')
composerController = @get('controllers.composer')
composerOpts =
post: post
action: Discourse.Composer.REPLY
draftKey: @get('post.topic.draft_key')
# If the composer is associated with a different post, we don't change it.
if composerPost = composerController.get('content.post')
composerOpts.post = composerPost if (composerPost.get('id') != @get('post.id'))
buffer = @get('buffer')
quotedText = Discourse.BBCode.buildQuoteBBCode(post, buffer)
if composerController.wouldLoseChanges()
composerController.appendText(quotedText)
else
composerController.open(composerOpts).then =>
composerController.appendText(quotedText)
@set('buffer', '')
false

View File

@@ -0,0 +1,14 @@
Discourse.ShareController = Ember.Controller.extend
# When the user clicks the post number, we pop up a share box
shareLink: (e, url) ->
x = e.pageX - 150
x = 25 if x < 25
$('#share-link').css(left: "#{x}px", top: "#{e.pageY - 100}px")
@set('link', url)
false
# Close the share controller
close: ->
@set('link', '')
false

View File

@@ -0,0 +1,21 @@
Discourse.StaticController = Ember.Controller.extend
content: null
loadPath: (path) ->
@set('content', null)
# Load from <noscript> if we have it.
$preloaded = $("noscript[data-path=\"#{path}\"]")
if $preloaded.length
text = $preloaded.text()# + ""
text = text.replace(/\<header[\s\S]*\<\/header\>/, '')
@set('content', text)
else
jQuery.ajax
url: "#{path}.json"
success: (result) =>
@set('content', result)
Discourse.StaticController.reopenClass(pages: ['faq', 'tos', 'privacy'])

View File

@@ -0,0 +1,6 @@
Discourse.TopicAdminMenuController = Ember.ObjectController.extend
visible: false
show: -> @set('visible', true)
hide: -> @set('visible', false)

View File

@@ -0,0 +1,309 @@
Discourse.TopicController = Ember.ObjectController.extend Discourse.Presence,
# A list of usernames we want to filter by
userFilters: new Em.Set()
multiSelect: false
bestOf: false
showExtraHeaderInfo: false
needs: ['header', 'modal', 'composer', 'quoteButton']
filter: (->
return 'best_of' if @get('bestOf') == true
return 'user' if @get('userFilters').length > 0
return null
).property('userFilters.[]', 'bestOf')
filterDesc: (->
return null unless filter = @get('filter')
Em.String.i18n("topic.filters.#{filter}")
).property('filter')
selectedPosts: (->
return null unless posts = @get('content.posts')
posts.filterProperty('selected')
).property('content.posts.@each.selected')
selectedCount: (->
return 0 unless @get('selectedPosts')
@get('selectedPosts').length
).property('selectedPosts')
canMoveSelected: (->
return false unless @get('content.can_move_posts')
# For now, we can move it if we can delete it since the posts
# need to be deleted.
@get('canDeleteSelected')
).property('canDeleteSelected')
showExtraHeaderInfoChanged: (->
@set('controllers.header.showExtraInfo', @get('showExtraHeaderInfo'))
).observes('showExtraHeaderInfo')
canDeleteSelected: (->
selectedPosts = @get('selectedPosts')
return false unless selectedPosts and selectedPosts.length > 0
canDelete = true
selectedPosts.each (p) ->
unless p.get('can_delete')
canDelete = false
return false
canDelete
).property('selectedPosts')
multiSelectChanged: (->
# Deselect all posts when multi select is turned off
unless @get('multiSelect')
if posts = @get('content.posts')
posts.forEach (p) -> p.set('selected', false)
).observes('multiSelect')
hideProgress: (->
return true unless @get('content.loaded')
return true unless @get('currentPost')
return true unless @get('content.highest_post_number') > 1
@present('filter')
).property('filter', 'content.loaded', 'currentPost')
selectPost: (post) ->
post.toggleProperty('selected')
toggleMultiSelect: ->
@toggleProperty('multiSelect')
moveSelected: ->
@get('controllers.modal')?.show(Discourse.MoveSelectedView.create(topic: @get('content'), selectedPosts: @get('selectedPosts')))
deleteSelected: ->
bootbox.confirm Em.String.i18n("post.delete.confirm", count: @get('selectedCount')), (result) =>
if (result)
Discourse.Post.deleteMany(@get('selectedPosts'))
@get('content.posts').removeObjects(@get('selectedPosts'))
jumpTop: ->
Discourse.routeTo(@get('content.url'))
jumpBottom: ->
Discourse.routeTo(@get('content.lastPostUrl'))
cancelFilter: ->
@set('bestOf', false)
@get('userFilters').clear()
replyAsNewTopic: (post) ->
composerController = @get('controllers.composer')
#TODO shut down topic draft cleanly if it exists ...
promise = composerController.open
action: Discourse.Composer.CREATE_TOPIC
draftKey: Discourse.Composer.REPLY_AS_NEW_TOPIC_KEY
postUrl = "#{location.protocol}//#{location.host}#{post.get('url')}"
postLink = "[#{@get('title')}](#{postUrl})"
promise.then ->
Discourse.Post.loadQuote(post.get('id')).then (q) ->
composerController.appendText("#{Em.String.i18n("post.continue_discussion", postLink: postLink)}\n\n#{q}")
# Topic related
reply: ->
composerController = @get('controllers.composer')
composerController.open
topic: @get('content')
action: Discourse.Composer.REPLY
draftKey: @get('content.draft_key')
draftSequence: @get('content.draft_sequence')
toggleParticipant: (user) ->
@set('bestOf', false)
username = Em.get(user, 'username')
userFilters = @get('userFilters')
if userFilters.contains(username)
userFilters.remove(username)
else
userFilters.add(username)
false
enableBestOf: (e) ->
@set('bestOf', true)
@get('userFilters').clear()
false
showBestOf: (->
return false if @get('bestOf') == true
@get('content.has_best_of') == true
).property('bestOf', 'content.has_best_of')
postFilters: (->
return {bestOf: true} if @get('bestOf') == true
return {userFilters: @get('userFilters')}
).property('userFilters.[]', 'bestOf')
reloadTopics: (->
topic = @get('content')
return unless topic
posts = topic.get('posts')
return unless posts
posts.clear()
@set('content.loaded', false)
Discourse.Topic.find(@get('content.id'), @get('postFilters')).then (result) =>
first = result.posts.first()
@set('currentPost', first.post_number) if first
$('#topic-progress .solid').data('progress', false)
result.posts.each (p) =>
posts.pushObject(Discourse.Post.create(p, topic))
@set('content.loaded', true)
).observes('postFilters')
deleteTopic: (e) ->
@unsubscribe()
@get('content').delete =>
@set('message', "The topic has been deleted")
@set('loaded', false)
toggleVisibility: ->
@get('content').toggleStatus('visible')
toggleClosed: ->
@get('content').toggleStatus('closed')
togglePinned: ->
@get('content').toggleStatus('pinned')
toggleArchived: ->
@get('content').toggleStatus('archived')
convertToRegular: ->
@get('content').convertArchetype('regular')
startTracking: ->
screenTrack = Discourse.ScreenTrack.create(topic_id: @get('content.id'))
screenTrack.start()
@set('content.screenTrack', screenTrack)
stopTracking: ->
@get('content.screenTrack')?.stop()
@set('content.screenTrack', null)
# Toggle the star on the topic
toggleStar: (e) ->
@get('content').toggleStar()
# Receive notifications for this topic
subscribe: ->
bus = Discourse.MessageBus
# there is a condition where the view never calls unsubscribe, navigate to a topic from a topic
bus.unsubscribe('/topic/*')
bus.subscribe "/topic/#{@get('content.id')}", (data) =>
topic = @get('content')
if data.notification_level_change
topic.set('notification_level', data.notification_level_change)
topic.set('notifications_reason_id', data.notifications_reason_id)
return
posts = topic.get('posts')
return if posts.some (p) -> p.get('post_number') == data.post_number
topic.set 'posts_count', topic.get('posts_count') + 1
topic.set 'highest_post_number', data.post_number
topic.set 'last_poster', data.user
topic.set 'last_posted_at', data.created_at
Discourse.notifyTitle()
unsubscribe: ->
topicId = @get('content.id')
return unless topicId
bus = Discourse.MessageBus
bus.unsubscribe("/topic/#{topicId}")
# Post related methods
replyToPost: (post) ->
composerController = @get('controllers.composer')
quoteController = @get('controllers.quoteButton')
quotedText = Discourse.BBCode.buildQuoteBBCode(quoteController.get('post'), quoteController.get('buffer'))
quoteController.set('buffer', '')
if (composerController.get('content.topic.id') == post.get('topic.id') and composerController.get('content.action') == Discourse.Composer.REPLY)
composerController.set('content.post', post)
composerController.set('content.composeState', Discourse.Composer.OPEN)
composerController.appendText(quotedText)
else
promise = composerController.open
post: post
action: Discourse.Composer.REPLY
draftKey: post.get('topic.draft_key')
draftSequence: post.get('topic.draft_sequence')
promise.then =>
composerController.appendText(quotedText)
false
# Edits a post
editPost: (post) ->
@get('controllers.composer').open
post: post
action: Discourse.Composer.EDIT
draftKey: post.get('topic.draft_key')
draftSequence: post.get('topic.draft_sequence')
toggleBookmark: (post) ->
unless Discourse.get('currentUser')
alert Em.String.i18n("bookmarks.not_bookmarked")
return
post.toggleProperty('bookmarked')
false
# Who acted on a particular post / action type
whoActed: (actionType) ->
actionType.loadUsers()
false
like:(e) ->
like_action = Discourse.get('site.post_action_types').findProperty('name_key', 'like')
e.context.act(like_action.get('id'))
# log a post action towards this post
act: (action) ->
action.act()
false
undoAction: (action) ->
action.undo()
false
showPrivateInviteModal: ->
modal = Discourse.InvitePrivateModalView.create(topic: @get('content'))
@get('controllers.modal')?.show(modal)
false
showInviteModal: ->
@get('controllers.modal')?.show(Discourse.InviteModalView.create(topic: @get('content')))
false
# Clicked the flag button
showFlags: (post) ->
flagView = Discourse.FlagView.create(post: post, controller: @)
@get('controllers.modal')?.show(flagView)
showHistory: (post) ->
view = Discourse.HistoryView.create(originalPost: post)
@get('controllers.modal')?.show(view)
false
deletePost: (post) ->
deleted = !!post.get('deleted_at')
if deleted
post.set('deleted_at', null)
else
post.set('deleted_at', new Date())
post.delete =>
# nada

View File

@@ -0,0 +1,15 @@
Discourse.UserActivityController = Ember.ObjectController.extend
needs: ['composer']
kickOffPrivateMessage: ( ->
if @get('content.openPrivateMessage')
@composePrivateMessage()
).observes('content.openPrivateMessage')
composePrivateMessage: ->
@get('controllers.composer').open
action: Discourse.Composer.PRIVATE_MESSAGE
usernames: @get('content').username
archetypeId: 'private_message'
draftKey: 'new_private_message'

View File

@@ -0,0 +1,9 @@
Discourse.UserController = Ember.ObjectController.extend
viewingSelf: (->
@get('content.username') == Discourse.get('currentUser.username')
).property('content.username', 'Discourse.currentUser.username')
canSeePrivateMessages: (->
@get('viewingSelf') || Discourse.get('currentUser.admin')
).property('viewingSelf', 'Discourse.currentUser')

View File

@@ -0,0 +1,5 @@
Discourse.UserInvitedController = Ember.ObjectController.extend
rescind: (invite) ->
invite.rescind()
false

View File

@@ -0,0 +1,11 @@
Discourse.UserPrivateMessagesController = Ember.ObjectController.extend
editPreferences: ->
Discourse.routeTo("/users/#{@get('content.username_lower')}/preferences")
composePrivateMessage: ->
composerController = Discourse.get('router.composerController')
composerController.open
action: Discourse.Composer.PRIVATE_MESSAGE
archetypeId: 'private_message'
draftKey: 'new_private_message'

View File

@@ -0,0 +1,128 @@
Handlebars.registerHelper 'breakUp', (property, options) ->
prop = Ember.Handlebars.get(this, property, options)
return "" unless prop
tokens = prop.match(RegExp(".{1,14}",'g'))
return prop if tokens.length == 1
result = ""
tokens.each (token, index) ->
result += token
if token.indexOf(' ') == -1 and (index < tokens.length - 1)
result += "- "
result
Handlebars.registerHelper 'shorten', (property, options) ->
str = Ember.Handlebars.get(this, property, options)
str.truncate(35)
Handlebars.registerHelper 'topicLink', (property, options) ->
topic = Ember.Handlebars.get(this, property, options)
"<a href='#{topic.get('lastReadUrl')}' class='title excerptable'>#{Handlebars.Utils.escapeExpression(topic.get('title'))}</a>"
Handlebars.registerHelper 'categoryLink', (property, options) ->
category = Ember.Handlebars.get(this, property, options)
new Handlebars.SafeString(Discourse.Utilities.categoryLink(category))
Handlebars.registerHelper 'titledLinkTo', (name, object) ->
options = [].slice.call(arguments, -1)[0]
if options.hash.titleKey
options.hash.title = Em.String.i18n(options.hash.titleKey)
if arguments.length is 3
Ember.Handlebars.helpers.linkTo.call(this, name, object, options)
else
Ember.Handlebars.helpers.linkTo.call(this, name, options)
Handlebars.registerHelper 'shortenUrl', (property, options) ->
url = Ember.Handlebars.get(this, property, options)
# Remove trailing slash if it's a top level URL
url = url.replace(/\/$/, '') if url.match(/\//g).length == 3
url = url.replace(/^https?:\/\//, '')
url = url.replace(/^www\./, '')
url.truncate(80)
Handlebars.registerHelper 'lower', (property, options) ->
o = Ember.Handlebars.get(this, property, options)
if o && typeof o == 'string'
o.toLowerCase()
else
""
Handlebars.registerHelper 'avatar', (user, options) ->
user = Ember.Handlebars.get(this, user, options) if typeof user is 'string'
username = Em.get(user, 'username')
username ||= Em.get(user, options.hash.usernamePath)
new Handlebars.SafeString Discourse.Utilities.avatarImg(
size: options.hash.imageSize
extraClasses: Em.get(user, 'extras') || options.hash.extraClasses
username: username
title: Em.get(user, 'title') || Em.get(user, 'description')
avatarTemplate: Ember.get(user, 'avatar_template') || options.hash.avatarTemplate
)
Handlebars.registerHelper 'unboundDate', (property, options) ->
dt = new Date(Ember.Handlebars.get(this, property, options))
month = Date.SugarMethods.getLocale.method().months[12 + dt.getMonth()]
"#{dt.getDate()} #{month}, #{dt.getFullYear()} #{dt.getHours()}:#{dt.getMinutes()}"
Handlebars.registerHelper 'editDate', (property, options) ->
dt = Date.create(Ember.Handlebars.get(this, property, options))
yesterday = new Date() - (60 * 60 * 24 * 1000)
if yesterday > dt.getTime()
dt.format("{d} {Mon}, {yyyy} {hh}:{mm}")
else
humaneDate(dt)
Handlebars.registerHelper 'number', (property, options) ->
orig = parseInt(Ember.Handlebars.get(this, property, options))
orig = 0 if isNaN(orig)
title = orig
if options.hash.numberKey
title = Em.String.i18n(options.hash.numberKey, number: orig)
# Round off the thousands to one decimal place
n = orig
n = (orig / 1000).toFixed(1) + "K" if orig > 999
new Handlebars.SafeString("<span class='number' title='#{title}'>#{n}</span>")
Handlebars.registerHelper 'date', (property, options) ->
if property.hash
leaveAgo = property.hash.leaveAgo == "true" if property.hash.leaveAgo
property = property.hash.path if property.hash.path
val = Ember.Handlebars.get(this, property, options)
return new Handlebars.SafeString("&mdash;") unless val
dt = new Date(val)
fullReadable = dt.format("{d} {Mon}, {yyyy} {hh}:{mm}")
displayDate = ""
fiveDaysAgo = ((new Date()) - 432000000) # 5 * 1000 * 60 * 60 * 24 - optimised 5 days ago
if fiveDaysAgo > (dt.getTime())
if (new Date()).getFullYear() != dt.getFullYear()
displayDate = dt.format("{d} {Mon} '{yy}")
else
displayDate = dt.format("{d} {Mon}")
else
humanized = humaneDate(dt)
return "" unless humanized
displayDate = humanized
displayDate = displayDate.replace(' ago', '') unless leaveAgo
new Handlebars.SafeString("<span class='date' title='#{fullReadable}'>#{displayDate}</span>")

View File

@@ -0,0 +1,25 @@
Ember.Handlebars.registerHelper 'i18n', (property, options) ->
# Resolve any properties
params = options.hash
Object.keys params, (key, value) =>
params[key] = Em.Handlebars.get(this, value, options)
Ember.String.i18n(property, params)
# We always prefix with .js to select exactly what we want passed through to the front end.
Ember.String.i18n = (scope, options) ->
I18n.translate("js.#{scope}", options)
# Bind an i18n count
Ember.Handlebars.registerHelper 'countI18n', (key, options) ->
view = Em.View.extend
tagName: 'span'
render: (buffer) -> buffer.push(Ember.String.i18n(key, count: @get('count')))
countChanged: (-> @rerender() ).observes('count')
Ember.Handlebars.helpers.view.call(this, view, options)
if Ember.EXTEND_PROTOTYPES
String.prototype.i18n = (options) ->
return Ember.String.i18n(String(this), options)

View File

@@ -0,0 +1,15 @@
window.Discourse.Presence = Em.Mixin.create
# Is a property blank?
blank: (name) ->
prop = @get(name)
return true unless prop
switch typeof(prop)
when "string"
return prop.trim().isBlank()
when "object"
return Object.isEmpty(prop)
false
present: (name) -> not @blank(name)

View File

@@ -0,0 +1,15 @@
# Use this mixin if you want to be notified every time the user scrolls the window
window.Discourse.Scrolling = Em.Mixin.create
bindScrolling: ->
onScroll = Discourse.debounce(=>
@scrolled()
, 100)
$(document).bind 'touchmove.discourse', onScroll
$(window).bind 'scroll.discourse', onScroll
unbindScrolling: ->
$(window).unbind 'scroll.discourse'
$(document).unbind 'touchmove.discourse'

View File

@@ -0,0 +1,67 @@
window.Discourse.ActionSummary = Em.Object.extend Discourse.Presence,
# Description for the action
description: (->
if @get('acted')
Em.String.i18n('post.actions.by_you_and_others', count: @get('count') - 1, long_form: @get('actionType.long_form'))
else
Em.String.i18n('post.actions.by_others', count: @get('count'), long_form: @get('actionType.long_form'))
).property('count', 'acted', 'actionType')
canAlsoAction: (->
return false if @get('hidden')
return @get('can_act')
).property('can_act', 'hidden')
# Remove it
removeAction: ->
@set('acted', false)
@set('count', @get('count') - 1)
@set('can_act', true)
@set('can_undo', false)
# Perform this action
act: (opts) ->
# Mark it as acted
@set('acted', true)
@set('count', @get('count') + 1)
@set('can_act', false)
@set('can_undo', true)
#TODO: mark all other flag types as acted
# Add ourselves to the users who liked it if present
@users.pushObject(Discourse.get('currentUser')) if @present('users')
# Create our post action
jQuery.ajax
url: "/post_actions",
type: 'POST'
data:
id: @get('post.id')
post_action_type_id: @get('id')
message: opts?.message || ""
error: (error) =>
@removeAction()
errors = jQuery.parseJSON(error.responseText).errors
bootbox.alert(errors[0])
# Undo this action
undo: ->
@removeAction()
# Remove our post action
jQuery.ajax
url: "/post_actions/#{@get('post.id')}"
type: 'DELETE'
data:
post_action_type_id: @get('id')
loadUsers: ->
$.getJSON "/post_actions/users",
id: @get('post.id'),
post_action_type_id: @get('id')
(result) =>
@set('users', Em.A())
result.each (u) => @get('users').pushObject(Discourse.User.create(u))

View File

@@ -0,0 +1,11 @@
window.Discourse.Archetype = Discourse.Model.extend
hasOptions: (->
return false unless @get('options')
@get('options').length > 0
).property('options.@each')
isDefault: (->
@get('id') == Discourse.get('site.default_archetype')
).property('id')

View File

@@ -0,0 +1,31 @@
window.Discourse.Category = Discourse.Model.extend
url: (->
"/category/#{@get('slug')}"
).property('name')
style: (->
"background-color: ##{@get('color')}"
).property('color')
moreTopics: (->
return @get('topic_count') > <%= SiteSetting.category_featured_topics %>
).property('topic_count')
save: (args) ->
url = "/categories"
url = "/categories/#{@get('id')}" if @get('id')
@ajax url,
data:
name: @get('name')
color: @get('color')
type: if @get('id') then 'PUT' else 'POST'
success: (result) => args.success(result)
error: (errors) => args.error(errors)
delete: (callback) ->
$.ajax "/categories/#{@get('slug')}",
type: 'DELETE'
success: => callback()

View File

@@ -0,0 +1,29 @@
window.Discourse.CategoryList = Discourse.Model.extend({})
window.Discourse.CategoryList.reopenClass
categoriesFrom: (result) ->
categories = Em.A()
users = @extractByKey(result.featured_users, Discourse.User)
result.category_list.categories.each (c) ->
if c.featured_user_ids
c.featured_users = c.featured_user_ids.map (u) -> users[u]
if c.topics
c.topics = c.topics.map (t) -> Discourse.Topic.create(t)
categories.pushObject(Discourse.Category.create(c))
categories
list: (filter) ->
promise = new RSVP.Promise()
jQuery.getJSON("/#{filter}.json").then (result) =>
categoryList = Discourse.TopicList.create()
categoryList.set('can_create_category', result.category_list.can_create_category)
categoryList.set('categories', @categoriesFrom(result))
categoryList.set('loaded', true)
promise.resolve(categoryList)
promise

View File

@@ -0,0 +1,422 @@
# The status the compose view can have
CLOSED = 'closed'
SAVING = 'saving'
OPEN = 'open'
DRAFT = 'draft'
# The actions the composer can take
CREATE_TOPIC = 'createTopic'
PRIVATE_MESSAGE = 'privateMessage'
REPLY = 'reply'
EDIT = 'edit'
REPLY_AS_NEW_TOPIC_KEY = "reply_as_new_topic"
window.Discourse.Composer = Discourse.Model.extend
init: ->
@_super()
val = (Discourse.KeyValueStore.get('composer.showPreview') or 'true')
@set('showPreview', val is 'true')
@set 'archetypeId', Discourse.get('site.default_archetype')
archetypesBinding: 'Discourse.site.archetypes'
creatingTopic: (-> @get('action') == CREATE_TOPIC ).property('action')
creatingPrivateMessage: (-> @get('action') == PRIVATE_MESSAGE ).property('action')
editingPost: (-> @get('action') == EDIT).property('action')
viewOpen: (-> @get('composeState') == OPEN ).property('composeState')
archetype: (->
@get('archetypes').findProperty('id', @get('archetypeId'))
).property('archetypeId')
archetypeChanged: (->
@set('metaData', Em.Object.create())
).observes('archetype')
editTitle: (->
return true if @get('creatingTopic') || @get('creatingPrivateMessage')
return true if @get('editingPost') and @get('post.post_number') == 1
false
).property('editingPost', 'creatingTopic', 'post.post_number')
togglePreview: ->
@toggleProperty('showPreview')
Discourse.KeyValueStore.set(key: 'showPreview', value: @get('showPreview'))
# Import a quote from the post
importQuote: ->
post = @get('post')
unless post
posts = @get('topic.posts')
post = posts[0] if posts && posts.length > 0
if post
@set('loading', true)
Discourse.Post.load post.get('id'), (result) =>
quotedText = Discourse.BBCode.buildQuoteBBCode(post, result.get('raw'))
@appendText(quotedText)
@set('loading', false)
appendText: (text)->
@set 'reply', (@get('reply') || '') + text
# Determine the appropriate title for this action
actionTitle: (->
topic = @get('topic')
postNumber = @get('post.post_number')
if topic
postLink = "<a href='#{topic.get('url')}/#{postNumber}'>post #{postNumber}</a>"
switch @get('action')
when PRIVATE_MESSAGE
Em.String.i18n('topic.private_message')
when CREATE_TOPIC
Em.String.i18n('topic.create_long')
when REPLY
if @get('post')
replyAvatar = Discourse.Utilities.avatarImg(
username: @get('post.username'),
size: 'tiny'
)
Em.String.i18n('post.reply', link: postLink, replyAvatar: replyAvatar, username: @get('post.username'))
else if topic
topicLink = "<a href='#{topic.get('url')}'> #{Handlebars.Utils.escapeExpression(topic.get('title'))}</a>"
Em.String.i18n('post.reply_topic', link: topicLink)
when EDIT
Em.String.i18n('post.edit', link: postLink)
).property('action', 'post', 'topic', 'topic.title')
toggleText: (->
return Em.String.i18n('composer.hide_preview') if @get('showPreview')
Em.String.i18n('composer.show_preview')
).property('showPreview')
hidePreview: (-> not @get('showPreview') ).property('showPreview')
# Whether to disable the post button
cantSubmitPost: (->
# Can't submit while loading
return true if @get('loading')
# Title is required on new posts
if @get('creatingTopic')
return true if @blank('title')
return true if @get('title').trim().length < Discourse.SiteSettings.min_topic_title_length
# Otherwise just reply is required
return true if @blank('reply')
return true if @get('reply').trim().length < Discourse.SiteSettings.min_post_length
false
).property('reply', 'title', 'creatingTopic', 'loading')
# The text for the save button
saveText: (->
switch @get('action')
when EDIT then Em.String.i18n('composer.save_edit')
when REPLY then Em.String.i18n('composer.reply')
when CREATE_TOPIC then Em.String.i18n('composer.create_topic')
when PRIVATE_MESSAGE then Em.String.i18n('composer.create_pm')
).property('action')
hasMetaData: (->
metaData = @get('metaData')
return false unless @get('metaData')
return Em.empty(Em.keys(@get('metaData')))
).property('metaData')
wouldLoseChanges: ()->
@get('reply') != @get('originalText') # TODO title check as well
# Open a composer
#
# opts:
# action - The action we're performing: edit, reply or createTopic
# post - The post we're replying to, if present
# topic - The topic we're replying to, if present
# quote - If we're opening a reply from a quote, the quote we're making
#
open: (opts={}) ->
@set('loading', false)
topicId = opts.topic.get('id') if opts.topic
replyBlank = (@get("reply") || "") == ""
if !replyBlank && (opts.action != @get('action') || ((opts.reply || opts.action == @EDIT) && @get('reply') != @get('originalText'))) && !opts.tested
opts.tested = true
@cancel(=> @open(opts))
return
@set 'draftKey', opts.draftKey
@set 'draftSequence', opts.draftSequence
throw 'draft key is required' unless opts.draftKey
throw 'draft sequence is required' if opts.draftSequence == null
@set 'composeState', opts.composerState || OPEN
@set 'action', opts.action
@set 'topic', opts.topic
@set 'targetUsernames', opts.usernames
if opts.post
@set 'post', opts.post
@set 'topic', opts.post.get('topic') unless @get('topic')
@set('categoryName', opts.categoryName || @get('topic.category.name'))
@set('archetypeId', opts.archetypeId || Discourse.get('site.default_archetype'))
@set('metaData', if opts.metaData then Em.Object.create(opts.metaData) else null)
@set('reply', opts.reply || @get("reply") || "")
if opts.postId
@set('loading', true)
Discourse.Post.load opts.postId, (result) =>
@set('post', result)
@set('loading', false)
# If we are editing a post, load it.
if opts.action == EDIT and opts.post
@set 'title', @get('topic.title')
@set('loading', true)
Discourse.Post.load opts.post.get('id'), (result) =>
@set 'reply', result.get('raw')
@set('originalText', @get('reply'))
@set('loading', false)
if opts.title
@set('title', opts.title)
if opts.draft
@set('originalText', '')
else if opts.reply
@set('originalText', @get('reply'))
false
save: (opts)->
if @get('editingPost')
@editPost(opts)
else
@createPost(opts)
# When you edit a post
editPost: (opts)->
promise = new RSVP.Promise
post = @get('post')
oldCooked = post.get('cooked')
# Update the title if we've changed it
if @get('title') and post.get('post_number') == 1
topic = @get('topic')
topic.set('title', @get('title'))
topic.set('categoryName', @get('categoryName'))
topic.save()
post.set('raw', @get('reply'))
post.set('imageSizes', opts.imageSizes)
post.set('cooked', $('#wmd-preview').html())
@set('composeState', CLOSED)
post.save (savedPost) =>
posts = @get('topic.posts')
# perhaps our post came from elsewhere eg. draft
idx = -1
postNumber = post.get('post_number')
posts.each (p,i)->
idx = i if p.get('post_number') == postNumber
if idx > -1
savedPost.set('topic', @get('topic'))
posts.replace(idx, 1, [savedPost])
promise.resolve(post: post)
@set('topic.draft_sequence', savedPost.draft_sequence)
, (error) =>
errors = jQuery.parseJSON(error.responseText).errors
promise.reject(errors[0])
post.set('cooked', oldCooked)
@set('composeState', OPEN)
promise
# Create a new Post
createPost: (opts)->
promise = new RSVP.Promise
post = @get('post')
topic = @get('topic')
createdPost = Discourse.Post.create
raw: @get('reply')
title: @get('title')
category: @get('categoryName')
topic_id: @get('topic.id')
reply_to_post_number: if post then post.get('post_number') else null
imageSizes: opts.imageSizes
post_number: @get('topic.highest_post_number') + 1
cooked: $('#wmd-preview').html()
reply_count: 0
display_username: Discourse.get('currentUser.name')
username: Discourse.get('currentUser.username')
metaData: @get('metaData')
archetype: @get('archetypeId')
post_type: Discourse.Post.REGULAR_TYPE
target_usernames: @get('targetUsernames')
actions_summary: Em.A()
yours: true
newPost: true
addedToStream = false
# If we're in a topic, we can append the post instantly.
if topic
# Increase the reply count
if post
post.set('reply_count', (post.get('reply_count') || 0) + 1)
# Supress replies
if (post.get('reply_count') == 1 && createdPost.get('cooked').length < Discourse.SiteSettings.max_length_show_reply)
post.set('replyFollowing', true)
post.set('reply_below_post_number', createdPost.get('post_number'))
topic.set('posts_count', topic.get('posts_count') + 1)
# Update last post
topic.set('last_posted_at', new Date())
topic.set('highest_post_number', createdPost.get('post_number'))
topic.set('last_poster', Discourse.get('currentUser'))
# Set the topic view for the new post
createdPost.set('topic', topic)
createdPost.set('created_at', new Date())
# If we're near the end of the topic, load new posts
lastPost = topic.posts.last()
if lastPost
diff = topic.get('highest_post_number') - lastPost.get('post_number')
# If the new post is within a threshold of the end of the topic,
# add it and scroll there instead of adding the link.
if diff < 5
createdPost.set('scrollToAfterInsert', createdPost.get('post_number'))
topic.pushPosts([createdPost])
addedToStream = true
# Save callback
createdPost.save (result) =>
addedPost = false
saving = true
createdPost.updateFromSave(result)
if topic
# It's no longer a new post
createdPost.set('newPost', false)
topic.set('draft_sequence', result.draft_sequence)
else
# We created a new topic, let's show it.
@set('composeState', CLOSED)
saving = false
@set('reply', '')
@set('createdPost', createdPost)
if addedToStream
@set('composeState', CLOSED)
else if saving
@set('composeState', SAVING)
promise.resolve(post: result)
, (error) =>
topic.posts.removeObject(createdPost) if topic
errors = jQuery.parseJSON(error.responseText).errors
promise.reject(errors[0])
@set('composeState', OPEN)
promise
saveDraft: ->
return if @disableDrafts
data =
reply: @get("reply"),
action: @get("action"),
title: @get("title"),
categoryName: @get("categoryName"),
postId: @get("post.id"),
archetypeId: @get('archetypeId')
metaData: @get('metaData')
usernames: @get('targetUsernames')
@set('draftStatus', Em.String.i18n('composer.saving_draft_tip'))
Discourse.Draft.save(@get('draftKey'), @get('draftSequence'), data)
.then(
(=> @set('draftStatus', Em.String.i18n('composer.saved_draft_tip'))),
(=> @set('draftStatus', 'drafts offline'))
# (=> @set('draftStatus', Em.String.i18n('composer.saved_local_draft_tip')))
)
resetDraftStatus: (->
@set('draftStatus', null)
).observes('reply','title')
blank: (prop)->
p = @get(prop)
!(p && p.length > 0)
Discourse.Composer.reopenClass
open: (opts) ->
composer = Discourse.Composer.create()
composer.open(opts)
composer
loadDraft: (draftKey, draftSequence, draft, topic) ->
try
draft = JSON.parse(draft) if draft && typeof draft == 'string'
catch error
draft = null
Discourse.Draft.clear(draftKey, draftSequence)
if draft && ((draft.title && draft.title != '') || (draft.reply && draft.reply != ''))
composer = @open
draftKey: draftKey
draftSequence: draftSequence
topic: topic
action: draft.action
title: draft.title
categoryName: draft.categoryName
postId: draft.postId
archetypeId: draft.archetypeId
reply: draft.reply
metaData: draft.metaData
usernames: draft.usernames
draft: true
composerState: DRAFT
composer
# The status the compose view can have
CLOSED: CLOSED
SAVING: SAVING
OPEN: OPEN
DRAFT: DRAFT
# The actions the composer can take
CREATE_TOPIC: CREATE_TOPIC
PRIVATE_MESSAGE: PRIVATE_MESSAGE
REPLY: REPLY
EDIT: EDIT
# Draft key
REPLY_AS_NEW_TOPIC_KEY: REPLY_AS_NEW_TOPIC_KEY

View File

@@ -0,0 +1,51 @@
window.Discourse.Draft = Discourse.Model.extend({})
Discourse.Draft.reopenClass
clear: (key, sequence)->
$.ajax
type: 'DELETE'
url: "/draft",
data: {draft_key: key, sequence: sequence}
# Discourse.KeyValueStore.remove("draft_#{key}")
get: (key) ->
promise = new RSVP.Promise
$.ajax
url: '/draft'
data: {draft_key: key}
dataType: 'json'
success: (data) =>
promise.resolve(data)
promise
getLocal: (key, current) ->
return current
# disabling for now to see if it helps with siracusa issue.
local = Discourse.KeyValueStore.get("draft_#{key}")
if !current || (local && local.length > current.length)
local
else
current
save: (key, sequence, data) ->
promise = new RSVP.Promise()
data = if typeof data == "string" then data else JSON.stringify(data)
$.ajax
type: 'POST'
url: "/draft",
data: {draft_key: key, data: data, sequence: sequence}
success: ->
# don't keep local
# Discourse.KeyValueStore.remove("draft_#{key}")
promise.resolve()
error: ->
# save local
# Discourse.KeyValueStore.set(key: "draft_#{key}", value: data)
promise.reject()
promise

View File

@@ -0,0 +1 @@
window.Discourse.InputValidation = Discourse.Model.extend({})

View File

@@ -0,0 +1,17 @@
window.Discourse.Invite = Discourse.Model.extend
rescind: ->
$.ajax '/invites'
type: 'DELETE'
data: {email: @get('email')}
@set('rescinded', true)
window.Discourse.Invite.reopenClass
create: (invite) ->
result = @_super(invite)
result.user = Discourse.User.create(result.user) if result.user
result

View File

@@ -0,0 +1,19 @@
window.Discourse.InviteList = Discourse.Model.extend Discourse.Presence,
empty: (->
return @blank('pending') and @blank('redeemed')
).property('pending.@each', 'redeemed.@each')
window.Discourse.InviteList.reopenClass
findInvitedBy: (user) ->
promise = new RSVP.Promise()
$.ajax
url: "/users/#{user.get('username_lower')}/invited.json"
success: (result) ->
invitedList = result.invited_list
invitedList.pending = (invitedList.pending.map (i) -> Discourse.Invite.create(i)) if invitedList.pending
invitedList.redeemed = (invitedList.redeemed.map (i) -> Discourse.Invite.create(i)) if invitedList.redeemed
invitedList.user = user
promise.resolve(Discourse.InviteList.create(invitedList))
promise

View File

@@ -0,0 +1,41 @@
Discourse.Mention = (->
localCache = {}
cache = (name, valid) ->
localCache[name] = valid
return
lookupCache = (name) ->
localCache[name]
lookup = (name, callback) ->
cached = lookupCache(name)
if cached == true || cached == false
callback(cached)
return false
else
$.get "/users/is_local_username", username: name, (r) ->
cache(name,r.valid)
callback(r.valid)
return true
load = (e) ->
$elem = $(e)
return if $elem.data('mention-tested')
username = $elem.text()
username = username.substr(1)
loading = lookup username, (valid) ->
if valid
$elem.replaceWith("<a href='/users/#{username.toLowerCase()}' class='mention'>@#{username}</a>")
else
$elem.removeClass('mention-loading').addClass('mention-tested')
$elem.addClass('mention-loading') if loading
load: load
lookup: lookup
lookupCache: lookupCache
)()

View File

@@ -0,0 +1,36 @@
window.Discourse.Model = Ember.Object.extend
# Our own AJAX handler that handles erronous responses
ajax: (url, args) ->
# Error handler
oldError = args.error
args.error = (xhr) =>
oldError($.parseJSON(xhr.responseText).errors)
$.ajax(url, args)
# Update our object from another object
mergeAttributes: (attrs, builders) ->
Object.keys attrs, (k, v) =>
# If they're in a builder we use that
if typeof(v) == 'object' and builders and builder = builders[k]
@set(k, Em.A()) unless @get(k)
col = @get(k)
v.each (obj) -> col.pushObject(builder.create(obj))
else
@set(k, v)
window.Discourse.Model.reopenClass
# Given an array of values, return them in a hash
extractByKey: (collection, klass) ->
retval = {}
return retval unless collection
collection.each (c) ->
obj = klass.create(c)
retval[c.id] = obj
retval

View File

@@ -0,0 +1,49 @@
# closure wrapping means this does not leak into global context
validNavNames = ['read','popular','categories', 'favorited', 'category', 'unread', 'new', 'posted']
validAnon = ['popular', 'category', 'categories']
window.Discourse.NavItem = Em.Object.extend
categoryName: (->
split = @get('name').split('/')
if (split[0] == 'category')
split[1]
else
null
).property()
href: (->
# href from this item
name = @get('name')
if name == 'category'
"/#{name}/#{@get('categoryName')}"
else
"/#{name}"
).property()
Discourse.NavItem.reopenClass
# create a nav item from the text, will return null if there is not valid nav item for this particular text
fromText: (text, opts) ->
countSummary = opts["countSummary"]
loggedOn = opts["loggedOn"]
hasCategories = opts["hasCategories"]
split = text.split(",")
name = split[0]
testName = name.split("/")[0] # to handle category ...
return null if !loggedOn && !validAnon.contains(testName)
return null if !hasCategories && testName == "categories"
return null unless validNavNames.contains(testName)
opts =
name: name
hasIcon: name == "unread" || name == "favorited"
filters: split.splice(1)
if countSummary
opts["count"] = countSummary[name] if countSummary && countSummary[name]
Discourse.NavItem.create opts

View File

@@ -0,0 +1,27 @@
window.Discourse.Notification = Discourse.Model.extend Discourse.Presence,
readClass: (->
if @read then 'read' else ''
).property('read')
url: (->
return "" if @blank('data.topic_title')
slug = @get('slug')
"/t/#{slug}/#{@get('topic_id')}/#{@get('post_number')}"
).property()
rendered: (->
notificationName = Discourse.get('site.notificationLookup')[@notification_type]
Em.String.i18n "notifications.#{notificationName}",
username: @data.display_username
link: "<a href='#{@get('url')}'>#{@data.topic_title}</a>"
).property()
window.Discourse.Notification.reopenClass
create: (obj) ->
result = @_super(obj)
result.set('data', Em.Object.create(obj.data)) if obj.data
result

View File

@@ -0,0 +1,48 @@
Discourse.Onebox = (->
# for now it only stores in a var, in future we can change it so it uses localStorage,
# trouble with localStorage is that expire semantics need some thinking
#cacheKey = "__onebox__"
localCache = {}
cache = (url, contents) ->
localCache[url] = contents
#if localStorage && localStorage.setItem
# localStorage.setItme
null
lookupCache = (url) ->
localCache[url]
lookup = (url, refresh, callback) ->
cached = lookupCache(url) unless refresh
if cached
callback(cached)
return false
else
$.get "/onebox", url: url, refresh: refresh, (html) ->
cache(url,html)
callback(html)
return true
load = (e, refresh=false) ->
url = e.href
$elem = $(e)
return if $elem.data('onebox-loaded')
loading = lookup url, refresh, (html) ->
$elem.removeClass('loading-onebox')
$elem.data('onebox-loaded')
return unless html
return unless html.trim().length > 0
$elem.replaceWith(html)
$elem.addClass('loading-onebox') if loading
load: load
lookup: lookup
lookupCache: lookupCache
)()

View File

@@ -0,0 +1,242 @@
window.Discourse.Post = Ember.Object.extend Discourse.Presence,
# Url to this post
url: (->
Discourse.Utilities.postUrl(@get('topic.slug') || @get('topic_slug'), @get('topic_id'), @get('post_number'))
).property('post_number', 'topic_id', 'topic.slug')
originalPostUrl: (->
"/t/#{@get('topic_id')}/#{@get('reply_to_post_number')}"
).property('reply_to_post_number')
showUserReplyTab: (->
@get('reply_to_user') and (@get('reply_to_post_number') < (@get('post_number') - 1))
).property('reply_to_user', 'reply_to_post_number', 'post_number')
firstPost: (->
return true if @get('bestOfFirst') == true
@get('post_number') == 1
).property('post_number')
hasHistory: (-> @get('version') > 1 ).property('version')
postElementId: (-> "post_#{@get('post_number')}").property()
# We only want to link to replies below if there's exactly one reply and it's below
replyBelowUrlComputed: (->
return null unless @get('reply_below_post_number')
return null if @get('reply_count') > 1
topic = @get('topic')
return unless topic
p = @get('reply_below_post_number')
diff = @get('reply_below_post_number') - @get('post_number')
return topic.urlForPostNumber(p) if (diff < 3)
null
).property('topic', 'reply_below_post_number')
# We do this because right now you can't subclass a computed property and we want to add
# plugin support. Later we should consider just subclassing it properly.
replyBelowUrl: (->
@get('replyBelowUrlComputed')
).property('replyBelowUrlComputed')
# The class for the read icon of the post. It starts with read-icon then adds 'seen' or
# 'last-read' if the post has been seen or is the highest post number seen so far respectively.
bookmarkClass: (->
result = 'read-icon'
return result + ' bookmarked' if @get('bookmarked')
topic = @get('topic')
if topic and topic.get('last_read_post_number') == @get('post_number')
result += ' last-read'
else
result += ' seen' if @get('read')
result
).property('read', 'topic.last_read_post_number', 'bookmarked')
# Custom tooltips for the bookmark icons
bookmarkTooltip: (->
return Em.String.i18n('bookmarks.created') if @get('bookmarked')
return "" unless @get('read')
topic = @get('topic')
if topic and topic.get('last_read_post_number') == @get('post_number')
return Em.String.i18n('bookmarks.last_read')
return Em.String.i18n('bookmarks.not_bookmarked')
).property('read', 'topic.last_read_post_number', 'bookmarked')
bookmarkedChanged: (->
jQuery.ajax
url: "/posts/#{@get('id')}/bookmark",
type: 'PUT'
data:
bookmarked: if @get('bookmarked') then true else false
error: (error) =>
errors = jQuery.parseJSON(error.responseText).errors
bootbox.alert(errors[0])
@toggleProperty('bookmarked')
).observes('bookmarked')
internalLinks: (->
return null if @blank('link_counts')
@get('link_counts').filterProperty('internal').filterProperty('title')
).property('link_counts.@each.internal')
# Edits are the version - 1, so version 2 = 1 edit
editCount: (->
@get('version') - 1
).property('version')
historyHeat: (->
return unless updatedAt = @get('updated_at')
rightNow = new Date().getTime()
# Show heat on age
updatedAtDate = Date.create(updatedAt).getTime()
return 'heatmap-high' if updatedAtDate > (rightNow - 60 * 60 * 1000 * 12)
return 'heatmap-med' if updatedAtDate > (rightNow - 60 * 60 * 1000 * 24)
return 'heatmap-low' if updatedAtDate > (rightNow - 60 * 60 * 1000 * 48)
).property('updated_at')
flagsAvailable: (->
Discourse.get('site.flagTypes').filter (item) =>
@get("actionByName.#{item.get('name_key')}.can_act")
).property('Discourse.site.flagTypes', 'actions_summary.@each.can_act')
actionsHistory: (->
return null unless @present('actions_summary')
@get('actions_summary').filter (i) ->
return false if i.get('count') == 0
return true if i.get('users') and i.get('users').length > 0
return not i.get('hidden')
).property('actions_summary.@each.users', 'actions_summary.@each.count')
# Save a post and call the callback when done.
save: (complete, error) ->
unless @get('newPost')
# We're updating a post
$.ajax
url: "/posts/#{@get('id')}",
type: 'PUT'
data:
post: {raw: @get('raw')}
image_sizes: @get('imageSizes')
success: (result) ->
complete?(Discourse.Post.create(result))
error: (result) -> error?(result)
else
# We're saving a post
data =
post: @getProperties('raw', 'topic_id', 'reply_to_post_number', 'category')
archetype: @get('archetype')
title: @get('title')
image_sizes: @get('imageSizes')
target_usernames: @get('target_usernames')
# Put the metaData into the request
if metaData = @get('metaData')
data.meta_data = {}
Ember.keys(metaData).forEach (key) -> data.meta_data[key] = metaData.get(key)
$.ajax
type: 'POST'
url: "/posts",
data: data
success: (result) -> complete?(Discourse.Post.create(result))
error: (result) -> error?(result)
delete: (complete) ->
$.ajax "/posts/#{@get('id')}", type: 'DELETE', success: (result) -> complete()
# Update the properties of this post from an obj, ignoring cooked as we should already
# have that rendered.
updateFromSave: (obj) ->
return unless obj
Object.each obj, (key, val) =>
return false if key == 'actions_summary'
@set(key, val) if val
# Rebuild actions summary
@set('actions_summary', Em.A())
if obj.actions_summary
lookup = Em.Object.create()
obj.actions_summary.each (a) =>
a.post = @
a.actionType = Discourse.get("site").postActionTypeById(a.id)
actionSummary = Discourse.ActionSummary.create(a)
@get('actions_summary').pushObject(actionSummary)
lookup.set(a.actionType.get('name_key'), actionSummary)
@set('actionByName', lookup)
# Load replies to this post
loadReplies: ->
promise = new RSVP.Promise()
@set('loadingReplies', true)
@set('replies', [])
jQuery.getJSON "/posts/#{@get('id')}/replies", (loaded) =>
loaded.each (reply) =>
@get('replies').pushObject Discourse.Post.create(reply)
@set('loadingReplies', false)
promise.resolve()
promise
loadVersions: (callback) ->
$.get "/posts/#{@get('id')}/versions.json", (result) -> callback(result)
window.Discourse.Post.reopenClass
REGULAR_TYPE: <%= Post::REGULAR %>
MODERATOR_ACTION_TYPE: <%= Post::MODERATOR_ACTION %>
createActionSummary: (result) ->
if (result.actions_summary)
lookup = Em.Object.create()
result.actions_summary = result.actions_summary.map (a) ->
a.post = result
a.actionType = Discourse.get("site").postActionTypeById(a.id)
actionSummary = Discourse.ActionSummary.create(a)
lookup.set(a.actionType.get('name_key'), actionSummary)
actionSummary
result.set('actionByName', lookup)
create: (obj, topic) ->
result = @_super(obj)
@createActionSummary(result)
result.set('reply_to_user', Discourse.User.create(obj.reply_to_user)) if obj.reply_to_user
result.set('topic', topic)
result
deleteMany: (posts) ->
$.ajax "/posts/destroy_many",
type: 'DELETE',
data:
post_ids: posts.map (p) -> p.get('id')
loadVersion: (postId, version, callback) ->
jQuery.getJSON "/posts/#{postId}.json?version=#{version}", (result) =>
callback(Discourse.Post.create(result))
loadByPostNumber: (topicId, postId, callback) ->
jQuery.getJSON "/posts/by_number/#{topicId}/#{postId}.json", (result) => callback(Discourse.Post.create(result))
loadQuote: (postId) ->
promise = new RSVP.Promise
jQuery.getJSON "/posts/#{postId}.json", (result) =>
post = Discourse.Post.create(result)
promise.resolve(Discourse.BBCode.buildQuoteBBCode(post, post.get('raw')))
promise
load: (postId, callback) ->
jQuery.getJSON "/posts/#{postId}.json", (result) => callback(Discourse.Post.create(result))

View File

@@ -0,0 +1,11 @@
window.Discourse.PostActionType = Em.Object.extend
alsoName: (->
return Em.String.i18n('post.actions.flag') if @get('is_flag')
@get('name')
).property('is_flag', 'name')
alsoNameLower: (->
@get('alsoName')?.toLowerCase()
).property('alsoName')

View File

@@ -0,0 +1,36 @@
window.Discourse.Site = Ember.Object.extend
notificationLookup: (->
result = Array()
Object.keys @get('notification_types'), (k, v) -> result[v] = k
result
).property('notification_types')
flagTypes: (->
postActionTypes = @get('post_action_types')
return [] unless postActionTypes
postActionTypes.filterProperty('is_flag', true)
).property('post_action_types.@each')
postActionTypeById: (id) -> @get("postActionByIdLookup.action#{id}")
window.Discourse.Site.reopenClass
create: (obj) ->
Object.tap @_super(obj), (result) =>
if result.categories
result.categories = result.categories.map (c) => Discourse.Category.create(c)
if result.post_action_types
result.postActionByIdLookup = Em.Object.create()
result.post_action_types = result.post_action_types.map (p) =>
actionType = Discourse.PostActionType.create(p)
result.postActionByIdLookup.set("action#{p.id}", actionType)
actionType
if result.archetypes
result.archetypes = result.archetypes.map (a) => Discourse.Archetype.create(a)

View File

@@ -0,0 +1,307 @@
Discourse.Topic = Discourse.Model.extend Discourse.Presence,
categoriesBinding: 'Discourse.site.categories'
fewParticipants: (->
return null unless @present('participants')
return @get('participants').slice(0, 3)
).property('participants')
canConvertToRegular: (->
a = @get('archetype')
a != 'regular' && a != 'private_message'
).property('archetype')
convertArchetype: (archetype) ->
a = @get('archetype')
if a != 'regular' && a != 'private_message'
@set('archetype','regular')
jQuery.post @get('url'),
_method: 'put'
archetype: 'regular'
category: (->
if @get('categories')
@get('categories').findProperty('name', @get('categoryName'))
).property('categoryName', 'categories')
url: (->
"/t/#{@get('slug')}/#{@get('id')}"
).property('id', 'slug')
# Helper to build a Url with a post number
urlForPostNumber: (postNumber) ->
url = @get('url')
url += "/#{postNumber}" if postNumber and (postNumber > 1)
url
lastReadUrl: (-> @urlForPostNumber(@get('last_read_post_number')) ).property('url', 'last_read_post_number')
lastPostUrl: (-> @urlForPostNumber(@get('highest_post_number')) ).property('url', 'highest_post_number')
# The last post in the topic
lastPost: -> @get('posts').last()
postsChanged: ( ->
posts = @get('posts')
last = posts.last()
return unless last && last.set && !last.lastPost
posts.each (p)->
p.set('lastPost', false) if p.lastPost
last.set('lastPost',true)
return true
).observes('posts.@each','posts')
# The amount of new posts to display. It might be different than what the server
# tells us if we are still asynchronously flushing our "recently read" data.
# So take what the browser has seen into consideration.
displayNewPosts: (->
if highestSeen = Discourse.get('highestSeenByTopic')[@get('id')]
delta = highestSeen - @get('last_read_post_number')
if delta > 0
result = (@get('new_posts') - delta)
result = 0 if result < 0
return result
@get('new_posts')
).property('new_posts', 'id')
displayTitle: (->
return null unless @get('title')
return @get('title') unless @get('category')
matches = @get('title').match(/^([a-zA-Z0-9]+\: )?(.*)/)
return matches[2]
).property('title')
# The coldmap class for the age of the topic
ageCold: (->
return unless lastPost = @get('last_posted_at')
return unless createdAt = @get('created_at')
daysSinceEpoch = (dt) ->
# 1000 * 60 * 60 * 24 = days since epoch
dt.getTime() / 86400000
# Show heat on age
nowDays = daysSinceEpoch(new Date())
createdAtDays = daysSinceEpoch(new Date(createdAt))
if daysSinceEpoch(new Date(lastPost)) > nowDays - 90
return 'coldmap-high' if createdAtDays < nowDays - 60
return 'coldmap-med' if createdAtDays < nowDays - 30
return 'coldmap-low' if createdAtDays < nowDays - 14
null
).property('age', 'created_at')
archetypeObject: (->
Discourse.get('site.archetypes').findProperty('id', @get('archetype'))
).property('archetype')
isPrivateMessage: (->
@get('archetype') == 'private_message'
).property('archetype')
# Does this topic only have a single post?
singlePost: (->
@get('posts_count') == 1
).property('posts_count')
toggleStatus: (property) ->
@toggleProperty(property)
jQuery.post "#{@get('url')}/status", _method: 'put', status: property, enabled: if @get(property) then 'true' else 'false'
toggleStar: ->
@toggleProperty('starred')
jQuery.ajax
url: "#{@get('url')}/star"
type: 'PUT'
data:
starred: if @get('starred') then true else false
error: (error) =>
@toggleProperty('starred')
errors = jQuery.parseJSON(error.responseText).errors
bootbox.alert(errors[0])
# Save any changes we've made to the model
save: ->
# Don't save unless we can
return unless @get('can_edit')
jQuery.post @get('url'),
_method: 'put'
title: @get('title')
category: @get('category.name')
# Reset our read data for this topic
resetRead: (callback) ->
$.ajax "/t/#{@get('id')}/timings",
type: 'DELETE'
success: -> callback?()
# Invite a user to this topic
inviteUser: (user) ->
$.ajax
type: 'POST'
url: "/t/#{@get('id')}/invite",
data: {user: user}
# Delete this topic
delete: (callback) ->
$.ajax "/t/#{@get('id')}",
type: 'DELETE'
success: -> callback?()
# Load the posts for this topic
loadPosts: (opts) ->
opts = {} unless opts
# Load the first post by default
opts.nearPost ||= 1 unless opts.bestOf
# If we already have that post in the DOM, jump to it
return if Discourse.TopicView.scrollTo @get('id'), opts.nearPost
Discourse.Topic.find @get('id'),
nearPost: opts.nearPost
bestOf: opts.bestOf
trackVisit: opts.trackVisit
.then (result) =>
# If loading the topic succeeded...
# Update the slug if different
@set('slug', result.slug) if result.slug
# If we want to scroll to a post that doesn't exist, just pop them to the closest
# one instead. This is likely happening due to a deleted post.
opts.nearPost = parseInt(opts.nearPost)
closestPostNumber = 0
postDiff = Number.MAX_VALUE
result.posts.each (p) ->
diff = Math.abs(p.post_number - opts.nearPost)
if diff < postDiff
postDiff = diff
closestPostNumber = p.post_number
return false if diff == 0
opts.nearPost = closestPostNumber
@get('participants').clear() if @get('participants')
@set('suggested_topics', Em.A()) if result.suggested_topics
@mergeAttributes result, suggested_topics: Discourse.Topic
@set('posts', Em.A())
if opts.trackVisit and result.draft and result.draft.length > 0
Discourse.openComposer
draft: Discourse.Draft.getLocal(result.draft_key, result.draft)
draftKey: result.draft_key
draftSequence: result.draft_sequence
topic: @
ignoreIfChanged: true
# Okay this is weird, but let's store the length of the next post
# when there
lastPost = null
result.posts.each (p) =>
# Determine whether there is a short reply below
if (lastPost && lastPost.get('reply_count') == 1) && (p.reply_to_post_number == lastPost.get('post_number')) && (p.cooked.length < Discourse.SiteSettings.max_length_show_reply)
lastPost.set('replyFollowing', true)
p.scrollToAfterInsert = opts.nearPost
post = Discourse.Post.create(p)
post.set('topic', @)
@get('posts').pushObject(post)
lastPost = post
@set('loaded', true)
, (result) =>
@set('missing', true)
@set('message', Em.String.i18n('topic.not_found.description'))
notificationReasonText: (->
locale_string = "topic.notifications.reasons.#{@notification_level}"
if typeof @notifications_reason_id == 'number'
locale_string += "_#{@notifications_reason_id}"
Em.String.i18n(locale_string, username: Discourse.currentUser.username.toLowerCase())
).property('notifications_reason_id')
updateNotifications: (v)->
@set('notification_level', v)
@set('notifications_reason_id', null)
$.ajax
url: "/t/#{@get('id')}/notifications"
type: 'POST'
data: {notification_level: v}
# use to add post to topics protecting from dupes
pushPosts: (newPosts)->
map = {}
posts = @get('posts')
posts.each (p)->
map["#{p.post_number}"] = true
newPosts.each (p)->
posts.pushObject(p) unless map[p.get('post_number')]
window.Discourse.Topic.reopenClass
NotificationLevel:
WATCHING: 3
TRACKING: 2
REGULAR: 1
MUTE: 0
# Load a topic, but accepts a set of filters
#
# options:
# onLoad - the callback after the topic is loaded
find: (topicId, opts) ->
url = "/t/#{topicId}"
url += "/#{opts.nearPost}" if opts.nearPost
data = {}
data.posts_after = opts.postsAfter if opts.postsAfter
data.posts_before = opts.postsBefore if opts.postsBefore
data.track_visit = true if opts.trackVisit
# Add username filters if we have them
if opts.userFilters and opts.userFilters.length > 0
data.username_filters = []
opts.userFilters.forEach (username) => data.username_filters.push(username)
# Add the best of filter if we have it
data.best_of = true if opts.bestOf == true
# Check the preload store. If not, load it via JSON
promise = new RSVP.Promise()
PreloadStore.get("topic_#{topicId}", -> jQuery.getJSON url + ".json", data).then (result) ->
first = result.posts.first()
first.bestOfFirst = true if first and opts and opts.bestOf
promise.resolve(result)
, (result) -> promise.reject(result)
promise
# Create a topic from posts
movePosts: (topicId, title, postIds) ->
$.ajax "/t/#{topicId}/move-posts",
type: 'POST'
data:
title: title
post_ids: postIds
create: (obj, topicView) ->
Object.tap @_super(obj), (result) =>
if result.participants
result.participants = result.participants.map (u) => Discourse.User.create(u)
result.fewParticipants = Em.A()
result.participants.each (p) =>
return false if result.fewParticipants.length >= 8
result.fewParticipants.pushObject(p)
true

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