diff --git a/internal_packages/developer-bar/lib/developer-bar.cjsx b/internal_packages/developer-bar/lib/developer-bar.cjsx
index 45f31f2aa..1132398d2 100644
--- a/internal_packages/developer-bar/lib/developer-bar.cjsx
+++ b/internal_packages/developer-bar/lib/developer-bar.cjsx
@@ -128,7 +128,11 @@ class DeveloperBar extends React.Component
expandedDiv
_onChange: =>
- @setState(@_getStateFromStores())
+ # The developer bar is hidden almost all the time. Rather than render when
+ # API requests come in, etc., just ignore changes from our store and retrieve
+ # state when we open.
+ if @state.visible and @state.height > DeveloperBarClosedHeight
+ @setState(@_getStateFromStores())
_onClear: =>
Actions.clearDeveloperConsole()
@@ -144,9 +148,11 @@ class DeveloperBar extends React.Component
height: DeveloperBarClosedHeight
_onShow: =>
+ @setState(@_getStateFromStores())
@setState(height: 200) if @state.height < 100
_onExpandSection: (section) =>
+ @setState(@_getStateFromStores())
@setState(section: section)
@_onShow()
diff --git a/internal_packages/thread-list/lib/thread-list-quick-actions.cjsx b/internal_packages/thread-list/lib/thread-list-quick-actions.cjsx
index 8466bb575..5d2517b53 100644
--- a/internal_packages/thread-list/lib/thread-list-quick-actions.cjsx
+++ b/internal_packages/thread-list/lib/thread-list-quick-actions.cjsx
@@ -5,7 +5,6 @@ React = require 'react'
Thread,
AddRemoveTagsTask,
NamespaceStore} = require 'nylas-exports'
-{RetinaImg} = require 'nylas-component-kit'
class ThreadListQuickActions extends React.Component
@displayName: 'ThreadListQuickActions'
@@ -14,10 +13,10 @@ class ThreadListQuickActions extends React.Component
render: =>
actions = []
- actions.push
{actions}
diff --git a/internal_packages/thread-list/lib/thread-list-store.coffee b/internal_packages/thread-list/lib/thread-list-store.coffee
index ec799f3c0..bed36d77c 100644
--- a/internal_packages/thread-list/lib/thread-list-store.coffee
+++ b/internal_packages/thread-list/lib/thread-list-store.coffee
@@ -70,8 +70,15 @@ ThreadListStore = Reflux.createStore
matchers = []
matchers.push Thread.attributes.namespaceId.equal(namespaceId)
matchers.push Thread.attributes.tags.contains(tagId) if tagId isnt "*"
- @setView new DatabaseView Thread, {matchers}, (item) ->
- DatabaseStore.findAll(Message, {threadId: item.id})
+ view = new DatabaseView Thread, {matchers}, (ids) =>
+ DatabaseStore.findAll(Message).where(Message.attributes.threadId.in(ids)).then (messages) ->
+ messagesByThread = {}
+ for id in ids
+ messagesByThread[id] = []
+ for message in messages
+ messagesByThread[message.threadId].push message
+ messagesByThread
+ @setView(view)
Actions.setFocus(collection: 'thread', item: null)
diff --git a/internal_packages/thread-list/lib/thread-list.cjsx b/internal_packages/thread-list/lib/thread-list.cjsx
index 50c895305..e674c5241 100644
--- a/internal_packages/thread-list/lib/thread-list.cjsx
+++ b/internal_packages/thread-list/lib/thread-list.cjsx
@@ -38,8 +38,12 @@ class ThreadListScrollTooltip extends React.Component
item: ThreadListStore.view().get(idx)
render: ->
+ if @state.item
+ content = timestamp(@state.item.lastMessageTimestamp)
+ else
+ content = "Loading..."
- {timestamp(@state.item?.lastMessageTimestamp)}
+ {content}
class ThreadList extends React.Component
diff --git a/internal_packages/thread-list/stylesheets/thread-list.less b/internal_packages/thread-list/stylesheets/thread-list.less
index ca0a2f23e..637b4b87c 100644
--- a/internal_packages/thread-list/stylesheets/thread-list.less
+++ b/internal_packages/thread-list/stylesheets/thread-list.less
@@ -185,11 +185,24 @@
.thread-list .list-item .list-column-HoverActions {
display:none;
.action {
- margin:8px;
display:inline-block;
+ background-size: 100%;
+ zoom:0.5;
+ width:48px;
+ height:48px;
+ margin:16px;
}
.action:last-child {
- margin-right:20px;
+ margin-right:40px;
+ }
+ .action.action-reply {
+ background: url(../static/images/toolbar/toolbar-reply@2x.png) top left no-repeat;
+ }
+ .action.action-forward {
+ background: url(../static/images/toolbar/toolbar-forward@2x.png) top left no-repeat;
+ }
+ .action.action-archive {
+ background: url(../static/images/toolbar/toolbar-archive@2x.png) top left no-repeat;
}
}
.thread-list .list-item:hover .list-column-HoverActions {
@@ -270,4 +283,4 @@
.inverseContent;
}
}
-}
\ No newline at end of file
+}
diff --git a/spec-nylas/database-view-spec.coffee b/spec-nylas/database-view-spec.coffee
index ea975666a..8f045985b 100644
--- a/spec-nylas/database-view-spec.coffee
+++ b/spec-nylas/database-view-spec.coffee
@@ -41,7 +41,7 @@ describe "DatabaseView", ->
it "should optionally accept a metadata provider", ->
provider = ->
view = new DatabaseView(Message, {}, provider)
- expect(view._itemMetadataProvider).toEqual(provider)
+ expect(view._metadataProvider).toEqual(provider)
it "should initialize the row count to -1", ->
view = new DatabaseView(Message)
@@ -73,9 +73,9 @@ describe "DatabaseView", ->
@view._count = 1
spyOn(@view, 'invalidateRetainedRange').andCallFake ->
- describe "setItemMetadataProvider", ->
+ describe "setMetadataProvider", ->
it "should empty the page cache and re-fetch all pages", ->
- @view.setItemMetadataProvider( -> false)
+ @view.setMetadataProvider( -> false)
expect(@view._pages).toEqual({})
expect(@view.invalidateRetainedRange).toHaveBeenCalled()
@@ -317,8 +317,11 @@ describe "DatabaseView", ->
describe "if an item metadata provider is configured", ->
beforeEach ->
- @view._itemMetadataProvider = (item) ->
- Promise.resolve('metadata-for-'+item.id)
+ @view._metadataProvider = (ids) ->
+ results = {}
+ for id in ids
+ results[id] = "metadata-for-#{id}"
+ Promise.resolve(results)
it "should set .metadata of each item", ->
runs ->
@@ -342,9 +345,12 @@ describe "DatabaseView", ->
it "should always wait for metadata promises to resolve", ->
@resolves = []
- @view._itemMetadataProvider = (item) =>
+ @view._metadataProvider = (ids) =>
new Promise (resolve, reject) =>
- @resolves.push -> resolve('metadata-for-'+item.id)
+ results = {}
+ for id in ids
+ results[id] = "metadata-for-#{id}"
+ @resolves.push -> resolve(results)
runs ->
@completeQuery()
diff --git a/spec-nylas/stores/database-store-spec.coffee b/spec-nylas/stores/database-store-spec.coffee
index 72418e4bd..eb3e66254 100644
--- a/spec-nylas/stores/database-store-spec.coffee
+++ b/spec-nylas/stores/database-store-spec.coffee
@@ -12,6 +12,7 @@ class TestModel extends Model
modelKey: 'id'
TestModel.configureWithAllAttributes = ->
+ TestModel.additionalSQLiteConfig = undefined
TestModel.attributes =
'datetime': Attributes.DateTime
queryable: true
@@ -30,6 +31,7 @@ TestModel.configureWithAllAttributes = ->
modelKey: 'other'
TestModel.configureWithCollectionAttribute = ->
+ TestModel.additionalSQLiteConfig = undefined
TestModel.attributes =
'id': Attributes.String
queryable: true
@@ -41,6 +43,7 @@ TestModel.configureWithCollectionAttribute = ->
TestModel.configureWithJoinedDataAttribute = ->
+ TestModel.additionalSQLiteConfig = undefined
TestModel.attributes =
'id': Attributes.String
queryable: true
@@ -50,6 +53,22 @@ TestModel.configureWithJoinedDataAttribute = ->
modelKey: 'body'
+TestModel.configureWithAdditionalSQLiteConfig = ->
+ TestModel.attributes =
+ 'id': Attributes.String
+ queryable: true
+ modelKey: 'id'
+ 'body': Attributes.JoinedData
+ modelTable: 'TestModelBody'
+ modelKey: 'body'
+ TestModel.additionalSQLiteConfig =
+ setup: ->
+ ['CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_timestamp DESC, namespace_id, id)']
+ writeModel: jasmine.createSpy('additionalWriteModel')
+ deleteModel: jasmine.createSpy('additionalDeleteModel')
+
+
+
testMatchers = {'id': 'b'}
testModelInstance = new TestModel(id: '1234')
testModelInstanceA = new TestModel(id: 'AAA')
@@ -161,6 +180,19 @@ describe "DatabaseStore", ->
change = DatabaseStore.triggerSoon.mostRecentCall.args[0]
expect(change).toEqual({objectClass: TestModel.name, objects: [testModelInstance], type:'unpersist'})
+ describe "when the model provides additional sqlite config", ->
+ beforeEach ->
+ TestModel.configureWithAdditionalSQLiteConfig()
+
+ it "should call the deleteModel method and provide the transaction and model", ->
+ DatabaseStore.unpersistModel(testModelInstance)
+ expect(TestModel.additionalSQLiteConfig.deleteModel).toHaveBeenCalled()
+ expect(TestModel.additionalSQLiteConfig.deleteModel.mostRecentCall.args[1]).toBe(testModelInstance)
+
+ it "should not fail if additional config is present, but deleteModel is not defined", ->
+ delete TestModel.additionalSQLiteConfig['deleteModel']
+ expect( => DatabaseStore.unpersistModel(testModelInstance)).not.toThrow()
+
describe "when the model has collection attributes", ->
it "should delete all of the elements in the join tables", ->
TestModel.configureWithCollectionAttribute()
@@ -178,7 +210,7 @@ describe "DatabaseStore", ->
expect(@performed[2].values[0]).toBe('1234')
describe "queriesForTableSetup", ->
- it "should return the queries for creating the table and indexes on queryable columns", ->
+ it "should return the queries for creating the table and the primary unique index", ->
TestModel.attributes =
'attrQueryable': Attributes.DateTime
queryable: true
@@ -191,7 +223,6 @@ describe "DatabaseStore", ->
queries = DatabaseStore.queriesForTableSetup(TestModel)
expected = [
'CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,attr_queryable INTEGER)',
- 'CREATE INDEX IF NOT EXISTS `TestModel_attr_queryable` ON `TestModel` (`attr_queryable`)',
'CREATE UNIQUE INDEX IF NOT EXISTS `TestModel_id` ON `TestModel` (`id`)'
]
for query,i in queries
@@ -214,6 +245,19 @@ describe "DatabaseStore", ->
queries = DatabaseStore.queriesForTableSetup(TestModel)
expect(queries[0]).toBe('CREATE TABLE IF NOT EXISTS `TestModel` (id TEXT PRIMARY KEY,data BLOB,datetime INTEGER,string-json-key TEXT,boolean INTEGER,number INTEGER)')
+ describe "when the model provides additional sqlite config", ->
+ it "the setup method should return these queries", ->
+ TestModel.configureWithAdditionalSQLiteConfig()
+ spyOn(TestModel.additionalSQLiteConfig, 'setup').andCallThrough()
+ queries = DatabaseStore.queriesForTableSetup(TestModel)
+ expect(TestModel.additionalSQLiteConfig.setup).toHaveBeenCalledWith()
+ expect(queries.pop()).toBe('CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_timestamp DESC, namespace_id, id)')
+
+ it "should not fail if additional config is present, but setup is undefined", ->
+ delete TestModel.additionalSQLiteConfig['setup']
+ @m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
+ expect( => DatabaseStore.queriesForTableSetup(TestModel)).not.toThrow()
+
describe "writeModels", ->
it "should compose a REPLACE INTO query to save the model", ->
TestModel.configureWithCollectionAttribute()
@@ -276,3 +320,18 @@ describe "DatabaseStore", ->
@m = new TestModel(id: 'local-6806434c-b0cd')
DatabaseStore.writeModels(@spyTx(), [@m])
expect(@performed.length).toBe(1)
+
+ describe "when the model provides additional sqlite config", ->
+ beforeEach ->
+ TestModel.configureWithAdditionalSQLiteConfig()
+
+ it "should call the writeModel method and provide the transaction and model", ->
+ tx = @spyTx()
+ @m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
+ DatabaseStore.writeModels(tx, [@m])
+ expect(TestModel.additionalSQLiteConfig.writeModel).toHaveBeenCalledWith(tx, @m)
+
+ it "should not fail if additional config is present, but writeModel is not defined", ->
+ delete TestModel.additionalSQLiteConfig['writeModel']
+ @m = new TestModel(id: 'local-6806434c-b0cd', body: 'hello world')
+ expect( => DatabaseStore.writeModels(@spyTx(), [@m])).not.toThrow()
diff --git a/src/components/list-tabular.cjsx b/src/components/list-tabular.cjsx
index e8c7c74f3..b3649b650 100644
--- a/src/components/list-tabular.cjsx
+++ b/src/components/list-tabular.cjsx
@@ -3,8 +3,6 @@ React = require 'react/addons'
ScrollRegion = require './scroll-region'
{Utils} = require 'nylas-exports'
-RangeChunkSize = 10
-
class ListColumn
constructor: ({@name, @resolver, @flex, @width}) ->
@@ -15,7 +13,6 @@ class ListTabularItem extends React.Component
columns: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
item: React.PropTypes.object.isRequired
itemProps: React.PropTypes.object
- displayHeaders: React.PropTypes.bool
onSelect: React.PropTypes.func
onClick: React.PropTypes.func
onDoubleClick: React.PropTypes.func
@@ -78,41 +75,24 @@ class ListTabular extends React.Component
@state =
renderedRangeStart: -1
renderedRangeEnd: -1
- scrollTop: 0
- scrollInProgress: false
componentDidMount: =>
@updateRangeState()
- componentWillUnmount: =>
- clearTimeout(@_scrollTimer) if @_scrollTimer
-
componentDidUpdate: (prevProps, prevState) =>
# If our view has been swapped out for an entirely different one,
# reset our scroll position to the top.
if prevProps.dataView isnt @props.dataView
@refs.container.scrollTop = 0
- @updateRangeState()
- updateScrollState: =>
- window.requestAnimationFrame =>
- # Create an event that fires when we stop receiving scroll events.
- # There is no "scrollend" event, but we really need one.
- clearTimeout(@_scrollTimer) if @_scrollTimer
- @_scrollTimer = setTimeout(@onDoneReceivingScrollEvents, 100)
+ unless @updateRangeStateFiring
+ @updateRangeState()
+ @updateRangeStateFiring = false
- # If we just started scrolling, scrollInProgress changes our CSS styles
- # and disables pointer events to our contents for rendering speed
- @setState({scrollInProgress: true}) unless @state.scrollInProgress
- # If we've shifted enough pixels from our previous scrollTop to require
- # new rows to be rendered, update our state!
- if Math.abs(@state.scrollTop - @refs.container.scrollTop) >= @props.itemHeight * RangeChunkSize
- @updateRangeState()
-
- onDoneReceivingScrollEvents: =>
- return unless React.findDOMNode(@refs.container)
- @setState({scrollInProgress: false})
+ onScroll: =>
+ # If we've shifted enough pixels from our previous scrollTop to require
+ # new rows to be rendered, update our state!
@updateRangeState()
updateRangeState: =>
@@ -120,66 +100,42 @@ class ListTabular extends React.Component
# Determine the exact range of rows we want onscreen
rangeStart = Math.floor(scrollTop / @props.itemHeight)
- rangeEnd = rangeStart + window.innerHeight / @props.itemHeight
+ rangeSize = Math.ceil(window.innerHeight / @props.itemHeight)
+ rangeEnd = rangeStart + rangeSize
# 1. Clip this range to the number of available items
#
- # 2. Expand the range by more than RangeChunkSize so that
- # the user can scroll through RangeChunkSize more items before
- # another render is required.
+ # 2. Expand the range by a bit so that we prepare items offscreen
+ # before they're seen. This works because we force a compositor
+ # layer using transform:translate3d(0,0,0)
#
- rangeStart = Math.max(0, rangeStart - RangeChunkSize * 1.5)
- rangeEnd = Math.min(rangeEnd + RangeChunkSize * 1.5, @props.dataView.count())
-
- if @state.scrollInProgress
- # only extend the range while scrolling. If we remove the DOM node
- # the user started scrolling over, the deceleration stops.
- # https://code.google.com/p/chromium/issues/detail?id=312427
- if @state.renderedRangeStart != -1
- rangeStart = Math.min(@state.renderedRangeStart, rangeStart)
- if @state.renderedRangeEnd != -1
- rangeEnd = Math.max(@state.renderedRangeEnd, rangeEnd)
+ rangeStart = Math.max(0, rangeStart - rangeSize)
+ rangeEnd = Math.min(rangeEnd + rangeSize, @props.dataView.count())
# Final sanity check to prevent needless work
return if rangeStart is @state.renderedRangeStart and
- rangeEnd is @state.renderedRangeEnd and
- scrollTop is @state.scrollTop
+ rangeEnd is @state.renderedRangeEnd
+
+ @updateRangeStateFiring = true
@props.dataView.setRetainedRange
start: rangeStart
end: rangeEnd
@setState
- scrollTop: scrollTop
renderedRangeStart: rangeStart
renderedRangeEnd: rangeEnd
render: =>
innerStyles =
height: @props.dataView.count() * @props.itemHeight
- pointerEvents: if @state.scrollInProgress then 'none' else 'auto'
-
- {@_headers()}
+
{@_rows()}
- _headers: =>
- return [] unless @props.displayHeaders
-
- headerColumns = @props.columns.map (column) ->
-
- {column.name}
-
-
-
- {headerColumns}
-
-
_rows: =>
rows = []
diff --git a/src/components/multiselect-list.cjsx b/src/components/multiselect-list.cjsx
index cda87db82..a483d6e64 100644
--- a/src/components/multiselect-list.cjsx
+++ b/src/components/multiselect-list.cjsx
@@ -118,7 +118,7 @@ class MultiselectList extends React.Component
@state.handler.onShift(delta, options)
- # This onChange handler can be called many times back to back and setState
- # sometimes triggers an immediate render. Ensure that we never render back-to-back,
- # because rendering this view (even just to determine that there are no changes)
- # is expensive.
_onChange: =>
- @_onChangeDebounced ?= _.debounce =>
- @setState(@_getStateFromStores())
- , 1
- @_onChangeDebounced()
+ @setState(@_getStateFromStores())
_visible: =>
if WorkspaceStore.layoutMode() is "list"
@@ -184,22 +177,32 @@ class MultiselectList extends React.Component
_getStateFromStores: (props) =>
props ?= @props
+ state = @state ? {}
view = props.dataStore?.view()
return {} unless view
- columns = [].concat(props.columns)
+ # Do we need to re-compute columns? Don't do this unless we really have to,
+ # it will cause a re-render of the entire ListTabular. To know whether our
+ # computed columns are still valid, we store the original columns in our state
+ # along with the computed ones.
+ if props.columns isnt state.columns
+ computedColumns = [].concat(props.columns)
+ if WorkspaceStore.layoutMode() is 'list'
+ computedColumns.splice(0, 0, @_getCheckmarkColumn())
+ else
+ computedColumns = state.computedColumns
if WorkspaceStore.layoutMode() is 'list'
handler = new MultiselectListInteractionHandler(view, props.collection)
- columns.splice(0, 0, @_getCheckmarkColumn())
else
handler = new MultiselectSplitInteractionHandler(view, props.collection)
dataView: view
- columns: columns
handler: handler
ready: view.loaded()
+ columns: props.columns
+ computedColumns: computedColumns
selectedIds: view.selection.ids()
focusedId: FocusedContentStore.focusedId(props.collection)
keyboardCursorId: FocusedContentStore.keyboardCursorId(props.collection)
diff --git a/src/components/scroll-region.cjsx b/src/components/scroll-region.cjsx
index ab0bb24dc..1a811a75f 100644
--- a/src/components/scroll-region.cjsx
+++ b/src/components/scroll-region.cjsx
@@ -12,14 +12,16 @@ class ScrollRegion extends React.Component
@propTypes:
onScroll: React.PropTypes.func
+ onScrollEnd: React.PropTypes.func
className: React.PropTypes.string
scrollTooltipComponent: React.PropTypes.func
+ children: React.PropTypes.oneOfType([React.PropTypes.element, React.PropTypes.array])
constructor: (@props) ->
@state =
totalHeight:0
viewportHeight: 0
- viewportOffset: 0
+ viewportScrollTop: 0
dragging: false
scrolling: false
@@ -31,14 +33,14 @@ class ScrollRegion extends React.Component
componentDidMount: =>
@_recomputeDimensions()
- componentDidUpdate: =>
- @_recomputeDimensions()
-
componentWillUnmount: =>
@_onHandleUp()
shouldComponentUpdate: (newProps, newState) =>
- not Utils.isEqualReact(newProps, @props) or not Utils.isEqualReact(newState, @state)
+ # Because this component renders @props.children, it needs to update
+ # on props.children changes. Unfortunately, computing isEqual on the
+ # @props.children tree extremely expensive. Just let React's algorithm do it's work.
+ true
render: =>
containerClasses = "#{@props.className ? ''} " + classNames
@@ -50,7 +52,7 @@ class ScrollRegion extends React.Component
tooltip = []
if @props.scrollTooltipComponent
- tooltip = <@props.scrollTooltipComponent viewportCenter={@state.viewportOffset + @state.viewportHeight / 2} totalHeight={@state.totalHeight} />
+ tooltip = <@props.scrollTooltipComponent viewportCenter={@state.viewportScrollTop + @state.viewportHeight / 2} totalHeight={@state.totalHeight} />
@@ -61,7 +63,9 @@ class ScrollRegion extends React.Component
- {@props.children}
+
+ {@props.children}
+
@@ -81,7 +85,7 @@ class ScrollRegion extends React.Component
_scrollbarHandleStyles: =>
handleHeight = @_getHandleHeight()
- handleTop = (@state.viewportOffset / (@state.totalHeight - @state.viewportHeight)) * (@state.trackHeight - handleHeight)
+ handleTop = (@state.viewportScrollTop / (@state.totalHeight - @state.viewportHeight)) * (@state.trackHeight - handleHeight)
position:'relative'
height: handleHeight
@@ -90,22 +94,31 @@ class ScrollRegion extends React.Component
_getHandleHeight: =>
Math.min(@state.totalHeight, Math.max(40, (@state.trackHeight / @state.totalHeight) * @state.trackHeight))
- _recomputeDimensions: =>
+ _recomputeDimensions: ({avoidForcingLayout} = {}) =>
return unless @refs.content
contentNode = React.findDOMNode(@refs.content)
trackNode = React.findDOMNode(@refs.track)
+ viewportScrollTop = contentNode.scrollTop
- totalHeight = contentNode.scrollHeight
- trackHeight = trackNode.clientHeight
- viewportHeight = contentNode.clientHeight
- viewportOffset = contentNode.scrollTop
+ # While we're scrolling, calls to contentNode.scrollHeight / clientHeight
+ # force the browser to immediately flush any DOM changes and compute the
+ # height of the node. This hurts performance and also kind of unnecessary,
+ # since it's unlikely these values will change while scrolling.
+ if avoidForcingLayout
+ totalHeight = @state.totalHeight ? contentNode.scrollHeight
+ trackHeight = @state.trackHeight ? contentNode.scrollHeight
+ viewportHeight = @state.viewportHeight ? contentNode.clientHeight
+ else
+ totalHeight = contentNode.scrollHeight
+ trackHeight = trackNode.clientHeight
+ viewportHeight = contentNode.clientHeight
if @state.totalHeight != totalHeight or
@state.trackHeight != trackHeight or
- @state.viewportOffset != viewportOffset or
- @state.viewportHeight != viewportHeight
- @setState({totalHeight, trackHeight, viewportOffset, viewportHeight})
+ @state.viewportHeight != viewportHeight or
+ @state.viewportScrollTop != viewportScrollTop
+ @setState({totalHeight, trackHeight, viewportScrollTop, viewportHeight})
_onHandleDown: (event) =>
handleNode = React.findDOMNode(@refs.handle)
@@ -132,24 +145,24 @@ class ScrollRegion extends React.Component
event.stopPropagation()
_onScrollJump: (event) =>
+ @_trackOffset = React.findDOMNode(@refs.track).getBoundingClientRect().top
@_mouseOffsetWithinHandle = @_getHandleHeight() / 2
@_onHandleMove(event)
_onScroll: (event) =>
- if not @_requestedAnimationFrame
- @_requestedAnimationFrame = true
- window.requestAnimationFrame =>
- @_requestedAnimationFrame = false
- @_recomputeDimensions()
- @props.onScroll?(event)
+ if not @state.scrolling
+ @_recomputeDimensions()
+ @setState(scrolling: true)
+ else
+ @_recomputeDimensions({avoidForcingLayout: true})
- if not @state.scrolling
- @setState(scrolling: true)
+ @props.onScroll?(event)
- @_onStoppedScroll ?= _.debounce =>
- @setState(scrolling: false)
- , 250
- @_onStoppedScroll()
+ @_onScrollEnd ?= _.debounce =>
+ @setState(scrolling: false)
+ @props.onScrollEnd?(event)
+ , 250
+ @_onScrollEnd()
module.exports = ScrollRegion
diff --git a/src/error-reporter.js b/src/error-reporter.js
index d1943deb0..412e1bd22 100644
--- a/src/error-reporter.js
+++ b/src/error-reporter.js
@@ -163,8 +163,8 @@ module.exports = ErrorReporter = (function() {
for (var ii = 1; ii < arguments.length; ii++) {
args.push(arguments[ii]);
}
- if ((this.dev === true) && (showIt === true)) {
- console.log.apply(this, args);
+ if ((this.inDevMode === true) && (showIt === true)) {
+ console.log.apply(console, args);
}
this.appendLog.apply(this, [args]);
}
diff --git a/src/flux/attributes/attribute.coffee b/src/flux/attributes/attribute.coffee
index 88a532e1c..91e3f056b 100644
--- a/src/flux/attributes/attribute.coffee
+++ b/src/flux/attributes/attribute.coffee
@@ -22,6 +22,12 @@ class Attribute
throw (new Error "this field cannot be queried against.") unless @queryable
new Matcher(@, '=', val)
+ # Public: Returns a {Matcher} for objects `=` to the provided value.
+ in: (val) ->
+ throw (new Error "Attribute.in: you must pass an array of values.") unless val instanceof Array
+ throw (new Error "this field cannot be queried against.") unless @queryable
+ new Matcher(@, 'in', val)
+
# Public: Returns a {Matcher} for objects `!=` to the provided value.
not: (val) ->
throw (new Error "this field cannot be queried against.") unless @queryable
diff --git a/src/flux/attributes/matcher.coffee b/src/flux/attributes/matcher.coffee
index 0ed2401f9..a258afca3 100644
--- a/src/flux/attributes/matcher.coffee
+++ b/src/flux/attributes/matcher.coffee
@@ -38,7 +38,7 @@ class Matcher
@muid = Matcher.muid
Matcher.muid = (Matcher.muid + 1) % 50
@
-
+
attribute: ->
@attr
@@ -53,6 +53,7 @@ class Matcher
when '=' then return value == @val
when '<' then return value < @val
when '>' then return value > @val
+ when 'in' then return value in @val
when 'contains'
# You can provide an ID or an object, and an array of IDs or an array of objects
# Assumes that `value` is an array of items
@@ -84,6 +85,10 @@ class Matcher
escaped = 1
else if val is false
escaped = 0
+ else if val instanceof Array
+ escapedVals = []
+ escapedVals.push("'#{v.replace(/'/g, '\\\'')}'") for v in val
+ escaped = "(#{escapedVals.join(',')})"
else
escaped = val
diff --git a/src/flux/models/message.coffee b/src/flux/models/message.coffee
index d01ba1f32..3193a6be7 100644
--- a/src/flux/models/message.coffee
+++ b/src/flux/models/message.coffee
@@ -132,6 +132,10 @@ class Message extends Model
@naturalSortOrder: ->
Message.attributes.date.ascending()
+ @additionalSQLiteConfig:
+ setup: ->
+ ['CREATE INDEX IF NOT EXISTS MessageListIndex ON Message(thread_id, date ASC)']
+
constructor: ->
super
@subject ||= ""
diff --git a/src/flux/models/query.coffee b/src/flux/models/query.coffee
index e245b1ba0..deeb363fa 100644
--- a/src/flux/models/query.coffee
+++ b/src/flux/models/query.coffee
@@ -173,16 +173,20 @@ class ModelQuery
if @_count
return result[0][0] / 1
else
- objects = []
- for i in [0..result.length-1] by 1
- row = result[i]
- json = JSON.parse(row[0])
- object = (new @_klass).fromJSON(json)
- for attr, j in @_includeJoinedData
- value = row[j+1]
- value = null if value is AttributeJoinedData.NullPlaceholder
- object[attr.modelKey] = value
- objects.push(object)
+ row = null
+ try
+ objects = []
+ for i in [0..result.length-1] by 1
+ row = result[i]
+ json = JSON.parse(row[0])
+ object = (new @_klass).fromJSON(json)
+ for attr, j in @_includeJoinedData
+ value = row[j+1]
+ value = null if value is AttributeJoinedData.NullPlaceholder
+ object[attr.modelKey] = value
+ objects.push(object)
+ catch jsonError
+ throw new Error("Query could not parse the database result. Query: #{@sql()}, Row Data: #{row[0]}, Error: #{jsonError.toString()}")
return objects[0] if @_singular
return objects
diff --git a/src/flux/models/thread.coffee b/src/flux/models/thread.coffee
index 2ead471c2..25412be1a 100644
--- a/src/flux/models/thread.coffee
+++ b/src/flux/models/thread.coffee
@@ -76,6 +76,10 @@ class Thread extends Model
@getter 'unread', -> @isUnread()
+ @additionalSQLiteConfig:
+ setup: ->
+ ['CREATE INDEX IF NOT EXISTS ThreadListIndex ON Thread(last_message_timestamp DESC, namespace_id, id)']
+
# Public: Returns an {Array} of {Tag} IDs
#
tagIds: ->
@@ -86,7 +90,10 @@ class Thread extends Model
# * `id` A {String} {Tag} ID
#
hasTagId: (id) ->
- @tagIds().indexOf(id) != -1
+ return false unless id and @tags
+ for tag in @tags
+ return true if tag.id is id
+ return false
# Public: Returns a {Boolean}, true if the thread is unread.
isUnread: ->
diff --git a/src/flux/stores/database-store.coffee b/src/flux/stores/database-store.coffee
index 8af3301cc..5d30447d3 100644
--- a/src/flux/stores/database-store.coffee
+++ b/src/flux/stores/database-store.coffee
@@ -43,7 +43,7 @@ class DatabaseProxy
duration: duration
result_length: result?.length
- console.debug(printToConsole, "DatabaseStore: #{query}", metadata)
+ console.debug(printToConsole, "DatabaseStore: (#{duration}) #{query}", metadata)
if duration > 300
atom.errorReporter.shipLogs("Poor Query Performance")
@@ -320,6 +320,12 @@ class DatabaseStore
if model[attr.modelKey]?
tx.execute("REPLACE INTO `#{attr.modelTable}` (`id`, `value`) VALUES (?, ?)", [model.id, model[attr.modelKey]])
+ # For each model, execute any other code the model wants to run.
+ # This allows model classes to do things like update a full-text table
+ # that holds a composite of several fields
+ if klass.additionalSQLiteConfig?.writeModel?
+ for model in models
+ klass.additionalSQLiteConfig.writeModel(tx, model)
deleteModel: (tx, model) =>
klass = model.constructor
@@ -343,6 +349,12 @@ class DatabaseStore
joinedDataAttributes.forEach (attr) ->
tx.execute("DELETE FROM `#{attr.modelTable}` WHERE `id` = ?", [model.id])
+ # Execute any other code the model wants to run.
+ # This allows model classes to do things like update a full-text table
+ # that holds a composite of several fields, or update entirely
+ # separate database systems
+ klass.additionalSQLiteConfig?.deleteModel?(tx, model)
+
# Public: Asynchronously writes `model` to the cache and triggers a change event.
#
# - `model` A {Model} to write to the database.
@@ -553,9 +565,6 @@ class DatabaseStore
columns = ['id TEXT PRIMARY KEY', 'data BLOB']
columnAttributes.forEach (attr) ->
columns.push(attr.columnSQL())
- # TODO: These indexes are not effective because SQLite only uses one index-per-table
- # and there will almost always be an additional `where namespaceId =` clause.
- queries.push("CREATE INDEX IF NOT EXISTS `#{klass.name}_#{attr.jsonKey}` ON `#{klass.name}` (`#{attr.jsonKey}`)")
columnsSQL = columns.join(',')
queries.unshift("CREATE TABLE IF NOT EXISTS `#{klass.name}` (#{columnsSQL})")
@@ -576,6 +585,8 @@ class DatabaseStore
joinedDataAttributes.forEach (attribute) ->
queries.push("CREATE TABLE IF NOT EXISTS `#{attribute.modelTable}` (id TEXT PRIMARY KEY, `value` TEXT)")
+ if klass.additionalSQLiteConfig?.setup?
+ queries = queries.concat(klass.additionalSQLiteConfig.setup())
queries
diff --git a/src/flux/stores/database-view.coffee b/src/flux/stores/database-view.coffee
index f1e579a77..fcfa7223e 100644
--- a/src/flux/stores/database-view.coffee
+++ b/src/flux/stores/database-view.coffee
@@ -31,7 +31,7 @@ verbose = true
#
class DatabaseView extends ModelView
- constructor: (@klass, config = {}, @_itemMetadataProvider) ->
+ constructor: (@klass, config = {}, @_metadataProvider) ->
super
@_pageSize = 100
@_matchers = config.matchers ? []
@@ -49,11 +49,11 @@ class DatabaseView extends ModelView
arguments[0] = "DatabaseView (#{@klass.name}): "+arguments[0]
console.log(arguments...)
- itemMetadataProvider: ->
- @_itemMetadataProvider
+ metadataProvider: ->
+ @_metadataProvider
- setItemMetadataProvider: (fn) ->
- @_itemMetadataProvider = fn
+ setMetadataProvider: (fn) ->
+ @_metadataProvider = fn
@_pages = {}
@invalidate()
@@ -314,16 +314,16 @@ class DatabaseView extends ModelView
@retrievePage(idx)
return
- metadataPromises = {}
+ idsMissingMetadata = []
for item in items
- if metadataPromises[item.id]
- @log("Request for threads returned the same thread id (#{item.id}) multiple times.")
+ if not page.metadata[item.id]
+ idsMissingMetadata.push(item.id)
- metadataPromises[item.id] ?= page.metadata[item.id]
- if @_itemMetadataProvider
- metadataPromises[item.id] ?= @_itemMetadataProvider(item)
+ metadataPromise = Promise.resolve({})
+ if idsMissingMetadata.length > 0 and @_metadataProvider
+ metadataPromise = @_metadataProvider(idsMissingMetadata)
- Promise.props(metadataPromises).then (results) =>
+ metadataPromise.then (results) =>
# If we've started reloading since we made our query, don't do any more work
if page.lastTouchTime >= touchTime
@log("Metadata version #{touchTime} fetched, but out of date (current is #{page.lastTouchTime})")
@@ -332,8 +332,8 @@ class DatabaseView extends ModelView
for item, idx in items
if Object.isFrozen(item)
item = items[idx] = new @klass(item)
- item.metadata = results[item.id]
- page.metadata[item.id] = results[item.id]
+ metadata = results[item.id] ? page.metadata[item.id]
+ item.metadata = page.metadata[item.id] = metadata
# Prevent anything from mutating these objects or their nested objects.
# Accidentally modifying items somewhere downstream (in a component)
diff --git a/static/components/list-tabular.less b/static/components/list-tabular.less
index 45dd7f712..f615b98ae 100644
--- a/static/components/list-tabular.less
+++ b/static/components/list-tabular.less
@@ -160,12 +160,6 @@
}
}
- .list-rows {
- overflow: auto;
- // Add back when when we re-implement list-headers
- // padding-top: @font-size-base * 2; /* height of list-headers*/
- }
-
.list-column {
// The width is set by React.
display: inline-block;
diff --git a/static/components/scroll-region.less b/static/components/scroll-region.less
index 04a3715c0..8fe557e6e 100644
--- a/static/components/scroll-region.less
+++ b/static/components/scroll-region.less
@@ -51,6 +51,9 @@
width: 100%;
overflow-y: scroll;
}
+ .scroll-region-content-inner {
+ transform:translate3d(0,0,0);
+ }
.scrollbar-track {
opacity: 0;
@@ -87,6 +90,11 @@
}
}
.scroll-region.scrolling {
+
+ .scroll-region-content-inner {
+ pointer-events: none;
+ }
+
.scrollbar-track {
opacity: 1;
transition-delay: 0s;