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.push
+ actions.push
+ actions.push
if not @props.thread.hasTagId('archive') - actions.push
+ 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;