├── app ├── dist │ ├── .gitkeep │ └── fonts │ │ ├── fontawesome-webfont.eot │ │ ├── fontawesome-webfont.ttf │ │ ├── fontawesome-webfont.woff │ │ └── fontawesome-webfont.woff2 ├── src │ ├── renderer │ │ ├── vuex │ │ │ ├── getters.js │ │ │ ├── modules │ │ │ │ ├── index.js │ │ │ │ ├── lists.js │ │ │ │ ├── user.js │ │ │ │ ├── sidebar.js │ │ │ │ ├── notifications.js │ │ │ │ └── tweets.js │ │ │ ├── store.js │ │ │ ├── mutation-types.js │ │ │ └── actions.js │ │ ├── libraries │ │ │ ├── event-emitter.js │ │ │ └── store.js │ │ ├── routes.js │ │ ├── main.js │ │ ├── components │ │ │ ├── SidebarView.vue │ │ │ ├── SidebarView │ │ │ │ ├── HomeBtn.vue │ │ │ │ ├── MentionBtn.vue │ │ │ │ ├── TweetBtn.vue │ │ │ │ ├── SearchBtn.vue │ │ │ │ ├── SettingBtn.vue │ │ │ │ ├── NotificationBtn.vue │ │ │ │ ├── ListBtn.vue │ │ │ │ └── NotificationColumn.vue │ │ │ ├── HomeView │ │ │ │ ├── Timeline.vue │ │ │ │ ├── TweetBody.vue │ │ │ │ ├── Tweet.vue │ │ │ │ └── Profile.vue │ │ │ ├── HomeView.vue │ │ │ └── TweetbarView.vue │ │ └── App.vue │ └── main │ │ ├── index.js │ │ ├── index.dev.js │ │ ├── application.js │ │ └── authentication-window.js ├── icons │ ├── icon.icns │ └── icon.ico ├── index.ejs └── package.json ├── .eslintignore ├── test ├── .eslintrc ├── e2e │ ├── specs │ │ └── Launch.spec.js │ ├── index.js │ └── utils.js └── unit │ ├── specs │ └── LandingPageView.spec.js │ ├── index.js │ └── karma.conf.js ├── .gitignore ├── .babelrc ├── .eslintrc.js ├── config.js ├── LICENSE ├── tasks ├── release.js └── runner.js ├── webpack.main.config.js ├── README.md ├── package.json └── webpack.renderer.config.js /app/dist/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/getters.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YuheiNakasaka/vue-twitter-client/HEAD/app/icons/icon.icns -------------------------------------------------------------------------------- /app/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YuheiNakasaka/vue-twitter-client/HEAD/app/icons/icon.ico -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | app/node_modules/** 2 | app/dist/** 3 | test/unit/coverage/** 4 | test/unit/*.js 5 | test/e2e/*.js 6 | -------------------------------------------------------------------------------- /app/dist/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YuheiNakasaka/vue-twitter-client/HEAD/app/dist/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /app/dist/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YuheiNakasaka/vue-twitter-client/HEAD/app/dist/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /app/dist/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YuheiNakasaka/vue-twitter-client/HEAD/app/dist/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /app/dist/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/YuheiNakasaka/vue-twitter-client/HEAD/app/dist/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /app/src/main/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | import Application from './application' 3 | 4 | global.application = new Application() 5 | global.application.run() 6 | -------------------------------------------------------------------------------- /app/src/renderer/libraries/event-emitter.js: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'events' 2 | 3 | class MyEmitter extends EventEmitter {} 4 | export const eventEmitter = new MyEmitter() 5 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "globals": { 6 | "assert": true, 7 | "expect": true, 8 | "should": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/src/renderer/routes.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | path: '/', 4 | name: 'home-view', 5 | component: require('components/HomeView') 6 | }, 7 | { 8 | path: '*', 9 | redirect: '/' 10 | } 11 | ] 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | app/dist/index.html 3 | app/dist/main.js 4 | app/dist/renderer.js 5 | app/dist/styles.css 6 | builds/* 7 | coverage 8 | node_modules/ 9 | npm-debug.log 10 | npm-debug.log.* 11 | thumbs.db 12 | !.gitkeep 13 | app/src/main/.env 14 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/modules/index.js: -------------------------------------------------------------------------------- 1 | const files = require.context('.', false, /\.js$/) 2 | const modules = {} 3 | 4 | files.keys().forEach((key) => { 5 | if (key === './index.js') return 6 | modules[key.replace(/(\.\/|\.js)/g, '')] = files(key).default 7 | }) 8 | 9 | export default modules 10 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/modules/lists.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types' 2 | 3 | const state = { 4 | items: [] 5 | } 6 | 7 | const mutations = { 8 | [types.SET_LISTS] (state, lists) { 9 | lists.forEach((list) => { 10 | state.items.push(list) 11 | }) 12 | } 13 | } 14 | 15 | export default { 16 | state, 17 | mutations 18 | } 19 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/store.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | 4 | import * as actions from './actions' 5 | import * as getters from './getters' 6 | import modules from './modules' 7 | 8 | Vue.use(Vuex) 9 | 10 | export default new Vuex.Store({ 11 | actions, 12 | getters, 13 | modules, 14 | strict: process.env.NODE_ENV !== 'production' 15 | }) 16 | -------------------------------------------------------------------------------- /test/e2e/specs/Launch.spec.js: -------------------------------------------------------------------------------- 1 | import utils from '../utils' 2 | 3 | describe('Launch', function () { 4 | beforeEach(utils.beforeEach) 5 | afterEach(utils.afterEach) 6 | 7 | it('shows the proper application title', function () { 8 | return this.app.client.getTitle() 9 | .then(title => { 10 | expect(title).to.equal('vue-twitter-client-app') 11 | }) 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /test/unit/specs/LandingPageView.spec.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import LandingPageView from 'renderer/components/LandingPageView' 3 | 4 | describe('LandingPageView.vue', () => { 5 | it('should render correct contents', () => { 6 | const vm = new Vue({ 7 | el: document.createElement('div'), 8 | render: h => h(LandingPageView) 9 | }).$mount() 10 | 11 | expect(vm.$el.querySelector('h1').textContent).to.contain('Welcome.') 12 | }) 13 | }) 14 | -------------------------------------------------------------------------------- /test/unit/index.js: -------------------------------------------------------------------------------- 1 | // require all test files (files that ends with .spec.js) 2 | const testsContext = require.context('./specs', true, /\.spec$/) 3 | testsContext.keys().forEach(testsContext) 4 | 5 | // require all src files except main.js for coverage. 6 | // you can also change this to match only the subset of files that 7 | // you want coverage for. 8 | const srcContext = require.context('../../app/src/renderer', true, /^\.\/(?!main(\.js)?$)/) 9 | srcContext.keys().forEach(srcContext) 10 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "comments": false, 3 | "env": { 4 | "testing-unit": { 5 | "presets": ["es2015", "stage-0"], 6 | "plugins": ["istanbul"] 7 | }, 8 | "testing-e2e": { 9 | "presets": ["es2015", "stage-0"] 10 | }, 11 | "main": { 12 | "presets": ["es2015", "stage-0"] 13 | }, 14 | "renderer": { 15 | "presets": [ 16 | ["es2015", { "modules": false }], 17 | "stage-0" 18 | ] 19 | } 20 | }, 21 | "plugins": ["transform-runtime"] 22 | } 23 | -------------------------------------------------------------------------------- /test/e2e/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Set BABEL_ENV to use proper preset config 4 | process.env.BABEL_ENV = 'testing-e2e' 5 | 6 | // Enable use of es2015 on required files 7 | require('babel-register')({ 8 | ignore: /node_modules/ 9 | }) 10 | 11 | // Attach Chai APIs to global scope 12 | const { expect, should, assert } = require('chai') 13 | global.expect = expect 14 | global.should = should 15 | global.assert = assert 16 | 17 | // Require all JS files in `./specs` for Mocha to consume 18 | require('require-dir')('./specs') 19 | -------------------------------------------------------------------------------- /test/e2e/utils.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron' 2 | import { Application } from 'spectron' 3 | 4 | export default { 5 | afterEach () { 6 | this.timeout(10000) 7 | 8 | if (this.app && this.app.isRunning()) { 9 | return this.app.stop() 10 | } 11 | }, 12 | beforeEach () { 13 | this.timeout(10000) 14 | this.app = new Application({ 15 | path: electron, 16 | args: ['app'], 17 | startTimeout: 10000, 18 | waitTimeout: 10000 19 | }) 20 | 21 | return this.app.start() 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | sourceType: 'module' 6 | }, 7 | env: { 8 | browser: true, 9 | node: true 10 | }, 11 | extends: 'standard', 12 | plugins: [ 13 | 'html' 14 | ], 15 | 'rules': { 16 | // allow paren-less arrow functions 17 | 'arrow-parens': 0, 18 | // allow async-await 19 | 'generator-star-spacing': 0, 20 | // allow debugger during development 21 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | vue-twitter-client 6 | <% if (htmlWebpackPlugin.options.appModules) { %> 7 | 8 | 11 | <% } %> 12 | 13 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /app/src/renderer/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Electron from 'vue-electron' 3 | import Router from 'vue-router' 4 | 5 | import App from './App' 6 | import routes from './routes' 7 | 8 | import VueLazyload from 'vue-lazyload' 9 | import 'font-awesome/css/font-awesome.css' 10 | 11 | Vue.use(Electron) 12 | Vue.use(Router) 13 | Vue.use(VueLazyload, { 14 | preLoad: 1.3, 15 | lazyComponent: true 16 | }) 17 | Vue.config.debug = true 18 | 19 | const router = new Router({ 20 | scrollBehavior: () => ({ y: 0 }), 21 | routes 22 | }) 23 | 24 | /* eslint-disable no-new */ 25 | new Vue({ 26 | router, 27 | ...App 28 | }).$mount('#app') 29 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/modules/user.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types' 2 | 3 | const state = { 4 | user: null, 5 | profileOpen: '' 6 | } 7 | 8 | const mutations = { 9 | [types.INIT_USER] (state, user) { 10 | state.user = user 11 | }, 12 | [types.FOLLOW] (state, tweet) { 13 | tweet.user.following = true 14 | }, 15 | [types.UNFOLLOW] (state, tweet) { 16 | tweet.user.following = false 17 | }, 18 | [types.TOGGLE_PROFILE] (state, tweet) { 19 | state.profileOpen = tweet.id 20 | }, 21 | [types.CLOSE_PROFILE] (state) { 22 | state.profileOpen = '' 23 | } 24 | } 25 | 26 | export default { 27 | state, 28 | mutations 29 | } 30 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-twitter-client", 3 | "version": "0.0.1", 4 | "description": "Simple twitter client created with electron-vue", 5 | "main": "./dist/main.js", 6 | "dependencies": { 7 | "babel-runtime": "^6.23.0", 8 | "dotenv": "^4.0.0", 9 | "font-awesome": "^4.7.0", 10 | "moment": "^2.18.1", 11 | "node-twitter-api": "^1.8.0", 12 | "twit": "^2.2.5", 13 | "twitter-text": "^1.14.3", 14 | "vue": "^2.1.10", 15 | "vue-electron": "^1.0.6", 16 | "vue-lazyload": "^1.0.3", 17 | "vue-router": "^2.1.2", 18 | "vuex": "^2.1.1" 19 | }, 20 | "devDependencies": {}, 21 | "author": "YuheiNakasaka " 22 | } 23 | -------------------------------------------------------------------------------- /app/src/renderer/libraries/store.js: -------------------------------------------------------------------------------- 1 | import electron from 'electron' 2 | import path from 'path' 3 | import fs from 'fs' 4 | 5 | export default class Store { 6 | constructor (opts) { 7 | const userDataPath = (electron.app || electron.remote.app).getPath('userData') 8 | this.path = path.join(userDataPath, opts.configName + '.json') 9 | this.data = parseDataFile(this.path, opts.defaults) 10 | } 11 | 12 | get (key) { 13 | return this.data[key] 14 | } 15 | 16 | set (key, val) { 17 | this.data[key] = val 18 | fs.writeFileSync(this.path, JSON.stringify(this.data)) 19 | } 20 | 21 | delete (key) { 22 | delete this.data[key] 23 | } 24 | } 25 | 26 | function parseDataFile (filePath, defaults) { 27 | try { 28 | return JSON.parse(fs.readFileSync(filePath)) 29 | } catch (error) { 30 | return defaults 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | 5 | let config = { 6 | // Name of electron app 7 | // Will be used in production builds 8 | name: 'vue-twitter-client', 9 | 10 | // Use ESLint (extends `standard`) 11 | // Further changes can be made in `.eslintrc.js` 12 | eslint: true, 13 | 14 | // webpack-dev-server port 15 | port: 9080, 16 | 17 | // electron-packager options 18 | // Docs: https://simulatedgreg.gitbooks.io/electron-vue/content/docs/building_your_app.html 19 | building: { 20 | arch: 'x64', 21 | asar: true, 22 | dir: path.join(__dirname, 'app'), 23 | icon: path.join(__dirname, 'app/icons/icon'), 24 | ignore: /\b(src|index\.ejs|icons)\b/, 25 | out: path.join(__dirname, 'builds'), 26 | overwrite: true, 27 | platform: process.env.PLATFORM_TARGET || 'all' 28 | } 29 | } 30 | 31 | config.building.name = config.name 32 | 33 | module.exports = config 34 | -------------------------------------------------------------------------------- /app/src/main/index.dev.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is used specifically and only for development. It enables the use of ES6+ 3 | * features for the main process and installs `electron-debug` & `vue-devtools`. There 4 | * shouldn't be any need to modify this file, but it can be used to extend your 5 | * development environment. 6 | */ 7 | 8 | /* eslint-disable no-console */ 9 | 10 | // Set babel `env` and install `babel-register` 11 | process.env.NODE_ENV = 'development' 12 | process.env.BABEL_ENV = 'main' 13 | 14 | require('babel-register')({ 15 | ignore: /node_modules/ 16 | }) 17 | 18 | // Install `electron-debug` with `devtron` 19 | require('electron-debug')({ showDevTools: true }) 20 | 21 | // Install `vue-devtools` 22 | require('electron').app.on('ready', () => { 23 | let installExtension = require('electron-devtools-installer') 24 | installExtension.default(installExtension.VUEJS_DEVTOOLS) 25 | .then(() => {}) 26 | .catch(err => { 27 | console.log('Unable to install `vue-devtools`: \n', err) 28 | }) 29 | }) 30 | 31 | // Require `main` process to boot app 32 | require('./index') 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Yuhei Nakasaka 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 37 | 38 | 44 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView/HomeBtn.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 34 | 35 | 55 | -------------------------------------------------------------------------------- /app/src/renderer/components/HomeView/Timeline.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 36 | 37 | 55 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView/MentionBtn.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 34 | 35 | 55 | -------------------------------------------------------------------------------- /tasks/release.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const exec = require('child_process').exec 4 | const packager = require('electron-packager') 5 | 6 | if (process.env.PLATFORM_TARGET === 'clean') { 7 | require('del').sync(['builds/*', '!.gitkeep']) 8 | console.log('\x1b[33m`builds` directory cleaned.\n\x1b[0m') 9 | } else pack() 10 | 11 | /** 12 | * Build webpack in production 13 | */ 14 | function pack () { 15 | console.log('\x1b[33mBuilding webpack in production mode...\n\x1b[0m') 16 | let pack = exec('npm run pack') 17 | 18 | pack.stdout.on('data', data => console.log(data)) 19 | pack.stderr.on('data', data => console.error(data)) 20 | pack.on('exit', code => build()) 21 | } 22 | 23 | /** 24 | * Use electron-packager to build electron app 25 | */ 26 | function build () { 27 | let options = require('../config').building 28 | 29 | console.log('\x1b[34mBuilding electron app(s)...\n\x1b[0m') 30 | packager(options, (err, appPaths) => { 31 | if (err) { 32 | console.error('\x1b[31mError from `electron-packager` when building app...\x1b[0m') 33 | console.error(err) 34 | } else { 35 | console.log('Build(s) successful!') 36 | console.log(appPaths) 37 | 38 | console.log('\n\x1b[34mDONE\n\x1b[0m') 39 | } 40 | }) 41 | } 42 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/modules/sidebar.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types' 2 | 3 | const state = { 4 | isTweetbarOpen: false, 5 | isSearchbarOpen: false, 6 | isNotificationbarOpen: false, 7 | isListbarOpen: false, 8 | isSettingbarOpen: false, 9 | text: '' 10 | } 11 | 12 | const mutations = { 13 | [types.TOGGLE_TWEET_BAR] () { 14 | state.isTweetbarOpen = !state.isTweetbarOpen 15 | }, 16 | [types.TOGGLE_SEARCH_BAR] () { 17 | state.isSearchbarOpen = !state.isSearchbarOpen 18 | }, 19 | [types.TOGGLE_NOTIFICATION_BAR] () { 20 | state.isNotificationbarOpen = !state.isNotificationbarOpen 21 | }, 22 | [types.TOGGLE_LIST_BAR] () { 23 | state.isListbarOpen = !state.isListbarOpen 24 | }, 25 | [types.TOGGLE_SETTING_BAR] () { 26 | state.isSettingbarOpen = !state.isSettingbarOpen 27 | }, 28 | [types.CLOSE_ALL_BAR] () { 29 | state.isTweetbarOpen = false 30 | state.isSearchbarOpen = false 31 | state.isNotificationbarOpen = false 32 | state.isListbarOpen = false 33 | state.isSettingbarOpen = false 34 | }, 35 | [types.UPDATE_FORM_TEXT] (state, text) { 36 | state.text = text 37 | }, 38 | [types.CLEAR_FORM_TEXT] (state) { 39 | state.text = '' 40 | } 41 | } 42 | 43 | export default { 44 | state, 45 | mutations 46 | } 47 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/modules/notifications.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types' 2 | 3 | const state = { 4 | items: [], 5 | visibleNotificationCount: 0 6 | } 7 | 8 | const mutations = { 9 | [types.SET_FAVORITE_FOR_NOTIFICATION] (_state, tweet) { 10 | console.log('favorite') 11 | tweet.notificationType = 'favorite' 12 | state.items.push(tweet) 13 | state.visibleNotificationCount++ 14 | }, 15 | [types.SET_FOLLOW_FOR_NOTIFICATION] (_state, tweet) { 16 | console.log('follow') 17 | tweet.notificationType = 'follow' 18 | state.items.push(tweet) 19 | state.visibleNotificationCount++ 20 | }, 21 | [types.SET_MENTION_FOR_NOTIFICATION] (_state, tweet) { 22 | console.log('mention') 23 | tweet.notificationType = 'mention' 24 | state.items.push(tweet) 25 | state.visibleNotificationCount++ 26 | }, 27 | [types.SET_RT_FOR_NOTIFICATION] (_state, payload) { 28 | console.log('retweet') 29 | payload.retweet.notificationType = 'retweet' 30 | payload.retweet.retweeters = payload.retweeters 31 | state.items.push(payload.retweet) 32 | state.visibleNotificationCount++ 33 | }, 34 | [types.CLEAR_NOTIFICATION_COUNT] (_state) { 35 | state.visibleNotificationCount = 0 36 | } 37 | } 38 | 39 | export default { 40 | state, 41 | mutations 42 | } 43 | -------------------------------------------------------------------------------- /app/src/renderer/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | 19 | 65 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView/TweetBtn.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 34 | 35 | 61 | -------------------------------------------------------------------------------- /webpack.main.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'main' 4 | 5 | const path = require('path') 6 | const pkg = require('./app/package.json') 7 | const settings = require('./config.js') 8 | const webpack = require('webpack') 9 | 10 | let mainConfig = { 11 | entry: { 12 | main: path.join(__dirname, 'app/src/main/index.js') 13 | }, 14 | externals: Object.keys(pkg.dependencies || {}), 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.js$/, 19 | loader: 'babel-loader', 20 | exclude: /node_modules/ 21 | }, 22 | { 23 | test: /\.json$/, 24 | loader: 'json-loader' 25 | }, 26 | { 27 | test: /\.node$/, 28 | loader: 'node-loader' 29 | } 30 | ] 31 | }, 32 | node: { 33 | __dirname: false, 34 | __filename: false 35 | }, 36 | output: { 37 | filename: '[name].js', 38 | libraryTarget: 'commonjs2', 39 | path: path.join(__dirname, 'app/dist') 40 | }, 41 | plugins: [ 42 | new webpack.NoEmitOnErrorsPlugin(), 43 | new webpack.DefinePlugin({ 44 | 'process.env.NODE_ENV': '"production"' 45 | }), 46 | new webpack.optimize.UglifyJsPlugin({ 47 | compress: { 48 | warnings: false 49 | } 50 | }) 51 | ], 52 | resolve: { 53 | extensions: ['.js', '.json', '.node'], 54 | modules: [ 55 | path.join(__dirname, 'app/node_modules') 56 | ] 57 | }, 58 | target: 'electron-main' 59 | } 60 | 61 | module.exports = mainConfig 62 | -------------------------------------------------------------------------------- /app/src/renderer/components/HomeView.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 38 | 39 | 68 | -------------------------------------------------------------------------------- /tasks/runner.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const config = require('../config') 4 | const exec = require('child_process').exec 5 | const treeKill = require('tree-kill') 6 | 7 | let YELLOW = '\x1b[33m' 8 | let BLUE = '\x1b[34m' 9 | let END = '\x1b[0m' 10 | 11 | let isElectronOpen = false 12 | 13 | function format (command, data, color) { 14 | return color + command + END + 15 | ' ' + // Two space offset 16 | data.toString().trim().replace(/\n/g, '\n' + repeat(' ', command.length + 2)) + 17 | '\n' 18 | } 19 | 20 | function repeat (str, times) { 21 | return (new Array(times + 1)).join(str) 22 | } 23 | 24 | let children = [] 25 | 26 | function run (command, color, name) { 27 | let child = exec(command) 28 | 29 | child.stdout.on('data', data => { 30 | console.log(format(name, data, color)) 31 | 32 | /** 33 | * Start electron after successful compilation 34 | * (prevents electron from opening a blank window that requires refreshing) 35 | */ 36 | if (/Compiled/g.test(data.toString().trim().replace(/\n/g, '\n' + repeat(' ', command.length + 2))) && !isElectronOpen) { 37 | console.log(`${BLUE}Starting electron...\n${END}`) 38 | run('cross-env NODE_ENV=development electron app/src/main/index.dev.js', BLUE, 'electron') 39 | isElectronOpen = true 40 | } 41 | }) 42 | 43 | child.stderr.on('data', data => console.error(format(name, data, color))) 44 | child.on('exit', code => exit(code)) 45 | 46 | children.push(child) 47 | } 48 | 49 | function exit (code) { 50 | children.forEach(child => { 51 | treeKill(child.pid) 52 | }) 53 | } 54 | 55 | console.log(`${YELLOW}Starting webpack-dev-server...\n${END}`) 56 | run(`webpack-dev-server --hot --colors --config webpack.renderer.config.js --port ${config.port} --content-base app/dist`, YELLOW, 'webpack') 57 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/mutation-types.js: -------------------------------------------------------------------------------- 1 | // sidebar 2 | export const TOGGLE_TWEET_BAR = 'TOGGLE_TWEET_BAR' 3 | export const TOGGLE_SEARCH_BAR = 'TOGGLE_SEARCH_BAR' 4 | export const TOGGLE_NOTIFICATION_BAR = 'TOGGLE_NOTIFICATION_BAR' 5 | export const TOGGLE_LIST_BAR = 'TOGGLE_LIST_BAR' 6 | export const TOGGLE_SETTING_BAR = 'TOGGLE_SETTING_BAR' 7 | export const CLOSE_ALL_BAR = 'CLOSE_ALL_BAR' 8 | export const UPDATE_FORM_TEXT = 'UPDATE_FORM_TEXT' 9 | export const CLEAR_FORM_TEXT = 'CLEAR_FORM_TEXT' 10 | 11 | // user 12 | export const INIT_USER = 'INIT_USER' 13 | export const FOLLOW = 'FOLLOW' 14 | export const UNFOLLOW = 'UNFOLLOW' 15 | export const TOGGLE_PROFILE = 'TOGGLE_PROFILE' 16 | export const CLOSE_PROFILE = 'CLOSE_PROFILE' 17 | 18 | // lists 19 | export const SET_LISTS = 'SET_LISTS' 20 | 21 | // notification 22 | export const SET_FAVORITE_FOR_NOTIFICATION = 'SET_FAVORITE_FOR_NOTIFICATION' 23 | export const SET_FOLLOW_FOR_NOTIFICATION = 'SET_FOLLOW_FOR_NOTIFICATION' 24 | export const SET_MENTION_FOR_NOTIFICATION = 'SET_MENTION_FOR_NOTIFICATION' 25 | export const SET_RT_FOR_NOTIFICATION = 'SET_RT_FOR_NOTIFICATION' 26 | export const CLEAR_NOTIFICATION_COUNT = 'CLEAR_NOTIFICATION_COUNT' 27 | 28 | // tweets 29 | export const UPDATE_TWEET_NAME = 'UPDATE_TWEET_NAME' 30 | export const ADD_TWEETS = 'ADD_TWEETS' 31 | export const CLEAR_TWEETS = 'CLEAR_TWEETS' 32 | export const INCREASE_RT_COUNT = 'INCREASE_RT_COUNT' 33 | export const INCREASE_RT_COUNT_OF_RT = 'INCREASE_RT_COUNT_OF_RT' 34 | export const DECREASE_RT_COUNT = 'DECREASE_RT_COUNT' 35 | export const DECREASE_RT_COUNT_OF_RT = 'DECREASE_RT_COUNT_OF_RT' 36 | export const INCREASE_FAV_COUNT = 'INCREASE_FAV_COUNT' 37 | export const INCREASE_FAV_COUNT_OF_RT = 'INCREASE_FAV_COUNT_OF_RT' 38 | export const DECREASE_FAV_COUNT = 'DECREASE_FAV_COUNT' 39 | export const DECREASE_FAV_COUNT_OF_RT = 'DECREASE_FAV_COUNT_OF_RT' 40 | -------------------------------------------------------------------------------- /test/unit/karma.conf.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const merge = require('webpack-merge') 5 | const webpack = require('webpack') 6 | 7 | const baseConfig = require('../../webpack.renderer.config') 8 | const projectRoot = path.resolve(__dirname, '../../app') 9 | 10 | let webpackConfig = merge(baseConfig, { 11 | devtool: '#inline-source-map', 12 | plugins: [ 13 | new webpack.DefinePlugin({ 14 | 'process.env.NODE_ENV': '"testing"' 15 | }) 16 | ] 17 | }) 18 | 19 | // don't treat dependencies as externals 20 | delete webpackConfig.entry 21 | delete webpackConfig.externals 22 | delete webpackConfig.output.libraryTarget 23 | 24 | // only apply babel for test files when using isparta 25 | webpackConfig.module.rules.some(rule => { 26 | if (rule.use === 'babel-loader') { 27 | rule.include.push(path.resolve(projectRoot, '../test/unit')) 28 | return true 29 | } 30 | }) 31 | 32 | // apply vue option to apply isparta-loader on js 33 | webpackConfig.module.rules 34 | .find(rule => rule.use.loader === 'vue-loader').use.options.loaders.js = 'babel-loader' 35 | 36 | module.exports = config => { 37 | config.set({ 38 | browsers: ['visibleElectron'], 39 | client: { 40 | useIframe: false 41 | }, 42 | coverageReporter: { 43 | dir: './coverage', 44 | reporters: [ 45 | { type: 'lcov', subdir: '.' }, 46 | { type: 'text-summary' } 47 | ] 48 | }, 49 | customLaunchers: { 50 | 'visibleElectron': { 51 | base: 'Electron', 52 | flags: ['--show'] 53 | } 54 | }, 55 | frameworks: ['mocha', 'chai'], 56 | files: ['./index.js'], 57 | preprocessors: { 58 | './index.js': ['webpack', 'sourcemap'] 59 | }, 60 | reporters: ['spec', 'coverage'], 61 | singleRun: true, 62 | webpack: webpackConfig, 63 | webpackMiddleware: { 64 | noInfo: true 65 | } 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-twitter-client 2 | 3 | Twitter client built with Vue.js 2.x + Electron 4 | 5 | 6 | 7 | # Install 8 | 9 | To try this app, you need to set your consumer key and consumer secret. 10 | [https://github.com/YuheiNakasaka/vue-twitter-client/blob/master/app/src/main/authentication-window.js#L10](https://github.com/YuheiNakasaka/vue-twitter-client/blob/master/app/src/main/authentication-window.js#L10) 11 | 12 | And install a repo & build & package it in your local machine like this. 13 | 14 | ```[example] 15 | $ electron-packager . vue-twitter-client --platform=darwin --arch=x64 --version=0.0.1 16 | ``` 17 | 18 | # Features 19 | 20 | - display hometime line 21 | - display image, animated gif and video 22 | - display lists 23 | - display mentions 24 | - show profile 25 | - show some notifications 26 | - search tweet 27 | - tweet text 28 | - tweet text with images 29 | - retweet 30 | - favorite 31 | - reply 32 | - follow/unfollow 33 | - open media with link clicked 34 | - streaming timeline(home, search) 35 | - real time update with pooling(list, mention) 36 | 37 | # Development 38 | 39 | ## Build Setup 40 | 41 | ``` bash 42 | # install dependencies 43 | npm install 44 | 45 | # serve with hot reload at localhost:9080 46 | npm run dev 47 | 48 | # build electron app for production 49 | npm run build 50 | 51 | # lint all JS/Vue component files in `app/src` 52 | npm run lint 53 | 54 | # run webpack in production 55 | npm run pack 56 | ``` 57 | More information can be found [here](https://simulatedgreg.gitbooks.io/electron-vue/content/docs/npm_scripts.html). 58 | 59 | --- 60 | 61 | This project was generated from [electron-vue](https://github.com/SimulatedGREG/electron-vue) using [vue-cli](https://github.com/vuejs/vue-cli). Documentation about this project can be found [here](https://simulatedgreg.gitbooks.io/electron-vue/content/index.html). 62 | 63 | # License 64 | 65 | [MIT](https://opensource.org/licenses/MIT) 66 | -------------------------------------------------------------------------------- /app/src/main/application.js: -------------------------------------------------------------------------------- 1 | import { app, BrowserWindow } from 'electron' 2 | import AuthenticationWindow from './authentication-window' 3 | import Store from '../renderer/libraries/store' 4 | 5 | export default class Application { 6 | constructor () { 7 | this.store = new Store({ configName: 'user-preferences', defaults: {} }) 8 | this.winURL = process.env.NODE_ENV === 'development' 9 | ? `http://localhost:${require('../../../config').port}` 10 | : `file://${__dirname}/index.html` 11 | this.accessToken = null 12 | this.accessTokenSecret = null 13 | this.mainWindow = undefined 14 | } 15 | 16 | createWindow () { 17 | this.mainWindow = new BrowserWindow({ 18 | height: 600, 19 | width: 570 20 | }) 21 | 22 | this.mainWindow.loadURL(this.winURL) 23 | 24 | this.mainWindow.on('closed', () => { 25 | this.mainWindow = null 26 | }) 27 | } 28 | 29 | openAuthenticationWindow () { 30 | let that = this 31 | let defaultUser = this.store.get('defaultUser') 32 | if (defaultUser && defaultUser.accessToken && defaultUser.accessTokenSecret && defaultUser.consumerKey && defaultUser.consumerSecret) { 33 | this.createWindow() 34 | } else { 35 | new AuthenticationWindow().on('authentication-succeeded', (res) => { 36 | this.store.set('defaultUser', { 37 | user: res.user, 38 | accessToken: res.accessToken, 39 | accessTokenSecret: res.accessTokenSecret, 40 | consumerKey: res.consumerKey, 41 | consumerSecret: res.consumerSecret 42 | }) 43 | this.createWindow() 44 | }) 45 | } 46 | } 47 | 48 | onReady () { 49 | this.openAuthenticationWindow() 50 | } 51 | 52 | registerApplicationCallback () { 53 | app.on('ready', this.onReady.bind(this)) 54 | 55 | app.on('window-all-closed', () => { 56 | if (process.platform !== 'darwin') { 57 | app.quit() 58 | } 59 | }) 60 | 61 | app.on('activate', () => { 62 | if (this.mainWindow === null) { 63 | this.createWindow() 64 | } 65 | }) 66 | } 67 | 68 | run () { 69 | this.registerApplicationCallback() 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/src/main/authentication-window.js: -------------------------------------------------------------------------------- 1 | import { BrowserWindow } from 'electron' 2 | import { EventEmitter } from 'events' 3 | import Twitter from 'node-twitter-api' 4 | 5 | // Referenced from https://github.com/r7kamura/retro-twitter-client/blob/master/src/browser/authentication-window.js 6 | // and fixed some depricated api 7 | export default class AuthenticationWindow extends EventEmitter { 8 | constructor (callback) { 9 | super() 10 | this.consumerKey = 'YOUR_CONSUMERKEY' 11 | this.consumerSecret = 'YOUR_CONSUMER_SECRET' 12 | 13 | const twitter = new Twitter({ 14 | callback: 'http://example.com', 15 | consumerKey: this.consumerKey, 16 | consumerSecret: this.consumerSecret 17 | }) 18 | 19 | twitter.getRequestToken((error, requestToken, requestTokenSecret) => { 20 | const url = twitter.getAuthUrl(requestToken) 21 | this.window = new BrowserWindow({ width: 800, height: 600, 'node-integration': false}) 22 | this.getAccessToken(twitter, requestToken, requestTokenSecret, url) 23 | }) 24 | } 25 | 26 | getAccessToken (twitter, requestToken, requestTokenSecret, url) { 27 | let that = this 28 | this.window.webContents.on('will-navigate', (event, url) => { 29 | let matched 30 | if (matched = url.match(/\?oauth_token=([^&]*)&oauth_verifier=([^&]*)/)) { 31 | twitter.getAccessToken(requestToken, requestTokenSecret, matched[2], (error, accessToken, accessTokenSecret) => { 32 | twitter.verifyCredentials(accessToken, accessTokenSecret, {}, function (error, data, response) { 33 | if (!error) { 34 | that.emit( 35 | 'authentication-succeeded', 36 | { 37 | consumerKey: that.consumerKey, 38 | consumerSecret: that.consumerSecret, 39 | accessToken: accessToken, 40 | accessTokenSecret: accessTokenSecret, 41 | user: data 42 | } 43 | ) 44 | } 45 | }) 46 | }) 47 | event.preventDefault() 48 | setImmediate(() => { 49 | this.window.close() 50 | }) 51 | } else if (matched = url.match(/&redirect_after_login_verification=([^&]*)/)) { 52 | this.window.webContents.on('did-get-redirect-request', (event, oldUrl, newUrl, isMainFrame) => { 53 | this.getAccessToken(twitter, requestToken, requestTokenSecret, newUrl) 54 | }) 55 | } 56 | }) 57 | this.window.loadURL(url) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/modules/tweets.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types' 2 | 3 | const state = { 4 | tweetName: '', 5 | items: [] 6 | } 7 | 8 | const mutations = { 9 | [types.UPDATE_TWEET_NAME] (state, name) { 10 | state.tweetName = name 11 | }, 12 | [types.ADD_TWEETS] (state, tweets) { 13 | tweets.forEach((tweet, i) => { 14 | if (state.items.length > 500) { 15 | console.log('over 500: ' + i) 16 | state.items.pop() 17 | state.items.unshift(tweet) 18 | } else { 19 | state.items.unshift(tweet) 20 | } 21 | }) 22 | }, 23 | [types.CLEAR_TWEETS] (state) { 24 | state.items = [] 25 | }, 26 | [types.INCREASE_RT_COUNT] (state, tweet) { 27 | if (tweet.retweeted === false) { 28 | state.items[state.items.indexOf(tweet)].retweet_count++ 29 | tweet.retweeted = true 30 | } 31 | }, 32 | [types.INCREASE_RT_COUNT_OF_RT] (state, tweet) { 33 | if (tweet.retweeted_status.retweeted === false) { 34 | state.items[state.items.indexOf(tweet)].retweeted_status.retweet_count++ 35 | tweet.retweeted_status.retweeted = true 36 | } 37 | }, 38 | [types.DECREASE_RT_COUNT] (state, tweet) { 39 | if (tweet.retweeted === true) { 40 | state.items[state.items.indexOf(tweet)].retweet_count-- 41 | tweet.retweeted = false 42 | } 43 | }, 44 | [types.DECREASE_RT_COUNT_OF_RT] (state, tweet) { 45 | if (tweet.retweeted_status.retweeted === true) { 46 | state.items[state.items.indexOf(tweet)].retweeted_status.retweet_count-- 47 | tweet.retweeted_status.retweeted = false 48 | } 49 | }, 50 | [types.INCREASE_FAV_COUNT] (state, tweet) { 51 | if (tweet.favorited === false) { 52 | state.items[state.items.indexOf(tweet)].favorite_count++ 53 | tweet.favorited = true 54 | } 55 | }, 56 | [types.INCREASE_FAV_COUNT_OF_RT] (state, tweet) { 57 | if (tweet.retweeted_status.favorited === false) { 58 | state.items[state.items.indexOf(tweet)].retweeted_status.favorite_count++ 59 | tweet.retweeted_status.favorited = true 60 | } 61 | }, 62 | [types.DECREASE_FAV_COUNT] (state, tweet) { 63 | if (tweet.favorited === true) { 64 | state.items[state.items.indexOf(tweet)].favorite_count-- 65 | tweet.favorited = false 66 | } 67 | }, 68 | [types.DECREASE_FAV_COUNT_OF_RT] (state, tweet) { 69 | if (tweet.retweeted_status.favorited === true) { 70 | state.items[state.items.indexOf(tweet)].retweeted_status.favorite_count-- 71 | tweet.retweeted_status.favorited = false 72 | } 73 | } 74 | } 75 | 76 | export default { 77 | state, 78 | mutations 79 | } 80 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView/SearchBtn.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 50 | 51 | 105 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView/SettingBtn.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 54 | 55 | 125 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView/NotificationBtn.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 49 | 50 | 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-twitter-client-app", 3 | "version": "0.0.1", 4 | "description": "An electron-vue project", 5 | "scripts": { 6 | "build": "node tasks/release.js", 7 | "build:clean": "cross-env PLATFORM_TARGET=clean node tasks/release.js", 8 | "build:darwin": "cross-env PLATFORM_TARGET=darwin node tasks/release.js", 9 | "build:linux": "cross-env PLATFORM_TARGET=linux node tasks/release.js", 10 | "build:mas": "cross-env PLATFORM_TARGET=mas node tasks/release.js", 11 | "build:win32": "cross-env PLATFORM_TARGET=win32 node tasks/release.js", 12 | "dev": "node tasks/runner.js", 13 | "e2e": "npm run pack && mocha test/e2e", 14 | "lint": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter app test", 15 | "lint:fix": "eslint --ext .js,.vue -f ./node_modules/eslint-friendly-formatter --fix app test", 16 | "pack": "npm run pack:main && npm run pack:renderer", 17 | "pack:main": "cross-env NODE_ENV=production webpack -p --progress --colors --config webpack.main.config.js", 18 | "pack:renderer": "cross-env NODE_ENV=production webpack -p --progress --colors --config webpack.renderer.config.js", 19 | "test": "npm run unit && npm run e2e", 20 | "unit": "cross-env BABEL_ENV=testing-unit karma start test/unit/karma.conf.js", 21 | "postinstall": "npm run lint:fix && cd app && npm install" 22 | }, 23 | "author": "Greg Holguin ", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "babel-core": "^6.8.0", 27 | "babel-eslint": "^7.0.0", 28 | "babel-loader": "^6.2.4", 29 | "babel-plugin-istanbul": "^3.1.2", 30 | "babel-plugin-transform-runtime": "^6.8.0", 31 | "babel-preset-es2015": "^6.6.0", 32 | "babel-preset-stage-0": "^6.5.0", 33 | "babel-register": "^6.18.0", 34 | "babel-runtime": "^6.6.1", 35 | "chai": "^3.5.0", 36 | "cross-env": "^3.1.4", 37 | "css-loader": "^0.26.1", 38 | "del": "^2.2.1", 39 | "devtron": "^1.1.0", 40 | "electron": "^1.3.1", 41 | "electron-debug": "^1.1.0", 42 | "electron-devtools-installer": "^2.0.1", 43 | "electron-packager": "^8.5.0", 44 | "electron-rebuild": "^1.1.3", 45 | "eslint": "^3.13.1", 46 | "eslint-config-standard": "^6.2.1", 47 | "eslint-friendly-formatter": "^2.0.5", 48 | "eslint-loader": "^1.3.0", 49 | "eslint-plugin-html": "^2.0.0", 50 | "eslint-plugin-promise": "^3.4.0", 51 | "eslint-plugin-standard": "^2.0.1", 52 | "extract-text-webpack-plugin": "^2.0.0-beta.4", 53 | "file-loader": "^0.9.0", 54 | "html-webpack-plugin": "^2.16.1", 55 | "inject-loader": "^2.0.1", 56 | "json-loader": "^0.5.4", 57 | "karma": "^1.3.0", 58 | "karma-chai": "^0.1.0", 59 | "karma-coverage": "^1.1.1", 60 | "karma-electron": "^5.1.1", 61 | "karma-mocha": "^1.2.0", 62 | "karma-sourcemap-loader": "^0.3.7", 63 | "karma-spec-reporter": "0.0.26", 64 | "karma-webpack": "^2.0.1", 65 | "mocha": "^3.0.2", 66 | "node-sass": "^4.5.1", 67 | "require-dir": "^0.3.0", 68 | "sass-loader": "^6.0.3", 69 | "spectron": "^3.4.0", 70 | "style-loader": "^0.13.1", 71 | "tree-kill": "^1.1.0", 72 | "url-loader": "^0.5.7", 73 | "vue-hot-reload-api": "^2.0.7", 74 | "vue-html-loader": "^1.2.2", 75 | "vue-loader": "^10.0.2", 76 | "vue-style-loader": "^1.0.0", 77 | "vue-template-compiler": "^2.1.10", 78 | "webpack": "^2.2.1", 79 | "webpack-dev-server": "^2.3.0", 80 | "webpack-merge": "^2.4.0" 81 | }, 82 | "dependencies": { 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView/ListBtn.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 59 | 60 | 129 | -------------------------------------------------------------------------------- /webpack.renderer.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | process.env.BABEL_ENV = 'renderer' 4 | 5 | const path = require('path') 6 | const pkg = require('./app/package.json') 7 | const settings = require('./config.js') 8 | const webpack = require('webpack') 9 | 10 | const ExtractTextPlugin = require('extract-text-webpack-plugin') 11 | const HtmlWebpackPlugin = require('html-webpack-plugin') 12 | 13 | let rendererConfig = { 14 | devtool: '#eval-source-map', 15 | devServer: { overlay: true }, 16 | entry: { 17 | renderer: path.join(__dirname, 'app/src/renderer/main.js') 18 | }, 19 | externals: Object.keys(pkg.dependencies || {}), 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.css$/, 24 | use: ExtractTextPlugin.extract({ 25 | fallback: 'style-loader', 26 | use: 'css-loader' 27 | }) 28 | }, 29 | { 30 | test: /\.html$/, 31 | use: 'vue-html-loader' 32 | }, 33 | { 34 | test: /\.js$/, 35 | use: 'babel-loader', 36 | include: [ path.resolve(__dirname, 'app/src/renderer') ], 37 | exclude: /node_modules/ 38 | }, 39 | { 40 | test: /\.json$/, 41 | use: 'json-loader' 42 | }, 43 | { 44 | test: /\.node$/, 45 | use: 'node-loader' 46 | }, 47 | { 48 | test: /\.vue$/, 49 | use: { 50 | loader: 'vue-loader', 51 | options: { 52 | loaders: { 53 | sass: 'vue-style-loader!css-loader!sass-loader?indentedSyntax=1', 54 | scss: 'vue-style-loader!css-loader!sass-loader' 55 | } 56 | } 57 | } 58 | }, 59 | { 60 | test: /\.(png|jpe?g|gif|svg)(\?.*)?$/, 61 | use: { 62 | loader: 'url-loader', 63 | query: { 64 | limit: 10000, 65 | name: 'imgs/[name].[ext]' 66 | } 67 | } 68 | }, 69 | { 70 | test: /\.(woff2?|eot|ttf|otf)(\?.*)?$/, 71 | use: { 72 | loader: 'url-loader', 73 | query: { 74 | limit: 10000, 75 | name: 'fonts/[name].[ext]' 76 | } 77 | } 78 | } 79 | ] 80 | }, 81 | plugins: [ 82 | new ExtractTextPlugin('styles.css'), 83 | new HtmlWebpackPlugin({ 84 | filename: 'index.html', 85 | template: './app/index.ejs', 86 | appModules: process.env.NODE_ENV !== 'production' 87 | ? path.resolve(__dirname, 'app/node_modules') 88 | : false, 89 | }), 90 | new webpack.NoEmitOnErrorsPlugin() 91 | ], 92 | output: { 93 | filename: '[name].js', 94 | libraryTarget: 'commonjs2', 95 | path: path.join(__dirname, 'app/dist') 96 | }, 97 | resolve: { 98 | alias: { 99 | 'components': path.join(__dirname, 'app/src/renderer/components'), 100 | 'renderer': path.join(__dirname, 'app/src/renderer') 101 | }, 102 | extensions: ['.js', '.vue', '.json', '.css', '.node'], 103 | modules: [ 104 | path.join(__dirname, 'app/node_modules'), 105 | path.join(__dirname, 'node_modules') 106 | ] 107 | }, 108 | target: 'electron-renderer' 109 | } 110 | 111 | if (process.env.NODE_ENV !== 'production') { 112 | /** 113 | * Apply ESLint 114 | */ 115 | if (settings.eslint) { 116 | rendererConfig.module.rules.push( 117 | { 118 | test: /\.(js|vue)$/, 119 | enforce: 'pre', 120 | exclude: /node_modules/, 121 | use: { 122 | loader: 'eslint-loader', 123 | options: { 124 | formatter: require('eslint-friendly-formatter') 125 | } 126 | } 127 | } 128 | ) 129 | } 130 | } 131 | 132 | /** 133 | * Adjust rendererConfig for production settings 134 | */ 135 | if (process.env.NODE_ENV === 'production') { 136 | rendererConfig.devtool = '' 137 | 138 | rendererConfig.plugins.push( 139 | new webpack.DefinePlugin({ 140 | 'process.env.NODE_ENV': '"production"' 141 | }), 142 | new webpack.LoaderOptionsPlugin({ 143 | minimize: true 144 | }), 145 | new webpack.optimize.UglifyJsPlugin({ 146 | compress: { 147 | warnings: false 148 | } 149 | }) 150 | ) 151 | } 152 | 153 | module.exports = rendererConfig 154 | -------------------------------------------------------------------------------- /app/src/renderer/components/SidebarView/NotificationColumn.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | 104 | 105 | 160 | -------------------------------------------------------------------------------- /app/src/renderer/components/HomeView/TweetBody.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 124 | 125 | 173 | -------------------------------------------------------------------------------- /app/src/renderer/components/TweetbarView.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 125 | 126 | 253 | -------------------------------------------------------------------------------- /app/src/renderer/components/HomeView/Tweet.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 142 | 143 | 225 | -------------------------------------------------------------------------------- /app/src/renderer/components/HomeView/Profile.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 126 | 127 | 298 | -------------------------------------------------------------------------------- /app/src/renderer/vuex/actions.js: -------------------------------------------------------------------------------- 1 | import * as types from './mutation-types' 2 | import Twitter from 'twit' 3 | import Store from '../libraries/store' 4 | import { eventEmitter } from '../libraries/event-emitter' 5 | 6 | let client 7 | 8 | function getStore () { 9 | return new Store({ configName: 'user-preferences' }) 10 | } 11 | 12 | function getClient (accountType = 'defaultUser') { 13 | let store = getStore() 14 | let data = store.data.defaultUser 15 | 16 | if (client === undefined) { 17 | client = new Twitter({ 18 | consumer_key: data.consumerKey, 19 | consumer_secret: data.consumerSecret, 20 | access_token: data.accessToken, 21 | access_token_secret: data.accessTokenSecret 22 | }) 23 | } 24 | return client 25 | } 26 | 27 | function hasRetweetedStatus (payload) { 28 | return payload.tweet.retweeted_status !== undefined 29 | } 30 | 31 | function getIdStr (payload) { 32 | if (hasRetweetedStatus(payload)) { 33 | return payload.tweet.retweeted_status.id_str 34 | } else { 35 | return payload.tweet.id_str 36 | } 37 | } 38 | 39 | // To switch contents by only one feed, 40 | // need to reset stream of twitter, pooling timer and eventEmitters 41 | function resetFeedFetcher () { 42 | eventEmitter.emit('resetStream') 43 | eventEmitter.emit('stopTimerOfList') 44 | eventEmitter.removeAllListeners() 45 | } 46 | 47 | export const toggleTweetBar = (context) => { 48 | context.commit(types.TOGGLE_TWEET_BAR) 49 | } 50 | 51 | export const toggleSearchBar = (context) => { 52 | context.commit(types.TOGGLE_SEARCH_BAR) 53 | } 54 | 55 | export const toggleNotificationBar = (context) => { 56 | context.commit(types.TOGGLE_NOTIFICATION_BAR) 57 | context.commit(types.CLEAR_NOTIFICATION_COUNT) 58 | } 59 | 60 | export const toggleListBar = (context) => { 61 | context.commit(types.TOGGLE_LIST_BAR) 62 | } 63 | 64 | export const toggleSettingBar = (context) => { 65 | context.commit(types.TOGGLE_SETTING_BAR) 66 | } 67 | 68 | export const closeAllBar = (context) => { 69 | context.commit(types.CLOSE_ALL_BAR) 70 | } 71 | 72 | export const updateFormText = (context, payload) => { 73 | context.commit(types.UPDATE_FORM_TEXT, payload.text) 74 | } 75 | 76 | export const clearFormText = (context) => { 77 | context.commit(types.CLEAR_FORM_TEXT) 78 | } 79 | 80 | export const initUser = (context) => { 81 | let store = getStore() 82 | context.commit(types.INIT_USER, store.data.defaultUser.user) 83 | } 84 | 85 | export const follow = (context, payload) => { 86 | let client = getClient() 87 | return new Promise((resolve, reject) => { 88 | client.post('friendships/create', {user_id: payload.tweet.user.id_str}, (error, tweet, response) => { 89 | if (!error) { 90 | context.commit(types.FOLLOW, payload.tweet) 91 | resolve() 92 | } else { 93 | console.log(error) 94 | reject() 95 | } 96 | }) 97 | }) 98 | } 99 | 100 | export const unfollow = (context, payload) => { 101 | let client = getClient() 102 | return new Promise((resolve, reject) => { 103 | client.post('friendships/destroy', {user_id: payload.tweet.user.id_str}, (error, tweet, response) => { 104 | if (!error) { 105 | context.commit(types.UNFOLLOW, payload.tweet) 106 | resolve() 107 | } else { 108 | reject() 109 | } 110 | }) 111 | }) 112 | } 113 | 114 | export const toggleProfile = (context, payload) => { 115 | if (hasRetweetedStatus(payload)) { 116 | context.commit(types.TOGGLE_PROFILE, payload.tweet.retweeted_status) 117 | } else { 118 | context.commit(types.TOGGLE_PROFILE, payload.tweet) 119 | } 120 | } 121 | 122 | export const closeProfile = (context) => { 123 | context.commit(types.CLOSE_PROFILE) 124 | } 125 | 126 | export const postTweet = (context, payload) => { 127 | let client = getClient() 128 | 129 | eventEmitter.on('postTextTweet', (textTweet, mediaIds = []) => { 130 | return new Promise((resolve, reject) => { 131 | client.post('statuses/update', {status: textTweet, media_ids: mediaIds}, (error, tweet, response) => { 132 | if (!error) { 133 | resolve() 134 | } else { 135 | reject() 136 | } 137 | }) 138 | }) 139 | }) 140 | 141 | eventEmitter.on('uploadImage', (images) => { 142 | return new Promise((resolve, reject) => { 143 | let outResolve = resolve 144 | let mediaIdStrings = [] 145 | _loop() 146 | function _loop () { 147 | return new Promise((resolve, reject) => { 148 | let b64Image = images[mediaIdStrings.length] 149 | b64Image = b64Image.replace(/^data:image\/.+;base64,/, '') 150 | client.post('media/upload', {media_data: b64Image}, (error, data, response) => { 151 | if (!error) { 152 | mediaIdStrings.push(data.media_id_string) 153 | resolve() 154 | } else { 155 | mediaIdStrings.push(null) 156 | reject() 157 | } 158 | }) 159 | }).then(() => { 160 | if (images.length === mediaIdStrings.length) { 161 | console.log('finish', mediaIdStrings.length) 162 | outResolve(mediaIdStrings.filter((id) => { return id !== null })) 163 | } else { 164 | console.log('yet', mediaIdStrings.length) 165 | _loop() 166 | } 167 | }) 168 | } 169 | }).then((mediaIdStrings) => { 170 | console.log(mediaIdStrings) 171 | eventEmitter.emit('postTextTweet', payload.tweet, mediaIdStrings) 172 | }).catch((e) => { 173 | console.log('error: ', e) 174 | }) 175 | }) 176 | 177 | if (payload.images.length > 0) { 178 | eventEmitter.emit('uploadImage', payload.images) 179 | } else { 180 | eventEmitter.emit('postTextTweet', payload.tweet) 181 | } 182 | } 183 | 184 | export const postRT = (context, payload) => { 185 | let client = getClient() 186 | return new Promise((resolve, reject) => { 187 | client.post('statuses/retweet/' + getIdStr(payload), (error, tweet, response) => { 188 | if (!error) { 189 | if (hasRetweetedStatus(payload)) { 190 | context.commit(types.INCREASE_RT_COUNT_OF_RT, payload.tweet) 191 | } else { 192 | context.commit(types.INCREASE_RT_COUNT, payload.tweet) 193 | } 194 | resolve() 195 | } else { 196 | reject() 197 | } 198 | }) 199 | }) 200 | } 201 | 202 | export const deleteRT = (context, payload) => { 203 | let client = getClient() 204 | return new Promise((resolve, reject) => { 205 | client.post('statuses/unretweet/' + getIdStr(payload), (error, tweet, response) => { 206 | if (!error) { 207 | if (hasRetweetedStatus(payload)) { 208 | context.commit(types.DECREASE_RT_COUNT_OF_RT, payload.tweet) 209 | } else { 210 | context.commit(types.DECREASE_RT_COUNT, payload.tweet) 211 | } 212 | resolve() 213 | } else { 214 | reject() 215 | } 216 | }) 217 | }) 218 | } 219 | 220 | export const postFav = (context, payload) => { 221 | let client = getClient() 222 | return new Promise((resolve, reject) => { 223 | client.post('favorites/create', {id: getIdStr(payload)}, (error, tweet, response) => { 224 | if (!error) { 225 | if (hasRetweetedStatus(payload)) { 226 | context.commit(types.INCREASE_FAV_COUNT_OF_RT, payload.tweet) 227 | } else { 228 | context.commit(types.INCREASE_FAV_COUNT, payload.tweet) 229 | } 230 | resolve() 231 | } else { 232 | reject() 233 | } 234 | }) 235 | }) 236 | } 237 | 238 | export const deleteFav = (context, payload) => { 239 | let client = getClient() 240 | return new Promise((resolve, reject) => { 241 | client.post('favorites/destroy', {id: getIdStr(payload)}, (error, tweet, response) => { 242 | if (!error) { 243 | if (hasRetweetedStatus(payload)) { 244 | context.commit(types.DECREASE_FAV_COUNT_OF_RT, payload.tweet) 245 | } else { 246 | context.commit(types.DECREASE_FAV_COUNT, payload.tweet) 247 | } 248 | resolve() 249 | } else { 250 | reject() 251 | } 252 | }) 253 | }) 254 | } 255 | 256 | export const getHomeTweets = (context) => { 257 | let client = getClient() 258 | resetFeedFetcher() 259 | context.commit(types.UPDATE_TWEET_NAME, 'HOME') 260 | context.commit(types.CLEAR_TWEETS) 261 | // first, get tweets with rest api 262 | client.get('statuses/home_timeline', {count: 20}, (error, data, response) => { 263 | if (!error) { 264 | context.commit(types.ADD_TWEETS, data.reverse()) 265 | eventEmitter.emit('finishFetchHomeTimeline') 266 | } 267 | }) 268 | 269 | // second, start streaming 270 | let stream 271 | eventEmitter.on('finishFetchHomeTimeline', () => { 272 | stream = client.stream('user') 273 | stream.on('tweet', (tweet) => { 274 | context.commit(types.ADD_TWEETS, [tweet]) 275 | }) 276 | stream.on('error', (e) => { 277 | console.log(e) 278 | }) 279 | }) 280 | 281 | eventEmitter.on('resetStream', () => { 282 | stream.stop() 283 | }) 284 | } 285 | 286 | export const getSearchTweets = (context, payload) => { 287 | let client = getClient() 288 | resetFeedFetcher() 289 | context.commit(types.UPDATE_TWEET_NAME, 'Search: ' + payload.q) 290 | context.commit(types.CLEAR_TWEETS) 291 | client.get('search/tweets', {q: payload.q, count: 100}, (error, data, response) => { 292 | if (!error) { 293 | context.commit(types.ADD_TWEETS, data.statuses.reverse()) 294 | eventEmitter.emit('finishFetchSearchTweets') 295 | } 296 | }) 297 | 298 | let stream 299 | eventEmitter.on('finishFetchSearchTweets', () => { 300 | stream = client.stream('statuses/filter', {track: payload.q}) 301 | stream.on('tweet', (tweet) => { 302 | context.commit(types.ADD_TWEETS, [tweet]) 303 | }) 304 | stream.on('error', (e) => { 305 | console.log(e) 306 | }) 307 | }) 308 | 309 | eventEmitter.on('resetStream', () => { 310 | stream.stop() 311 | }) 312 | } 313 | 314 | export const getListTweets = (context, payload) => { 315 | let client = getClient() 316 | resetFeedFetcher() 317 | context.commit(types.UPDATE_TWEET_NAME, payload.list.full_name) 318 | context.commit(types.CLEAR_TWEETS) 319 | 320 | client.get('lists/statuses', {list_id: payload.list.id, count: 500}, (error, data, response) => { 321 | if (!error) { 322 | let tweets = data.reverse() 323 | context.commit(types.ADD_TWEETS, tweets) 324 | eventEmitter.emit('finishFetchListTweetsFirst', tweets[tweets.length - 1]) 325 | } 326 | }) 327 | 328 | let timerOfList 329 | eventEmitter.on('finishFetchListTweetsFirst', (tweet) => { 330 | let latestTweet = tweet 331 | timerOfList = setInterval(() => { 332 | client.get('lists/statuses', {list_id: payload.list.id, since_id: latestTweet.id_str, count: 10}, (error, data, response) => { 333 | if (!error) { 334 | if (data.length > 0) { 335 | let tweets = data.reverse() 336 | context.commit(types.ADD_TWEETS, tweets) 337 | latestTweet = tweets[tweets.length - 1] 338 | } 339 | } 340 | }) 341 | }, 10000) 342 | }) 343 | 344 | eventEmitter.on('stopTimerOfList', () => { 345 | clearTimeout(timerOfList) 346 | }) 347 | } 348 | 349 | export const getMyList = (context) => { 350 | let client = getClient() 351 | client.get('lists/list', {user_id: context.state.user.user.id, screen_name: context.state.user.user.screen_name}, (error, data, response) => { 352 | if (!error) { 353 | context.commit(types.SET_LISTS, data) 354 | } 355 | }) 356 | } 357 | 358 | export const getNotifications = (context) => { 359 | let client = getClient() 360 | 361 | // start streaming 362 | // notification is not stopped 363 | let stream = client.stream('user', {replies: 'all'}) 364 | stream.on('favorite', (data) => { 365 | if (data.source.id_str !== context.state.user.user.id_str) { 366 | context.commit(types.SET_FAVORITE_FOR_NOTIFICATION, data.source) 367 | } 368 | }) 369 | stream.on('follow', (data) => { 370 | if (data.source.id_str !== context.state.user.user.id_str) { 371 | context.commit(types.SET_FOLLOW_FOR_NOTIFICATION, data.source) 372 | } 373 | }) 374 | stream.on('tweet', (data) => { 375 | let screenName = context.state.user.user.screen_name 376 | let rexp = new RegExp('@' + screenName) 377 | if (data.text.match(rexp) && !data.retweeted_status && data.user.id_str !== context.state.user.user.id_str) { 378 | context.commit(types.SET_MENTION_FOR_NOTIFICATION, data) 379 | } 380 | }) 381 | 382 | // start timer for retweet 383 | _startTimerForRT() 384 | 385 | // retweet is monitored with timer 386 | function _startTimerForRT () { 387 | // get retweets of my tweets per 30 seconds(api limit) 388 | let sinceId 389 | setInterval(() => { 390 | client.get('statuses/retweets_of_me', {count: 2, since_id: sinceId}, (error, data, response) => { 391 | if (error) return false 392 | data.map((dt) => { 393 | sinceId = data[0].id_str 394 | eventEmitter.emit('finishGetRetweetOfMe', {retweets: data}) 395 | }) 396 | }) 397 | }, 30000) 398 | 399 | // get users of retweets 400 | eventEmitter.on('finishGetRetweetOfMe', ({ retweets }) => { 401 | retweets.forEach((retweet, i) => { 402 | client.get('statuses/retweets/' + retweet.id_str, {count: 100}, (error, data, response) => { 403 | if (error) return false 404 | if (data.length === 0) return false 405 | eventEmitter.emit('finishGetRetweeters', {retweet: retweet, retweeters: data}) 406 | }) 407 | }) 408 | }) 409 | 410 | // publish retweets notification 411 | eventEmitter.on('finishGetRetweeters', ({ retweet, retweeters }) => { 412 | context.commit(types.SET_RT_FOR_NOTIFICATION, {retweet: retweet, retweeters: retweeters}) 413 | }) 414 | } 415 | } 416 | 417 | export const getMentions = (context) => { 418 | let client = getClient() 419 | resetFeedFetcher() 420 | context.commit(types.UPDATE_TWEET_NAME, 'Mention') 421 | context.commit(types.CLEAR_TWEETS) 422 | 423 | client.get('statuses/mentions_timeline', {count: 100}, (error, data, response) => { 424 | if (!error) { 425 | let tweets = data.reverse() 426 | context.commit(types.ADD_TWEETS, tweets) 427 | eventEmitter.emit('finishFetchMentionTweetsFirst', tweets[tweets.length - 1]) 428 | } 429 | }) 430 | 431 | let timerOfMention 432 | eventEmitter.on('finishFetchMentionTweetsFirst', (tweet) => { 433 | let latestTweet = tweet 434 | timerOfMention = setInterval(() => { 435 | client.get('statuses/mentions_timeline', {since_id: latestTweet.id_str, count: 10}, (error, data, response) => { 436 | if (!error) { 437 | if (data.length > 0) { 438 | let tweets = data.reverse() 439 | context.commit(types.ADD_TWEETS, tweets) 440 | latestTweet = tweets[tweets.length - 1] 441 | } 442 | } 443 | }) 444 | }, 30000) 445 | }) 446 | 447 | eventEmitter.on('stopTimerOfList', () => { 448 | clearTimeout(timerOfMention) 449 | }) 450 | } 451 | --------------------------------------------------------------------------------