├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── .yarnrc ├── appveyor.yml ├── config └── index.js ├── main ├── index.js ├── menu.js ├── notify.js ├── static │ ├── entitlements.mac.plist │ ├── icons │ │ ├── mac.icns │ │ └── windows.ico │ ├── pages │ │ └── status.html │ └── tray │ │ ├── iconTemplate.png │ │ ├── iconTemplate@2x.png │ │ ├── iconTemplate@3x.png │ │ └── iconWhite.png ├── updates.js └── utils │ ├── api │ └── index.js │ ├── events.js │ ├── frames │ ├── ipc.js │ ├── list.js │ ├── open.js │ └── toggle.js │ ├── highlight.js │ ├── logout.js │ ├── migrate.js │ ├── mixpanel.js │ ├── setup.js │ └── store.js ├── package.json ├── patches └── app-builder-lib+22.5.1.patch ├── readme.md ├── renderer ├── components │ ├── AreaSeparator.js │ ├── ConnectedCaret.js │ ├── ConvertText.js │ ├── DetectTimezone.js │ ├── ErrorBoundary.js │ ├── ExternalLink.js │ ├── Imprint.js │ ├── JoinBox.js │ ├── Link.js │ ├── List.js │ ├── ListBtnRow.js │ ├── LocationPicker.js │ ├── LoggedIn.js │ ├── MiniLogo.js │ ├── NotificationBox.js │ ├── PersonForm │ │ ├── PersonForm.js │ │ └── index.js │ ├── PhotoSelector │ │ ├── index.js │ │ └── styles.js │ ├── PlaceForm │ │ ├── PlaceForm.js │ │ └── index.js │ ├── Popover.js │ ├── SocialButtons.js │ ├── Space.js │ ├── TinyButton.js │ ├── Toolbar.js │ ├── add │ │ ├── Person │ │ │ ├── Person.js │ │ │ └── index.js │ │ ├── Place │ │ │ ├── Place.js │ │ │ └── index.js │ │ ├── Search │ │ │ ├── PersonRow.js │ │ │ ├── PersonSearch.js │ │ │ ├── Search.js │ │ │ └── index.js │ │ └── helpers.js │ ├── edit │ │ ├── LoadingOverlay.js │ │ ├── Person │ │ │ ├── Person.js │ │ │ └── index.js │ │ └── Place │ │ │ ├── Place.js │ │ │ └── index.js │ ├── form │ │ ├── Button.js │ │ ├── ButtonWrapper.js │ │ ├── ErrorText.js │ │ ├── Field.js │ │ ├── Input.js │ │ ├── Label.js │ │ ├── Row.js │ │ └── Select.js │ ├── tray │ │ ├── AddFirstOne.js │ │ ├── Banner.js │ │ ├── CaretColorContainer.js │ │ ├── Desc.js │ │ ├── Following │ │ │ ├── MinuteWithFade.js │ │ │ ├── PinnedFollowing.js │ │ │ ├── helpers.js │ │ │ ├── index.js │ │ │ └── styles.js │ │ ├── Followings.js │ │ ├── FollowingsList.js │ │ ├── FollowingsWrapper.js │ │ ├── Group │ │ │ ├── Content.js │ │ │ ├── Group.js │ │ │ ├── Head.js │ │ │ └── index.js │ │ ├── Loading.js │ │ ├── Pinneds │ │ │ └── index.js │ │ ├── SortModeContainer.js │ │ ├── SortableFollowings.js │ │ ├── Title.js │ │ ├── TopArrowPosition.js │ │ └── TryAgain.js │ └── window │ │ ├── ConnectionBar.js │ │ ├── Desc.js │ │ ├── FlexWrapper.js │ │ ├── Heading.js │ │ ├── SafeArea.js │ │ ├── TitleBar.js │ │ └── WindowWrapper.js ├── next.config.js ├── pages │ ├── _document.js │ ├── add.js │ ├── edit-manual.js │ ├── join.js │ ├── tray.js │ └── update-location.js ├── static │ ├── app-icon-counters.svg │ ├── app-icon.png │ ├── demo │ │ ├── phil.jpg │ │ └── profile-photo.jpg │ ├── drawings │ │ └── countryside.svg │ ├── photos │ │ └── screenshot.png │ ├── ship.png │ ├── tile.png │ └── tile.svg ├── utils │ ├── api.js │ ├── auth.js │ ├── errorHandlers.js │ ├── graphql │ │ ├── fragments.js │ │ └── gql.js │ ├── hoc.js │ ├── keys │ │ └── groupKeys.js │ ├── online.js │ ├── photo.js │ ├── store.js │ ├── styles │ │ ├── globalStyles.js │ │ ├── mixins.js │ │ ├── provideTheme.js │ │ └── theme.js │ ├── timezones │ │ ├── detect.js │ │ └── helpers.js │ ├── unsplash.js │ ├── urql │ │ ├── cache.js │ │ ├── client.js │ │ └── provideUrql.js │ └── windows │ │ └── helpers.js └── vectors │ ├── AddPerson.js │ ├── Caret.js │ ├── Close.js │ ├── Cog.js │ ├── Email.js │ ├── Flag.js │ ├── Image.js │ ├── LittleLineArrow.js │ ├── Location.js │ ├── Party.js │ ├── People.js │ ├── Person.js │ ├── Place.js │ ├── QuestionMark.js │ ├── Reload.js │ ├── Search.js │ ├── SingleStar.js │ ├── Stars.js │ ├── TwitterLogo.js │ └── UnsplashLogo.js ├── scripts └── notarize.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | [ 5 | "styled-components", 6 | { 7 | "displayName": true, 8 | "ssr": true 9 | } 10 | ], 11 | "polished", 12 | "transform-class-properties" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | renderer/.next 2 | renderer/out 3 | dist/ 4 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": ["prettier", "plugin:react/recommended", "eslint:recommended"], 4 | "plugins": ["prettier", "react"], 5 | "rules": { 6 | "class-methods-use-this": 0, 7 | "symbol-description": 0, 8 | "no-unused-vars": [2, { "varsIgnorePattern": "^_+$" }], 9 | "import/no-extraneous-dependencies": 0, 10 | "no-confusing-arrow": 0, 11 | "no-else-return": 0, 12 | "react/sort-comp": 0, 13 | "react/jsx-filename-extension": 0, 14 | "no-prototype-builtins": 0, 15 | "no-duplicate-imports": 0, 16 | "react/react-in-jsx-scope": 0, 17 | "react/prop-types": 0, 18 | "react/no-unescaped-entities": 0, 19 | "react/display-name": 0, 20 | "react/no-children-prop": 0, 21 | "no-undef": 2, 22 | "no-console": 0 23 | }, 24 | "env": { 25 | "browser": true, 26 | "node": true, 27 | "es6": true 28 | }, 29 | "globals": { 30 | "Promise": true 31 | }, 32 | "parserOptions": { 33 | "ecmaVersion": 7, 34 | "sourceType": "module", 35 | "ecmaFeatures": { 36 | "impliedStrict": true, 37 | "experimentalObjectRestSpread": true 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node 2 | node_modules/ 3 | .env 4 | *.env 5 | *.log 6 | .DS_Store 7 | 8 | # Electron 9 | dist/ 10 | 11 | # React 12 | renderer/out/ 13 | renderer/.next/ 14 | .cache/ 15 | 16 | # Editor 17 | .vscode 18 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | os: osx 3 | osx_image: 'xcode11.3' 4 | node_js: 5 | - '10' 6 | 7 | matrix: 8 | fast_finish: true 9 | 10 | cache: 11 | yarn: true 12 | directories: 13 | - node_modules 14 | - $HOME/Library/Caches/electron 15 | - $HOME/Library/Caches/electron-builder 16 | 17 | script: 18 | - yarn build 19 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix "" -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | environment: 2 | matrix: 3 | - platform: x64 4 | GH_TOKEN: 5 | secure: 21nU+z4F77LDrdkEQXafkb2uzNJSGy8EsGWrlmR+x5cRnJiVASEexZUGyrnD/Mbl 6 | 7 | image: Visual Studio 2015 8 | 9 | install: 10 | - ps: Install-Product node 10 x64 11 | - set CI=true 12 | - yarn --ignore-engines 13 | 14 | build: off 15 | 16 | matrix: 17 | fast_finish: true 18 | 19 | shallow_clone: true 20 | 21 | test_script: 22 | - node --version 23 | - yarn --version 24 | - yarn test-lint 25 | - yarn build-renderer 26 | - yarn build-app 27 | # on_success: 28 | # - ps: Get-ChildItem .\dist\squirrel-windows\*.exe | % { Push-AppveyorArtifact $_.FullName } 29 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | const isDev = require('electron-is-dev') 2 | 3 | let host 4 | const remoteEndpoint = `https://there-api.usenoor.com` 5 | if (process.env.ONLINE_API === '1') { 6 | host = remoteEndpoint 7 | } else if (process.env.ONLINE_API === '0') { 8 | host = 'http://localhost:9900' 9 | } else { 10 | host = isDev ? 'http://localhost:9900' : remoteEndpoint 11 | } 12 | 13 | console.log(`🔥 Connecting to server at: ${host}`) 14 | 15 | const mixpanelProjectToken = isDev 16 | ? '31a53a5d9fb1a091846b5abffac684e7' // DEV 17 | : 'e7859c5640d175b8f34d425735fba85e' // PROD 18 | 19 | module.exports = { 20 | devPort: 8008, 21 | apiUrl: host, 22 | graphqlEndpoint: `${host}/graphql`, 23 | restEndpoint: `${host}/rest`, 24 | crispWebsiteId: `bb14ccd2-0869-40e7-b0f1-b520e93db7e1`, 25 | sentryDSN: `https://83a762162f104b8196ee89a8037e0b27@sentry.io/287684`, 26 | GATrackingId: `UA-116027138-1`, 27 | googleCloudStorage: `https://storage.googleapis.com/there-192619.appspot.com`, 28 | mixpanelProjectToken, 29 | whatsNewUrl: `https://www.notion.so/there/What-s-New-503917feb74540f596faef3e4e6ec40e`, 30 | maxPinLimit: 4, 31 | } 32 | -------------------------------------------------------------------------------- /main/notify.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | const { shell, Notification } = require('electron') 3 | const { resolve } = require('app-root-path') 4 | 5 | const icon = resolve('./main/static/icons/windows.ico') 6 | 7 | module.exports = ({ title, body, url, onClick }) => { 8 | const specs = { 9 | title, 10 | body, 11 | icon, 12 | silent: true, 13 | } 14 | 15 | const notification = new Notification(specs) 16 | 17 | if (url || onClick) { 18 | notification.on('click', () => { 19 | if (onClick) { 20 | return onClick() 21 | } 22 | 23 | shell.openExternal(url) 24 | }) 25 | } 26 | 27 | notification.show() 28 | console.log(`[Notification] ${title}: ${body}`) 29 | } 30 | -------------------------------------------------------------------------------- /main/static/entitlements.mac.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.cs.allow-unsigned-executable-memory 6 | 7 | com.apple.security.cs.disable-library-validation 8 | 9 | com.apple.security.cs.allow-jit 10 | 11 | com.apple.security.cs.allow-dyld-environment-variables 12 | 13 | 14 | -------------------------------------------------------------------------------- /main/static/icons/mac.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therehq/there-desktop/6f2ff86be12b51a45288352ba56ca0a2c390c550/main/static/icons/mac.icns -------------------------------------------------------------------------------- /main/static/icons/windows.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therehq/there-desktop/6f2ff86be12b51a45288352ba56ca0a2c390c550/main/static/icons/windows.ico -------------------------------------------------------------------------------- /main/static/pages/status.html: -------------------------------------------------------------------------------- 1 | 2 | 11 | -------------------------------------------------------------------------------- /main/static/tray/iconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therehq/there-desktop/6f2ff86be12b51a45288352ba56ca0a2c390c550/main/static/tray/iconTemplate.png -------------------------------------------------------------------------------- /main/static/tray/iconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therehq/there-desktop/6f2ff86be12b51a45288352ba56ca0a2c390c550/main/static/tray/iconTemplate@2x.png -------------------------------------------------------------------------------- /main/static/tray/iconTemplate@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therehq/there-desktop/6f2ff86be12b51a45288352ba56ca0a2c390c550/main/static/tray/iconTemplate@3x.png -------------------------------------------------------------------------------- /main/static/tray/iconWhite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/therehq/there-desktop/6f2ff86be12b51a45288352ba56ca0a2c390c550/main/static/tray/iconWhite.png -------------------------------------------------------------------------------- /main/updates.js: -------------------------------------------------------------------------------- 1 | const { app } = require('electron') 2 | const autoUpdater = require('electron-updater').autoUpdater 3 | const logger = require('electron-log') 4 | const isDev = require('electron-is-dev') 5 | const semver = require('semver') 6 | const Raven = require('raven') 7 | const ms = require('ms') 8 | 9 | // Utilities 10 | const notify = require('./notify') 11 | const mixpanel = require('./utils/mixpanel') 12 | const { getUpdateChannel, getUser } = require('./utils/store') 13 | 14 | // Set GH_TOKEN for authenticated repo read 15 | // process.env.GH_TOKEN = 'xxxxxxxxx' 16 | // Parcel Bundler already inlines this 17 | 18 | const updateApp = async () => { 19 | if (process.env.CONNECTION === 'offline') { 20 | // We are offline, we stop here and check in 30 minutes 21 | setTimeout(updateApp, ms('30m')) 22 | return 23 | } 24 | 25 | try { 26 | // Check for updates and download them 27 | // then install them on quit 28 | await autoUpdater.checkForUpdates() 29 | } catch (e) { 30 | console.error(e) 31 | Raven.captureException(e) 32 | } 33 | } 34 | 35 | module.exports = () => { 36 | autoUpdater.logger = logger 37 | autoUpdater.logger.transports.file.level = 'info' 38 | autoUpdater.allowPrerelease = getUpdateChannel() === 'canary' 39 | 40 | autoUpdater.on('error', error => { 41 | // We report errors to console and send 42 | // to Sentry as well 43 | console.log(error) 44 | Raven.captureException(error) 45 | 46 | // Then check again for updates 47 | setTimeout(updateApp, ms('2h')) 48 | }) 49 | 50 | autoUpdater.on('update-downloaded', ({ version }) => { 51 | const oldVersion = app.getVersion() 52 | const diff = semver.diff(version, oldVersion) 53 | 54 | // Don't notify user of patch updates 55 | if (diff !== 'patch') { 56 | notify({ 57 | title: `Update to ${version || 'latest version'} is downloaded!`, 58 | body: `Click to relaunch now! (Or we'll do it later)`, 59 | onClick: () => { 60 | autoUpdater.quitAndInstall() 61 | }, 62 | }) 63 | } 64 | 65 | const user = getUser() 66 | 67 | // Track event 68 | mixpanel.track( 69 | null, 70 | 'Update Download', 71 | user ? { distinct_id: user.id, userId: user.id } : {} 72 | ) 73 | }) 74 | 75 | if (!isDev) { 76 | updateApp() 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /main/utils/api/index.js: -------------------------------------------------------------------------------- 1 | const { GraphQLClient } = require('graphql-request') 2 | 3 | // Utilities 4 | const { getToken } = require('../store') 5 | const config = require('../../../config') 6 | 7 | exports.deleteAccount = async () => { 8 | const token = getToken() 9 | 10 | const client = new GraphQLClient(config.graphqlEndpoint, { 11 | headers: { 12 | 'Content-type': 'application/json', 13 | Authorization: token ? `Bearer ${token}` : null, 14 | }, 15 | }) 16 | 17 | const query = `#graphql 18 | mutation { 19 | deleteAccount 20 | } 21 | ` 22 | return client.request(query) 23 | } 24 | -------------------------------------------------------------------------------- /main/utils/events.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron') 2 | 3 | // Utilities 4 | const { openChat, openUpdateLocation, openAdd } = require('./frames/open') 5 | 6 | exports.listenToEvents = (app, tray, windows) => { 7 | ipcMain.on('reload-main', () => { 8 | windows.main.reload() 9 | }) 10 | 11 | ipcMain.on('reload-main-and-show', () => { 12 | windows.main.reload() 13 | windows.main.once('ready-to-show', () => { 14 | windows.main.show() 15 | windows.main.focus() 16 | }) 17 | }) 18 | 19 | ipcMain.on('show-main', () => { 20 | global.menuBar.showWindow() 21 | }) 22 | 23 | ipcMain.on('show-main-when-ready', () => { 24 | if (global.menuBar.window) { 25 | global.menuBar.window.once('ready-to-show', () => { 26 | global.menuBar.showWindow() 27 | }) 28 | return 29 | } 30 | 31 | global.menuBar.showWindow() 32 | }) 33 | 34 | ipcMain.on('open-add', () => { 35 | openAdd(tray, windows) 36 | }) 37 | 38 | ipcMain.on('open-chat', (event, user) => { 39 | openChat(tray, windows, user) 40 | }) 41 | 42 | ipcMain.on('open-update-location', () => { 43 | openUpdateLocation(windows) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /main/utils/frames/ipc.js: -------------------------------------------------------------------------------- 1 | // Notify all windows / Send an event to all available windows 2 | exports.sendToAll = (windows, event, ...args) => { 3 | for (let winKey in windows) { 4 | const window = windows[winKey] 5 | if (window && window.webContents && window.webContents.send) { 6 | window.webContents.send(event, ...args) 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /main/utils/frames/list.js: -------------------------------------------------------------------------------- 1 | // Native 2 | const path = require('path') 3 | 4 | // Packages 5 | const electron = require('electron') 6 | const isDev = require('electron-is-dev') 7 | const { resolve } = require('app-root-path') 8 | const { menubar } = require('menubar') 9 | 10 | // Utilities 11 | const store = require('../store') 12 | const attachTrayState = require('../highlight') 13 | const { devPort } = require('../../../config') 14 | 15 | const windowUrl = page => { 16 | if (isDev) { 17 | return `http://localhost:${devPort}/${page}` 18 | } 19 | 20 | return path.join('file://', resolve('./renderer/out'), page, 'index.html') 21 | } 22 | 23 | exports.windowUrl = windowUrl 24 | 25 | exports.chatWindow = tray => { 26 | const win = new electron.BrowserWindow({ 27 | width: 320, 28 | height: 440, 29 | title: 'Ask There Team!', 30 | resizable: false, 31 | center: true, 32 | show: false, 33 | fullscreenable: false, 34 | maximizable: false, 35 | backgroundColor: '#fff', 36 | webPreferences: { 37 | nodeIntegration: false, 38 | }, 39 | }) 40 | 41 | attachTrayState(win, tray, 'chat') 42 | 43 | return win 44 | } 45 | 46 | exports.addWindow = tray => { 47 | const win = new electron.BrowserWindow({ 48 | width: 530, 49 | height: 440, 50 | title: 'Add (Person or Place)', 51 | resizable: true, 52 | center: true, 53 | show: false, 54 | frame: false, 55 | titleBarStyle: 'hiddenInset', 56 | fullscreenable: false, 57 | maximizable: true, 58 | backgroundColor: '#fff', 59 | webPreferences: { 60 | nodeIntegration: true, 61 | }, 62 | }) 63 | 64 | win.loadURL(windowUrl('add')) 65 | attachTrayState(win, tray, 'add') 66 | 67 | return win 68 | } 69 | 70 | exports.joinWindow = tray => { 71 | const win = new electron.BrowserWindow({ 72 | width: 550, 73 | height: 440, 74 | title: 'Join!', 75 | resizable: true, 76 | center: true, 77 | show: false, 78 | frame: false, 79 | titleBarStyle: 'hiddenInset', 80 | fullscreenable: false, 81 | maximizable: true, 82 | backgroundColor: '#fff', 83 | webPreferences: { 84 | backgroundThrottling: false, 85 | nodeIntegration: true, 86 | }, 87 | }) 88 | 89 | win.loadURL(windowUrl('join')) 90 | attachTrayState(win, tray, 'join') 91 | 92 | return win 93 | } 94 | 95 | exports.editWindow = tray => { 96 | const win = new electron.BrowserWindow({ 97 | width: 550, 98 | height: 390, 99 | title: 'Edit', 100 | resizable: true, 101 | center: true, 102 | show: false, 103 | frame: false, 104 | titleBarStyle: 'hiddenInset', 105 | fullscreenable: false, 106 | maximizable: true, 107 | backgroundColor: '#fff', 108 | webPreferences: { 109 | backgroundThrottling: false, 110 | nodeIntegration: true, 111 | }, 112 | }) 113 | 114 | attachTrayState(win, tray, 'edit') 115 | 116 | return win 117 | } 118 | 119 | exports.trayWindow = tray => { 120 | const menuBar = menubar({ 121 | tray, 122 | index: windowUrl('tray'), 123 | windowPosition: 'trayCenter', 124 | 125 | browserWindow: { 126 | maxWidth: 320, 127 | minWidth: 320, 128 | minHeight: 150, 129 | maxHeight: 750, 130 | height: store.getWindowHeight(), 131 | width: 320, 132 | movable: false, 133 | resizable: true, 134 | // preloadWindow: true, 135 | hasShadow: true, 136 | transparent: true, 137 | frame: false, 138 | center: false, 139 | darkTheme: true, 140 | 141 | webPreferences: { 142 | backgroundThrottling: false, 143 | devTools: true, 144 | nodeIntegration: true, 145 | }, 146 | }, 147 | 148 | preloadWindow: true, 149 | show: false, 150 | }) 151 | 152 | // const saveHeight = () => { 153 | // const sizeArray = window.getSize() 154 | // const height = sizeArray.length > 1 ? sizeArray[1] : null 155 | // store.saveWindowHeight(height) 156 | // } 157 | 158 | // Save window height before close 159 | // menuBar.window.on('close', saveHeight) 160 | // menuBar.window.on('hide', saveHeight) 161 | 162 | const { globalShortcut } = electron 163 | 164 | // Global shortcut to open tray window 165 | globalShortcut.register('CommandOrControl+Shift+Option+J', () => { 166 | menuBar.window && menuBar.window.isVisible() 167 | ? menuBar.hideWindow() 168 | : menuBar.showWindow() 169 | }) 170 | 171 | return menuBar 172 | } 173 | -------------------------------------------------------------------------------- /main/utils/frames/open.js: -------------------------------------------------------------------------------- 1 | // Utitlities 2 | const { crispWebsiteId } = require('../../../config') 3 | const { 4 | windowUrl, 5 | chatWindow, 6 | joinWindow, 7 | editWindow, 8 | addWindow, 9 | } = require('./list') 10 | 11 | exports.openChat = (tray, windows, user) => { 12 | if (!windows.chat) { 13 | windows.chat = chatWindow(tray) 14 | } 15 | 16 | let chatUrl = `https://go.crisp.chat/chat/embed/?website_id=${crispWebsiteId}` 17 | if (user) { 18 | // Use a unique identifier for signed in users 19 | chatUrl = `https://go.crisp.chat/chat/embed/?website_id=${crispWebsiteId}&user_email=${encodeURI( 20 | user.email 21 | )}&user_nickname=${encodeURI(user.firstName)}&token_id=${encodeURI( 22 | user.id 23 | )}` 24 | } 25 | 26 | windows.chat.loadURL(chatUrl) 27 | windows.chat.show() 28 | windows.chat.focus() 29 | } 30 | 31 | exports.openJoin = (tray, windows) => { 32 | if (!windows.join) { 33 | windows.join = joinWindow(tray) 34 | windows.join.loadURL(windowUrl('join')) 35 | 36 | windows.join.once('ready-to-show', () => { 37 | windows.join.show() 38 | windows.join.focus() 39 | }) 40 | return 41 | } 42 | 43 | // If window is already active, just show/focus on it 44 | windows.join.show() 45 | windows.join.focus() 46 | } 47 | 48 | exports.openAdd = (tray, windows) => { 49 | if (!windows.add) { 50 | windows.add = addWindow(tray) 51 | windows.add.loadURL(windowUrl('add')) 52 | windows.add.once('ready-to-show', () => { 53 | windows.add.show() 54 | windows.add.focus() 55 | }) 56 | return 57 | } 58 | 59 | // If window is already active, just show/focus on it 60 | windows.add.show() 61 | windows.add.focus() 62 | } 63 | 64 | const openEdit = (propWindows, page, size, customData) => { 65 | if (!page) { 66 | return 67 | } 68 | 69 | const windows = propWindows || global.windows 70 | 71 | if (!windows.edit) { 72 | windows.edit = editWindow(global.tray) 73 | } 74 | 75 | if (size && size.width && size.height) { 76 | const { width, height } = size 77 | windows.edit.setSize(width, height, true) 78 | } 79 | 80 | if (customData) { 81 | windows.edit.customData = customData 82 | } 83 | 84 | windows.edit.loadURL(windowUrl(page)) 85 | windows.edit.once('ready-to-show', () => { 86 | windows.edit.show() 87 | windows.edit.focus() 88 | }) 89 | } 90 | exports.openEdit = openEdit 91 | 92 | exports.openUpdateLocation = windows => { 93 | openEdit(windows, `update-location`, { width: 550, height: 390 }) 94 | } 95 | 96 | exports.openEditManual = (windows, { __typename, id }) => { 97 | openEdit( 98 | windows, 99 | `edit-manual`, 100 | { width: 530, height: 320 }, 101 | // Custom data 102 | { __typename, id } 103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /main/utils/frames/toggle.js: -------------------------------------------------------------------------------- 1 | module.exports = (event, window) => { 2 | const isVisible = window.isVisible() 3 | const isWin = process.platform === 'win32' 4 | const isMain = global.windows && window === global.windows.main 5 | 6 | if (event) { 7 | // Don't open the menu 8 | event.preventDefault() 9 | } 10 | 11 | // If window open and not focused, bring it to focus 12 | if (!isWin && isVisible && !window.isFocused()) { 13 | window.focus() 14 | return 15 | } 16 | 17 | // Show or hide onboarding window 18 | // Calling `.close()` will actually make it 19 | // hide, but it's a special scenario which we're 20 | // listening for in a different// If the "blur" event was triggered when 21 | // clicking on the tray icon, don't do anything place 22 | if (isVisible) { 23 | window.close() 24 | } else { 25 | // Position main window correctly under the tray icon 26 | if (isMain) { 27 | global.menuBar && global.menuBar.showWindow() 28 | } 29 | 30 | window.show() 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /main/utils/highlight.js: -------------------------------------------------------------------------------- 1 | const states = { 2 | hide: false, 3 | close: false, 4 | show: true, 5 | minimize: false, 6 | restore: true, 7 | focus: true, 8 | } 9 | 10 | const windowLeft = () => { 11 | const windows = global.windows 12 | 13 | if (!windows) { 14 | return false 15 | } 16 | 17 | if (windows.main && windows.main.isVisible()) { 18 | return true 19 | } 20 | 21 | return false 22 | } 23 | 24 | module.exports = (win, tray, key) => { 25 | if (!tray) { 26 | return 27 | } 28 | 29 | for (const state in states) { 30 | if (!{}.hasOwnProperty.call(states, state)) { 31 | return 32 | } 33 | 34 | win.on(state, () => { 35 | // Don't toggle highlighting if one window is still open 36 | if (windowLeft(win)) { 37 | return 38 | } 39 | }) 40 | } 41 | 42 | win.on('close', event => { 43 | if (key) { 44 | global.windows[key] = null 45 | } else { 46 | event.preventDefault() 47 | win.hide() 48 | } 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /main/utils/logout.js: -------------------------------------------------------------------------------- 1 | /* global windows */ 2 | 3 | // Modules 4 | const Raven = require('raven') 5 | 6 | // Utilities 7 | const { clearCache, setToken } = require('./store') 8 | const { openJoin } = require('./frames/open') 9 | const mixpanel = require('./mixpanel') 10 | 11 | module.exports = app => { 12 | // Remove user token immediately 13 | setToken(null) 14 | 15 | // Hide the main window 16 | if (windows && windows.main) { 17 | windows.main.reload() 18 | windows.main.hide() 19 | } 20 | 21 | // Close all windows 22 | if (windows) { 23 | for (let winKey in windows) { 24 | if (winKey !== 'join' && winKey !== 'main') { 25 | const win = windows[winKey] 26 | if (!win) { 27 | continue 28 | } 29 | 30 | win.close() 31 | } 32 | } 33 | } 34 | 35 | // Clear urql cache thus user details 36 | try { 37 | clearCache() 38 | } catch (e) { 39 | Raven.captureException(e) 40 | } 41 | 42 | // Show the login window 43 | openJoin(global.tray, windows) 44 | 45 | // Track the logout 46 | mixpanel.track(app, 'Logout') 47 | } 48 | -------------------------------------------------------------------------------- /main/utils/migrate.js: -------------------------------------------------------------------------------- 1 | const semver = require('semver') 2 | const isDev = require('electron-is-dev') 3 | const { getVersion, setVersion, clearCache } = require('./store') 4 | 5 | module.exports = app => { 6 | if (isDev) { 7 | return 8 | } 9 | 10 | const appVersion = app.getVersion() 11 | const storeVersion = getVersion() 12 | 13 | if (!semver.valid(storeVersion)) { 14 | clearCache() 15 | setVersion(appVersion) 16 | return 17 | } 18 | 19 | if (semver.gt(appVersion, storeVersion)) { 20 | // Store is older 21 | clearCache() 22 | setVersion(appVersion) 23 | return 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /main/utils/mixpanel.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const Mixpanel = require('mixpanel') 3 | const isDev = require('electron-is-dev') 4 | const { app: electronApp } = require('electron') 5 | const { machineId: genMachineId } = require('node-machine-id') 6 | 7 | // Utilities 8 | const { mixpanelProjectToken } = require('../../config') 9 | const { getUser } = require('../utils/store') 10 | 11 | const mixpanel = Mixpanel.init(mixpanelProjectToken) 12 | 13 | const track = async (app = electronApp, event, additionalData, callback) => { 14 | // I do not ever want to spoil Sentry with useless errors 15 | try { 16 | const machineId = await genMachineId() 17 | const appVersion = app && 'getVersion' in app ? app.getVersion() : '' 18 | const userId = (getUser() || {}).id 19 | 20 | mixpanel.track( 21 | event, 22 | Object.assign( 23 | { 24 | distinct_id: machineId, 25 | process: process.type, 26 | platform: os.platform(), 27 | platform_release: os.release(), 28 | }, 29 | !isDev && { appVersion }, 30 | userId && { userId }, 31 | additionalData 32 | ), 33 | callback 34 | ) 35 | } catch (err) { 36 | return 37 | } 38 | } 39 | 40 | module.exports = { 41 | instance: mixpanel, 42 | track, 43 | } 44 | -------------------------------------------------------------------------------- /main/utils/setup.js: -------------------------------------------------------------------------------- 1 | const os = require('os') 2 | const Raven = require('raven') 3 | 4 | // Utilities 5 | const { getUser } = require('../utils/store') 6 | 7 | exports.devtools = { 8 | setupElectronDebug() { 9 | const electronDebug = require('electron-debug') 10 | electronDebug({ showDevTools: 'undocked', enabled: false }) 11 | }, 12 | // Add React and Apollo extensions to the devtools 13 | installExtensions() { 14 | const { 15 | default: installExtension, 16 | REACT_DEVELOPER_TOOLS, 17 | APOLLO_DEVELOPER_TOOLS, 18 | } = require('electron-devtools-installer') 19 | // Add both extenstions async 20 | Promise.all([ 21 | installExtension(REACT_DEVELOPER_TOOLS), 22 | installExtension(APOLLO_DEVELOPER_TOOLS), 23 | ]) 24 | .then((...names) => 25 | console.log(`Added Extension(s): ${names.join(', ')}`) 26 | ) 27 | .catch(err => console.log('An error occurred: ', err)) 28 | }, 29 | } 30 | 31 | exports.setupSentry = app => { 32 | const user_id = (getUser() || {}).id 33 | 34 | Raven.config('https://83a762162f104b8196ee89a8037e0b27@sentry.io/287684', { 35 | captureUnhandledRejections: true, 36 | tags: Object.assign( 37 | { 38 | process: process.type, 39 | electron: process.versions.electron, 40 | chrome: process.versions.chrome, 41 | app_version: app ? app.getVersion() : '', 42 | platform: os.platform(), 43 | platform_release: os.release(), 44 | }, 45 | user_id && { user_id } 46 | ), 47 | }).install() 48 | } 49 | -------------------------------------------------------------------------------- /main/utils/store.js: -------------------------------------------------------------------------------- 1 | const { ipcMain } = require('electron') 2 | const Store = require('electron-store') 3 | 4 | // Local 5 | const { sendToAll } = require('./frames/ipc') 6 | 7 | const store = new Store() 8 | 9 | // IPC channel keys 10 | const LOGGED_IN_CHANGED_CHANNEL = 'logged-in-changed' 11 | 12 | // Store keys 13 | const WINDOW_HEIGHT = 'window-height' 14 | const URQL_CACHE = 'urql-cache' 15 | const CONFIG = 'config' 16 | const USER = 'user' 17 | const VERSON = 'version' 18 | const DISPLAY_FORMAT = 'display-format' 19 | const UPDATE_CHANNEL = 'update-channel' 20 | const TIME_ZONE_AUTO_UPDATE = 'timzone-auto-update' 21 | 22 | exports.store = store 23 | 24 | exports.getStore = () => { 25 | return store 26 | } 27 | 28 | // Config 29 | exports.getConfig = () => store.get(CONFIG) 30 | 31 | // Window 32 | exports.saveWindowHeight = height => { 33 | store.set(WINDOW_HEIGHT, height) 34 | } 35 | exports.getWindowHeight = () => store.get(WINDOW_HEIGHT, 300) 36 | 37 | // Version 38 | exports.getVersion = () => store.get(VERSON, '1.0.0') 39 | exports.setVersion = newV => store.set(VERSON, newV) 40 | 41 | // Update Channel 42 | exports.getUpdateChannel = () => store.get(UPDATE_CHANNEL, 'stable') 43 | exports.setUpdateChannel = c => store.set(UPDATE_CHANNEL, c) 44 | 45 | // Timezone auto-update 46 | exports.getTimeZoneAutoUpdate = () => store.get(TIME_ZONE_AUTO_UPDATE, true) 47 | exports.setTimeZoneAutoUpdate = c => store.set(TIME_ZONE_AUTO_UPDATE, c) 48 | 49 | // User 50 | const tokenFieldKey = `token` 51 | exports.tokenFieldKey = tokenFieldKey 52 | exports.getUser = () => store.get(USER) 53 | exports.getToken = () => store.get(tokenFieldKey) 54 | exports.setToken = newToken => { 55 | store.set(tokenFieldKey, newToken) 56 | // Notify all available windows 57 | sendToAll(global.windows, LOGGED_IN_CHANGED_CHANNEL, Boolean(newToken)) 58 | } 59 | 60 | // Setup event handling for token 61 | exports.setupTokenListener = windows => { 62 | ipcMain.on('token-changed', (e, newToken) => { 63 | // Notify all available windows 64 | sendToAll(windows, LOGGED_IN_CHANGED_CHANNEL, Boolean(newToken)) 65 | // Needs a change to call onDidChange in the main thread 66 | store.set(tokenFieldKey, newToken) 67 | // Reload main window on token change 68 | if (windows && windows.main) { 69 | windows.main.reload() 70 | } 71 | }) 72 | } 73 | 74 | // Display Format 75 | exports.getDisplayFormat = () => store.get(DISPLAY_FORMAT, '12h') // 12h or 24h 76 | exports.setDisplayFormat = newFormat => store.set(DISPLAY_FORMAT, newFormat) 77 | 78 | // Urql 79 | exports.clearCache = () => store.delete(URQL_CACHE) 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "there-desktop", 3 | "productName": "There", 4 | "description": "Desktop client for There PM", 5 | "homepage": "https://there.pm", 6 | "version": "1.8.10", 7 | "main": "main/index.js", 8 | "license": "MIT", 9 | "author": { 10 | "name": "There", 11 | "email": "support@there.pm" 12 | }, 13 | "contributors": [ 14 | "Mohammad Rajabifard " 15 | ], 16 | "devDependencies": { 17 | "@zeit/next-css": "0.1.4", 18 | "babel-eslint": "8.2.1", 19 | "babel-plugin-polished": "1.1.0", 20 | "babel-plugin-styled-components": "1.4.0", 21 | "babel-plugin-transform-class-properties": "6.24.1", 22 | "babel-preset-env": "1.7.0", 23 | "babel-preset-react": "6.24.1", 24 | "concurrently": "^3.5.1", 25 | "cross-env": "5.1.3", 26 | "downshift": "1.28.5", 27 | "electron": "7.1.7", 28 | "electron-builder": "22.5.1", 29 | "electron-builder-squirrel-windows": "20.8.0", 30 | "electron-notarize": "0.2.0", 31 | "eslint": "4.16.0", 32 | "eslint-config-prettier": "2.9.0", 33 | "eslint-plugin-jsx-a11y": "6.0.3", 34 | "eslint-plugin-prettier": "2.5.0", 35 | "eslint-plugin-react": "7.5.1", 36 | "jstimezonedetect": "1.0.6", 37 | "just-compare": "1.2.1", 38 | "just-compose": "1.0.6", 39 | "just-debounce-it": "1.0.1", 40 | "lint-staged": "9.2.5", 41 | "luxon": "1.21.1", 42 | "moment": "2.24.0", 43 | "moment-timezone": "0.5.27", 44 | "next": "5.1.0", 45 | "nodemon": "1.14.11", 46 | "polished": "1.9.0", 47 | "prettier": "1.19.1", 48 | "prop-types": "15.6.0", 49 | "raven-js": "3.22.3", 50 | "react": "16.4.0", 51 | "react-animate-height": "0.10.10", 52 | "react-beautiful-dnd": "6.0.0", 53 | "react-content-loader": "3.1.1", 54 | "react-dom": "16.4.0", 55 | "react-dropzone": "4.2.9", 56 | "react-measure": "^2.0.0", 57 | "react-onclickoutside": "6.7.1", 58 | "socket.io-client": "2.0.4", 59 | "styled-components": "3.2.3", 60 | "unsplash-js": "4.8.0", 61 | "unstated": "1.1.0", 62 | "urql": "0.3.0-next1", 63 | "wenk": "1.0.7" 64 | }, 65 | "dependencies": { 66 | "app-root-path": "2.0.1", 67 | "create-react-context": "0.1.6", 68 | "dotenv": "4.0.0", 69 | "electron-debug": "1.5.0", 70 | "electron-default-menu": "1.0.1", 71 | "electron-devtools-installer": "2.2.4", 72 | "electron-is-dev": "0.3.0", 73 | "electron-log": "2.2.14", 74 | "electron-next": "3.1.4", 75 | "electron-react-devtools": "0.5.3", 76 | "electron-squirrel-startup": "1.0.0", 77 | "electron-store": "1.3.0", 78 | "electron-updater": "4.2.5", 79 | "electron-util": "0.6.0", 80 | "first-run": "1.2.0", 81 | "fix-path": "2.1.0", 82 | "graphql-request": "1.8.2", 83 | "husky": "3.0.5", 84 | "menubar": "8.0.1", 85 | "mixpanel": "0.11.0", 86 | "ms": "2.1.1", 87 | "node-fetch": "2.6.0", 88 | "node-machine-id": "1.1.10", 89 | "patch-package": "6.2.0", 90 | "qs": "6.5.1", 91 | "raven": "2.6.4", 92 | "semver": "5.5.0" 93 | }, 94 | "lint-staged": { 95 | "*.js": [ 96 | "eslint --fix", 97 | "prettier --write", 98 | "git add" 99 | ] 100 | }, 101 | "prettier": { 102 | "semi": false, 103 | "singleQuote": true, 104 | "trailingComma": "es5" 105 | }, 106 | "build": { 107 | "buildVersion": "0", 108 | "appId": "pm.there.desktop", 109 | "afterSign": "scripts/notarize.js", 110 | "mac": { 111 | "category": "public.app-category.productivity", 112 | "icon": "main/static/icons/mac.icns", 113 | "entitlements": "main/static/entitlements.mac.plist", 114 | "entitlementsInherit": "main/static/entitlements.mac.plist", 115 | "gatekeeperAssess": false, 116 | "hardenedRuntime": true, 117 | "target": [ 118 | "zip", 119 | "dmg" 120 | ], 121 | "darkModeSupport": true, 122 | "extendInfo": { 123 | "LSUIElement": 1, 124 | "NSUserNotificationAlertStyle": "alert" 125 | } 126 | }, 127 | "win": { 128 | "target": [ 129 | "nsis" 130 | ], 131 | "verifyUpdateCodeSignature": false 132 | }, 133 | "files": [ 134 | "**/*", 135 | "!renderer", 136 | "renderer/out" 137 | ], 138 | "appx": { 139 | "displayName": "There PM", 140 | "identityName": "11339MoRajabifard.TherePM", 141 | "applicationId": "MoRajabifard.TherePM", 142 | "publisher": "CN=937F087C-7B47-48C8-B363-6D0234B7403C", 143 | "publisherDisplayName": "Mo Rajabifard" 144 | } 145 | }, 146 | "scripts": { 147 | "dev": "electron main", 148 | "start": "cross-env ONLINE_API=1 electron main", 149 | "build": "yarn build-renderer && yarn build-app", 150 | "build-app": "electron-builder", 151 | "build-renderer": "next build renderer && next export renderer", 152 | "format": "prettier --write '{renderer,main}/**/*.{js,json}'", 153 | "test": "yarn test-lint", 154 | "test-lint": "eslint . --fix", 155 | "precommit": "lint-staged", 156 | "postinstall": "patch-package" 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /patches/app-builder-lib+22.5.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/app-builder-lib/.DS_Store b/node_modules/app-builder-lib/.DS_Store 2 | new file mode 100644 3 | index 0000000..aed2c26 4 | Binary files /dev/null and b/node_modules/app-builder-lib/.DS_Store differ 5 | diff --git a/node_modules/app-builder-lib/out/targets/ArchiveTarget.js b/node_modules/app-builder-lib/out/targets/ArchiveTarget.js 6 | index 72a59df..cb72f10 100644 7 | --- a/node_modules/app-builder-lib/out/targets/ArchiveTarget.js 8 | +++ b/node_modules/app-builder-lib/out/targets/ArchiveTarget.js 9 | @@ -120,7 +120,8 @@ class ArchiveTarget extends _core().Target { 10 | await (0, _archive().archive)(format, artifactPath, dirToArchive, archiveOptions); 11 | 12 | if (this.isWriteUpdateInfo && format === "zip") { 13 | - updateInfo = await (0, _differentialUpdateInfoBuilder().appendBlockmap)(artifactPath); 14 | + // This causes ZIP to become invalid 15 | + // updateInfo = await (0, _differentialUpdateInfoBuilder().appendBlockmap)(artifactPath); 16 | } 17 | } 18 | 19 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # There Desktop 2 | 3 | [![TravicCI Build Status](https://travis-ci.org/therepm/there-desktop.svg?branch=master)](https://travis-ci.org/therepm/there-desktop) ![with-love](https://img.shields.io/badge/made%20with-%F0%9F%92%8C-red.svg) [![https://ci.appveyor.com/api/projects/status/32r7s2skrgm9ubva?svg=true](https://ci.appveyor.com/api/projects/status/32r7s2skrgm9ubva?svg=true)](https://ci.appveyor.com/api/projects/status/github/therepm/there-desktop) 4 | 5 | > ⚠️ **Note:** This is a previous version of There. Latest version lives at https://github.com/dena-sohrabi/There 6 | > 7 | > Download it at https://there.pm 8 | 9 | Your teammates and friends' time, no matter where they are, right in your tray menu. 10 | -------------------------------------------------------------------------------- /renderer/components/AreaSeparator.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const AreaSeparator = ({ area = 'Europe', ...rest }) => ( 5 | {area} 6 | ) 7 | export default AreaSeparator 8 | 9 | export const height = 22 10 | 11 | const Wrapper = styled.div` 12 | height: ${height}px; 13 | margin-top: 10px; 14 | padding: 0 ${p => p.theme.sizes.sidePadding}px; 15 | overflow: hidden; /* for safety, to not leak elements into whole UI */ 16 | font-size: ${p => p.theme.sizes.fontSizeTiny}px; 17 | line-height: ${height}px; 18 | 19 | /* background: rgba(255, 255, 255, 0.05); */ 20 | color: ${p => p.theme.colors.lightMutedText}; 21 | ` 22 | -------------------------------------------------------------------------------- /renderer/components/ConnectedCaret.js: -------------------------------------------------------------------------------- 1 | import { Connect, query } from 'urql' 2 | 3 | // Utilities 4 | import { Following } from '../utils/graphql/fragments' 5 | 6 | // Local 7 | import Caret from '../vectors/Caret' 8 | 9 | export const ConnectedCaret = () => ( 10 | 11 | {({ fetching, data, loaded }) => ( 12 | 0} /> 13 | )} 14 | 15 | ) 16 | 17 | const PinnedList = `#graphql 18 | query { 19 | pinnedList { 20 | ...Following 21 | } 22 | } 23 | ${Following} 24 | ` 25 | 26 | const shouldInvalidate = changedTypenames => { 27 | const relatedTypenames = [ 28 | 'User', 29 | 'ManualPlace', 30 | 'ManualPerson', 31 | 'Refresh', 32 | 'UserPinResponse', 33 | ] 34 | const allTypenames = new Set(relatedTypenames.concat(changedTypenames)) 35 | 36 | if (allTypenames.size !== relatedTypenames.length + changedTypenames.length) { 37 | return true 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /renderer/components/ConvertText.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron' 2 | import { Component } from 'react' 3 | import moment from 'moment-timezone' 4 | 5 | // Utilities 6 | import { detectTimezone } from '../utils/timezones/detect' 7 | 8 | export default class ConvertText extends Component { 9 | ipc = electron.ipcRenderer || false 10 | 11 | componentDidMount() { 12 | if (!this.ipc) { 13 | return 14 | } 15 | 16 | this.ipc.on('text-dropped', this.textDopped) 17 | } 18 | 19 | componentWillUnmount() { 20 | if (!this.ipc) { 21 | return 22 | } 23 | 24 | this.ipc.removeListener('text-dropped', this.textDopped) 25 | } 26 | 27 | textDopped = (event, arg) => { 28 | const timezone = detectTimezone() 29 | const momentInstance = moment(arg) 30 | const isValidDateTime = momentInstance.isValid() 31 | 32 | if (!isValidDateTime) { 33 | this.answer({ 34 | message: `Sorry, the data / time format wasn't recognized.`, 35 | }) 36 | return 37 | } 38 | 39 | const data = momentInstance.format('DD/MM/YYYY (ddd, MMMM)') 40 | const time = momentInstance 41 | .tz(timezone) 42 | .format('hh:mm:ss A ([converted to] Z UTC)') 43 | const fromNow = momentInstance.fromNow() 44 | const hasTime = !time.startsWith('00:00:00') 45 | 46 | this.answer({ 47 | message: `${data}${hasTime ? `\n${time}` : ``}\n${fromNow}`, 48 | detail: `\nBased on your local time, with considering Daylight saving time.`, 49 | }) 50 | } 51 | 52 | answer = msg => { 53 | this.ipc.send('dropped-text-converted', msg) 54 | } 55 | 56 | render() { 57 | return null 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /renderer/components/DetectTimezone.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron' 2 | import { Component } from 'react' 3 | import { ConnectHOC, query, mutation } from 'urql' 4 | import ms from 'ms' 5 | import moment from 'moment-timezone' 6 | 7 | // Utilities 8 | import { getTimeZoneAutoUpdate } from '../utils/store' 9 | import { getAbbrOrUtc } from '../utils/timezones/helpers' 10 | import { detectTimezone } from '../utils/timezones/detect' 11 | 12 | class DetectTimezone extends Component { 13 | refetchInterval = null 14 | 15 | state = { 16 | updating: false, 17 | } 18 | 19 | componentDidMount() { 20 | if (this.refetchInterval) { 21 | clearInterval(this.refetchInterval) 22 | } 23 | 24 | this.refetchInterval = setInterval(() => { 25 | this.props.refetch() 26 | 27 | setTimeout(() => { 28 | this.updateTimezone() 29 | }, ms('10s')) 30 | }, ms('90m')) 31 | 32 | // Run it once first. 33 | // 10s wait to load timezone. 34 | setTimeout(() => { 35 | this.updateTimezone() 36 | }, ms('10s')) 37 | } 38 | 39 | componentWillUnmount() { 40 | if (this.refetchInterval) { 41 | clearInterval(this.refetchInterval) 42 | } 43 | } 44 | 45 | // componentDidUpdate(prevProps) { 46 | // const isFirstTime = !prevProps.data && this.props.data 47 | 48 | // if (isFirstTime) { 49 | // this.updateTimezone() 50 | // } 51 | // } 52 | 53 | updateTimezone = async () => { 54 | // if updating, don't run again. 55 | if (this.state.updating) { 56 | return 57 | } 58 | 59 | console.log('update called...') 60 | const currentTimezone = this.props.data 61 | ? this.props.data.user.timezone 62 | : false 63 | const guessedTimezone = detectTimezone() 64 | 65 | // Check if data isn't loaded 66 | if (currentTimezone === false) { 67 | return 68 | } 69 | 70 | const noCurrentTimezone = currentTimezone === null || currentTimezone === '' 71 | 72 | // Only if there is a current timezone check for 73 | if (!noCurrentTimezone) { 74 | const areSame = this.compareTimezones(currentTimezone, guessedTimezone) 75 | 76 | if (areSame) { 77 | return 78 | } 79 | } 80 | 81 | console.log('updating') 82 | 83 | // Updating timezone... 84 | this.setState({ updating: true }) 85 | 86 | // If timezone has changed, we should update it 87 | try { 88 | await this.props.updateTimezone({ timezone: guessedTimezone }) 89 | 90 | // Only if it's not the first timezone, notify 91 | if (!noCurrentTimezone) { 92 | // Push a notification for user to know we updated it 93 | this.notifyOfUpdate(guessedTimezone) 94 | } 95 | } catch (err) { 96 | console.log(err) 97 | } finally { 98 | this.setState({ updating: false }) 99 | } 100 | } 101 | 102 | notifyOfUpdate = newTimezone => { 103 | const title = this.getMessage(newTimezone) 104 | const notification = new Notification(title, { 105 | body: `Click to set city, or dismiss.`, 106 | requireInteraction: true, 107 | }) 108 | 109 | notification.onclick = () => { 110 | this.openLocationWindow() 111 | } 112 | } 113 | 114 | getMessage = newTimezone => { 115 | const abbrOrUtc = getAbbrOrUtc(newTimezone) 116 | 117 | return `Time Zone Updated to ${abbrOrUtc}! ✈️` 118 | } 119 | 120 | compareTimezones = (aZone, bZone) => { 121 | const now = Date.now() 122 | 123 | const aTime = moment(now) 124 | .tz(aZone) 125 | .format() 126 | const bTime = moment(now) 127 | .tz(bZone) 128 | .format() 129 | 130 | return aTime === bTime 131 | } 132 | 133 | openLocationWindow = () => { 134 | const sender = electron.ipcRenderer || false 135 | if (!sender) { 136 | return 137 | } 138 | 139 | sender.send('open-update-location') 140 | } 141 | 142 | render() { 143 | return
144 | } 145 | } 146 | 147 | const User = `#graphql 148 | query { 149 | user { 150 | timezone 151 | } 152 | } 153 | ` 154 | 155 | const UpdateTimezone = `#graphql 156 | mutation updateTimezone($timezone: String!) { 157 | updateTimezone(timezone: $timezone) { 158 | timezone 159 | } 160 | } 161 | ` 162 | 163 | export default ConnectHOC(() => { 164 | // Get user preference 165 | const shouldUpdate = getTimeZoneAutoUpdate() 166 | 167 | return { 168 | cache: false, 169 | query: shouldUpdate ? query(User) : undefined, 170 | mutation: { 171 | updateTimezone: mutation(UpdateTimezone), 172 | }, 173 | } 174 | })(DetectTimezone) 175 | -------------------------------------------------------------------------------- /renderer/components/ErrorBoundary.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import styled from 'styled-components' 3 | import Raven from 'raven-js' 4 | 5 | class ErrorBoundary extends Component { 6 | state = { 7 | error: null, 8 | hasError: false, 9 | showError: false, 10 | } 11 | 12 | componentDidCatch(e, errorInfo) { 13 | this.setState({ hasError: true, error: e.message }) 14 | Raven.captureException(e, { extra: errorInfo }) 15 | } 16 | 17 | render() { 18 | return this.state.hasError ? ( 19 | 20 | Uh, there was an error 😞 21 | 22 | Engineering team already notified of the catch, but you can go ahead 23 | and chat with us. 24 | 25 | {!this.state.showError && ( 26 | this.setState({ showError: true })}> 27 | Show error → 28 | 29 | )} 30 | {this.state.showError && {this.state.error}} 31 | 32 | ) : ( 33 | this.props.children 34 | ) 35 | } 36 | } 37 | 38 | export default ErrorBoundary 39 | 40 | const Wrapper = styled.div` 41 | height: 100%; 42 | width: 100%; 43 | padding: 17px; 44 | overflow-x: hidden; 45 | overflow-y: auto; 46 | background: ${p => p.theme.colors.light}; 47 | color: ${p => p.theme.colors.lightText}; 48 | ` 49 | 50 | const Title = styled.h2` 51 | margin: 0; 52 | font-size: ${p => p.theme.sizes.fontSizeBig}; 53 | font-weight: bold; 54 | color: white; 55 | ` 56 | 57 | const Desc = styled.p` 58 | margin: 10px 0 0 0; 59 | 60 | font-size: ${p => p.theme.sizes.fontSizeNormal}; 61 | line-height: 1.3; 62 | color: ${p => p.theme.colors.lightText}; 63 | ` 64 | 65 | const ShowError = styled.button` 66 | display: inline-block; 67 | margin: 10px 0 0 0; 68 | padding: 5px 0; 69 | 70 | font-size: ${p => p.theme.sizes.fontSizeTiny}; 71 | color: ${p => p.theme.colors.lightMutedText}; 72 | background: none; 73 | border: none; 74 | outline: none; 75 | cursor: pointer; 76 | ` 77 | 78 | const Error = styled.pre` 79 | display: block; 80 | margin: 15px 0 0 0; 81 | overflow-x: auto; 82 | 83 | font-family: Inconsolata, Monaco, monospace; 84 | font-size: ${p => p.theme.sizes.fontSizeTiny}; 85 | color: ${p => p.theme.colors.lightMutedText}; 86 | ` 87 | -------------------------------------------------------------------------------- /renderer/components/ExternalLink.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron' 2 | import React from 'react' 3 | 4 | export default class ExternalLink extends React.Component { 5 | render() { 6 | const { href, ...props } = this.props 7 | return ( 8 | { 12 | const shell = electron.shell || false 13 | if (!shell) { 14 | return 15 | } 16 | shell.openExternal(href) 17 | }} 18 | /> 19 | ) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /renderer/components/Imprint.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Imprint = () => ( 4 |
5 | Icons made by 6 | 7 | SimpleIcon 8 | ,{' '} 9 | 10 | Freepik 11 | ,{' '} 12 | 16 | Daniel Bruce 17 | ,{' '} 18 | 19 | Lucy G 20 | ,{' '} 21 | 22 | Google 23 | ,{' '} 24 | 25 | Icomoon 26 | 27 | {/* -------- */} 28 | from{' '} 29 | 30 | www.flaticon.com 31 | {' '} 32 | is licensed by{' '} 33 | 37 | CC 3.0 BY 38 | 39 |
40 | ) 41 | 42 | export default Imprint 43 | -------------------------------------------------------------------------------- /renderer/components/JoinBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import People from '../vectors/People' 5 | 6 | const JoinBox = () => ( 7 | 8 | 9 | 10 | 11 | 12 | Enable Auto-Sync! 13 | 14 | Join for free or login using with Twitter in seconds. Why join → 15 | 16 | 17 | 18 | ) 19 | 20 | export default JoinBox 21 | 22 | const Wrapper = styled.div` 23 | height: auto; 24 | flex: 0 0 auto; 25 | display: flex; 26 | overflow: hidden; /* for safety, to not leak elements into whole UI */ 27 | padding: 10px ${p => p.theme.sizes.sidePadding}px; 28 | 29 | box-sizing: border-box; 30 | border-top: 1px solid ${p => p.theme.colors.lighter}; 31 | background: url('/static/tile.svg'), ${p => p.theme.colors.light}; 32 | background-size: 40px; 33 | background-position: 0 -30px; 34 | color: white; 35 | 36 | transition: background 80ms ease-out; 37 | 38 | &:hover { 39 | background: url('/static/tile.svg'), ${p => p.theme.colors.lighter}; 40 | background-size: 40px; 41 | background-position: -10px -20px; 42 | } 43 | ` 44 | 45 | const Photo = styled.div` 46 | flex: 0 0 auto; 47 | margin-right: ${p => p.theme.sizes.sidePadding + 2}px; 48 | opacity: 0.7; 49 | ` 50 | 51 | const Content = styled.div` 52 | flex: 1 1 auto; 53 | padding: 3px 0; 54 | ` 55 | 56 | const Title = styled.div` 57 | font-weight: 600; 58 | font-size: 16px; 59 | color: ${p => p.theme.colors.lightText}; 60 | color: ${p => p.theme.colors.blue}; 61 | ` 62 | 63 | const Desc = styled.div` 64 | font-size: ${p => p.theme.sizes.fontSizeTiny}px; 65 | color: ${p => p.theme.colors.lightMutedText}; 66 | margin-top: 3px; 67 | ` 68 | -------------------------------------------------------------------------------- /renderer/components/Link.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import styled from 'styled-components' 3 | 4 | import { transition } from '../utils/styles/mixins' 5 | 6 | export const StyledLink = styled(Link)` 7 | display: inline-block; 8 | padding: 2px 5px; 9 | 10 | font-size: 14px; 11 | font-weight: 600; 12 | 13 | text-decoration: underline; 14 | text-decoration-skip: ink; 15 | text-decoration-style: dotted; 16 | text-decoration-color: #999; 17 | 18 | color: ${p => p.theme.colors.primaryOnLight}; 19 | background: transparent; 20 | border-radius: 3px; 21 | cursor: pointer; 22 | 23 | ${transition('background', 'text-decoration-color')}; 24 | 25 | &:hover { 26 | background: ${p => p.theme.colors.subtle}; 27 | text-decoration-color: ${p => p.theme.colors.subtle}; 28 | } 29 | ` 30 | 31 | export const StyledButton = StyledLink.withComponent('button').extend` 32 | border: none; 33 | outline: none; 34 | background: none; 35 | ` 36 | -------------------------------------------------------------------------------- /renderer/components/List.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import styled from 'styled-components' 3 | 4 | class List extends Component { 5 | render() { 6 | const { ...props } = this.props 7 | 8 | return 9 | } 10 | } 11 | 12 | export default List 13 | 14 | const Wrapper = styled.div`` 15 | -------------------------------------------------------------------------------- /renderer/components/ListBtnRow.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | 3 | // Utilities 4 | import { transition } from '../utils/styles/mixins' 5 | 6 | const ListBtnRow = ({ 7 | iconComponent: IconComponent, 8 | children, 9 | title, 10 | fullWidth = true, 11 | noBorder = false, 12 | highlight = false, 13 | ...props 14 | }) => ( 15 | 22 | 23 | 24 | 25 | 26 | {children || title} 27 | 28 | 29 | ) 30 | 31 | export default ListBtnRow 32 | 33 | const photoSize = 40 34 | 35 | const wrapperHighlighted = p => css` 36 | background: ${p.theme.colors.subtle}; 37 | color: ${p.theme.colors.primaryOnLight}; 38 | ` 39 | 40 | const Wrapper = styled.div` 41 | display: flex; 42 | align-items: center; 43 | 44 | /* Full Width-ify :) */ 45 | ${p => 46 | p.fullWidth 47 | ? css` 48 | width: 100%; 49 | padding: 5px ${p => p.theme.sizes.sidePaddingLarge}px; 50 | ` 51 | : null}; 52 | 53 | /* Remove Button-style */ 54 | -webkit-app-region: no-drag; 55 | color: ${p => p.theme.colors.primaryOnLight}; 56 | background: transparent; 57 | border-bottom: ${p => (p.noBorder ? `none` : `1px solid #eee`)}; 58 | cursor: pointer; 59 | 60 | ${transition('background', 'color')}; 61 | 62 | &:hover, 63 | &:focus { 64 | ${p => wrapperHighlighted(p)}; 65 | } 66 | 67 | ${p => (p.highlight ? wrapperHighlighted(p) : null)}; 68 | ` 69 | 70 | const IconWrapper = styled.div` 71 | flex: 0 0 auto; 72 | width: ${photoSize}px; 73 | height: 35px; 74 | overflow: hidden; 75 | margin-right: 12px; 76 | 77 | display: flex; 78 | align-items: center; 79 | justify-content: flex-end; 80 | 81 | svg { 82 | fill: ${p => p.theme.colors.primaryOnLight}; 83 | } 84 | ` 85 | 86 | const Info = styled.div` 87 | flex: 1 1 auto; 88 | width: auto; 89 | cursor: pointer; 90 | display: flex; 91 | align-items: center; 92 | font-size: 16px; 93 | ` 94 | 95 | const Arrow = styled.span` 96 | margin-left: auto; 97 | ` 98 | -------------------------------------------------------------------------------- /renderer/components/LocationPicker.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import styled, { keyframes } from 'styled-components' 3 | import { query } from 'urql' 4 | import Downshift from 'downshift' 5 | import Raven from 'raven-js' 6 | import debounce from 'just-debounce-it' 7 | 8 | // Utilities 9 | import { client } from '../utils/urql/client' 10 | import gql from '../utils/graphql/gql' 11 | 12 | // Local 13 | import Input from './form/Input' 14 | 15 | class LocationPicker extends Component { 16 | static defaultProps = { 17 | onPick: () => {}, 18 | onInputChange: () => {}, 19 | } 20 | 21 | inputValueChangeCount = 0 22 | 23 | state = { 24 | fetching: false, 25 | loaded: false, 26 | placesAutoComplete: {}, 27 | } 28 | 29 | render() { 30 | const { 31 | grabFocusOnRerender = false, 32 | inputValue, 33 | onInputValueChange, 34 | onInputChange, 35 | ...props 36 | } = this.props 37 | const { loaded, placesAutoComplete, fetching } = this.state 38 | 39 | return ( 40 | (place ? place.description : '')} 42 | inputValue={inputValue} 43 | onInputValueChange={onInputValueChange} 44 | onChange={this.placePicked} 45 | render={({ 46 | getRootProps, 47 | getInputProps, 48 | getItemProps, 49 | isOpen, 50 | inputValue, 51 | highlightedIndex, 52 | }) => { 53 | const isUTCTyped = inputValue.trim().toLowerCase() === 'utc' 54 | 55 | return ( 56 | 57 | { 60 | const value = e.target.value 61 | if (!value) { 62 | return 63 | } 64 | 65 | // Increment counter so we can spot 66 | // out of sync results 67 | this.inputValueChangeCount++ 68 | 69 | onInputChange() 70 | this.fetchPlaces(value) 71 | }, 72 | })} 73 | style={{ minWidth: 300 }} 74 | innerRef={ref => { 75 | if (ref && grabFocusOnRerender) { 76 | ref.focus() 77 | } 78 | }} 79 | textAlign="center" 80 | value={inputValue} 81 | placeholder="Which city are you in?" 82 | {...props} 83 | /> 84 | 85 | {/* If user wants UTC, only show UTC */} 86 | {isUTCTyped && 87 | isOpen && ( 88 | 96 | UTC (GMT) 97 | 98 | )} 99 | 100 | {inputValue.trim() !== '' && 101 | !isUTCTyped && 102 | isOpen && 103 | loaded && 104 | placesAutoComplete.map((place, i) => ( 105 | 110 | {place.description} 111 | 112 | ))} 113 | 114 | {fetching && } 115 | 116 | 117 | ) 118 | }} 119 | /> 120 | ) 121 | } 122 | 123 | fetchPlaces = debounce(async value => { 124 | this.setState({ fetching: true }) 125 | const currentChangeCount = this.inputValueChangeCount 126 | 127 | try { 128 | const { data: { placesAutoComplete } } = await client.executeQuery( 129 | query(AutoComplete, { query: value }) 130 | ) 131 | 132 | // If user has continued typing, do not show the out of sync result 133 | if (currentChangeCount === this.inputValueChangeCount) { 134 | this.setState({ loaded: true, fetching: false, placesAutoComplete }) 135 | } 136 | } catch (err) { 137 | this.setState({ fetching: false }) 138 | Raven.captureException(err) 139 | console.log(err) 140 | } 141 | }, 260) 142 | 143 | placePicked = ({ description, placeId, timezone }) => { 144 | this.props.onPick({ description, placeId, timezone }) 145 | } 146 | } 147 | 148 | const AutoComplete = gql` 149 | query($query: String!) { 150 | placesAutoComplete(query: $query) { 151 | description 152 | placeId 153 | } 154 | } 155 | ` 156 | 157 | export default LocationPicker 158 | 159 | const Wrapper = styled.div` 160 | position: relative; 161 | display: inline-block; 162 | ` 163 | 164 | const List = styled.div.attrs({ 165 | className: 'ignore-react-onclickoutside', 166 | })` 167 | width: 100%; 168 | max-height: 100px; 169 | overflow: auto; 170 | position: absolute; 171 | left: 50%; 172 | z-index: ${p => p.theme.sizes.dropDownZIndex}; 173 | transform: translateX(-50%); 174 | box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2); 175 | background: white; 176 | ` 177 | 178 | const ListItem = styled.div` 179 | width: 100%; 180 | padding: 7px 10px; 181 | 182 | font-size: 14px; 183 | line-height: 1.35; 184 | text-align: left; 185 | cursor: pointer; 186 | transition: background 200ms cubic-bezier(0.19, 1, 0.22, 1); 187 | border-bottom: 1px solid rgba(0, 0, 0, 0.05); 188 | background: ${p => (p.highlighted ? `rgba(0, 0, 0, 0.05)` : `transparent`)}; 189 | 190 | &:hover { 191 | background: rgba(0, 0, 0, 0.05); 192 | } 193 | ` 194 | 195 | const spin = keyframes` 196 | from { transform: rotate(0deg); } 197 | to { transform: rotate(360deg); } 198 | ` 199 | 200 | const Loading = styled.div` 201 | position: absolute; 202 | right: 10px; 203 | top: 10px; 204 | 205 | width: 10px; 206 | height: 10px; 207 | border-radius: 10px; 208 | border: 2px solid rgba(0, 0, 0, 0.3); 209 | border-right: none; 210 | animation: ${spin} 3s 0s infinite; 211 | ` 212 | -------------------------------------------------------------------------------- /renderer/components/LoggedIn.js: -------------------------------------------------------------------------------- 1 | // Native 2 | import electron from 'electron' 3 | 4 | // Packages 5 | import React from 'react' 6 | import createReactContext from 'create-react-context' 7 | 8 | // Local 9 | import { getToken } from '../utils/store' 10 | 11 | const isLoggedInDefault = Boolean(getToken()) 12 | 13 | const LOGGED_IN_CHANGED_CHANNEL = 'logged-in-changed' 14 | 15 | const LoggedInContext = createReactContext(isLoggedInDefault) 16 | 17 | class LoggedInProvider extends React.Component { 18 | mounted = true 19 | state = { isLoggedIn: isLoggedInDefault } 20 | 21 | componentDidMount() { 22 | const sender = electron.ipcRenderer || false 23 | 24 | if (!sender) { 25 | return 26 | } 27 | 28 | sender.on(LOGGED_IN_CHANGED_CHANNEL, (event, isLoggedIn) => { 29 | // Prevent preforming setState on the unmounted component 30 | if (this.mounted) { 31 | this.setState(() => ({ isLoggedIn })) 32 | } 33 | }) 34 | } 35 | 36 | componentWillUnmount() { 37 | // Flag if we're unmounting 38 | this.mounted = false 39 | } 40 | 41 | render() { 42 | return ( 43 | // Pass the current context value to the Provider's `value` prop. 44 | // Changes are detected using strict comparison (Object.is) 45 | 46 | {this.props.children} 47 | 48 | ) 49 | } 50 | } 51 | 52 | const LoggedIn = LoggedInContext.Consumer 53 | 54 | export { LoggedInProvider, LoggedIn } 55 | -------------------------------------------------------------------------------- /renderer/components/MiniLogo.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const MiniLogo = ({ type = 'normal', size, ...props }) => ( 4 | 13 | ) 14 | 15 | export default MiniLogo 16 | 17 | const Image = styled.img` 18 | width: ${p => p.size || 'auto'}; 19 | height: ${p => p.size || 'auto'}; 20 | 21 | display: inline-block; 22 | line-height: 1; 23 | margin: 0; 24 | padding: 0; 25 | ` 26 | -------------------------------------------------------------------------------- /renderer/components/NotificationBox.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | 3 | import CloseIcon from '../vectors/Close' 4 | 5 | const NotificationBox = ({ 6 | children, 7 | visible, 8 | closeButton = true, 9 | onCloseClick, 10 | ...props 11 | }) => ( 12 | 20 | ) 21 | 22 | export default NotificationBox 23 | 24 | const Wrapper = styled.div` 25 | position: absolute; 26 | bottom: 25px; 27 | left: 50%; 28 | transform: translateX(-50%); 29 | z-index: ${p => p.theme.sizes.notificationZIndex}; 30 | 31 | max-width: 90%; 32 | padding: 7px ${p => (p.closeButton ? 8 : 14)}px 7px 14px; 33 | display: flex; 34 | align-items: center; 35 | flex-direction: row; 36 | 37 | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); 38 | border: 1px solid #e5e5e5; 39 | background: white; 40 | transition: bottom 200ms ease, opacity 200ms ease, visibility 200ms ease, 41 | transform 150ms ease-in, box-shadow 150ms ease; 42 | 43 | ${p => 44 | p.hidden && 45 | css` 46 | bottom: 10px; 47 | opacity: 0; 48 | visibility: hidden; 49 | `}; 50 | 51 | &:hover { 52 | transform: translateY(-1.5px) translateX(-50%); 53 | box-shadow: 0 4.5px 13px rgba(0, 0, 0, 0.12); 54 | } 55 | ` 56 | 57 | const Text = styled.div` 58 | flex: 1 1 auto; 59 | font-size: ${p => p.theme.sizes.fontSizeSmall}px; 60 | color: #666; 61 | line-height: 1.35; 62 | white-space: nowrap; 63 | ` 64 | 65 | const CloseWrapper = styled.div` 66 | flex: 0 1 auto; 67 | margin-left: 10px; 68 | margin-right: 4px; 69 | cursor: pointer; 70 | 71 | svg { 72 | display: block; 73 | path { 74 | fill: #d0d0d0; 75 | transition: fill 100ms ease; 76 | } 77 | } 78 | 79 | &:hover { 80 | svg path { 81 | fill: #999; 82 | } 83 | } 84 | ` 85 | -------------------------------------------------------------------------------- /renderer/components/PersonForm/PersonForm.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | import { Component, Fragment } from 'react' 3 | import styled from 'styled-components' 4 | 5 | // Local 6 | import TwitterLogo from '../../vectors/TwitterLogo' 7 | import Close from '../../vectors/Close' 8 | import Input from '../form/Input' 9 | import Button from '../form/Button' 10 | import FormRow from '../form/Row' 11 | import Label from '../form/Label' 12 | import ErrorText from '../form/ErrorText' 13 | import LocationPicker from '../LocationPicker' 14 | import ButtonWrapper from '../form/ButtonWrapper' 15 | import PhotoSelector, { PhotoOptions, PhotoBtn } from '../PhotoSelector' 16 | 17 | export const photoModes = { 18 | TWITTER: 'twitter', 19 | UPLOAD: 'upload', 20 | } 21 | 22 | class PersonForm extends Component { 23 | render() { 24 | const { 25 | fetching, 26 | error, 27 | firstName, 28 | lastName, 29 | twitterHandle, 30 | locationInputValue, 31 | photoUrl, 32 | uploading, 33 | photoMode, 34 | allowSubmit = true, 35 | submitText = 'Add', 36 | // Event handlers 37 | onPhotoFileAccept, 38 | onPhotoClear, 39 | onPhotoModeChange, 40 | onFirstNameChange, 41 | onLastNameChange, 42 | onLocationInputValueChange, 43 | onLocationPick, 44 | onTwitterChange, 45 | onSubmit, 46 | ...props 47 | } = this.props 48 | 49 | return ( 50 | 51 | 52 | 57 | 58 | {photoUrl && ( 59 | 60 | 61 | 62 | )} 63 | {photoMode !== photoModes.TWITTER && ( 64 | onPhotoModeChange(photoModes.TWITTER)} 69 | > 70 | 71 | 72 | )} 73 | 74 | 75 | 76 |
77 | 78 | 85 | 91 | 92 | 93 | 94 | 104 | 105 | {photoMode === photoModes.TWITTER && ( 106 | 107 | 108 | 115 | 116 | )} 117 | 118 | 119 | {error && 🤔 Try again please!} 120 | 123 | 124 | 125 |
126 | ) 127 | } 128 | } 129 | 130 | export default PersonForm 131 | 132 | const Wrapper = styled.div` 133 | display: flex; 134 | max-width: 300px; 135 | 136 | margin-top: 30px; 137 | margin-right: auto; 138 | margin-left: auto; 139 | ` 140 | 141 | const Form = styled.form` 142 | display: block; 143 | flex: 1 1 auto; 144 | ` 145 | 146 | const PhotoWrapper = styled.div` 147 | flex: 0 0 auto; 148 | margin-right: 18px; 149 | margin-top: 5px; 150 | ` 151 | 152 | const Spacing = styled.div` 153 | height: 12px; 154 | ` 155 | 156 | const AtSign = styled.span` 157 | display: inline-block; 158 | line-height: 1; 159 | 160 | :before { 161 | content: '@'; 162 | vertical-align: 2px; 163 | } 164 | ` 165 | -------------------------------------------------------------------------------- /renderer/components/PersonForm/index.js: -------------------------------------------------------------------------------- 1 | export { default, photoModes } from './PersonForm' 2 | -------------------------------------------------------------------------------- /renderer/components/PhotoSelector/index.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | // Local 5 | import { 6 | StyledDropZone, 7 | Photo, 8 | Overlay, 9 | Loading, 10 | BlackImage, 11 | WhiteImage, 12 | } from './styles' 13 | export { PhotoOptions, PhotoBtn } from './styles' 14 | 15 | export default class PhotoSelector extends PureComponent { 16 | static propTypes = { 17 | photoUrl: PropTypes.string, 18 | uploading: PropTypes.bool, 19 | onAccept: PropTypes.func, 20 | onError: PropTypes.func, 21 | } 22 | 23 | static defaultProps = { 24 | onAccept: () => {}, 25 | onError: () => {}, 26 | } 27 | 28 | accept = 'image/jpeg,image/png,image/gif' 29 | 30 | render() { 31 | const { photoUrl, uploading } = this.props 32 | 33 | return ( 34 | 44 | 45 | {photoUrl && } 46 | 50 | {uploading ? ( 51 | 52 | ) : !photoUrl ? ( 53 | 54 | ) : ( 55 | 56 | )} 57 | 58 | 59 | 60 | ) 61 | } 62 | 63 | dropped = acceptedFiles => { 64 | if (acceptedFiles.length === 0) { 65 | return 66 | } 67 | 68 | const photoFile = acceptedFiles[0] 69 | this.props.onError('') 70 | this.props.onAccept(photoFile) 71 | } 72 | 73 | rejected = () => { 74 | alert('Oh, only images upto 2mb are allowed!') 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /renderer/components/PhotoSelector/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components' 2 | import Dropzone from 'react-dropzone' 3 | 4 | // Local 5 | import Image from '../../vectors/Image' 6 | 7 | export const StyledDropZone = styled(Dropzone)` 8 | cursor: pointer; 9 | ` 10 | 11 | const beap = keyframes` 12 | from { opacity: 1; transform: scale(1); } 13 | 50% { opacity: 0.4; transform: scale(0.8); } 14 | to { opacity: 1; transform: scale(1); } 15 | ` 16 | 17 | export const Loading = styled.div` 18 | width: 10px; 19 | height: 10px; 20 | border-radius: 20px; 21 | background: white; 22 | animation: ${beap} 1s infinite ease; 23 | ` 24 | 25 | export const Overlay = styled.span` 26 | position: absolute; 27 | top: 0; 28 | bottom: 0; 29 | right: 0; 30 | left: 0; 31 | background: ${p => (p.light ? `transparent` : `rgba(0, 0, 0, 0.3)`)}; 32 | color: white; 33 | opacity: ${p => (p.visible ? 1 : 0)}; 34 | display: flex; 35 | justify-content: center; 36 | align-items: center; 37 | transition: all 150ms ease; 38 | ` 39 | 40 | export const Photo = styled.div` 41 | --size: ${p => p.theme.sizes.photoSelector}px; 42 | 43 | position: relative; 44 | width: var(--size); 45 | height: var(--size); 46 | overflow: hidden; 47 | 48 | object-fit: cover; 49 | background: linear-gradient(45deg, #eee 0%, #f7f7f7 100%); 50 | border-radius: var(--size); 51 | 52 | img { 53 | display: block; 54 | width: var(--size); 55 | height: var(--size); 56 | } 57 | 58 | &:hover { 59 | ${Overlay} { 60 | opacity: 1; 61 | } 62 | } 63 | ` 64 | 65 | export const BlackImage = styled(Image)` 66 | path { 67 | fill: #999; 68 | } 69 | ` 70 | 71 | export const WhiteImage = styled(Image)` 72 | path { 73 | fill: white; 74 | } 75 | ` 76 | 77 | // Helpers for forms 78 | export const PhotoOptions = styled.div` 79 | width: ${p => p.theme.sizes.photoSelector}px; 80 | margin-top: 5px; 81 | display: flex; 82 | align-items: stretch; 83 | justify-content: center; 84 | ` 85 | 86 | export const PhotoBtn = styled.button` 87 | display: block; 88 | line-height: 1; 89 | padding: 4px 0 5px 0; 90 | width: 21px; 91 | text-align: center; 92 | background: transparent; 93 | transition: all 150ms ease; 94 | border: none; 95 | cursor: pointer; 96 | margin-left: 3px; 97 | border-radius: 3px; 98 | 99 | &:first-child { 100 | margin-left: 0; 101 | } 102 | 103 | svg { 104 | vertical-align: middle; 105 | path { 106 | fill: #aaa; 107 | } 108 | } 109 | 110 | &:hover { 111 | background: #e5e5e5; 112 | } 113 | ` 114 | -------------------------------------------------------------------------------- /renderer/components/PlaceForm/PlaceForm.js: -------------------------------------------------------------------------------- 1 | import { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import styled from 'styled-components' 4 | 5 | // Local 6 | import UnsplashLogo from '../../vectors/UnsplashLogo' 7 | import Close from '../../vectors/Close' 8 | import Input from '../form/Input' 9 | import Label from '../form/Label' 10 | import Button from '../form/Button' 11 | import ErrorText from '../form/ErrorText' 12 | import LocationPicker from '../LocationPicker' 13 | import ButtonWrapper from '../form/ButtonWrapper' 14 | import PhotoSelector, { PhotoOptions, PhotoBtn } from '../PhotoSelector' 15 | import ExternalLink from '../ExternalLink' 16 | 17 | class PlaceForm extends Component { 18 | static propTypes = { 19 | // Unsplash photo shape 20 | photo: PropTypes.shape({ 21 | url: PropTypes.string, 22 | name: PropTypes.string, 23 | username: PropTypes.string, 24 | }), 25 | } 26 | 27 | render() { 28 | const { 29 | loading, 30 | error, 31 | name, 32 | locationInputValue, 33 | photo, 34 | uploading, 35 | fetchingUnsplash, 36 | submitButton = 'Add', 37 | // Event handlers 38 | onNameChange, 39 | onLocationPick, 40 | onLocationInputValueChange, 41 | onFormSubmit, 42 | onPhotoClear, 43 | onUnsplashClick, 44 | onPhotoFileAccept, 45 | ...props 46 | } = this.props 47 | 48 | return ( 49 | 50 | 51 | 56 | 57 | {photo.name && ( 58 | 59 | Photo by{' '} 60 | 65 | {photo.name} 66 | {' '} 67 | on{' '} 68 | 69 | Unsplash 70 | 71 | 72 | )} 73 | 74 | 75 | {photo.url && ( 76 | 77 | 78 | 79 | )} 80 | 81 | 87 | 88 | 89 | 90 | 91 | 92 |
93 | 101 | 102 | 112 | 113 | 114 | {error && {error}} 115 | 118 | 119 | 120 |
121 | ) 122 | } 123 | } 124 | 125 | export default PlaceForm 126 | 127 | const Wrapper = styled.div` 128 | display: flex; 129 | width: 390px; 130 | 131 | margin-top: 30px; 132 | margin-right: auto; 133 | margin-left: auto; 134 | 135 | @media (max-width: 350px) { 136 | width: auto; 137 | } 138 | ` 139 | 140 | const PhotoWrapper = styled.div` 141 | flex: 0 1 auto; 142 | width: 180px; 143 | margin-right: 18px; 144 | 145 | display: flex; 146 | flex-direction: column; 147 | align-items: flex-end; 148 | ` 149 | 150 | const PhotoCaption = styled.div` 151 | margin-top: 10px; 152 | text-align: right; 153 | line-height: 1.1; 154 | font-size: 11px; 155 | color: #aaa; 156 | 157 | a { 158 | text-decoration: none; 159 | color: #888; 160 | 161 | &:hover { 162 | color: #555; 163 | } 164 | } 165 | ` 166 | 167 | const Form = styled.form` 168 | display: block; 169 | flex: 1 1 auto; 170 | width: 100%; 171 | ` 172 | 173 | const Spacing = styled.div` 174 | height: 12px; 175 | ` 176 | -------------------------------------------------------------------------------- /renderer/components/PlaceForm/index.js: -------------------------------------------------------------------------------- 1 | export { default, photoModes } from './PlaceForm' 2 | -------------------------------------------------------------------------------- /renderer/components/Popover.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | // Local 5 | import TopArrowPosition from './tray/TopArrowPosition' 6 | import { ConnectedCaret } from './ConnectedCaret' 7 | 8 | // const notWindows = process.platform !== 'win32' 9 | // macOS white line bug 10 | const showBalloon = false 11 | 12 | const PopoverBox = styled.div` 13 | height: 100%; 14 | position: relative; 15 | border-radius: 5px; 16 | background: ${p => p.theme.colors.primary}; 17 | color: white; 18 | ` 19 | 20 | const TopArrowWrapper = styled.div` 21 | position: absolute; 22 | top: -12px; 23 | left: 0; 24 | right: 0; 25 | 26 | /* Position based on where tray is */ 27 | padding-left: ${p => p.left || 0}px; 28 | ` 29 | 30 | const TransparentWrapper = styled.div` 31 | background: transparent; 32 | height: 100vh; 33 | width: 100vw; 34 | padding-top: ${p => (p.arrowSpace ? 14 : 0)}px; 35 | box-sizing: border-box; 36 | ` 37 | 38 | const Popover = ({ children, ...props }) => ( 39 | 40 | 41 | {showBalloon && ( 42 | 43 | {left => ( 44 | 45 | 46 | 47 | )} 48 | 49 | )} 50 | 51 | {children} 52 | 53 | 54 | ) 55 | 56 | export default Popover 57 | -------------------------------------------------------------------------------- /renderer/components/SocialButtons.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | import { shade } from 'polished' 3 | 4 | import { transition } from '../utils/styles/mixins' 5 | import TwitterLogo from '../vectors/TwitterLogo' 6 | import Email from '../vectors/Email' 7 | 8 | export const TwitterButton = ({ ...props }) => ( 9 | 10 | 11 | 12 | 13 | Continue with Twitter 14 | 15 | ) 16 | 17 | export const EmailButton = ({ ...props }) => ( 18 | 19 | 20 | 21 | 22 | Continue with email 23 | 24 | ) 25 | 26 | export const ButtonsStack = styled.div` 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: space-between; 30 | ` 31 | 32 | const BaseButton = styled.button` 33 | display: inline-flex; 34 | align-items: center; 35 | 36 | padding: 9px 13px; 37 | font-size: 15px; 38 | font-weight: 600; 39 | 40 | border: none; 41 | outline: none; 42 | cursor: pointer; 43 | border-radius: 5px; 44 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); 45 | 46 | ${p => 47 | p.type === 'outline' 48 | ? css` 49 | background: transparent; 50 | color: ${p.color}; 51 | border: 2px solid ${p.color}; 52 | 53 | svg { 54 | fill: ${p.color}; 55 | stroke: ${p.color}; 56 | } 57 | 58 | &:hover { 59 | color: white; 60 | 61 | svg { 62 | color: white; 63 | stroke: white; 64 | } 65 | 66 | background: ${p => shade(0.85, p.color)}; 67 | } 68 | ` 69 | : css` 70 | background: ${p.color}; 71 | color: #fff; 72 | 73 | svg { 74 | fill: white; 75 | stroke: white; 76 | } 77 | 78 | &:hover { 79 | background: ${p => shade(0.85, p.color)}; 80 | } 81 | 82 | &:active { 83 | background: ${p => shade(0.6, p.color)}; 84 | box-shadow: 0 0 0; 85 | } 86 | `}; 87 | 88 | justify-content: ${p => (p.center ? 'center' : 'initial')}; 89 | 90 | svg { 91 | display: block; 92 | height: 16px; 93 | width: auto; 94 | 95 | ${transition('fill', 'stroke')}; 96 | } 97 | 98 | ${transition('box-shadow', 'background', 'color')}; 99 | ` 100 | 101 | const IconWrapper = styled.span` 102 | margin-right: 8px; 103 | ` 104 | -------------------------------------------------------------------------------- /renderer/components/Space.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | 3 | export const Space = styled.div` 4 | width: ${p => p.width || 0}px; 5 | height: ${p => p.height || 0}px; 6 | pointer-events: none; 7 | 8 | ${p => 9 | p.fillVertically && 10 | css` 11 | margin-top: auto; 12 | margin-bottom: auto; 13 | `}; 14 | 15 | ${p => 16 | p.fillHorizentally && 17 | css` 18 | margin-right: auto; 19 | margin-left: auto; 20 | `}; 21 | ` 22 | 23 | export default Space 24 | -------------------------------------------------------------------------------- /renderer/components/TinyButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styled from 'styled-components' 3 | 4 | const TinyButton = props => 5 | 6 | export default TinyButton 7 | 8 | const height = '25px' 9 | const textColor = p => 10 | p.primary ? p.theme.colors.lightText : p.theme.colors.lightMutedText 11 | const background = p => (p.primary ? 'rgba(255, 255, 255, 0.1)' : 'transparent') 12 | 13 | const Wrapper = styled.button` 14 | display: inline-block; 15 | height: ${height}; 16 | line-height: ${height}; 17 | padding: 0 7px; 18 | box-sizing: border-box; 19 | font-size: 13px; 20 | 21 | border: none; 22 | outline: none; 23 | cursor: pointer; 24 | border-radius: 3px; 25 | background: ${background}; 26 | color: ${textColor}; 27 | transition: transform 80ms, color 80ms, background 100ms ease-out; 28 | 29 | &:hover { 30 | background: rgba(255, 255, 255, 0.2); 31 | color: ${p => p.theme.colors.lightText}; 32 | } 33 | 34 | &:active { 35 | background: rgba(255, 255, 255, 0.15); 36 | transform: scale(0.97); 37 | } 38 | ` 39 | -------------------------------------------------------------------------------- /renderer/components/add/Person/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Person' 2 | -------------------------------------------------------------------------------- /renderer/components/add/Place/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Place' 2 | -------------------------------------------------------------------------------- /renderer/components/add/Search/PersonRow.js: -------------------------------------------------------------------------------- 1 | import { PureComponent } from 'react' 2 | import styled, { css } from 'styled-components' 3 | 4 | // Utilities 5 | import { restEndpoint } from '../../../../config' 6 | import { transition } from '../../../utils/styles/mixins' 7 | 8 | export default class PersonRow extends PureComponent { 9 | image = null 10 | state = { backupPhotoUrl: '' } 11 | 12 | render() { 13 | const { 14 | photoUrl, 15 | fullName, 16 | countryFlag, 17 | highlight, 18 | fullWidth = true, 19 | ...props 20 | } = this.props 21 | const { backupPhotoUrl } = this.state 22 | 23 | return ( 24 | 30 | 31 | { 35 | this.image = r 36 | }} 37 | /> 38 | 39 | 40 | 41 | {fullName} 42 | 43 | 44 | 45 | ) 46 | } 47 | 48 | componentDidMount() { 49 | if (this.image) { 50 | this.image.addEventListener('error', this.imageFailed) 51 | } 52 | } 53 | 54 | componentWillUnmount() { 55 | if (this.image) { 56 | this.image.removeEventListener('error', this.imageFailed) 57 | } 58 | } 59 | 60 | imageFailed = () => { 61 | const { twitterHandle } = this.props 62 | if (twitterHandle) { 63 | this.setState({ 64 | backupPhotoUrl: `${restEndpoint}/twivatar/${twitterHandle}`, 65 | }) 66 | } 67 | } 68 | } 69 | 70 | const photoSize = 34 71 | 72 | const wrapperHighlighted = css` 73 | background: ${p => p.theme.colors.subtle}; 74 | color: ${p => p.theme.colors.primaryOnLight}; 75 | 76 | & img { 77 | filter: saturate(1.1) brightness(1.2); 78 | } 79 | ` 80 | 81 | const Wrapper = styled.div` 82 | display: flex; 83 | align-items: center; 84 | -webkit-app-region: no-drag; 85 | 86 | /* Full Width-ify :) */ 87 | ${p => 88 | p.fullWidth 89 | ? css` 90 | width: 100%; 91 | padding: 5px ${p => p.theme.sizes.sidePaddingLarge}px; 92 | ` 93 | : null}; 94 | 95 | /* Remove Button-style */ 96 | color: #666; 97 | border-bottom: 1px solid #eee; 98 | background: transparent; 99 | cursor: pointer; 100 | 101 | ${transition('background', 'color')}; 102 | 103 | & img { 104 | transition: filter 100ms; 105 | } 106 | 107 | &:hover, 108 | &:focus { 109 | ${wrapperHighlighted}; 110 | } 111 | 112 | ${p => (p.highlight ? wrapperHighlighted : null)}; 113 | ` 114 | 115 | const Photo = styled.div` 116 | flex: 0 0 auto; 117 | width: ${photoSize}px; 118 | height: ${photoSize}px; 119 | overflow: hidden; 120 | margin-right: 12px; 121 | border-radius: ${photoSize}px; 122 | 123 | img { 124 | width: ${photoSize}px; 125 | height: auto; 126 | cursor: pointer; 127 | } 128 | ` 129 | 130 | const Info = styled.div` 131 | flex: 1 1 auto; 132 | width: auto; 133 | cursor: pointer; 134 | display: flex; 135 | align-items: center; 136 | ` 137 | 138 | const Name = styled.span` 139 | font-size: 16px; 140 | font-weight: 600; 141 | line-height: 1.3; 142 | ` 143 | 144 | const Time = styled.span` 145 | margin-left: auto; 146 | margin-right: 5px; 147 | 148 | font-size: 18px; 149 | font-variant-numeric: tabular-nums; 150 | 151 | color: #aaa; 152 | opacity: 0.6; 153 | cursor: pointer; 154 | ` 155 | -------------------------------------------------------------------------------- /renderer/components/add/Search/PersonSearch.js: -------------------------------------------------------------------------------- 1 | // Packages 2 | import electron from 'electron' 3 | import { Component } from 'react' 4 | import styled from 'styled-components' 5 | import { Connect, query, mutation } from 'urql' 6 | import debounce from 'just-debounce-it' 7 | import v4 from 'uuid/v4' 8 | 9 | // Utilities 10 | import { closeWindowAndShowMain } from '../../../utils/windows/helpers' 11 | import { User as UserFragment } from '../../../utils/graphql/fragments' 12 | 13 | // Local 14 | import gql from '../../../utils/graphql/gql' 15 | import Input from '../../form/Input' 16 | import Place from '../../../vectors/Place' 17 | import SearchIcon from '../../../vectors/Search' 18 | import AddPerson from '../../../vectors/AddPerson' 19 | import PersonRow from './PersonRow' 20 | import ListBtnRow from '../../ListBtnRow' 21 | import NotificationBox from '../../NotificationBox' 22 | import { StyledButton } from '../../Link' 23 | 24 | class PersonSearch extends Component { 25 | state = { 26 | name: '', 27 | debouncedName: '', 28 | fetched: false, 29 | } 30 | 31 | render() { 32 | const { onManuallyClick, onPlaceClick } = this.props 33 | const { name, debouncedName, fetched } = this.state 34 | const shouldQuery = debouncedName.trim() && debouncedName.length > 2 35 | 36 | return ( 37 | 38 | 39 | 44 | 45 | 51 | 52 | 53 | 54 | 55 | 56 | 65 | 66 | 67 | 68 | 74 | {({ data, followUser }) => 75 | // Instantly hide the list if input was cleared 76 | name.trim() && 77 | data && 78 | data.allUsersByName && 79 | data.allUsersByName.map(item => ( 80 | this.userPicked(item, followUser)} 83 | {...item} 84 | /> 85 | )) 86 | } 87 | 88 | 89 | 90 | this.setState({ fetched: false })} 93 | > 94 | 💫 Followed successfully!{' '} 95 | Close Window{' '} 96 | or continue! 97 | 98 | 99 | ) 100 | } 101 | 102 | inputChanged = e => { 103 | const name = e.target.value 104 | this.setState({ name }) 105 | this.debouncedNameChanged(name) 106 | } 107 | 108 | debouncedNameChanged = debounce(name => { 109 | this.setState({ debouncedName: name }) 110 | }, 300) 111 | 112 | userPicked = async (item, followUser) => { 113 | await followUser({ userId: item.id }) 114 | 115 | const sender = electron.ipcRenderer || false 116 | 117 | if (!sender) { 118 | return false 119 | } 120 | // Refresh the main window to reflect the change 121 | sender.send('reload-main') 122 | this.setState({ fetched: true, name: '' }) 123 | } 124 | 125 | closeWindow = () => { 126 | closeWindowAndShowMain() 127 | } 128 | } 129 | 130 | const AllUsers = gql` 131 | query($name: String!) { 132 | allUsersByName(name: $name, limit: 4) { 133 | id 134 | fullName 135 | firstName 136 | lastName 137 | twitterHandle 138 | photoUrl 139 | countryFlag 140 | city 141 | } 142 | } 143 | ` 144 | 145 | const FollowUser = gql` 146 | mutation($userId: ID!) { 147 | followUser(userId: $userId) { 148 | ...User 149 | } 150 | } 151 | ${UserFragment} 152 | ` 153 | 154 | export default PersonSearch 155 | 156 | const ListWrapper = styled.div` 157 | flex: 1 1 auto; 158 | padding-top: 10px; 159 | overflow: auto; 160 | margin: 0 ${p => -p.theme.sizes.sidePaddingLarge}px; 161 | ` 162 | 163 | const Wrapper = styled.div` 164 | display: flex; 165 | flex-direction: column; 166 | ` 167 | 168 | const InputWrapper = styled.div` 169 | flex: 0 1 auto; 170 | ` 171 | 172 | const OrLine = styled.div` 173 | width: 100%; 174 | position: relative; 175 | margin: 5px 0; 176 | 177 | &:before { 178 | content: ''; 179 | position: absolute; 180 | right: 0; 181 | left: 0; 182 | top: 50%; 183 | height: 1px; 184 | background: #d3d3d3; 185 | } 186 | 187 | &:after { 188 | content: 'or'; 189 | font-size: 12px; 190 | color: #d0d0d5; 191 | background: #fff; 192 | line-height: 20px; 193 | padding: 0 10px; 194 | position: relative; 195 | margin-left: 40px; 196 | } 197 | ` 198 | -------------------------------------------------------------------------------- /renderer/components/add/Search/Search.js: -------------------------------------------------------------------------------- 1 | // Modules 2 | import React, { Component } from 'react' 3 | 4 | // Local 5 | import Desc from '../../window/Desc' 6 | import Heading from '../../window/Heading' 7 | import PersonSearch from './PersonSearch' 8 | import FlexWrapper from '../../window/FlexWrapper' 9 | import { Center } from '../helpers' 10 | 11 | class SearchPage extends Component { 12 | state = {} 13 | 14 | render() { 15 | const { pageRouter } = this.props 16 | return ( 17 | 18 |
19 | Add a User 20 | 21 | If they have There, find and follow. Otherwise, add manually. 22 | 23 |
24 | 25 | 29 |
30 | ) 31 | } 32 | } 33 | 34 | export default SearchPage 35 | -------------------------------------------------------------------------------- /renderer/components/add/Search/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Search' 2 | -------------------------------------------------------------------------------- /renderer/components/add/helpers.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { transition } from '../../utils/styles/mixins' 3 | 4 | export const Center = styled.div` 5 | text-align: center; 6 | ` 7 | 8 | export const LinkWrapper = styled.div` 9 | margin-top: auto; 10 | 11 | text-align: center; 12 | font-size: 14px; 13 | color: #888; 14 | opacity: 0.8; 15 | 16 | /* Reserve some padding for hover */ 17 | padding: ${p => p.theme.sizes.sidePaddingLarge}px 0; 18 | 19 | ${transition('opacity')}; 20 | 21 | &:hover { 22 | opacity: 1; 23 | } 24 | ` 25 | -------------------------------------------------------------------------------- /renderer/components/edit/LoadingOverlay.js: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from 'styled-components' 2 | 3 | const fadeIn = keyframes` 4 | from { opacity: 0; } 5 | to { opacity: 1; } 6 | ` 7 | 8 | const LoadingOverlay = styled.div` 9 | position: absolute; 10 | top: 0; 11 | bottom: 0; 12 | left: 0; 13 | right: 0; 14 | z-index: ${p => p.theme.sizes.loadingOverlayZIndex}; 15 | 16 | background: rgba(255, 255, 255, 0.6); 17 | opacity: 0; 18 | 19 | animation: ${fadeIn} 200ms forwards ease-in; 20 | ` 21 | 22 | export default LoadingOverlay 23 | -------------------------------------------------------------------------------- /renderer/components/edit/Person/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Person' 2 | -------------------------------------------------------------------------------- /renderer/components/edit/Place/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Place' 2 | -------------------------------------------------------------------------------- /renderer/components/form/Button.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | import { darken, shade } from 'polished' 3 | 4 | import { transition } from '../../utils/styles/mixins' 5 | 6 | export default ({ disabled, ...props }) => ( 7 |