mirror of
https://github.com/Foundry376/Mailspring.git
synced 2024-12-25 01:21:14 +08:00
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:
parent
5843c260f3
commit
b7e314ac62
18 changed files with 479 additions and 247 deletions
|
@ -5,6 +5,7 @@ _ = require 'underscore'
|
|||
File,
|
||||
Actions,
|
||||
DraftStore,
|
||||
ContactStore,
|
||||
UndoManager,
|
||||
FileUploadStore,
|
||||
QuotedHTMLParser,
|
||||
|
@ -582,12 +583,19 @@ class ComposerView extends React.Component
|
|||
remote = require('remote')
|
||||
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(), {
|
||||
type: 'warning',
|
||||
buttons: ['Edit Message'],
|
||||
message: 'Cannot Send',
|
||||
detail: 'You need to provide one or more recipients before sending the message.'
|
||||
detail: dealbreaker
|
||||
})
|
||||
return
|
||||
|
||||
|
|
|
@ -40,11 +40,13 @@ class ParticipantsTextField extends React.Component
|
|||
ref="textField"
|
||||
tokens={@props.participants[@props.field]}
|
||||
tokenKey={ (p) -> p.email }
|
||||
tokenIsValid={ (p) -> ContactStore.isValidContact(p) }
|
||||
tokenNode={@_tokenNode}
|
||||
onRequestCompletions={ (input) -> ContactStore.searchContacts(input) }
|
||||
completionNode={@_completionNode}
|
||||
onAdd={@_add}
|
||||
onRemove={@_remove}
|
||||
onEdit={@_edit}
|
||||
onEmptied={@props.onEmptied}
|
||||
onTokenAction={@_showContextMenu}
|
||||
tabIndex={@props.tabIndex}
|
||||
|
@ -71,6 +73,20 @@ class ParticipantsTextField extends React.Component
|
|||
<span className="participant-primary">{p.email}</span>
|
||||
</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) =>
|
||||
field = @props.field
|
||||
|
@ -81,22 +97,26 @@ class ParticipantsTextField extends React.Component
|
|||
false
|
||||
@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={}) =>
|
||||
# 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 _.isString(values)
|
||||
values = ContactStore.parseContactsInString(values, options)
|
||||
values = @_tokensForString(values, options)
|
||||
|
||||
# Safety check: remove anything from the incoming values that isn't
|
||||
# a Contact. We should never receive anything else in the values array.
|
||||
|
||||
values = _.compact _.map values, (value) ->
|
||||
if value instanceof Contact
|
||||
return value
|
||||
else
|
||||
return null
|
||||
values = _.filter values, (value) -> value instanceof Contact
|
||||
|
||||
updates = {}
|
||||
for field in Object.keys(@props.participants)
|
||||
|
|
|
@ -14,6 +14,7 @@ ReactTestUtils = React.addons.TestUtils
|
|||
NylasTestUtils,
|
||||
NamespaceStore,
|
||||
FileUploadStore,
|
||||
ContactStore,
|
||||
ComponentRegistry} = require "nylas-exports"
|
||||
|
||||
{InjectedComponent} = require 'nylas-component-kit'
|
||||
|
@ -58,12 +59,16 @@ draftStoreProxyStub = (localId, returnedDraft) ->
|
|||
searchContactStub = (email) ->
|
||||
_.filter(users, (u) u.email.toLowerCase() is email.toLowerCase())
|
||||
|
||||
isValidContactStub = (contact) ->
|
||||
contact.email.indexOf('@') > 0
|
||||
|
||||
ComposerView = proxyquire "../lib/composer-view",
|
||||
"./file-upload": reactStub("file-upload")
|
||||
"./image-file-upload": reactStub("image-file-upload")
|
||||
"nylas-exports":
|
||||
ContactStore:
|
||||
searchContacts: (email) -> searchContactStub
|
||||
searchContacts: searchContactStub
|
||||
isValidContact: isValidContactStub
|
||||
DraftStore: DraftStore
|
||||
|
||||
beforeEach ->
|
||||
|
@ -335,13 +340,26 @@ describe "populated composer", ->
|
|||
spyOn(@dialog, "showMessageBox")
|
||||
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"
|
||||
makeComposer.call(@)
|
||||
@composer._sendDraft()
|
||||
expect(Actions.sendDraft).not.toHaveBeenCalled()
|
||||
expect(@dialog.showMessageBox).toHaveBeenCalled()
|
||||
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']
|
||||
|
||||
describe "empty body warning", ->
|
||||
|
|
|
@ -30,8 +30,6 @@ participant5 = new Contact
|
|||
name: 'EVAN'
|
||||
|
||||
describe 'ParticipantsTextField', ->
|
||||
NylasTestUtils.loadKeymap()
|
||||
|
||||
beforeEach ->
|
||||
@propChange = jasmine.createSpy('change')
|
||||
|
||||
|
@ -54,7 +52,7 @@ describe 'ParticipantsTextField', ->
|
|||
|
||||
@expectInputToYield = (input, expected) ->
|
||||
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: input}})
|
||||
NylasTestUtils.keyPress('enter', @renderedInput)
|
||||
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Enter', keyCode: 9})
|
||||
|
||||
reviver = (k,v) ->
|
||||
return undefined if k in ["id", "object"]
|
||||
|
|
|
@ -207,19 +207,6 @@
|
|||
padding: 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
|
||||
|
|
|
@ -18,7 +18,7 @@ class Participants extends React.Component
|
|||
|
||||
render: =>
|
||||
chips = @getParticipants().map (p) =>
|
||||
<ContactChip key={p.nameEmail()} clickable={@props.clickable} participant={p} />
|
||||
<ContactChip key={p.toString()} clickable={@props.clickable} participant={p} />
|
||||
|
||||
<span>
|
||||
{chips}
|
||||
|
|
|
@ -214,29 +214,3 @@
|
|||
'shift-tab': 'native!'
|
||||
|
||||
# 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'
|
||||
|
|
|
@ -39,14 +39,14 @@ participant5 = new Contact
|
|||
name: 'Michael'
|
||||
|
||||
describe 'TokenizingTextField', ->
|
||||
NylasTestUtils.loadKeymap()
|
||||
|
||||
beforeEach ->
|
||||
@completions = []
|
||||
@propAdd = jasmine.createSpy 'add'
|
||||
@propEdit = jasmine.createSpy 'edit'
|
||||
@propRemove = jasmine.createSpy 'remove'
|
||||
@propEmptied = jasmine.createSpy 'emptied'
|
||||
@propTokenKey = jasmine.createSpy("tokenKey").andCallFake (p) -> p.email
|
||||
@propTokenIsValid = jasmine.createSpy("tokenIsValid").andReturn(true)
|
||||
@propTokenNode = (p) -> <CustomToken item={p} />
|
||||
@propOnTokenAction = jasmine.createSpy 'tokenAction'
|
||||
@propCompletionNode = (p) -> <CustomSuggestion item={p} />
|
||||
|
@ -58,26 +58,32 @@ describe 'TokenizingTextField', ->
|
|||
@tabIndex = 100
|
||||
@tokens = [participant1, participant2, participant3]
|
||||
|
||||
@renderedField = ReactTestUtils.renderIntoDocument(
|
||||
<TokenizingTextField
|
||||
tokens={@tokens}
|
||||
tokenKey={@propTokenKey}
|
||||
tokenNode={@propTokenNode}
|
||||
onRequestCompletions={@propCompletionsForInput}
|
||||
completionNode={@propCompletionNode}
|
||||
onAdd={@propAdd}
|
||||
onRemove={@propRemove}
|
||||
onEmptied={@propEmptied}
|
||||
onTokenAction={@propOnTokenAction}
|
||||
tabIndex={@tabIndex}
|
||||
/>
|
||||
)
|
||||
|
||||
@renderedInput = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input'))
|
||||
@rebuildRenderedField = =>
|
||||
@renderedField = ReactTestUtils.renderIntoDocument(
|
||||
<TokenizingTextField
|
||||
tokens={@tokens}
|
||||
tokenKey={@propTokenKey}
|
||||
tokenNode={@propTokenNode}
|
||||
tokenIsValid={@propTokenIsValid}
|
||||
onRequestCompletions={@propCompletionsForInput}
|
||||
completionNode={@propCompletionNode}
|
||||
onAdd={@propAdd}
|
||||
onEdit={@propEdit}
|
||||
onRemove={@propRemove}
|
||||
onEmptied={@propEmptied}
|
||||
onTokenAction={@propOnTokenAction}
|
||||
tabIndex={@tabIndex}
|
||||
/>
|
||||
)
|
||||
@renderedInput = React.findDOMNode(ReactTestUtils.findRenderedDOMComponentWithTag(@renderedField, 'input'))
|
||||
@rebuildRenderedField()
|
||||
|
||||
it 'renders into the document', ->
|
||||
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', ->
|
||||
expect(@renderedInput.tabIndex).toBe(@tabIndex)
|
||||
|
||||
|
@ -90,6 +96,25 @@ describe 'TokenizingTextField', ->
|
|||
for i in [0..@tokens.length-1]
|
||||
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", ->
|
||||
beforeEach ->
|
||||
token = ReactTestUtils.scryRenderedDOMComponentsWithClass(@renderedField, 'token')[0]
|
||||
|
@ -175,20 +200,20 @@ describe 'TokenizingTextField', ->
|
|||
expect(@propCompletionsForInput.calls[2].args[0]).toBe('ab c')
|
||||
expect(@propAdd).not.toHaveBeenCalled()
|
||||
|
||||
['enter', ','].forEach (key) ->
|
||||
[{key:'Enter', keyCode:13}, {key:',', keyCode: 188}].forEach ({key, keyCode}) ->
|
||||
describe "when the user presses #{key}", ->
|
||||
describe "and there is an completion available", ->
|
||||
it "should call add with the first completion", ->
|
||||
@completions = [participant4]
|
||||
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
|
||||
NylasTestUtils.keyPress(key, @renderedInput)
|
||||
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: key, keyCode: keyCode})
|
||||
expect(@propAdd).toHaveBeenCalledWith([participant4])
|
||||
|
||||
describe "and there is NO completion available", ->
|
||||
it 'should call add, allowing the parent to (optionally) turn the text into a token', ->
|
||||
@completions = []
|
||||
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
|
||||
NylasTestUtils.keyPress(key, @renderedInput)
|
||||
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: key, keyCode: keyCode})
|
||||
expect(@propAdd).toHaveBeenCalledWith('abc', {})
|
||||
|
||||
describe "when the user presses tab", ->
|
||||
|
@ -196,7 +221,7 @@ describe 'TokenizingTextField', ->
|
|||
it "should call add with the first completion", ->
|
||||
@completions = [participant4]
|
||||
ReactTestUtils.Simulate.change(@renderedInput, {target: {value: 'abc'}})
|
||||
NylasTestUtils.keyPress('tab', @renderedInput)
|
||||
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Tab', keyCode: 9})
|
||||
expect(@propAdd).toHaveBeenCalledWith([participant4])
|
||||
|
||||
describe "when blurred", ->
|
||||
|
@ -218,12 +243,43 @@ describe 'TokenizingTextField', ->
|
|||
ReactTestUtils.Simulate.blur(@renderedInput)
|
||||
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", ->
|
||||
|
||||
it "deletes with the backspace key", ->
|
||||
spyOn(@renderedField, "_removeToken")
|
||||
NylasTestUtils.keyPress("backspace", @renderedInput)
|
||||
ReactTestUtils.Simulate.keyDown(@renderedInput, {key: 'Backspace', keyCode: 8})
|
||||
expect(@renderedField._removeToken).toHaveBeenCalled()
|
||||
|
||||
describe "when removal is passed in a token object", ->
|
||||
|
|
|
@ -94,19 +94,30 @@ describe "ContactStore", ->
|
|||
|
||||
describe 'parseContactsInString', ->
|
||||
testCases =
|
||||
# Single contact test cases
|
||||
"evan@nylas.com": [new Contact(name: "evan@nylas.com", email: "evan@nylas.com")]
|
||||
"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")]
|
||||
"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")]
|
||||
|
||||
# Multiple contact test cases
|
||||
"Evan Morikawa <evan@nylas.com>, Ben <ben@nylas.com>": [
|
||||
new Contact(name: "Evan Morikawa", email: "evan@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) ->
|
||||
it "works for #{key}", ->
|
||||
testContacts = ContactStore.parseContactsInString(key).map (c) -> c.nameEmail()
|
||||
expectedContacts = value.map (c) -> c.nameEmail()
|
||||
testContacts = ContactStore.parseContactsInString(key).map (c) -> c.toString()
|
||||
expectedContacts = value.map (c) -> c.toString()
|
||||
expect(testContacts).toEqual expectedContacts
|
||||
|
||||
|
|
|
@ -33,6 +33,7 @@ var hotCompile = (function () {
|
|||
clearTimeout(timeout);
|
||||
timeout = setTimeout(function () {
|
||||
hotCompile(module, module.filename, true);
|
||||
console.log('hot reloaded '+module.filename);
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -5,48 +5,125 @@ _ = require 'underscore'
|
|||
{Utils, RegExpUtils, Contact, ContactStore} = require 'nylas-exports'
|
||||
RetinaImg = require './retina-img'
|
||||
|
||||
Token = React.createClass
|
||||
displayName: "Token"
|
||||
class SizeToFitInput extends React.Component
|
||||
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,
|
||||
select: React.PropTypes.func.isRequired,
|
||||
action: React.PropTypes.func,
|
||||
valid: React.PropTypes.bool,
|
||||
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
|
||||
"token": true
|
||||
"dragging": @state.dragging
|
||||
"invalid": !@props.valid
|
||||
"selected": @props.selected
|
||||
|
||||
<div onDragStart={@_onDragStart} onDragEnd={@_onDragEnd} draggable="true"
|
||||
className={classes}
|
||||
<div className={classes}
|
||||
onDragStart={@_onDragStart}
|
||||
onDragEnd={@_onDragEnd}
|
||||
draggable="true"
|
||||
onDoubleClick={@_onDoubleClick}
|
||||
onClick={@_onSelect}>
|
||||
<button className="action" onClick={@_onAction} style={marginTop: "2px"}>
|
||||
<button className="action" onClick={@_onAction}>
|
||||
<RetinaImg mode={RetinaImg.Mode.ContentIsMask} name="composer-caret.png" />
|
||||
</button>
|
||||
{@props.children}
|
||||
</div>
|
||||
|
||||
_onDragStart: (event) ->
|
||||
textValue = React.findDOMNode(@).innerText
|
||||
_onDragStart: (event) =>
|
||||
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)
|
||||
|
||||
_onDragEnd: (event) ->
|
||||
_onDragEnd: (event) =>
|
||||
@setState(dragging: false)
|
||||
|
||||
_onSelect: (event) ->
|
||||
@props.select(@props.item)
|
||||
event.preventDefault()
|
||||
_onSelect: (event) =>
|
||||
@props.onSelected(@props.item)
|
||||
|
||||
_onAction: (event) ->
|
||||
@props.action(@props.item)
|
||||
_onDoubleClick: (event) =>
|
||||
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()
|
||||
|
||||
###
|
||||
|
@ -60,10 +137,14 @@ See documentation on the propTypes for usage info.
|
|||
|
||||
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.
|
||||
#
|
||||
# A token is usually an object type like a
|
||||
|
@ -77,8 +158,6 @@ TokenizingTextField = React.createClass
|
|||
# unlimited number of tokens may be given
|
||||
maxTokens: React.PropTypes.number
|
||||
|
||||
# A unique ID for each token object
|
||||
#
|
||||
# A function that, given an object used for tokens, returns a unique
|
||||
# id (key) for that object.
|
||||
#
|
||||
|
@ -86,6 +165,13 @@ TokenizingTextField = React.createClass
|
|||
# unique key.
|
||||
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
|
||||
#
|
||||
# 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.
|
||||
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
|
||||
#
|
||||
# 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.
|
||||
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
|
||||
onEmptied: React.PropTypes.func
|
||||
|
||||
|
@ -157,134 +249,109 @@ TokenizingTextField = React.createClass
|
|||
# A classSet hash applied to the Menu item
|
||||
menuClassSet: React.PropTypes.object
|
||||
|
||||
getInitialState: ->
|
||||
inputValue: ""
|
||||
completions: []
|
||||
selectedTokenKey: null
|
||||
constructor: (@props) ->
|
||||
@state =
|
||||
inputValue: ""
|
||||
completions: []
|
||||
selectedTokenKey: null
|
||||
|
||||
componentDidMount: ->
|
||||
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, " ")
|
||||
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: ->
|
||||
render: =>
|
||||
{Menu} = require 'nylas-component-kit'
|
||||
|
||||
classes = classNames _.extend (@props.menuClassSet ? {}),
|
||||
"tokenizing-field": true
|
||||
"focused": @state.focus
|
||||
"native-key-bindings": true
|
||||
"focused": @state.focus
|
||||
"empty": (@state.inputValue ? "").trim().length is 0
|
||||
"has-suggestions": @state.completions.length > 0
|
||||
|
||||
<Menu className={classes} ref="completions"
|
||||
items={@state.completions}
|
||||
itemKey={ (item) -> item.id }
|
||||
itemContent={@props.completionNode}
|
||||
headerComponents={[@_fieldComponent()]}
|
||||
onFocus={@_onInputFocused}
|
||||
onBlur={@_onInputBlurred}
|
||||
onSelect={@_addToken}
|
||||
/>
|
||||
|
||||
_fieldComponent: ->
|
||||
<div key="field-component" onClick={@focus} onDrop={@_onDrop}>
|
||||
_fieldComponent: =>
|
||||
<div key="field-component" onClick={@_onClick} onDrop={@_onDrop}>
|
||||
{@_renderPrompt()}
|
||||
<div className="tokenizing-field-input">
|
||||
{@_placeholder()}
|
||||
|
||||
{@_fieldTokenComponents()}
|
||||
|
||||
{@_fieldComponents()}
|
||||
{@_inputEl()}
|
||||
<span ref="measure" style={
|
||||
position: 'absolute'
|
||||
visibility: 'hidden'
|
||||
}/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
_inputEl: ->
|
||||
if @_atMaxTokens()
|
||||
<input type="text"
|
||||
ref="input"
|
||||
spellCheck="false"
|
||||
className="noop-input"
|
||||
onCopy={@_onCopy}
|
||||
onCut={@_onCut}
|
||||
onBlur={@_onInputBlurred}
|
||||
onFocus={ => @_onInputFocused(noCompletions: true)}
|
||||
onChange={ -> "noop" }
|
||||
tabIndex={@props.tabIndex}
|
||||
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} />
|
||||
_inputEl: =>
|
||||
props =
|
||||
onCopy: @_onCopy
|
||||
onCut: @_onCut
|
||||
onPaste: @_onPaste
|
||||
onKeyDown: @_onInputKeydown
|
||||
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?
|
||||
return <div className="placeholder">{@props.placeholder}</div>
|
||||
else
|
||||
return <span></span>
|
||||
|
||||
_atMaxTokens: ->
|
||||
_atMaxTokens: =>
|
||||
if @props.maxTokens
|
||||
@props.tokens.length >= @props.maxTokens
|
||||
else return false
|
||||
|
||||
_renderPrompt: ->
|
||||
_renderPrompt: =>
|
||||
if @props.menuPrompt
|
||||
<div className="tokenizing-field-label">{"#{@props.menuPrompt}:"}</div>
|
||||
else
|
||||
<div></div>
|
||||
|
||||
_fieldTokenComponents: ->
|
||||
_fieldComponents: =>
|
||||
@props.tokens.map (item) =>
|
||||
key = @props.tokenKey(item)
|
||||
valid = true
|
||||
if @props.tokenIsValid
|
||||
valid = @props.tokenIsValid(item)
|
||||
|
||||
<Token item={item}
|
||||
key={@props.tokenKey(item)}
|
||||
select={@_selectToken}
|
||||
action={@props.onTokenAction || @_showDefaultTokenMenu}
|
||||
selected={@state.selectedTokenKey is @props.tokenKey(item)}>
|
||||
key={key}
|
||||
valid={valid}
|
||||
selected={@state.selectedTokenKey is key}
|
||||
onSelected={@_selectToken}
|
||||
onEdited={@props.onEdit}
|
||||
onAction={@props.onTokenAction || @_showDefaultTokenMenu}>
|
||||
{@props.tokenNode(item)}
|
||||
</Token>
|
||||
|
||||
# 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
|
||||
|
||||
try
|
||||
|
@ -296,11 +363,24 @@ TokenizingTextField = React.createClass
|
|||
if model and model instanceof Contact
|
||||
@_addToken(model)
|
||||
|
||||
_onInputFocused: ({noCompletions}={}) ->
|
||||
@setState focus: true
|
||||
_onInputFocused: ({noCompletions}={}) =>
|
||||
@setState(focus: true)
|
||||
@_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()
|
||||
@setState
|
||||
selectedTokenKey: null
|
||||
|
@ -308,51 +388,52 @@ TokenizingTextField = React.createClass
|
|||
|
||||
# If it looks like an email, and the last character entered was a
|
||||
# space, then let's add the input value.
|
||||
# TODO WHY IS THIS EMAIL RELATED?
|
||||
if RegExpUtils.emailRegex().test(val) and _.last(val) is " "
|
||||
@_addInputValue(val[0...-1], skipNameLookup: true)
|
||||
else
|
||||
@_refreshCompletions(val)
|
||||
|
||||
_onInputBlurred: ->
|
||||
if @props.clearOnBlur
|
||||
@_clearInput()
|
||||
else
|
||||
@_addInputValue()
|
||||
_onInputBlurred: (event) =>
|
||||
if event.relatedTarget is React.findDOMNode(@)
|
||||
return
|
||||
|
||||
@_addInputValue()
|
||||
@_refreshCompletions("", clear: true)
|
||||
@setState
|
||||
selectedTokenKey: null
|
||||
focus: false
|
||||
|
||||
_clearInput: ->
|
||||
@setState inputValue: ""
|
||||
_clearInput: =>
|
||||
@setState(inputValue: "")
|
||||
@_refreshCompletions("", clear: true)
|
||||
|
||||
focus: ->
|
||||
React.findDOMNode(@refs.input).focus()
|
||||
focus: =>
|
||||
@refs.input.focus()
|
||||
|
||||
# Managing Tokens
|
||||
|
||||
_addInputValue: (input, options={}) ->
|
||||
_addInputValue: (input, options={}) =>
|
||||
return if @_atMaxTokens()
|
||||
input ?= @state.inputValue
|
||||
@props.onAdd(input, options)
|
||||
@_clearInput()
|
||||
|
||||
_selectToken: (token) ->
|
||||
_selectToken: (token) =>
|
||||
@setState
|
||||
selectedTokenKey: @props.tokenKey(token)
|
||||
|
||||
_selectedToken: ->
|
||||
_selectedToken: =>
|
||||
_.find @props.tokens, (t) =>
|
||||
@props.tokenKey(t) is @state.selectedTokenKey
|
||||
|
||||
_addToken: (token) ->
|
||||
_addToken: (token) =>
|
||||
return unless token
|
||||
@props.onAdd([token])
|
||||
@_clearInput()
|
||||
@focus()
|
||||
|
||||
_removeToken: (token = null) ->
|
||||
_removeToken: (token = null) =>
|
||||
if @state.inputValue.trim().length is 0 and @props.tokens.length is 0 and @props.onEmptied?
|
||||
@props.onEmptied()
|
||||
|
||||
|
@ -369,7 +450,7 @@ TokenizingTextField = React.createClass
|
|||
@setState
|
||||
selectedTokenKey: null
|
||||
|
||||
_showDefaultTokenMenu: (token) ->
|
||||
_showDefaultTokenMenu: (token) =>
|
||||
remote = require('remote')
|
||||
Menu = remote.require('menu')
|
||||
MenuItem = remote.require('menu-item')
|
||||
|
@ -379,23 +460,22 @@ TokenizingTextField = React.createClass
|
|||
label: 'Remove',
|
||||
click: => @_removeToken(token)
|
||||
))
|
||||
|
||||
menu.popup(remote.getCurrentWindow())
|
||||
|
||||
# Copy and Paste
|
||||
|
||||
_onCut: (event) ->
|
||||
_onCut: (event) =>
|
||||
if @state.selectedTokenKey
|
||||
event.clipboardData?.setData('text/plain', @props.tokenKey(@_selectedToken()))
|
||||
event.preventDefault()
|
||||
@_removeToken(@_selectedToken())
|
||||
|
||||
_onCopy: (event) ->
|
||||
_onCopy: (event) =>
|
||||
if @state.selectedTokenKey
|
||||
event.clipboardData.setData('text/plain', @props.tokenKey(@_selectedToken()))
|
||||
event.preventDefault()
|
||||
|
||||
_onPaste: (event) ->
|
||||
_onPaste: (event) =>
|
||||
data = event.clipboardData.getData('text/plain')
|
||||
@_addInputValue(data)
|
||||
event.preventDefault()
|
||||
|
@ -406,7 +486,7 @@ TokenizingTextField = React.createClass
|
|||
# current inputValue. Since `onRequestCompletions` can be asynchronous,
|
||||
# this function will handle calling `setState` on `completions` when
|
||||
# `onRequestCompletions` returns.
|
||||
_refreshCompletions: (val = @state.inputValue, {clear}={}) ->
|
||||
_refreshCompletions: (val = @state.inputValue, {clear}={}) =>
|
||||
existingKeys = _.map(@props.tokens, @props.tokenKey)
|
||||
filterTokens = (tokens) =>
|
||||
_.reject tokens, (t) => @props.tokenKey(t) in existingKeys
|
||||
|
|
|
@ -1,6 +1,16 @@
|
|||
_ = require 'underscore'
|
||||
|
||||
DOMUtils =
|
||||
|
||||
escapeHTMLCharacters: (text) ->
|
||||
map =
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
text.replace /[&<>"']/g, (m) -> map[m]
|
||||
|
||||
removeElements: (elements=[]) ->
|
||||
for el in elements
|
||||
try
|
||||
|
|
|
@ -53,9 +53,13 @@ class Contact extends Model
|
|||
'phone': Attributes.String(modelKey: 'phone')
|
||||
'company': Attributes.String(modelKey: 'company')
|
||||
|
||||
# Used to uniquely identify a contact
|
||||
nameEmail: ->
|
||||
"#{(@name ? "").toLowerCase().trim()} #{@email.toLowerCase().trim()}"
|
||||
# Public: Returns a string of the format `Full Name <email@address.com>` if
|
||||
# the contact has a populated name, just the email address otherwise.
|
||||
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: ->
|
||||
json = super
|
||||
|
@ -70,10 +74,6 @@ class Contact extends Model
|
|||
return "You" if @email is NamespaceStore.current()?.emailAddress
|
||||
@_nameParts().join(' ')
|
||||
|
||||
# Full Name <email@address.com>
|
||||
messageName: ->
|
||||
if @name then "#{@name} <#{@email}>" else @email
|
||||
|
||||
displayFirstName: ->
|
||||
return "You" if @email is NamespaceStore.current()?.emailAddress
|
||||
@firstName()
|
||||
|
|
|
@ -263,7 +263,7 @@ class Message extends Model
|
|||
# localized for the current user.
|
||||
# ie "On Dec. 12th, 2015 at 4:00PM, Ben Gotow wrote:"
|
||||
replyAttributionLine: ->
|
||||
"On #{@formattedDate()}, #{@fromContact().messageName()} wrote:"
|
||||
"On #{@formattedDate()}, #{@fromContact().toString()} wrote:"
|
||||
|
||||
formattedDate: -> moment(@date).format("MMM D YYYY, [at] h:mm a")
|
||||
|
||||
|
|
|
@ -207,9 +207,18 @@ class ContactStore extends NylasStore
|
|||
|
||||
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}={}) =>
|
||||
detected = []
|
||||
emailRegex = RegExpUtils.emailRegex()
|
||||
lastMatchEnd = 0
|
||||
|
||||
while (match = emailRegex.exec(contactString))
|
||||
email = match[0]
|
||||
name = null
|
||||
|
@ -218,8 +227,8 @@ class ContactStore extends NylasStore
|
|||
hasTrailingParen = contactString[match.index+email.length] in [')','>']
|
||||
|
||||
if hasLeadingParen and hasTrailingParen
|
||||
nameStart = 0
|
||||
for char in ['>', ')', ',', '\n', '\r']
|
||||
nameStart = lastMatchEnd
|
||||
for char in [',', '\n', '\r']
|
||||
i = contactString.lastIndexOf(char, match.index)
|
||||
nameStart = i+1 if i+1 > nameStart
|
||||
name = contactString.substr(nameStart, match.index - 1 - nameStart).trim()
|
||||
|
@ -233,7 +242,13 @@ class ContactStore extends NylasStore
|
|||
else
|
||||
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
|
||||
|
||||
__refreshCache: =>
|
||||
|
|
|
@ -19,9 +19,9 @@ Actions = require '../actions'
|
|||
TaskQueue = require './task-queue'
|
||||
|
||||
{subjectWithPrefix, generateTempId} = require '../models/utils'
|
||||
|
||||
{Listener, Publisher} = require '../modules/reflux-coffee'
|
||||
CoffeeHelpers = require '../coffee-helpers'
|
||||
DOMUtils = require '../../dom-utils'
|
||||
|
||||
###
|
||||
Public: DraftStore responds to Actions that interact with Drafts and exposes
|
||||
|
@ -258,7 +258,8 @@ class DraftStore
|
|||
attributes.subject ?= subjectWithPrefix(thread.subject, 'Re:')
|
||||
attributes.body ?= ""
|
||||
|
||||
contactStrings = (cs) -> _.invoke(cs, "messageName").join(", ")
|
||||
contactsAsHtml = (cs) ->
|
||||
DOMUtils.escapeHTMLCharacters(_.invoke(cs, "toString").join(", "))
|
||||
|
||||
if attributes.replyToMessage
|
||||
msg = attributes.replyToMessage
|
||||
|
@ -268,7 +269,7 @@ class DraftStore
|
|||
attributes.body = """
|
||||
<br><br><blockquote class="gmail_quote"
|
||||
style="margin:0 0 0 .8ex;border-left:1px #ccc solid;padding-left:1ex;">
|
||||
#{msg.replyAttributionLine()}
|
||||
#{DOMUtils.escapeHTMLCharacters(msg.replyAttributionLine())}
|
||||
<br>
|
||||
#{@_formatBodyForQuoting(msg.body)}
|
||||
</blockquote>"""
|
||||
|
@ -277,12 +278,12 @@ class DraftStore
|
|||
if attributes.forwardMessage
|
||||
msg = attributes.forwardMessage
|
||||
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("Date: #{msg.formattedDate()}")
|
||||
fields.push("To: #{contactStrings(msg.to)}") if msg.to.length > 0
|
||||
fields.push("CC: #{contactStrings(msg.cc)}") if msg.cc.length > 0
|
||||
fields.push("BCC: #{contactStrings(msg.bcc)}") if msg.bcc.length > 0
|
||||
fields.push("To: #{contactsAsHtml(msg.to)}") if msg.to.length > 0
|
||||
fields.push("CC: #{contactsAsHtml(msg.cc)}") if msg.cc.length > 0
|
||||
fields.push("BCC: #{contactsAsHtml(msg.bcc)}") if msg.bcc.length > 0
|
||||
|
||||
if msg.files?.length > 0
|
||||
attributes.files ?= []
|
||||
|
|
|
@ -86,11 +86,11 @@ FocusedContactsStore = Reflux.createStore
|
|||
_assignScore: (contact, score=0) ->
|
||||
return unless contact?.email
|
||||
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)
|
||||
|
||||
@_contactScores[contact.nameEmail()] =
|
||||
@_contactScores[contact.toString()] =
|
||||
contact: contact
|
||||
score: score - penalties
|
||||
|
||||
|
|
|
@ -1,5 +1,17 @@
|
|||
@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 {
|
||||
display: block;
|
||||
margin: 0;
|
||||
|
@ -28,42 +40,83 @@
|
|||
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 {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
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;
|
||||
margin: 2px 5px 3px 0;
|
||||
border-radius: @border-radius-base;
|
||||
font-size: 15px;
|
||||
background-color: @background-secondary;
|
||||
margin: 3px 6px 6px 1px;
|
||||
border-radius: @border-radius-base * 0.8;
|
||||
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 {
|
||||
position:absolute;
|
||||
padding: 0;
|
||||
padding-top: 1px;
|
||||
right: 7px;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
right: 7px;
|
||||
background-color: transparent;
|
||||
color: @text-color-very-subtle;
|
||||
img { background-color: @text-color-very-subtle; }
|
||||
font-size: 10px;
|
||||
}
|
||||
&: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;
|
||||
}
|
||||
&.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,
|
||||
&.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;
|
||||
.action {
|
||||
color: @text-color-inverse-subtle;
|
||||
img { background-color: @text-color-inverse-subtle; }
|
||||
}
|
||||
.secondary,
|
||||
.participant-secondary {
|
||||
color: @text-color-inverse-subtle;
|
||||
}
|
||||
}
|
||||
&.dragging {
|
||||
cursor: -webkit-grabbing;
|
||||
|
@ -127,6 +180,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
body.is-blurred .tokenizing-field .token {
|
||||
background-color: @background-secondary;
|
||||
body.is-blurred .tokenizing-field .token:not(.invalid) {
|
||||
background: @background-secondary;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue