├── 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 |
2 |
13 |
14 |
15 |
37 |
38 |
44 |
--------------------------------------------------------------------------------
/app/src/renderer/components/SidebarView/HomeBtn.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
12 |
13 |
34 |
35 |
55 |
--------------------------------------------------------------------------------
/app/src/renderer/components/HomeView/Timeline.vue:
--------------------------------------------------------------------------------
1 |
2 |
10 |
11 |
12 |
36 |
37 |
55 |
--------------------------------------------------------------------------------
/app/src/renderer/components/SidebarView/MentionBtn.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
18 |
19 |
65 |
--------------------------------------------------------------------------------
/app/src/renderer/components/SidebarView/TweetBtn.vue:
--------------------------------------------------------------------------------
1 |
2 |
11 |
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 |
2 |
3 |
6 |
9 |
10 |
11 |
12 |
13 |
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 |
2 |
19 |
20 |
21 |
50 |
51 |
105 |
--------------------------------------------------------------------------------
/app/src/renderer/components/SidebarView/SettingBtn.vue:
--------------------------------------------------------------------------------
1 |
2 |
27 |
28 |
29 |
54 |
55 |
125 |
--------------------------------------------------------------------------------
/app/src/renderer/components/SidebarView/NotificationBtn.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | {{ notifications.visibleNotificationCount }}
9 |
10 |
11 |
12 |
19 |
20 |
21 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | Lists
14 |
15 |
16 |
17 |
18 | {{ item.full_name }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
13 |
14 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | followed you
31 |
32 |
33 |
39 |
40 |
41 |
42 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
66 |
67 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
104 |
105 |
160 |
--------------------------------------------------------------------------------
/app/src/renderer/components/HomeView/TweetBody.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
{{ i = 0 }}
4 |
5 | {{ getText().substring(i, entity.indices[0]) }}
6 |
11 |
16 |
21 |
26 |
31 | {{ i = entity.indices[1] }}
32 |
33 |
{{ getText().substring(i, getText().length) }}
34 |
48 |
49 |
50 |
51 |
124 |
125 |
173 |
--------------------------------------------------------------------------------
/app/src/renderer/components/TweetbarView.vue:
--------------------------------------------------------------------------------
1 |
2 |
53 |
54 |
55 |
125 |
126 |
253 |
--------------------------------------------------------------------------------
/app/src/renderer/components/HomeView/Tweet.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
36 |
37 |
67 |
68 |
69 |
70 |
142 |
143 |
225 |
--------------------------------------------------------------------------------
/app/src/renderer/components/HomeView/Profile.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
21 |
22 | {{ tweet.user.name }}
23 |
24 |
25 | @{{ tweet.user.screen_name }}
26 |
27 |
28 | {{ tweet.user.description }}
29 |
30 |
36 |
37 |
38 |
39 |
40 |
41 | TWEETS
42 | {{ tweet.user.statuses_count }}
43 |
44 |
45 | FOLLOWING
46 | {{ tweet.user.friends_count }}
47 |
48 |
49 | FOLLOWERS
50 | {{ tweet.user.followers_count }}
51 |
52 |
53 | LISTED
54 | {{ tweet.user.listed_count }}
55 |
56 |
57 |
58 | Following
59 |
60 |
61 | Follow
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
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 |
--------------------------------------------------------------------------------