fix(contact-chips): Contact chips are editable and have much better style

Summary:
The TokenizingTextField has several new optional props, including onEdit, which enables
editing of the tokens and tokenIsInvalid, which allows you to make tokens red, while still
accepting them as tokens.

When you go to send a message with invalid recipients it won't let you until you remove/
edit them.

Hotloading

Edit chips, keymappings not through command registry, 7 new tests for editing chips

Test Plan: Run 7 new tests

Reviewers: evan

Reviewed By: evan

Differential Revision: https://phab.nylas.com/D1825
This commit is contained in:
Ben Gotow 2015-08-03 13:06:28 -07:00
parent 6b18b9715c
commit 1dc7c03ebd
18 changed files with 479 additions and 247 deletions

View file

@ -5,6 +5,7 @@ _ = require 'underscore'
File, File,
Actions, Actions,
DraftStore, DraftStore,
ContactStore,
UndoManager, UndoManager,
FileUploadStore, FileUploadStore,
QuotedHTMLParser, QuotedHTMLParser,
@ -582,12 +583,19 @@ class ComposerView extends React.Component
remote = require('remote') remote = require('remote')
dialog = remote.require('dialog') dialog = remote.require('dialog')
if [].concat(draft.to, draft.cc, draft.bcc).length is 0 allRecipients = [].concat(draft.to, draft.cc, draft.bcc)
for contact in allRecipients
if not ContactStore.isValidContact(contact)
dealbreaker = "#{contact.email} is not a valid email address - please remove or edit it before sending."
if allRecipients.length is 0
dealbreaker = 'You need to provide one or more recipients before sending the message.'
if dealbreaker
dialog.showMessageBox(remote.getCurrentWindow(), { dialog.showMessageBox(remote.getCurrentWindow(), {
type: 'warning', type: 'warning',
buttons: ['Edit Message'], buttons: ['Edit Message'],
message: 'Cannot Send', message: 'Cannot Send',
detail: 'You need to provide one or more recipients before sending the message.' detail: dealbreaker
}) })
return return

View file

@ -40,11 +40,13 @@ class ParticipantsTextField extends React.Component
ref="textField" ref="textField"
tokens={@props.participants[@props.field]} tokens={@props.participants[@props.field]}
tokenKey={ (p) -> p.email } tokenKey={ (p) -> p.email }
tokenIsValid={ (p) -> ContactStore.isValidContact(p) }
tokenNode={@_tokenNode} tokenNode={@_tokenNode}
onRequestCompletions={ (input) -> ContactStore.searchContacts(input) } onRequestCompletions={ (input) -> ContactStore.searchContacts(input) }
completionNode={@_completionNode} completionNode={@_completionNode}
onAdd={@_add} onAdd={@_add}
onRemove={@_remove} onRemove={@_remove}
onEdit={@_edit}
onEmptied={@props.onEmptied} onEmptied={@props.onEmptied}
onTokenAction={@_showContextMenu} onTokenAction={@_showContextMenu}
tabIndex={@props.tabIndex} tabIndex={@props.tabIndex}
@ -71,6 +73,20 @@ class ParticipantsTextField extends React.Component
<span className="participant-primary">{p.email}</span> <span className="participant-primary">{p.email}</span>
</div> </div>
_tokensForString: (string, options = {}) =>
# If the input is a string, parse out email addresses and build
# an array of contact objects. For each email address wrapped in
# parentheses, look for a preceding name, if one exists.
if string.length is 0
return []
contacts = ContactStore.parseContactsInString(string, options)
if contacts.length > 0
return contacts
else
# If no contacts are returned, treat the entire string as a single
# (malformed) contact object.
return [new Contact(email: string, name: null)]
_remove: (values) => _remove: (values) =>
field = @props.field field = @props.field
@ -81,22 +97,26 @@ class ParticipantsTextField extends React.Component
false false
@props.change(updates) @props.change(updates)
_edit: (token, replacementString) =>
field = @props.field
tokenIndex = @props.participants[field].indexOf(token)
replacements = @_tokensForString(replacementString)
updates = {}
updates[field] = [].concat(@props.participants[field])
updates[field].splice(tokenIndex, 1, replacements...)
@props.change(updates)
_add: (values, options={}) => _add: (values, options={}) =>
# If the input is a string, parse out email addresses and build # If the input is a string, parse out email addresses and build
# an array of contact objects. For each email address wrapped in # an array of contact objects. For each email address wrapped in
# parentheses, look for a preceding name, if one exists. # parentheses, look for a preceding name, if one exists.
if _.isString(values) if _.isString(values)
values = ContactStore.parseContactsInString(values, options) values = @_tokensForString(values, options)
# Safety check: remove anything from the incoming values that isn't # Safety check: remove anything from the incoming values that isn't
# a Contact. We should never receive anything else in the values array. # a Contact. We should never receive anything else in the values array.
values = _.filter values, (value) -> value instanceof Contact
values = _.compact _.map values, (value) ->
if value instanceof Contact
return value
else
return null
updates = {} updates = {}
for field in Object.keys(@props.participants) for field in Object.keys(@props.participants)

View file

@ -14,6 +14,7 @@ ReactTestUtils = React.addons.TestUtils
NylasTestUtils, NylasTestUtils,
NamespaceStore, NamespaceStore,
FileUploadStore, FileUploadStore,
ContactStore,
ComponentRegistry} = require "nylas-exports" ComponentRegistry} = require "nylas-exports"
{InjectedComponent} = require 'nylas-component-kit' {InjectedComponent} = require 'nylas-component-kit'
@ -58,12 +59,16 @@ draftStoreProxyStub = (localId, returnedDraft) ->
searchContactStub = (email) -> searchContactStub = (email) ->
_.filter(users, (u) u.email.toLowerCase() is email.toLowerCase()) _.filter(users, (u) u.email.toLowerCase() is email.toLowerCase())
isValidContactStub = (contact) ->
contact.email.indexOf('@') > 0
ComposerView = proxyquire "../lib/composer-view", ComposerView = proxyquire "../lib/composer-view",
"./file-upload": reactStub("file-upload") "./file-upload": reactStub("file-upload")
"./image-file-upload": reactStub("image-file-upload") "./image-file-upload": reactStub("image-file-upload")
"nylas-exports": "nylas-exports":
ContactStore: ContactStore:
searchContacts: (email) -> searchContactStub searchContacts: searchContactStub
isValidContact: isValidContactStub
DraftStore: DraftStore DraftStore: DraftStore
beforeEach -> beforeEach ->
@ -335,13 +340,26 @@ describe "populated composer", ->
spyOn(@dialog, "showMessageBox") spyOn(@dialog, "showMessageBox")
spyOn(Actions, "sendDraft") spyOn(Actions, "sendDraft")
it "shows a warning if there are no recipients", -> it "shows an error if there are no recipients", ->
useDraft.call @, subject: "no recipients" useDraft.call @, subject: "no recipients"
makeComposer.call(@) makeComposer.call(@)
@composer._sendDraft() @composer._sendDraft()
expect(Actions.sendDraft).not.toHaveBeenCalled() expect(Actions.sendDraft).not.toHaveBeenCalled()
expect(@dialog.showMessageBox).toHaveBeenCalled() expect(@dialog.showMessageBox).toHaveBeenCalled()
dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1] dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1]
expect(dialogArgs.detail).toEqual("You need to provide one or more recipients before sending the message.")
expect(dialogArgs.buttons).toEqual ['Edit Message']
it "shows an error if a recipient is invalid", ->
useDraft.call @,
subject: 'hello world!'
to: [new Contact(email: 'lol', name: 'lol')]
makeComposer.call(@)
@composer._sendDraft()
expect(Actions.sendDraft).not.toHaveBeenCalled()
expect(@dialog.showMessageBox).toHaveBeenCalled()
dialogArgs = @dialog.showMessageBox.mostRecentCall.args[1]
expect(dialogArgs.detail).toEqual("lol is not a valid email address - please remove or edit it before sending.")
expect(dialogArgs.buttons).toEqual ['Edit Message'] expect(dialogArgs.buttons).toEqual ['Edit Message']
describe "empty body warning", -> describe "empty body warning", ->

View file

@ -30,8 +30,6 @@ participant5 = new Contact
name: 'EVAN' name: 'EVAN'
describe 'ParticipantsTextField', -> describe 'ParticipantsTextField', ->
NylasTestUtils.loadKeymap()
beforeEach -> beforeEach ->
@propChange = jasmine.createSpy('change') @propChange = jasmine.createSpy('change')
@ -54,7 +52,7 @@ describe 'ParticipantsTextField', ->
@expectInputToYield = (input, expected) -> @expectInputToYield = (input, expected) ->
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: input}}) ReactTestUtils.Simulate.change(@renderedInput, {target: {value: input}})
NylasTestUtils.keyPress('enter', @renderedInput) ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Enter', keyCode: 9})
reviver = (k,v) -> reviver = (k,v) ->
return undefined if k in ["id", "object"] return undefined if k in ["id", "object"]

View file

@ -207,19 +207,6 @@
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.token {
background: transparent;
&.selected,
&.dragging {
.participant-secondary {
color: @text-color-inverse-very-subtle;
}
}
}
}
body.is-blurred .composer-inner-wrap .tokenizing-field .token {
background: transparent;
} }
// Overrides for the full-window popout composer // Overrides for the full-window popout composer

View file

@ -18,7 +18,7 @@ class Participants extends React.Component
render: => render: =>
chips = @getParticipants().map (p) => chips = @getParticipants().map (p) =>
<ContactChip key={p.nameEmail()} clickable={@props.clickable} participant={p} /> <ContactChip key={p.toString()} clickable={@props.clickable} participant={p} />
<span> <span>
{chips} {chips}

View file

@ -214,29 +214,3 @@
'shift-tab': 'native!' 'shift-tab': 'native!'
# Tokenizing Text fields # Tokenizing Text fields
# The default state. There's no input in the text field and there are no
# autocomplete suggestions
'.tokenizing-field input':
'backspace': 'native!'
'delete': 'native!'
'tab': 'native!'
',': 'native!'
'.tokenizing-field.empty input':
'backspace': 'tokenizing-field:remove'
'delete': 'tokenizing-field:remove'
# When we found a name to suggest
'.tokenizing-field.has-suggestions input':
',': 'tokenizing-field:add-suggestion'
'tab': 'tokenizing-field:add-suggestion'
'enter': 'tokenizing-field:add-suggestion'
'escape': 'tokenizing-field:cancel'
# When there is some text in the input field, but we have no suggestion
'.tokenizing-field:not(.has-suggestions) input':
'tab': 'native!'
',': 'tokenizing-field:add-input-value'
'enter': 'tokenizing-field:add-input-value'
'escape': 'tokenizing-field:cancel'

View file

@ -39,14 +39,14 @@ participant5 = new Contact
name: 'Michael' name: 'Michael'
describe 'TokenizingTextField', -> describe 'TokenizingTextField', ->
NylasTestUtils.loadKeymap()
beforeEach -> beforeEach ->
@completions = [] @completions = []
@propAdd = jasmine.createSpy 'add' @propAdd = jasmine.createSpy 'add'
@propEdit = jasmine.createSpy 'edit'
@propRemove = jasmine.createSpy 'remove' @propRemove = jasmine.createSpy 'remove'
@propEmptied = jasmine.createSpy 'emptied' @propEmptied = jasmine.createSpy 'emptied'
@propTokenKey = jasmine.createSpy("tokenKey").andCallFake (p) -> p.email @propTokenKey = jasmine.createSpy("tokenKey").andCallFake (p) -> p.email
@propTokenIsValid = jasmine.createSpy("tokenIsValid").andReturn(true)
@propTokenNode = (p) -> <CustomToken item={p} /> @propTokenNode = (p) -> <CustomToken item={p} />
@propOnTokenAction = jasmine.createSpy 'tokenAction' @propOnTokenAction = jasmine.createSpy 'tokenAction'
@propCompletionNode = (p) -> <CustomSuggestion item={p} /> @propCompletionNode = (p) -> <CustomSuggestion item={p} />
@ -58,26 +58,32 @@ describe 'TokenizingTextField', ->
@tabIndex = 100 @tabIndex = 100
@tokens = [participant1, participant2, participant3] @tokens = [participant1, participant2, participant3]
@renderedField = ReactTestUtils.renderIntoDocument( @rebuildRenderedField = =>
<TokenizingTextField @renderedField = ReactTestUtils.renderIntoDocument(
tokens={@tokens} <TokenizingTextField
tokenKey={@propTokenKey} tokens={@tokens}
tokenNode={@propTokenNode} tokenKey={@propTokenKey}
onRequestCompletions={@propCompletionsForInput} tokenNode={@propTokenNode}
completionNode={@propCompletionNode} tokenIsValid={@propTokenIsValid}
onAdd={@propAdd} onRequestCompletions={@propCompletionsForInput}
onRemove={@propRemove} completionNode={@propCompletionNode}
onEmptied={@propEmptied} onAdd={@propAdd}
onTokenAction={@propOnTokenAction} onEdit={@propEdit}
tabIndex={@tabIndex} onRemove={@propRemove}
/> onEmptied={@propEmptied}
) onTokenAction={@propOnTokenAction}
tabIndex={@tabIndex}
@renderedInput = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input')) />
)
@renderedInput = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input'))
@rebuildRenderedField()
it 'renders into the document', -> it 'renders into the document', ->
expect(ReactTestUtils.isCompositeComponentWithType @renderedField, TokenizingTextField).toBe(true) expect(ReactTestUtils.isCompositeComponentWithType @renderedField, TokenizingTextField).toBe(true)
it 'should render an input field', ->
expect(@renderedInput).toBeDefined()
it 'applies the tabIndex provided to the inner input', -> it 'applies the tabIndex provided to the inner input', ->
expect(@renderedInput.tabIndex).toBe(@tabIndex) expect(@renderedInput.tabIndex).toBe(@tabIndex)
@ -90,6 +96,25 @@ describe 'TokenizingTextField', ->
for i in [0..@tokens.length-1] for i in [0..@tokens.length-1]
expect(@renderedTokens[i].props.item).toBe(@tokens[i]) expect(@renderedTokens[i].props.item).toBe(@tokens[i])
describe "prop: tokenIsValid", ->
it "should be evaluated for each token when it's provided", ->
@propTokenIsValid = jasmine.createSpy("tokenIsValid").andCallFake (p) =>
if p is participant2 then true else false
@rebuildRenderedField()
@tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
expect(@tokens[0].props.valid).toBe(false)
expect(@tokens[1].props.valid).toBe(true)
expect(@tokens[2].props.valid).toBe(false)
it "should default to true when not provided", ->
@propTokenIsValid = null
@rebuildRenderedField()
@tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
expect(@tokens[0].props.valid).toBe(true)
expect(@tokens[1].props.valid).toBe(true)
expect(@tokens[2].props.valid).toBe(true)
describe "When the user selects a token", -> describe "When the user selects a token", ->
beforeEach -> beforeEach ->
token = ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'token')[0] token = ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'token')[0]
@ -175,20 +200,20 @@ describe 'TokenizingTextField', ->
expect(@propCompletionsForInput.calls[2].args[0]).toBe('ab c') expect(@propCompletionsForInput.calls[2].args[0]).toBe('ab c')
expect(@propAdd).not.toHaveBeenCalled() expect(@propAdd).not.toHaveBeenCalled()
['enter', ','].forEach (key) -> [{key:'Enter', keyCode:13}, {key:',', keyCode: 188}].forEach ({key, keyCode}) ->
describe "when the user presses #{key}", -> describe "when the user presses #{key}", ->
describe "and there is an completion available", -> describe "and there is an completion available", ->
it "should call add with the first completion", -> it "should call add with the first completion", ->
@completions = [participant4] @completions = [participant4]
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}}) ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
NylasTestUtils.keyPress(key, @renderedInput) ReactTestUtils.Simulate.keyDown(@renderedInput, {key: key, keyCode: keyCode})
expect(@propAdd).toHaveBeenCalledWith([participant4]) expect(@propAdd).toHaveBeenCalledWith([participant4])
describe "and there is NO completion available", -> describe "and there is NO completion available", ->
it 'should call add, allowing the parent to (optionally) turn the text into a token', -> it 'should call add, allowing the parent to (optionally) turn the text into a token', ->
@completions = [] @completions = []
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}}) ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
NylasTestUtils.keyPress(key, @renderedInput) ReactTestUtils.Simulate.keyDown(@renderedInput, {key: key, keyCode: keyCode})
expect(@propAdd).toHaveBeenCalledWith('abc', {}) expect(@propAdd).toHaveBeenCalledWith('abc', {})
describe "when the user presses tab", -> describe "when the user presses tab", ->
@ -196,7 +221,7 @@ describe 'TokenizingTextField', ->
it "should call add with the first completion", -> it "should call add with the first completion", ->
@completions = [participant4] @completions = [participant4]
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}}) ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
NylasTestUtils.keyPress('tab', @renderedInput) ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Tab', keyCode: 9})
expect(@propAdd).toHaveBeenCalledWith([participant4]) expect(@propAdd).toHaveBeenCalledWith([participant4])
describe "when blurred", -> describe "when blurred", ->
@ -218,12 +243,43 @@ describe 'TokenizingTextField', ->
ReactTestUtils.Simulate.blur(@renderedInput) ReactTestUtils.Simulate.blur(@renderedInput)
expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'focused').length).toBe(0) expect(ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'focused').length).toBe(0)
describe "when the user double-clicks a token", ->
describe "when an onEdit prop has been provided", ->
beforeEach ->
@propEdit = jasmine.createSpy('onEdit')
@rebuildRenderedField()
it "should enter editing mode", ->
tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
expect(tokens[0].state.editing).toBe(false)
ReactTestUtils.Simulate.doubleClick(React.findDOMNode(tokens[0]), {})
expect(tokens[0].state.editing).toBe(true)
it "should call onEdit to commit the new token value when the edit field is blurred", ->
tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
token = tokens[0]
tokenEl = React.findDOMNode(token)
expect(token.state.editing).toBe(false)
ReactTestUtils.Simulate.doubleClick(tokenEl, {})
tokenEditInput = ReactTestUtils.findRenderedDOMComponentWithTag(token, 'input')
ReactTestUtils.Simulate.change(tokenEditInput, {target: {value: 'new tag content'}})
ReactTestUtils.Simulate.blur(tokenEditInput)
expect(@propEdit).toHaveBeenCalledWith(participant1, 'new tag content')
describe "when no onEdit prop has been provided", ->
it "should not enter editing mode", ->
@propEdit = undefined
@rebuildRenderedField()
tokens = ReactTestUtils.scryRenderedComponentsWithType(@renderedField, TokenizingTextField.Token)
expect(tokens[0].state.editing).toBe(false)
ReactTestUtils.Simulate.doubleClick(React.findDOMNode(tokens[0]), {})
expect(tokens[0].state.editing).toBe(false)
describe "When the user removes a token", -> describe "When the user removes a token", ->
it "deletes with the backspace key", -> it "deletes with the backspace key", ->
spyOn(@renderedField, "_removeToken") spyOn(@renderedField, "_removeToken")
NylasTestUtils.keyPress("backspace", @renderedInput) ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Backspace', keyCode: 8})
expect(@renderedField._removeToken).toHaveBeenCalled() expect(@renderedField._removeToken).toHaveBeenCalled()
describe "when removal is passed in a token object", -> describe "when removal is passed in a token object", ->

View file

@ -94,19 +94,30 @@ describe "ContactStore", ->
describe 'parseContactsInString', -> describe 'parseContactsInString', ->
testCases = testCases =
# Single contact test cases
"evan@nylas.com": [new Contact(name: "evan@nylas.com", email: "evan@nylas.com")] "evan@nylas.com": [new Contact(name: "evan@nylas.com", email: "evan@nylas.com")]
"Evan Morikawa": [] "Evan Morikawa": []
"Evan Morikawa <evan@nylas.com>": [new Contact(name: "Evan Morikawa", email: "evan@nylas.com")] "Evan Morikawa <evan@nylas.com>": [new Contact(name: "Evan Morikawa", email: "evan@nylas.com")]
"Evan Morikawa (evan@nylas.com)": [new Contact(name: "Evan Morikawa", email: "evan@nylas.com")] "Evan Morikawa (evan@nylas.com)": [new Contact(name: "Evan Morikawa", email: "evan@nylas.com")]
"spang (Christine Spang) <noreply+phabricator@nilas.com>": [new Contact(name: "spang (Christine Spang)", email: "noreply+phabricator@nilas.com")]
"spang 'Christine Spang' <noreply+phabricator@nilas.com>": [new Contact(name: "spang 'Christine Spang'", email: "noreply+phabricator@nilas.com")]
"spang \"Christine Spang\" <noreply+phabricator@nilas.com>": [new Contact(name: "spang \"Christine Spang\"", email: "noreply+phabricator@nilas.com")]
"Evan (evan@nylas.com)": [new Contact(name: "Evan", email: "evan@nylas.com")] "Evan (evan@nylas.com)": [new Contact(name: "Evan", email: "evan@nylas.com")]
# Multiple contact test cases
"Evan Morikawa <evan@nylas.com>, Ben <ben@nylas.com>": [ "Evan Morikawa <evan@nylas.com>, Ben <ben@nylas.com>": [
new Contact(name: "Evan Morikawa", email: "evan@nylas.com") new Contact(name: "Evan Morikawa", email: "evan@nylas.com")
new Contact(name: "Ben", email: "ben@nylas.com") new Contact(name: "Ben", email: "ben@nylas.com")
] ]
"mark@nylas.com\nGleb (gleb@nylas.com)\rEvan Morikawa <evan@nylas.com>, spang (Christine Spang) <noreply+phabricator@nilas.com>": [
new Contact(name: "", email: "mark@nylas.com")
new Contact(name: "Gleb", email: "gleb@nylas.com")
new Contact(name: "Evan Morikawa", email: "evan@nylas.com")
new Contact(name: "spang (Christine Spang)", email: "noreply+phabricator@nilas.com")
]
_.forEach testCases, (value, key) -> _.forEach testCases, (value, key) ->
it "works for #{key}", -> it "works for #{key}", ->
testContacts = ContactStore.parseContactsInString(key).map (c) -> c.nameEmail() testContacts = ContactStore.parseContactsInString(key).map (c) -> c.toString()
expectedContacts = value.map (c) -> c.nameEmail() expectedContacts = value.map (c) -> c.toString()
expect(testContacts).toEqual expectedContacts expect(testContacts).toEqual expectedContacts

View file

@ -33,6 +33,7 @@ var hotCompile = (function () {
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(function () { timeout = setTimeout(function () {
hotCompile(module, module.filename, true); hotCompile(module, module.filename, true);
console.log('hot reloaded '+module.filename);
}, 100); }, 100);
}); });
} }

View file

@ -5,48 +5,125 @@ _ = require 'underscore'
{Utils, RegExpUtils, Contact, ContactStore} = require 'nylas-exports' {Utils, RegExpUtils, Contact, ContactStore} = require 'nylas-exports'
RetinaImg = require './retina-img' RetinaImg = require './retina-img'
Token = React.createClass class SizeToFitInput extends React.Component
displayName: "Token" constructor: (@props) ->
@state = {}
propTypes: componentDidMount: =>
@_sizeToFit()
componentDidUpdate: =>
@_sizeToFit()
_sizeToFit: =>
# Measure the width of the text in the input and
# resize the input field to fit.
input = React.findDOMNode(@refs.input)
measure = React.findDOMNode(@refs.measure)
measure.innerText = input.value
measure.style.top = input.offsetTop + "px"
measure.style.left = input.offsetLeft + "px"
# The 10px comes from the 7.5px left padding and 2.5px more of
# breathing room.
input.style.width = "#{measure.offsetWidth+10}px"
render: =>
<span>
<span ref="measure" style={visibility:'hidden', position: 'absolute'}></span>
<input ref="input" type="text" {...@props}/>
</span>
select: =>
React.findDOMNode(@refs.input).select()
focus: =>
React.findDOMNode(@refs.input).focus()
class Token extends React.Component
@displayName: "Token"
@propTypes:
selected: React.PropTypes.bool, selected: React.PropTypes.bool,
select: React.PropTypes.func.isRequired, valid: React.PropTypes.bool,
action: React.PropTypes.func,
item: React.PropTypes.object, item: React.PropTypes.object,
onSelected: React.PropTypes.func.isRequired,
onEdited: React.PropTypes.func,
onAction: React.PropTypes.func
getInitialState: -> constructor: (@props) ->
{} @state =
editing: false
editingValue: @props.item.toString()
render: -> render: =>
if @state.editing
@_renderEditing()
else
@_renderViewing()
componentDidUpdate: (prevProps, prevState) =>
if @state.editing && !prevState.editing
@refs.input.select()
componentWillReceiveProps: (props) =>
# never override the text the user is editing if they're looking at it
return if @state.editing
@setState(editingValue: @props.item.toString())
_renderEditing: =>
<SizeToFitInput
ref="input"
className="token-editing-input"
spellCheck="false"
value={@state.editingValue}
onKeyDown={@_onEditKeydown}
onBlur={@_onEditFinished}
onChange={ (event) => @setState(editingValue: event.target.value)}/>
_renderViewing: =>
classes = classNames classes = classNames
"token": true "token": true
"dragging": @state.dragging "dragging": @state.dragging
"invalid": !@props.valid
"selected": @props.selected "selected": @props.selected
<div onDragStart={@_onDragStart} onDragEnd={@_onDragEnd} draggable="true" <div className={classes}
className={classes} onDragStart={@_onDragStart}
onDragEnd={@_onDragEnd}
draggable="true"
onDoubleClick={@_onDoubleClick}
onClick={@_onSelect}> onClick={@_onSelect}>
<button className="action" onClick={@_onAction} style={marginTop: "2px"}> <button className="action" onClick={@_onAction}>
<RetinaImg mode={RetinaImg.Mode.ContentIsMask} name="composer-caret.png" /> <RetinaImg mode={RetinaImg.Mode.ContentIsMask} name="composer-caret.png" />
</button> </button>
{@props.children} {@props.children}
</div> </div>
_onDragStart: (event) -> _onDragStart: (event) =>
textValue = React.findDOMNode(@).innerText
event.dataTransfer.setData('nylas-token-item', JSON.stringify(@props.item)) event.dataTransfer.setData('nylas-token-item', JSON.stringify(@props.item))
event.dataTransfer.setData('text/plain', textValue) event.dataTransfer.setData('text/plain', @props.item.toString())
@setState(dragging: true) @setState(dragging: true)
_onDragEnd: (event) -> _onDragEnd: (event) =>
@setState(dragging: false) @setState(dragging: false)
_onSelect: (event) -> _onSelect: (event) =>
@props.select(@props.item) @props.onSelected(@props.item)
event.preventDefault()
_onAction: (event) -> _onDoubleClick: (event) =>
@props.action(@props.item) if @props.onEdited
@setState(editing: true)
_onEditKeydown: (event) =>
if event.key in ['Escape', 'Enter']
@_onEditFinished()
_onEditFinished: (event) =>
@props.onEdited?(@props.item, @state.editingValue)
@setState(editing: false)
_onAction: (event) =>
@props.onAction(@props.item)
event.preventDefault() event.preventDefault()
### ###
@ -60,10 +137,14 @@ See documentation on the propTypes for usage info.
Section: Component Kit Section: Component Kit
### ###
TokenizingTextField = React.createClass
displayName: "TokenizingTextField"
propTypes: class TokenizingTextField extends React.Component
@displayName: "TokenizingTextField"
# Exposed for tests
@Token: Token
@propTypes:
# An array of current tokens. # An array of current tokens.
# #
# A token is usually an object type like a # A token is usually an object type like a
@ -77,8 +158,6 @@ TokenizingTextField = React.createClass
# unlimited number of tokens may be given # unlimited number of tokens may be given
maxTokens: React.PropTypes.number maxTokens: React.PropTypes.number
# A unique ID for each token object
#
# A function that, given an object used for tokens, returns a unique # A function that, given an object used for tokens, returns a unique
# id (key) for that object. # id (key) for that object.
# #
@ -86,6 +165,13 @@ TokenizingTextField = React.createClass
# unique key. # unique key.
tokenKey: React.PropTypes.func.isRequired tokenKey: React.PropTypes.func.isRequired
# A function that, given a token, returns true if the token is valid
# and false if the token is invalid. Useful if your implementation of
# onAdd allows invalid tokens to be added to the field (ie malformed
# email addresses.) Optional.
#
tokenIsValid: React.PropTypes.func
# What each token looks like # What each token looks like
# #
# A function that is passed an object and should return React elements # A function that is passed an object and should return React elements
@ -125,10 +211,6 @@ TokenizingTextField = React.createClass
# updates this component's `tokens` prop. # updates this component's `tokens` prop.
onAdd: React.PropTypes.func.isRequired onAdd: React.PropTypes.func.isRequired
# By default, when blurred, whatever is in the field is added. If this
# is true, the field will be cleared instead.
clearOnBlur: React.PropTypes.bool
# Gets called when we remove a token # Gets called when we remove a token
# #
# It's passed an array of objects (the same ones used to render # It's passed an array of objects (the same ones used to render
@ -139,6 +221,16 @@ TokenizingTextField = React.createClass
# updates this component's `tokens` prop. # updates this component's `tokens` prop.
onRemove: React.PropTypes.func.isRequired onRemove: React.PropTypes.func.isRequired
# Gets called when an existing token is double-clicked and edited.
# Do not provide this method if you want to disable editing.
#
# It's passed a token index, and the new text typed in that location.
#
# The function doesn't need to return anything, but it is generally
# responible for mutating the parent's state in a way that eventually
# updates this component's `tokens` prop.
onEdit: React.PropTypes.func
# Called when we remove and there's nothing left to remove # Called when we remove and there's nothing left to remove
onEmptied: React.PropTypes.func onEmptied: React.PropTypes.func
@ -157,134 +249,109 @@ TokenizingTextField = React.createClass
# A classSet hash applied to the Menu item # A classSet hash applied to the Menu item
menuClassSet: React.PropTypes.object menuClassSet: React.PropTypes.object
getInitialState: -> constructor: (@props) ->
inputValue: "" @state =
completions: [] inputValue: ""
selectedTokenKey: null completions: []
selectedTokenKey: null
componentDidMount: -> render: =>
input = React.findDOMNode(@refs.input)
check = (fn) -> (event) ->
return unless event.target is input
# Wrapper to guard against events triggering on the wrong element
fn(event)
@subscriptions = new CompositeDisposable()
@subscriptions.add atom.commands.add '.tokenizing-field',
'tokenizing-field:cancel': check => @_clearInput()
'tokenizing-field:remove': check => @_removeToken()
'tokenizing-field:add-suggestion': check => @_addToken(@refs.completions.getSelectedItem() || @state.completions[0])
'tokenizing-field:add-input-value': check => @_addInputValue()
componentWillUnmount: ->
@subscriptions?.dispose()
componentDidUpdate: ->
# Measure the width of the text in the input and
# resize the input field to fit.
input = React.findDOMNode(@refs.input)
measure = React.findDOMNode(@refs.measure)
measure.innerHTML = @state.inputValue.replace(/\s/g, "&nbsp;")
measure.style.top = input.offsetTop + "px"
measure.style.left = input.offsetLeft + "px"
if @_atMaxTokens()
input.style.width = "4px"
else
# The 10px comes from the 7.5px left padding and 2.5px more of
# breathing room.
input.style.width = "#{measure.offsetWidth+10}px"
render: ->
{Menu} = require 'nylas-component-kit' {Menu} = require 'nylas-component-kit'
classes = classNames _.extend (@props.menuClassSet ? {}), classes = classNames _.extend (@props.menuClassSet ? {}),
"tokenizing-field": true "tokenizing-field": true
"focused": @state.focus
"native-key-bindings": true "native-key-bindings": true
"focused": @state.focus
"empty": (@state.inputValue ? "").trim().length is 0 "empty": (@state.inputValue ? "").trim().length is 0
"has-suggestions": @state.completions.length > 0
<Menu className={classes} ref="completions" <Menu className={classes} ref="completions"
items={@state.completions} items={@state.completions}
itemKey={ (item) -> item.id } itemKey={ (item) -> item.id }
itemContent={@props.completionNode} itemContent={@props.completionNode}
headerComponents={[@_fieldComponent()]} headerComponents={[@_fieldComponent()]}
onFocus={@_onInputFocused}
onBlur={@_onInputBlurred}
onSelect={@_addToken} onSelect={@_addToken}
/> />
_fieldComponent: -> _fieldComponent: =>
<div key="field-component" onClick={@focus} onDrop={@_onDrop}> <div key="field-component" onClick={@_onClick} onDrop={@_onDrop}>
{@_renderPrompt()} {@_renderPrompt()}
<div className="tokenizing-field-input"> <div className="tokenizing-field-input">
{@_placeholder()} {@_placeholder()}
{@_fieldComponents()}
{@_fieldTokenComponents()}
{@_inputEl()} {@_inputEl()}
<span ref="measure" style={
position: 'absolute'
visibility: 'hidden'
}/>
</div> </div>
</div> </div>
_inputEl: -> _inputEl: =>
if @_atMaxTokens() props =
<input type="text" onCopy: @_onCopy
ref="input" onCut: @_onCut
spellCheck="false" onPaste: @_onPaste
className="noop-input" onKeyDown: @_onInputKeydown
onCopy={@_onCopy} onBlur: @_onInputBlurred
onCut={@_onCut} onFocus: @_onInputFocused
onBlur={@_onInputBlurred} onChange: @_onInputChanged
onFocus={ => @_onInputFocused(noCompletions: true)} disabled: @props.disabled
onChange={ -> "noop" } tabIndex: @props.tabIndex
tabIndex={@props.tabIndex} value: @state.inputValue
value="" />
else
<input type="text"
ref="input"
spellCheck="false"
onCopy={@_onCopy}
onCut={@_onCut}
onPaste={@_onPaste}
onBlur={@_onInputBlurred}
onFocus={@_onInputFocused}
onChange={@_onInputChanged}
disabled={@props.disabled}
tabIndex={@props.tabIndex}
value={@state.inputValue} />
_placeholder: -> # If we can't accept additional tokens, override the events that would
# enable additional items to be inserted
if @_atMaxTokens()
props.className = "noop-input"
props.onFocus = => @_onInputFocused(noCompletions: true)
props.onPaste = => 'noop-input'
props.onChange = => 'noop'
props.value = ''
<SizeToFitInput ref="input" spellCheck="false" {...props} />
_placeholder: =>
if not @state.focus and @props.placeholder? if not @state.focus and @props.placeholder?
return <div className="placeholder">{@props.placeholder}</div> return <div className="placeholder">{@props.placeholder}</div>
else else
return <span></span> return <span></span>
_atMaxTokens: -> _atMaxTokens: =>
if @props.maxTokens if @props.maxTokens
@props.tokens.length >= @props.maxTokens @props.tokens.length >= @props.maxTokens
else return false else return false
_renderPrompt: -> _renderPrompt: =>
if @props.menuPrompt if @props.menuPrompt
<div className="tokenizing-field-label">{"#{@props.menuPrompt}:"}</div> <div className="tokenizing-field-label">{"#{@props.menuPrompt}:"}</div>
else else
<div></div> <div></div>
_fieldTokenComponents: -> _fieldComponents: =>
@props.tokens.map (item) => @props.tokens.map (item) =>
key = @props.tokenKey(item)
valid = true
if @props.tokenIsValid
valid = @props.tokenIsValid(item)
<Token item={item} <Token item={item}
key={@props.tokenKey(item)} key={key}
select={@_selectToken} valid={valid}
action={@props.onTokenAction || @_showDefaultTokenMenu} selected={@state.selectedTokenKey is key}
selected={@state.selectedTokenKey is @props.tokenKey(item)}> onSelected={@_selectToken}
onEdited={@props.onEdit}
onAction={@props.onTokenAction || @_showDefaultTokenMenu}>
{@props.tokenNode(item)} {@props.tokenNode(item)}
</Token> </Token>
# Maintaining Input State # Maintaining Input State
_onDrop: (event) -> _onClick: (event) =>
# Don't focus if the focus is already on an input within our field,
# like an editable token's input
if event.target.tagName is 'INPUT' and React.findDOMNode(@).contains(event.target)
return
@focus()
_onDrop: (event) =>
return unless 'nylas-token-item' in event.dataTransfer.types return unless 'nylas-token-item' in event.dataTransfer.types
try try
@ -296,11 +363,24 @@ TokenizingTextField = React.createClass
if model and model instanceof Contact if model and model instanceof Contact
@_addToken(model) @_addToken(model)
_onInputFocused: ({noCompletions}={}) -> _onInputFocused: ({noCompletions}={}) =>
@setState focus: true @setState(focus: true)
@_refreshCompletions() unless noCompletions @_refreshCompletions() unless noCompletions
_onInputChanged: (event) -> _onInputKeydown: (event) =>
if event.key in ["Backspace", "Delete"]
@_removeToken()
else if event.key in ["Escape"]
@_refreshCompletions("", clear: true)
else if event.key in ["Tab", "Enter"] or event.keyCode is 188 # comma
if @state.completions.length > 0
@_addToken(@refs.completions.getSelectedItem() || @state.completions[0])
else
@_addInputValue()
_onInputChanged: (event) =>
val = event.target.value.trimLeft() val = event.target.value.trimLeft()
@setState @setState
selectedTokenKey: null selectedTokenKey: null
@ -308,51 +388,52 @@ TokenizingTextField = React.createClass
# If it looks like an email, and the last character entered was a # If it looks like an email, and the last character entered was a
# space, then let's add the input value. # space, then let's add the input value.
# TODO WHY IS THIS EMAIL RELATED?
if RegExpUtils.emailRegex().test(val) and _.last(val) is " " if RegExpUtils.emailRegex().test(val) and _.last(val) is " "
@_addInputValue(val[0...-1], skipNameLookup: true) @_addInputValue(val[0...-1], skipNameLookup: true)
else else
@_refreshCompletions(val) @_refreshCompletions(val)
_onInputBlurred: -> _onInputBlurred: (event) =>
if @props.clearOnBlur if event.relatedTarget is React.findDOMNode(@)
@_clearInput() return
else
@_addInputValue() @_addInputValue()
@_refreshCompletions("", clear: true) @_refreshCompletions("", clear: true)
@setState @setState
selectedTokenKey: null selectedTokenKey: null
focus: false focus: false
_clearInput: -> _clearInput: =>
@setState inputValue: "" @setState(inputValue: "")
@_refreshCompletions("", clear: true) @_refreshCompletions("", clear: true)
focus: -> focus: =>
React.findDOMNode(@refs.input).focus() @refs.input.focus()
# Managing Tokens # Managing Tokens
_addInputValue: (input, options={}) -> _addInputValue: (input, options={}) =>
return if @_atMaxTokens() return if @_atMaxTokens()
input ?= @state.inputValue input ?= @state.inputValue
@props.onAdd(input, options) @props.onAdd(input, options)
@_clearInput() @_clearInput()
_selectToken: (token) -> _selectToken: (token) =>
@setState @setState
selectedTokenKey: @props.tokenKey(token) selectedTokenKey: @props.tokenKey(token)
_selectedToken: -> _selectedToken: =>
_.find @props.tokens, (t) => _.find @props.tokens, (t) =>
@props.tokenKey(t) is @state.selectedTokenKey @props.tokenKey(t) is @state.selectedTokenKey
_addToken: (token) -> _addToken: (token) =>
return unless token return unless token
@props.onAdd([token]) @props.onAdd([token])
@_clearInput() @_clearInput()
@focus() @focus()
_removeToken: (token = null) -> _removeToken: (token = null) =>
if @state.inputValue.trim().length is 0 and @props.tokens.length is 0 and @props.onEmptied? if @state.inputValue.trim().length is 0 and @props.tokens.length is 0 and @props.onEmptied?
@props.onEmptied() @props.onEmptied()
@ -369,7 +450,7 @@ TokenizingTextField = React.createClass
@setState @setState
selectedTokenKey: null selectedTokenKey: null
_showDefaultTokenMenu: (token) -> _showDefaultTokenMenu: (token) =>
remote = require('remote') remote = require('remote')
Menu = remote.require('menu') Menu = remote.require('menu')
MenuItem = remote.require('menu-item') MenuItem = remote.require('menu-item')
@ -379,23 +460,22 @@ TokenizingTextField = React.createClass
label: 'Remove', label: 'Remove',
click: => @_removeToken(token) click: => @_removeToken(token)
)) ))
menu.popup(remote.getCurrentWindow()) menu.popup(remote.getCurrentWindow())
# Copy and Paste # Copy and Paste
_onCut: (event) -> _onCut: (event) =>
if @state.selectedTokenKey if @state.selectedTokenKey
event.clipboardData?.setData('text/plain', @props.tokenKey(@_selectedToken())) event.clipboardData?.setData('text/plain', @props.tokenKey(@_selectedToken()))
event.preventDefault() event.preventDefault()
@_removeToken(@_selectedToken()) @_removeToken(@_selectedToken())
_onCopy: (event) -> _onCopy: (event) =>
if @state.selectedTokenKey if @state.selectedTokenKey
event.clipboardData.setData('text/plain', @props.tokenKey(@_selectedToken())) event.clipboardData.setData('text/plain', @props.tokenKey(@_selectedToken()))
event.preventDefault() event.preventDefault()
_onPaste: (event) -> _onPaste: (event) =>
data = event.clipboardData.getData('text/plain') data = event.clipboardData.getData('text/plain')
@_addInputValue(data) @_addInputValue(data)
event.preventDefault() event.preventDefault()
@ -406,7 +486,7 @@ TokenizingTextField = React.createClass
# current inputValue. Since `onRequestCompletions` can be asynchronous, # current inputValue. Since `onRequestCompletions` can be asynchronous,
# this function will handle calling `setState` on `completions` when # this function will handle calling `setState` on `completions` when
# `onRequestCompletions` returns. # `onRequestCompletions` returns.
_refreshCompletions: (val = @state.inputValue, {clear}={}) -> _refreshCompletions: (val = @state.inputValue, {clear}={}) =>
existingKeys = _.map(@props.tokens, @props.tokenKey) existingKeys = _.map(@props.tokens, @props.tokenKey)
filterTokens = (tokens) => filterTokens = (tokens) =>
_.reject tokens, (t) => @props.tokenKey(t) in existingKeys _.reject tokens, (t) => @props.tokenKey(t) in existingKeys

View file

@ -1,6 +1,16 @@
_ = require 'underscore' _ = require 'underscore'
DOMUtils = DOMUtils =
escapeHTMLCharacters: (text) ->
map =
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
text.replace /[&<>"']/g, (m) -> map[m]
removeElements: (elements=[]) -> removeElements: (elements=[]) ->
for el in elements for el in elements
try try

View file

@ -53,9 +53,13 @@ class Contact extends Model
'phone': Attributes.String(modelKey: 'phone') 'phone': Attributes.String(modelKey: 'phone')
'company': Attributes.String(modelKey: 'company') 'company': Attributes.String(modelKey: 'company')
# Used to uniquely identify a contact # Public: Returns a string of the format `Full Name <email@address.com>` if
nameEmail: -> # the contact has a populated name, just the email address otherwise.
"#{(@name ? "").toLowerCase().trim()} #{@email.toLowerCase().trim()}" toString: ->
# Note: This is used as the drag-drop text of a Contact token, in the
# creation of message bylines "From Ben Gotow <ben@nylas>", and several other
# places. Change with care.
if @name and @name isnt @email then "#{@name} <#{@email}>" else @email
toJSON: -> toJSON: ->
json = super json = super
@ -70,10 +74,6 @@ class Contact extends Model
return "You" if @email is NamespaceStore.current()?.emailAddress return "You" if @email is NamespaceStore.current()?.emailAddress
@_nameParts().join(' ') @_nameParts().join(' ')
# Full Name <email@address.com>
messageName: ->
if @name then "#{@name} &lt;#{@email}&gt;" else @email
displayFirstName: -> displayFirstName: ->
return "You" if @email is NamespaceStore.current()?.emailAddress return "You" if @email is NamespaceStore.current()?.emailAddress
@firstName() @firstName()

View file

@ -263,7 +263,7 @@ class Message extends Model
# localized for the current user. # localized for the current user.
# ie "On Dec. 12th, 2015 at 4:00PM, Ben Gotow wrote:" # ie "On Dec. 12th, 2015 at 4:00PM, Ben Gotow wrote:"
replyAttributionLine: -> replyAttributionLine: ->
"On #{@formattedDate()}, #{@fromContact().messageName()} wrote:" "On #{@formattedDate()}, #{@fromContact().toString()} wrote:"
formattedDate: -> moment(@date).format("MMM D YYYY, [at] h:mm a") formattedDate: -> moment(@date).format("MMM D YYYY, [at] h:mm a")

View file

@ -207,9 +207,18 @@ class ContactStore extends NylasStore
matches matches
# Public: Returns true if the contact provided is a {Contact} instance and
# contains a properly formatted email address.
#
isValidContact: (contact) =>
return false unless contact instanceof Contact
return contact.email and RegExpUtils.emailRegex().test(contact.email)
parseContactsInString: (contactString, {skipNameLookup}={}) => parseContactsInString: (contactString, {skipNameLookup}={}) =>
detected = [] detected = []
emailRegex = RegExpUtils.emailRegex() emailRegex = RegExpUtils.emailRegex()
lastMatchEnd = 0
while (match = emailRegex.exec(contactString)) while (match = emailRegex.exec(contactString))
email = match[0] email = match[0]
name = null name = null
@ -218,8 +227,8 @@ class ContactStore extends NylasStore
hasTrailingParen = contactString[match.index+email.length] in [')','>'] hasTrailingParen = contactString[match.index+email.length] in [')','>']
if hasLeadingParen and hasTrailingParen if hasLeadingParen and hasTrailingParen
nameStart = 0 nameStart = lastMatchEnd
for char in ['>', ')', ',', '\n', '\r'] for char in [',', '\n', '\r']
i = contactString.lastIndexOf(char, match.index) i = contactString.lastIndexOf(char, match.index)
nameStart = i+1 if i+1 > nameStart nameStart = i+1 if i+1 > nameStart
name = contactString.substr(nameStart, match.index - 1 - nameStart).trim() name = contactString.substr(nameStart, match.index - 1 - nameStart).trim()
@ -233,7 +242,13 @@ class ContactStore extends NylasStore
else else
name = email name = email
# The "nameStart" for the next match must begin after lastMatchEnd
lastMatchEnd = match.index+email.length
if hasTrailingParen
lastMatchEnd += 1
detected.push(new Contact({email, name})) detected.push(new Contact({email, name}))
detected detected
__refreshCache: => __refreshCache: =>

View file

@ -19,9 +19,9 @@ Actions = require '../actions'
TaskQueue = require './task-queue' TaskQueue = require './task-queue'
{subjectWithPrefix, generateTempId} = require '../models/utils' {subjectWithPrefix, generateTempId} = require '../models/utils'
{Listener, Publisher} = require '../modules/reflux-coffee' {Listener, Publisher} = require '../modules/reflux-coffee'
CoffeeHelpers = require '../coffee-helpers' CoffeeHelpers = require '../coffee-helpers'
DOMUtils = require '../../dom-utils'
### ###
Public: DraftStore responds to Actions that interact with Drafts and exposes Public: DraftStore responds to Actions that interact with Drafts and exposes
@ -258,7 +258,8 @@ class DraftStore
attributes.subject ?= subjectWithPrefix(thread.subject, 'Re:') attributes.subject ?= subjectWithPrefix(thread.subject, 'Re:')
attributes.body ?= "" attributes.body ?= ""
contactStrings = (cs) -> _.invoke(cs, "messageName").join(", ") contactsAsHtml = (cs) ->
DOMUtils.escapeHTMLCharacters(_.invoke(cs, "toString").join(", "))
if attributes.replyToMessage if attributes.replyToMessage
msg = attributes.replyToMessage msg = attributes.replyToMessage
@ -268,7 +269,7 @@ class DraftStore
attributes.body = """ attributes.body = """
<br><br><blockquote class="gmail_quote" <br><br><blockquote class="gmail_quote"
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;"> style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
#{msg.replyAttributionLine()} #{DOMUtils.escapeHTMLCharacters(msg.replyAttributionLine())}
<br> <br>
#{@_formatBodyForQuoting(msg.body)} #{@_formatBodyForQuoting(msg.body)}
</blockquote>""" </blockquote>"""
@ -277,12 +278,12 @@ class DraftStore
if attributes.forwardMessage if attributes.forwardMessage
msg = attributes.forwardMessage msg = attributes.forwardMessage
fields = [] fields = []
fields.push("From: #{contactStrings(msg.from)}") if msg.from.length > 0 fields.push("From: #{contactsAsHtml(msg.from)}") if msg.from.length > 0
fields.push("Subject: #{msg.subject}") fields.push("Subject: #{msg.subject}")
fields.push("Date: #{msg.formattedDate()}") fields.push("Date: #{msg.formattedDate()}")
fields.push("To: #{contactStrings(msg.to)}") if msg.to.length > 0 fields.push("To: #{contactsAsHtml(msg.to)}") if msg.to.length > 0
fields.push("CC: #{contactStrings(msg.cc)}") if msg.cc.length > 0 fields.push("CC: #{contactsAsHtml(msg.cc)}") if msg.cc.length > 0
fields.push("BCC: #{contactStrings(msg.bcc)}") if msg.bcc.length > 0 fields.push("BCC: #{contactsAsHtml(msg.bcc)}") if msg.bcc.length > 0
if msg.files?.length > 0 if msg.files?.length > 0
attributes.files ?= [] attributes.files ?= []

View file

@ -86,11 +86,11 @@ FocusedContactsStore = Reflux.createStore
_assignScore: (contact, score=0) -> _assignScore: (contact, score=0) ->
return unless contact?.email return unless contact?.email
return if contact.email.trim().length is 0 return if contact.email.trim().length is 0
return if @_contactScores[contact.nameEmail()]? # only assign the first time return if @_contactScores[contact.toString()]? # only assign the first time
penalties = @_calculatePenalties(contact, score) penalties = @_calculatePenalties(contact, score)
@_contactScores[contact.nameEmail()] = @_contactScores[contact.toString()] =
contact: contact contact: contact
score: score - penalties score: score - penalties

View file

@ -1,5 +1,17 @@
@import "ui-variables"; @import "ui-variables";
@token-top:lighten(@background-secondary,0.6%);
@token-bottom:darken(@background-secondary, 2.5%);
@token-hover-top: mix(@token-top, @component-active-color, 92%);
@token-hover-bottom: mix(@token-bottom, @component-active-color, 90%);
@token-selected-top: mix(@token-top, @component-active-color, 15%);
@token-selected-bottom: mix(@token-bottom, @component-active-color, 0%);
@token-invalid-selected-top: mix(@token-top, red, 60%);
@token-invalid-selected-bottom: mix(@token-bottom, red, 55%);
.tokenizing-field { .tokenizing-field {
display: block; display: block;
margin: 0; margin: 0;
@ -28,42 +40,83 @@
border:0; border:0;
} }
.token-editing-input {
max-width: 100%;
font-size: 15px;
line-height: 17px;
padding: 2em;//0.5em @spacing-three-quarters 0.5em @spacing-three-quarters;
padding-right: 1.5em;
margin: 3px 6px 6px 1px;
}
.token { .token {
display: inline-block; display: inline-block;
position: relative; position: relative;
color: @text-color; color: @text-color;
padding: 0.4em @spacing-half 0.4em @spacing-half; padding: 0.5em @spacing-three-quarters 0.5em @spacing-three-quarters;
padding-right: 1.5em; padding-right: 1.5em;
margin: 2px 5px 3px 0; margin: 3px 6px 6px 1px;
border-radius: @border-radius-base; border-radius: @border-radius-base * 0.8;
font-size: 15px;
background-color: @background-secondary;
max-width: 100%; max-width: 100%;
font-size: 15px;
line-height: 17px;
background: linear-gradient(to bottom, @token-top 0%, @token-bottom 100%);
box-shadow: 0 0.5px 0 rgba(0,0,0,0.17), 0 -0.5px 0 rgba(0,0,0,0.17), 0.5px 0 0 rgba(0,0,0,0.17), -0.5px 0 0 rgba(0,0,0,0.17), 0 1px 1px rgba(0, 0, 0, 0.1);
.action { .action {
position:absolute; position:absolute;
padding: 0; padding: 0;
padding-top: 1px;
right: 7px;
border: 0; border: 0;
margin: 0; margin: 0;
right: 7px;
background-color: transparent; background-color: transparent;
color: @text-color-very-subtle; color: @text-color-very-subtle;
img { background-color: @text-color-very-subtle; } img { background-color: @text-color-very-subtle; }
font-size: 10px; font-size: 10px;
} }
&:hover { &:hover {
background-color: darken(@background-secondary, 5%); background: linear-gradient(to bottom, @token-hover-top 0%, @token-hover-bottom 100%);
box-shadow: 0 0.5px 0 darken(@token-hover-bottom, 35%), 0 -0.5px 0 darken(@token-hover-top, 25%), 0.5px 0 0 darken(@token-hover-bottom, 25%), -0.5px 0 0 darken(@token-hover-bottom, 25%), 0 1px 1px rgba(0, 0, 0, 0.07);
cursor: default; cursor: default;
} }
&.invalid {
border-bottom:1px dashed red;
margin-bottom: -1px;
background: transparent;
&:hover {
box-shadow: 0 -0.5px 0 @token-invalid-selected-top, 0.5px 0 0 @token-invalid-selected-bottom, -0.5px 0 0 @token-invalid-selected-bottom, 0 1px 1px rgba(0, 0, 0, 0.07);
}
}
&.invalid.selected, &.invalid.dragging {
background: linear-gradient(to bottom, @token-invalid-selected-top 0%, @token-invalid-selected-bottom 100%);
box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.3), 0 1px 1px rgba(0, 0, 0, 0.1);
border: 1px solid darken(@token-invalid-selected-bottom, 8%);
border-top: 1px solid darken(@token-invalid-selected-top, 10%);
}
&.selected, &.selected,
&.dragging { &.dragging {
background-color: @background-tertiary; background: linear-gradient(to bottom, @token-selected-top 0%, @token-selected-bottom 100%);
box-shadow: inset 0 1.5px 0 rgba(255,255,255,0.3), 0 1px 1px rgba(0, 0, 0, 0.1);
border: 1px solid darken(@token-selected-bottom, 8%);
border-top: 1px solid darken(@token-selected-top, 10%);
border-radius: @border-radius-base;
// Note: we switch from 0.5px borders with box shadows to a real border,
// because the 0.5px shadows can't be as dark as we want. This means
// margins / border radius change by 1px.
margin: 2px 5px 5px 0;
color: @text-color-inverse; color: @text-color-inverse;
.action { .action {
color: @text-color-inverse-subtle; color: @text-color-inverse-subtle;
img { background-color: @text-color-inverse-subtle; } img { background-color: @text-color-inverse-subtle; }
} }
.secondary,
.participant-secondary {
color: @text-color-inverse-subtle;
}
} }
&.dragging { &.dragging {
cursor: -webkit-grabbing; cursor: -webkit-grabbing;
@ -127,6 +180,6 @@
} }
} }
body.is-blurred .tokenizing-field .token { body.is-blurred .tokenizing-field .token:not(.invalid) {
background-color: @background-secondary; background: @background-secondary;
} }