├── src ├── renderer │ ├── stylesheets │ │ ├── variables.scss │ │ ├── blocks │ │ │ ├── _account-switcher.scss │ │ │ ├── _application.scss │ │ │ ├── _channel-switcher.scss │ │ │ ├── _main.scss │ │ │ ├── _base.scss │ │ │ ├── _editor.scss │ │ │ ├── _accounts.scss │ │ │ ├── _account.scss │ │ │ ├── _header.scss │ │ │ └── _tweets.scss │ │ └── application.scss │ ├── singletons │ │ ├── view-event-publisher.js │ │ ├── list-repository.js │ │ ├── tweet-repository.js │ │ ├── user-repository.js │ │ ├── application-state.js │ │ ├── key-string-detector.js │ │ ├── domain-event-publisher.js │ │ └── twitter-signature.js │ ├── index.js │ ├── command-models │ │ ├── view-renderer.js │ │ ├── search-box-selector.js │ │ ├── default-web-browser.js │ │ ├── tweet-selector.js │ │ ├── desktop-notifier.js │ │ ├── channel-selector.js │ │ └── twitter-account.js │ ├── repositories │ │ └── map-repository.js │ ├── index.html │ ├── libraries │ │ ├── domain-event-publisher.js │ │ ├── keyboard-event-publisher.js │ │ ├── view-state.js │ │ ├── key-string-detector.js │ │ ├── application.js │ │ ├── application-state.js │ │ └── twitter-client.js │ └── components │ │ ├── root.js │ │ ├── favorite-button.js │ │ ├── unfavorite-button.js │ │ ├── account-switcher.js │ │ ├── tweets.js │ │ ├── list.js │ │ ├── application.js │ │ ├── header.js │ │ ├── editor.js │ │ ├── main.js │ │ ├── time.js │ │ ├── channel-switcher.js │ │ ├── tweet.js │ │ ├── retweet.js │ │ └── tweet-body.js └── browser │ ├── index.js │ ├── main-window.js │ ├── authentication-window.js │ ├── application.js │ └── application-menu.js ├── .gitignore ├── screenshots ├── preview1.png ├── preview2.png ├── preview3.png ├── preview4.png ├── preview5.png ├── preview6.png ├── preview7.png ├── preview8.png ├── preview9.png ├── preview10.png ├── preview11.png ├── preview12.png ├── preview13.png ├── preview14.png └── preview15.png ├── README.md ├── LICENSE.md ├── gulpfile.js ├── CHANGELOG.md └── package.json /src/renderer/stylesheets/variables.scss: -------------------------------------------------------------------------------- 1 | $color-link: #2b80b9; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /app 2 | /node_modules 3 | /npm-debug.log 4 | /packages 5 | -------------------------------------------------------------------------------- /screenshots/preview1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview1.png -------------------------------------------------------------------------------- /screenshots/preview2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview2.png -------------------------------------------------------------------------------- /screenshots/preview3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview3.png -------------------------------------------------------------------------------- /screenshots/preview4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview4.png -------------------------------------------------------------------------------- /screenshots/preview5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview5.png -------------------------------------------------------------------------------- /screenshots/preview6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview6.png -------------------------------------------------------------------------------- /screenshots/preview7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview7.png -------------------------------------------------------------------------------- /screenshots/preview8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview8.png -------------------------------------------------------------------------------- /screenshots/preview9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview9.png -------------------------------------------------------------------------------- /screenshots/preview10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview10.png -------------------------------------------------------------------------------- /screenshots/preview11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview11.png -------------------------------------------------------------------------------- /screenshots/preview12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview12.png -------------------------------------------------------------------------------- /screenshots/preview13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview13.png -------------------------------------------------------------------------------- /screenshots/preview14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview14.png -------------------------------------------------------------------------------- /screenshots/preview15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/r7kamura/retro-twitter-client/HEAD/screenshots/preview15.png -------------------------------------------------------------------------------- /src/renderer/singletons/view-event-publisher.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | export default new EventEmitter() 4 | -------------------------------------------------------------------------------- /src/browser/index.js: -------------------------------------------------------------------------------- 1 | import Application from './application' 2 | 3 | global.application = new Application(); 4 | global.application.run(); 5 | -------------------------------------------------------------------------------- /src/renderer/singletons/list-repository.js: -------------------------------------------------------------------------------- 1 | import MapRepository from '../repositories/map-repository' 2 | 3 | export default new MapRepository(); 4 | -------------------------------------------------------------------------------- /src/renderer/singletons/tweet-repository.js: -------------------------------------------------------------------------------- 1 | import MapRepository from '../repositories/map-repository' 2 | 3 | export default new MapRepository(); 4 | -------------------------------------------------------------------------------- /src/renderer/singletons/user-repository.js: -------------------------------------------------------------------------------- 1 | import MapRepository from '../repositories/map-repository' 2 | 3 | export default new MapRepository(); 4 | -------------------------------------------------------------------------------- /src/renderer/singletons/application-state.js: -------------------------------------------------------------------------------- 1 | import ApplicationState from '../libraries/application-state' 2 | 3 | export default new ApplicationState(); 4 | -------------------------------------------------------------------------------- /src/renderer/singletons/key-string-detector.js: -------------------------------------------------------------------------------- 1 | import KeyStringDetector from '../libraries/key-string-detector' 2 | 3 | export default new KeyStringDetector() 4 | -------------------------------------------------------------------------------- /src/renderer/index.js: -------------------------------------------------------------------------------- 1 | import Application from './libraries/application' 2 | 3 | try { 4 | new Application().run(); 5 | } catch (e) { 6 | console.log(e.stack); 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/singletons/domain-event-publisher.js: -------------------------------------------------------------------------------- 1 | import DomainEventPublisher from '../libraries/domain-event-publisher' 2 | 3 | export default new DomainEventPublisher() 4 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_account-switcher.scss: -------------------------------------------------------------------------------- 1 | .account-switcher { 2 | background-color: #202a33; 3 | height: 100vh; 4 | overflow-y: scroll; 5 | width: 64px; 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_application.scss: -------------------------------------------------------------------------------- 1 | .application { 2 | display: flex; 3 | height: 100%; 4 | overflow: scroll; 5 | -webkit-overflow-scrolling: touch; 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_channel-switcher.scss: -------------------------------------------------------------------------------- 1 | .channel-switcher { 2 | background-color: #303e4d; 3 | height: 100vh; 4 | overflow-y: scroll; 5 | width: 220px; 6 | } 7 | -------------------------------------------------------------------------------- /src/renderer/command-models/view-renderer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Root from '../components/root' 3 | 4 | export default class ViewRenderer { 5 | render() { 6 | React.render(, document.body); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_main.scss: -------------------------------------------------------------------------------- 1 | .main { 2 | color: #333; 3 | display: flex; 4 | flex-direction: column; 5 | flex: 1; 6 | font-size: 14px; 7 | height: 100vh; 8 | padding: 16px; 9 | width: 100%; 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/command-models/search-box-selector.js: -------------------------------------------------------------------------------- 1 | export default class SearchBoxSelector { 2 | select() { 3 | const textField = document.querySelector('#search-text-field') 4 | textField.focus(); 5 | textField.select(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/repositories/map-repository.js: -------------------------------------------------------------------------------- 1 | export default class MapRepository { 2 | constructor() { 3 | this.map = {}; 4 | } 5 | 6 | find(id) { 7 | return this.map[id]; 8 | } 9 | 10 | update(value) { 11 | this.map[value.id_str] = value; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Retro twitter client 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/renderer/libraries/domain-event-publisher.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter2 } from 'eventemitter2' 2 | 3 | export default class DomainEventPublisher extends EventEmitter2 { 4 | constructor() { 5 | super({ wildcard: true }); 6 | } 7 | 8 | publish(domainEvent) { 9 | this.emit(domainEvent.type, domainEvent); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_base.scss: -------------------------------------------------------------------------------- 1 | @import "qiita-coat/scss/variables"; 2 | 3 | body, 4 | html { 5 | height: 100%; 6 | overflow: hidden; 7 | width: 100%; 8 | } 9 | 10 | body { 11 | font-family: $font-family-sans-serif; 12 | height: 100%; 13 | line-height: 1.5; 14 | } 15 | 16 | img { 17 | -webkit-user-drag: none; 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/singletons/twitter-signature.js: -------------------------------------------------------------------------------- 1 | import remote from 'remote' 2 | 3 | const application = remote.getGlobal('application'); 4 | 5 | export default { 6 | accessToken: application.accessToken, 7 | accessTokenSecret: application.accessTokenSecret, 8 | consumerKey: application.consumerKey, 9 | consumerSecret: application.consumerSecret 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/command-models/default-web-browser.js: -------------------------------------------------------------------------------- 1 | import { openExternal } from 'shell' 2 | import domainEventPublisher from '../singletons/domain-event-publisher' 3 | 4 | export default class DefaultWebBrowser { 5 | openUrl(url) { 6 | openExternal(url); 7 | domainEventPublisher.publish({ 8 | type: 'URL_OPENED', 9 | url 10 | }); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_editor.scss: -------------------------------------------------------------------------------- 1 | .editor { 2 | border-radius: 3px; 3 | border: solid 1px #ddd; 4 | display: flex; 5 | padding: 4px 8px; 6 | 7 | &-counter { 8 | color: #aaa; 9 | } 10 | 11 | &-textarea { 12 | border: 0; 13 | display: block; 14 | flex: 1; 15 | line-height: 1.5; 16 | outline: none; 17 | resize: none; 18 | width: 100%; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_accounts.scss: -------------------------------------------------------------------------------- 1 | @import "qiita-coat/scss/avatars"; 2 | 3 | .accounts { 4 | &-item { 5 | margin-top: 16px; 6 | padding-left: 12px; 7 | padding-right: 12px; 8 | 9 | &-avatar { 10 | @include avatar; 11 | } 12 | 13 | &-key { 14 | color: #636a71; 15 | font-size: 12px; 16 | margin-top: 2px; 17 | text-align: center; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/command-models/tweet-selector.js: -------------------------------------------------------------------------------- 1 | import domainEventPublisher from '../singletons/domain-event-publisher' 2 | 3 | export default class TweetSelector { 4 | selectNextTweet() { 5 | domainEventPublisher.publish({ 6 | type: 'NEXT_TWEET_SELECTED' 7 | }); 8 | } 9 | 10 | selectPreviousTweet() { 11 | domainEventPublisher.publish({ 12 | type: 'PREVIOUS_TWEET_SELECTED' 13 | }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/application.scss: -------------------------------------------------------------------------------- 1 | @import "font-awesome/scss/font-awesome"; 2 | @import "HTML5-Reset/assets/css/reset"; 3 | @import "./blocks/account"; 4 | @import "./blocks/account-switcher"; 5 | @import "./blocks/accounts"; 6 | @import "./blocks/application"; 7 | @import "./blocks/base"; 8 | @import "./blocks/channel-switcher"; 9 | @import "./blocks/editor"; 10 | @import "./blocks/header"; 11 | @import "./blocks/main"; 12 | @import "./blocks/tweets"; 13 | -------------------------------------------------------------------------------- /src/renderer/libraries/keyboard-event-publisher.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import keyStringDetector from '../singletons/key-string-detector' 3 | 4 | export default class KeyboardEventPublisher extends EventEmitter { 5 | constructor() { 6 | super(); 7 | document.addEventListener('keydown', (event) => { 8 | if (event.target === document.body) { 9 | this.emit(keyStringDetector.detect(event), event); 10 | } 11 | }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # retro-twitter-client 2 | A retro twitter client. 3 | 4 | - [Download for Mac](https://github.com/r7kamura/retro-twitter-client/releases/download/v0.0.12/retro-twitter-client-darwin-x64.zip) 5 | - [Download for Win](https://github.com/r7kamura/retro-twitter-client/releases/download/v0.0.12/retro-twitter-client-win32-x64.zip) 6 | - [Download for Linux](https://github.com/r7kamura/retro-twitter-client/releases/download/v0.0.12/retro-twitter-client-linux-x64.zip) 7 | 8 | ![](/screenshots/preview15.png) 9 | -------------------------------------------------------------------------------- /src/renderer/components/root.js: -------------------------------------------------------------------------------- 1 | import Application from './application' 2 | import applicationState from '../singletons/application-state' 3 | import React from 'react'; 4 | import ViewState from '../libraries/view-state' 5 | 6 | export default class Root extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = new ViewState(applicationState); 10 | applicationState.on('changed', () => this.setState(new ViewState(applicationState))); 11 | } 12 | 13 | render() { 14 | return ; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/components/favorite-button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import viewEventPublisher from '../singletons/view-event-publisher' 3 | 4 | export default class FavoriteButton extends React.Component { 5 | onFavoriteButtonClicked(event) { 6 | event.preventDefault(); 7 | viewEventPublisher.emit('favorite-button-clicked', this.props.tweet.id_str); 8 | } 9 | 10 | render() { 11 | return( 12 | 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/components/unfavorite-button.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import viewEventPublisher from '../singletons/view-event-publisher' 3 | 4 | export default class UnfavoriteButton extends React.Component { 5 | onUnfavoriteButtonClicked(event) { 6 | event.preventDefault(); 7 | viewEventPublisher.emit('unfavorite-button-clicked', this.props.tweet.id_str); 8 | } 9 | 10 | render() { 11 | return( 12 | 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/components/account-switcher.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export default class AccountSwitcher extends React.Component { 4 | render() { 5 | return( 6 |
7 |
    8 |
  • 9 | 10 |
    11 | ⌘1 12 |
    13 |
  • 14 |
15 |
16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/command-models/desktop-notifier.js: -------------------------------------------------------------------------------- 1 | export default class DesktopNotifier { 2 | notifyFavorite({ source, target_object }) { 3 | new Notification( 4 | `${source.screen_name} favorited your Tweet`, 5 | { 6 | body: target_object.text, 7 | icon: source.profile_image_url 8 | } 9 | ); 10 | } 11 | 12 | notifyRetweet({ tweet }) { 13 | new Notification( 14 | `${tweet.user.screen_name} retweeted your Tweet`, 15 | { 16 | body: tweet.retweeted_status.text, 17 | icon: tweet.user.profile_image_url 18 | } 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/browser/main-window.js: -------------------------------------------------------------------------------- 1 | import BrowserWindow from 'browser-window' 2 | 3 | export default class MainWindow { 4 | constructor() { 5 | this.window = new BrowserWindow({ width: 1200, height: 800 }); 6 | this.window.loadUrl(`file://${__dirname}/../renderer/index.html`); 7 | this.window.on('closed', () => { 8 | this.window = null; 9 | }); 10 | } 11 | 12 | /** 13 | * This is a public interface to connect to window.webContents.send. 14 | * The reason why this method exists is to hide the internal window property from others. 15 | */ 16 | send(...args) { 17 | this.window.webContents.send(...args); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/components/tweets.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Retweet from './retweet' 3 | import Tweet from './tweet' 4 | 5 | export default class Tweets extends React.Component { 6 | getClassName() { 7 | return `tweets${this.props.selected ? '' : ' tweets-hidden'}`; 8 | } 9 | 10 | render() { 11 | return( 12 |
13 | {this.renderTweets()} 14 |
15 | ); 16 | } 17 | 18 | renderTweets() { 19 | return this.props.tweets.map((tweet) => { 20 | if (tweet.retweeted_status) { 21 | return 22 | } else { 23 | return 24 | } 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_account.scss: -------------------------------------------------------------------------------- 1 | .account { 2 | &-channel { 3 | border-radius: 0 3px 3px 0; 4 | color: #aaa; 5 | cursor: pointer; 6 | font-size: 15px; 7 | margin-right: 16px; 8 | padding-left: 16px; 9 | 10 | &:hover { 11 | background-color: #3f4c5b; 12 | } 13 | 14 | &-selected, 15 | &-selected:hover { 16 | background-color: #6698c8; 17 | color: #fff; 18 | } 19 | } 20 | 21 | &-section { 22 | padding-bottom: 16px; 23 | padding-top: 16px; 24 | 25 | &-heading { 26 | color: #aaa; 27 | margin-bottom: 4px; 28 | padding-left: 16px; 29 | } 30 | } 31 | 32 | &-screen-name { 33 | color: #fff; 34 | font-size: 20px; 35 | padding: 16px; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/components/list.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import viewEventPublisher from '../singletons/view-event-publisher' 3 | 4 | export default class List extends React.Component { 5 | getClassName() { 6 | return `account-channel ${this.getIsSelected() ? ' account-channel-selected' : ''}`; 7 | } 8 | 9 | getIsSelected() { 10 | return this.props.list.id_str === this.props.channelId; 11 | } 12 | 13 | onChannelClicked() { 14 | viewEventPublisher.emit('channel-clicked', this.props.list.id_str); 15 | } 16 | 17 | render() { 18 | return( 19 |
  • 20 | @{this.props.list.user.screen_name}/{this.props.list.name} 21 |
  • 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | display: flex; 3 | margin-bottom: 12px; 4 | 5 | &-search { 6 | &-box { 7 | content: "a"; 8 | margin-right: 4px; 9 | border-radius: 3px;; 10 | border: solid 1px #ddd; 11 | font-size: 14px; 12 | line-height: 14px; 13 | padding: 4px 8px; 14 | } 15 | 16 | &-icon { 17 | color: #ccc; 18 | } 19 | 20 | &-text-field { 21 | border: 0; 22 | color: #666; 23 | margin-left: 4px; 24 | width: 160px; 25 | 26 | // reset 27 | &:focus { 28 | outline: none; 29 | } 30 | } 31 | } 32 | 33 | &-title { 34 | color: #666; 35 | flex: 1; 36 | font-size: 20px; 37 | font-weight: bold; 38 | line-height: 24px; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/libraries/view-state.js: -------------------------------------------------------------------------------- 1 | import listRepository from '../singletons/list-repository' 2 | import tweetRepository from '../singletons/tweet-repository' 3 | import userRepository from '../singletons/user-repository' 4 | 5 | export default class ViewState { 6 | constructor(applicationState) { 7 | this.channelId = applicationState.channelId; 8 | this.homeTimelineTweets = applicationState.homeTimelineTweetIds.map((tweetId) => tweetRepository.find(tweetId)); 9 | this.listId = applicationState.listId; 10 | this.lists = applicationState.listIds.map((listId) => listRepository.find(listId)); 11 | this.listTweets = applicationState.listTweetIds.map((tweetId) => tweetRepository.find(tweetId)); 12 | this.searchedTweets = applicationState.searchedTweetIds.map((tweetId) => tweetRepository.find(tweetId)); 13 | this.user = userRepository.find(applicationState.userId) || {}; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/components/application.js: -------------------------------------------------------------------------------- 1 | import AccountSwitcher from './account-switcher' 2 | import ChannelSwitcher from './channel-switcher' 3 | import Main from './main' 4 | import React from 'react'; 5 | 6 | export default class Application extends React.Component { 7 | render() { 8 | return( 9 |
    10 | 13 | 18 |
    25 |
    26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/renderer/command-models/channel-selector.js: -------------------------------------------------------------------------------- 1 | import domainEventPublisher from '../singletons/domain-event-publisher' 2 | import applicationState from '../singletons/application-state' 3 | 4 | export default class ChannelSelector { 5 | selectChannel(channelId) { 6 | if (channelId !== 'HOME_TIMELINE_CHANNEL' && channelId !== 'SEARCH_CHANNEL') { 7 | if (applicationState.listId !== channelId) { 8 | domainEventPublisher.publish({ 9 | type: 'LIST_TWEETS_CLEARED' 10 | }); 11 | } 12 | domainEventPublisher.publish({ 13 | listId: channelId, 14 | type: 'LIST_CHANNEL_SELECTED' 15 | }); 16 | } 17 | domainEventPublisher.publish({ 18 | channelId, 19 | type: 'CHANNEL_SELECTED' 20 | }); 21 | } 22 | 23 | selectNextChannel() { 24 | this.selectChannel(applicationState.nextChannelId); 25 | } 26 | 27 | selectPreviousChannel() { 28 | this.selectChannel(applicationState.previousChannelId); 29 | } 30 | 31 | selectSearchChannel() { 32 | this.selectChannel('SEARCH_CHANNEL'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Ryo Nakamura 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | var babel = require('gulp-babel'); 2 | var gulp = require('gulp'); 3 | var plumber = require('gulp-plumber'); 4 | var sass = require('gulp-sass'); 5 | var sym = require('gulp-sym'); 6 | var watch = require('gulp-watch'); 7 | 8 | gulp.task( 9 | 'compile', 10 | [ 11 | 'compile-es6', 12 | 'compile-html', 13 | 'compile-scss', 14 | 'compile-symlink' 15 | ] 16 | ); 17 | 18 | gulp.task( 19 | 'compile-es6', 20 | function () { 21 | return gulp.src('src/**/*.js') 22 | .pipe(plumber()) 23 | .pipe(babel()) 24 | .pipe(gulp.dest('app/')); 25 | } 26 | ); 27 | 28 | gulp.task( 29 | 'compile-html', 30 | function () { 31 | gulp.src('src/**/*.html') 32 | .pipe(gulp.dest('app')); 33 | } 34 | ); 35 | 36 | gulp.task( 37 | 'compile-scss', 38 | function () { 39 | gulp.src('src/**/*.scss') 40 | .pipe(sass({ includePaths: ['node_modules'] }).on('error', sass.logError)) 41 | .pipe(gulp.dest('app')); 42 | } 43 | ); 44 | 45 | gulp.task( 46 | 'compile-symlink', 47 | function () { 48 | gulp.src('node_modules/font-awesome/fonts') 49 | .pipe(sym('app/renderer/fonts', { force: true })); 50 | } 51 | ); 52 | 53 | gulp.task( 54 | 'watch', 55 | function () { 56 | gulp.watch('src/**/*', ['compile']); 57 | } 58 | ); 59 | -------------------------------------------------------------------------------- /src/renderer/components/header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import viewEventPublisher from '../singletons/view-event-publisher' 3 | 4 | export default class Header extends React.Component { 5 | constructor(props) { 6 | super(props); 7 | this.state = { queryString: '' }; 8 | } 9 | 10 | onSearchQueryStringChanged(event) { 11 | this.setState({ queryString: event.target.value }); 12 | } 13 | 14 | onSubmitted(event) { 15 | event.preventDefault(); 16 | viewEventPublisher.emit('search-query-string-submitted', this.state.queryString); 17 | } 18 | 19 | render() { 20 | return( 21 |
    22 |

    23 | {this.props.title} 24 |

    25 |
    26 | 27 | 36 | 37 |
    38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/components/editor.js: -------------------------------------------------------------------------------- 1 | import keyStringDetector from '../singletons/key-string-detector' 2 | import React from 'react' 3 | import twitterClient from '../libraries/twitter-client' 4 | import viewEventPublisher from '../singletons/view-event-publisher' 5 | 6 | export default class Editor extends React.Component { 7 | constructor(props) { 8 | super(props); 9 | this.state = { text: '' }; 10 | } 11 | 12 | getRestTextLength() { 13 | return 140 - this.state.text.length; 14 | } 15 | 16 | onTextareaChanged(event) { 17 | this.setState({ text: event.target.value }); 18 | } 19 | 20 | onTextareaKeyDown(event) { 21 | if (keyStringDetector.detect(event) === 'Return') { 22 | event.preventDefault(); 23 | this.onTweetSubmitted(); 24 | } 25 | } 26 | 27 | render() { 28 | return( 29 |
    30 | 31 |
    32 | {this.getRestTextLength()} 33 |
    34 |
    35 | ); 36 | } 37 | 38 | onTweetSubmitted() { 39 | viewEventPublisher.emit('tweet-submitted', this.state.text); 40 | this.setState({ text: '' }); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/components/main.js: -------------------------------------------------------------------------------- 1 | import Editor from './editor' 2 | import Header from './header' 3 | import React from 'react' 4 | import Tweets from './tweets' 5 | 6 | export default class Main extends React.Component { 7 | get list() { 8 | return this.props.lists.filter((list) => { 9 | return list.id_str === this.props.channelId; 10 | })[0]; 11 | } 12 | 13 | get title() { 14 | switch (this.props.channelId) { 15 | case 'HOME_TIMELINE_CHANNEL': 16 | return 'Home'; 17 | case 'SEARCH_CHANNEL': 18 | return 'Search'; 19 | default: 20 | const list = this.list; 21 | return `@${list.user.screen_name}/${list.name}`; 22 | } 23 | } 24 | 25 | isHomeTimelineSelected() { 26 | return this.props.channelId === 'HOME_TIMELINE_CHANNEL'; 27 | } 28 | 29 | isListSelected() { 30 | return !this.isHomeTimelineSelected() && !this.isSearchSelected(); 31 | } 32 | 33 | isSearchSelected() { 34 | return this.props.channelId === 'SEARCH_CHANNEL'; 35 | } 36 | 37 | render() { 38 | return( 39 |
    40 |
    41 | 42 | 46 | 50 | 54 |
    55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/renderer/libraries/key-string-detector.js: -------------------------------------------------------------------------------- 1 | export default class KeyStringDetector { 2 | /** 3 | * @param {KeyboardEvent} event 4 | * @return {String} 5 | */ 6 | detect(event) { 7 | let keyString = ''; 8 | if (event.altKey) { 9 | keyString = `Alt+${keyString}`; 10 | } 11 | if (event.ctrlKey) { 12 | keyString = `Ctrl+${keyString}`; 13 | } 14 | if (event.shiftKey) { 15 | keyString = `Shift+${keyString}`; 16 | } 17 | keyString += this.constructor.keyCodeMap[event.keyCode] || 'Unknown'; 18 | return keyString; 19 | } 20 | } 21 | 22 | KeyStringDetector.keyCodeMap = { 23 | 8: 'BackSpace', 24 | 9: 'Tab', 25 | 13: 'Return', 26 | 27: 'Esc', 27 | 32: 'Space', 28 | 33: 'PageUp', 29 | 34: 'PageDown', 30 | 35: 'End', 31 | 36: 'Home', 32 | 37: 'Left', 33 | 38: 'Up', 34 | 39: 'Right', 35 | 40: 'Down', 36 | 45: 'Insert', 37 | 46: 'Delete', 38 | 48: '0', 39 | 49: '1', 40 | 50: '2', 41 | 51: '3', 42 | 52: '4', 43 | 53: '5', 44 | 54: '6', 45 | 55: '7', 46 | 56: '8', 47 | 57: '9', 48 | 65: 'A', 49 | 66: 'B', 50 | 67: 'C', 51 | 68: 'D', 52 | 69: 'E', 53 | 70: 'F', 54 | 71: 'G', 55 | 72: 'H', 56 | 73: 'I', 57 | 74: 'J', 58 | 75: 'K', 59 | 76: 'L', 60 | 77: 'M', 61 | 78: 'N', 62 | 79: 'O', 63 | 80: 'P', 64 | 81: 'Q', 65 | 82: 'R', 66 | 83: 'S', 67 | 84: 'T', 68 | 85: 'U', 69 | 86: 'V', 70 | 87: 'W', 71 | 88: 'X', 72 | 89: 'Y', 73 | 90: 'Z', 74 | 112: 'F1', 75 | 113: 'F2', 76 | 114: 'F3', 77 | 115: 'F4', 78 | 116: 'F5', 79 | 117: 'F6', 80 | 118: 'F7', 81 | 119: 'F8', 82 | 120: 'F9', 83 | 121: 'F10', 84 | 122: 'F11', 85 | 123: 'F12' 86 | }; 87 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.0.12 2 | - Add Command+] and Command+[ to select channels 3 | - Add Command+F shortcut to focus on search box 4 | - Add favorite button 5 | - Reduce focusable targets 6 | - Fix bug on desktop notification 7 | - Fix bug on self-favorite 8 | - Disable drag-and-drop on img elements for better usability 9 | 10 | ## v0.0.11 11 | - Support 2-factor authentication (thx @babie) 12 | - Fix scrolling UI bug (thx @yayugu) 13 | 14 | ## v0.0.10 15 | - Support general global shortcuts (⌘C, ⌘V, etc.) 16 | 17 | ## v0.0.9 18 | - Support search 19 | - Support image expansion 20 | - Make tweet time clickable 21 | - Put header area 22 | - Improve rendering performance 23 | - Reduce memory size 24 | 25 | ## v0.0.8 26 | - Support favorite & retweet desktop notification 27 | - Support `Alt+Down` and `Alt+Up` global shortcuts 28 | - Speed up rendering performance 29 | - Fix windows management bugs on Mac 30 | - Remove animation 31 | 32 | ## v0.0.7 33 | - Fix window management bug on Windows and Linux platforms 34 | 35 | ## v0.0.6 36 | - Trim package weight (300MB -> 50MB) 37 | 38 | ## v0.0.5 39 | - Support lists 40 | - Support Linux 41 | - Attach links to mentions, hashtags, cashtags, lists and normal urls 42 | - Add animation in prepending a new tweet 43 | - Show original posted time in retweet 44 | 45 | ## v0.0.4 46 | - Use auto-updatable relative time format 47 | - Improve retweet UI 48 | - Fix home timeline account bug 49 | 50 | ## v0.0.3 51 | - Support basic features: show account info, stream tweets, post tweet 52 | - Introduce some libraries: React, Redux, Qiita:Coat 53 | - Add Ctrl+Enter shortcut on textarea 54 | 55 | ## v0.0.2 56 | - Show twitter.com in WebView 57 | 58 | ## v0.0.1 59 | - Hello, world! 60 | -------------------------------------------------------------------------------- /src/renderer/components/time.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import React, { PropTypes } from 'react' 3 | 4 | moment.locale( 5 | 'en-short', 6 | { 7 | relativeTime: { 8 | d: "1d", 9 | dd: "%dd", 10 | future: "%s", 11 | h: "1h", 12 | hh: "%dh", 13 | m: "1m", 14 | M: "1M", 15 | mm: "%dm", 16 | MM: "%dM", 17 | past: "%s", 18 | s: "now", 19 | y: "1y", 20 | yy: "%dy" 21 | } 22 | } 23 | ); 24 | 25 | export default class Time extends React.Component { 26 | componentDidMount() { 27 | this.intervalId = setInterval(this.update.bind(this), 1000 * 60); 28 | } 29 | 30 | componentWillUnmount() { 31 | clearInterval(this.intervalId); 32 | } 33 | 34 | getRelativeTime() { 35 | return this.getMomentTime().locale('en-short').fromNow(); 36 | } 37 | 38 | getAbsoluteTime() { 39 | return this.getMomentTime().format('YYYY-MM-DD HH:mm'); 40 | } 41 | 42 | getHumanReadableTime() { 43 | if (this.getIsBefore24HoursAgo()) { 44 | return this.getAbsoluteTime(); 45 | } else { 46 | return this.getRelativeTime(); 47 | } 48 | } 49 | 50 | getIsBefore24HoursAgo() { 51 | return this.getMomentTime().isBefore(moment.duration(24, 'hours')); 52 | } 53 | 54 | getMachineReadableTime() { 55 | return this.getMomentTime().format('YYYY-MM-DDTHH:mm:ssZ'); 56 | } 57 | 58 | getMomentTime() { 59 | return moment(new Date(this.props.time)); 60 | } 61 | 62 | render() { 63 | return( 64 | 67 | ); 68 | } 69 | 70 | update() { 71 | this.forceUpdate(); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "retro-twitter-client", 3 | "private": true, 4 | "version": "0.0.12", 5 | "main": "app/browser/index.js", 6 | "scripts": { 7 | "clean": "rm -rf packages/v0.0.12/retro-twitter-client-{darwin,linux,win32}-x64", 8 | "compile": "gulp compile", 9 | "package": "electron-packager . retro-twitter-client --arch=x64 --out=packages/v0.0.12 --platform=darwin,linux,win32 --version=0.30.6 --ignore={packages,screenshots,src}/*", 10 | "release": "npm run zip && npm run clean && npm run upload", 11 | "setup": "npm install && brew tap tcnksm/ghr && brew install ghr && npm run compile", 12 | "start": "electron .", 13 | "upload": "ghr v0.0.12 packages/", 14 | "watch": "gulp watch", 15 | "zip": "npm run zip-darwin && npm run zip-win32", 16 | "zip-darwin": "cd packages/v0.0.12 && zip -r retro-twitter-client-darwin-x64.zip retro-twitter-client-darwin-x64", 17 | "zip-win32": "cd packages/v0.0.12 && zip -r retro-twitter-client-win32-x64.zip retro-twitter-client-win32-x64" 18 | }, 19 | "repository": "r7kamura/retro-twitter-client", 20 | "author": "Ryo Nakamura (https://github.com/r7kamura)", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "electron-packager": "^5.0.2", 24 | "electron-prebuilt": "^0.30.6", 25 | "gulp": "^3.9.0", 26 | "gulp-babel": "^5.2.1", 27 | "gulp-plumber": "^1.0.1", 28 | "gulp-sass": "^2.0.4", 29 | "gulp-sym": "0.0.14", 30 | "gulp-watch": "^4.3.5" 31 | }, 32 | "dependencies": { 33 | "HTML5-Reset": "git://github.com/r7kamura/HTML5-Reset.git", 34 | "eventemitter2": "^0.4.14", 35 | "font-awesome": "^4.4.0", 36 | "lodash": "^3.10.1", 37 | "moment": "^2.10.6", 38 | "node-twitter-api": "^1.6.0", 39 | "qiita-coat": "git://github.com/increments/qiita-coat.git#d751df7df60d0dcd0c97b9dc05c8ab5da3db22e3", 40 | "react": "^0.13.3", 41 | "twitter": "^1.2.5", 42 | "twitter-text": "^1.13.0" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/renderer/stylesheets/blocks/_tweets.scss: -------------------------------------------------------------------------------- 1 | @import "qiita-coat/scss/avatars"; 2 | @import "../variables"; 3 | 4 | .tweet { 5 | display: flex; 6 | 7 | & + & { 8 | margin-top: 16px; 9 | } 10 | 11 | &-anchor { 12 | color: $color-link; 13 | } 14 | 15 | &-avatar { 16 | @include avatar; 17 | 18 | &-child { 19 | @include avatar-child; 20 | } 21 | 22 | &-parent { 23 | @include avatar-parent; 24 | } 25 | } 26 | 27 | &-body { 28 | flex: 1; 29 | 30 | &-container { 31 | display: flex; 32 | } 33 | } 34 | 35 | &-button { 36 | &-favorite, 37 | &-reply, 38 | &-unfavorite { 39 | color: #ddd; 40 | cursor: pointer; 41 | margin-left: 2px; 42 | visibility: hidden; 43 | 44 | .tweet:hover & { 45 | visibility: inherit; 46 | } 47 | } 48 | 49 | &-unfavorite { 50 | color: orange; 51 | } 52 | } 53 | 54 | &-datetime { 55 | color: #aaa; 56 | padding-left: 8px; 57 | 58 | &-anchor { 59 | text-decoration: none; 60 | } 61 | } 62 | 63 | &-display-name { 64 | font-weight: bold; 65 | } 66 | 67 | &-header { 68 | display: flex; 69 | } 70 | 71 | &-image { 72 | @include avatar; 73 | 74 | &-container { 75 | margin-top: 12px; 76 | max-height: 300px; 77 | overflow: hidden; 78 | } 79 | } 80 | 81 | &-main { 82 | flex: 1; 83 | } 84 | 85 | &-names { 86 | margin-right: auto; 87 | } 88 | 89 | &-retweeter-display-name { 90 | color: #aaa; 91 | margin-left: 4px; 92 | } 93 | 94 | &-screen-name { 95 | color: #aaa; 96 | margin-left: 4px; 97 | } 98 | 99 | &-sub { 100 | margin-right: 12px; 101 | } 102 | } 103 | 104 | .tweets { 105 | flex: 1; 106 | margin-top: 16px; 107 | overflow-y: scroll; 108 | 109 | &-hidden { 110 | display: none; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/browser/authentication-window.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import BrowserWindow from 'browser-window' 3 | import Twitter from 'node-twitter-api' 4 | 5 | export default class AuthenticationWindow extends EventEmitter { 6 | /** 7 | * @param {Function} callback 8 | */ 9 | constructor(callback) { 10 | super(); 11 | const twitter = new Twitter({ 12 | callback: 'http://example.com', 13 | consumerKey: 'KAR2eM09o2GCddFfHUXz7vFKV', 14 | consumerSecret: '8MoozYzEzkstemW4fagnm5qlGMVELIxuWBTcBOz0BpUDIpDWqY' 15 | }); 16 | 17 | twitter.getRequestToken((error, requestToken, requestTokenSecret) => { 18 | const url = twitter.getAuthUrl(requestToken); 19 | this.window = new BrowserWindow({ width: 800, height: 600, 'node-integration': false}); 20 | this.getAccessToken(twitter, requestToken, requestTokenSecret, url); 21 | }); 22 | } 23 | 24 | getAccessToken(twitter, requestToken, requestTokenSecret, url) { 25 | this.window.webContents.on('will-navigate', (event, url) => { 26 | let matched; 27 | if (matched = url.match(/\?oauth_token=([^&]*)&oauth_verifier=([^&]*)/)) { 28 | twitter.getAccessToken(requestToken, requestTokenSecret, matched[2], (error, accessToken, accessTokenSecret) => { 29 | this.emit( 30 | 'authentication-succeeded', 31 | { 32 | accessToken: accessToken, 33 | accessTokenSecret: accessTokenSecret 34 | } 35 | ); 36 | }); 37 | event.preventDefault(); 38 | setImmediate(() => { 39 | this.window.close(); 40 | }); 41 | } else if (matched = url.match(/&redirect_after_login_verification=([^&]*)/)) { 42 | this.window.webContents.on('did-get-redirect-request', (event, oldUrl, newUrl, isMainFrame) => { 43 | this.getAccessToken(twitter, requestToken, requestTokenSecret, newUrl); 44 | }); 45 | }; 46 | }); 47 | this.window.loadUrl(url); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/renderer/components/channel-switcher.js: -------------------------------------------------------------------------------- 1 | import List from './list'; 2 | import React from 'react'; 3 | import viewEventPublisher from '../singletons/view-event-publisher' 4 | 5 | export default class ChannelSwitcher extends React.Component { 6 | getHomeChannelClassName() { 7 | return `account-channel ${this.getHomeChannelSelected() ? ' account-channel-selected' : ''}`; 8 | } 9 | 10 | getHomeChannelSelected() { 11 | return this.props.channelId === 'HOME_TIMELINE_CHANNEL'; 12 | } 13 | 14 | getSearchChannelClassName() { 15 | return `account-channel ${this.getSearchChannelSelected() ? ' account-channel-selected' : ''}`; 16 | } 17 | 18 | getSearchChannelSelected() { 19 | return this.props.channelId === 'SEARCH_CHANNEL'; 20 | } 21 | 22 | onHomeChannelClicked(event) { 23 | viewEventPublisher.emit('channel-clicked', 'HOME_TIMELINE_CHANNEL'); 24 | } 25 | 26 | onSearchChannelClicked(event) { 27 | viewEventPublisher.emit('channel-clicked', 'SEARCH_CHANNEL'); 28 | } 29 | 30 | render() { 31 | return( 32 |
    33 |
    34 | @{this.props.account.screen_name} 35 |
    36 |
    37 |

    38 | TIMELINES 39 |

    40 |
      41 |
    • 42 | Home 43 |
    • 44 |
    • 45 | Search 46 |
    • 47 |
    48 |
    49 |
    50 |

    51 | LISTS 52 |

    53 |
      54 | {this.renderLists()} 55 |
    56 |
    57 |
    58 | ); 59 | } 60 | 61 | renderLists() { 62 | return this.props.lists.map((list) => { 63 | return ; 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/components/tweet.js: -------------------------------------------------------------------------------- 1 | import FavoriteButton from './favorite-button' 2 | import React from 'react' 3 | import Time from './time' 4 | import TweetBody from './tweet-body' 5 | import UnfavoriteButton from './unfavorite-button' 6 | import viewEventPublisher from '../singletons/view-event-publisher' 7 | 8 | export default class Tweet extends React.Component { 9 | get favoriteButton() { 10 | if (this.props.tweet.favorited) { 11 | return ; 12 | } else { 13 | return ; 14 | } 15 | } 16 | 17 | get url() { 18 | return `https://twitter.com/${this.props.tweet.user.screen_name}/status/${this.props.tweet.id_str}`; 19 | } 20 | 21 | onAnchorClicked(event) { 22 | event.preventDefault(); 23 | viewEventPublisher.emit('anchor-clicked', event.currentTarget.href); 24 | } 25 | 26 | render() { 27 | return( 28 |
  • 29 |
    30 | 31 |
    32 |
    33 |
    34 |
    35 | 36 | {this.props.tweet.user.name} 37 | 38 | 39 | @{this.props.tweet.user.screen_name} 40 | 41 |
    42 | 43 | 45 |
    46 |
    47 | 48 |
    49 | {this.favoriteButton} 50 | 51 |
    52 |
    53 |
    54 |
  • 55 | ); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/browser/application.js: -------------------------------------------------------------------------------- 1 | import { openExternal } from 'shell' 2 | import app from 'app' 3 | import ApplicationMenu from './application-menu' 4 | import AuthenticationWindow from './authentication-window' 5 | import BrowserWindow from 'browser-window' 6 | import crashReporter from 'crash-reporter' 7 | import MainWindow from './main-window' 8 | 9 | export default class Application { 10 | constructor() { 11 | this.accessToken = null; 12 | this.accessTokenSecret = null; 13 | this.consumerKey = 'KAR2eM09o2GCddFfHUXz7vFKV'; 14 | this.consumerSecret = '8MoozYzEzkstemW4fagnm5qlGMVELIxuWBTcBOz0BpUDIpDWqY'; 15 | this.mainWindow = null; 16 | } 17 | 18 | onAuthenticationSucceeded({ accessToken, accessTokenSecret }) { 19 | this.accessToken = accessToken; 20 | this.accessTokenSecret = accessTokenSecret; 21 | this.openMainWindow(); 22 | } 23 | 24 | onReady() { 25 | this.openAuthenicationWindow(); 26 | this.setApplicationMenu(); 27 | } 28 | 29 | openAuthenicationWindow() { 30 | new AuthenticationWindow({ 31 | consumerKey: this.consumerKey, 32 | consumerSecret: this.consumerSecret, 33 | }).on( 34 | 'authentication-succeeded', 35 | this.onAuthenticationSucceeded.bind(this) 36 | ); 37 | } 38 | 39 | openMainWindow() { 40 | this.mainWindow = new MainWindow(); 41 | } 42 | 43 | registerApplicationCallbacks() { 44 | app.on('window-all-closed', () => {}); 45 | app.on('ready', this.onReady.bind(this)); 46 | } 47 | 48 | run() { 49 | this.startCrashReporter(); 50 | this.registerApplicationCallbacks(); 51 | } 52 | 53 | setApplicationMenu() { 54 | new ApplicationMenu().on('open-dev-tools', () => { 55 | this.mainWindow.window.toggleDevTools(); 56 | }).on('quit', () => { 57 | app.quit(); 58 | }).on('reload', () => { 59 | this.mainWindow.window.reloadIgnoringCache(); 60 | }).on('search', () => { 61 | this.mainWindow.send('select-search-box'); 62 | }).on('select-next-channel', () => { 63 | this.mainWindow.send('select-next-channel'); 64 | }).on('select-previous-channel', () => { 65 | this.mainWindow.send('select-previous-channel'); 66 | }); 67 | } 68 | 69 | startCrashReporter() { 70 | crashReporter.start(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/renderer/components/retweet.js: -------------------------------------------------------------------------------- 1 | import FavoriteButton from './favorite-button' 2 | import React from 'react' 3 | import Time from './time' 4 | import TweetBody from './tweet-body' 5 | import UnfavoriteButton from './unfavorite-button' 6 | import viewEventPublisher from '../singletons/view-event-publisher' 7 | 8 | export default class Retweet extends React.Component { 9 | get favoriteButton() { 10 | if (this.props.tweet.favorited) { 11 | return ; 12 | } else { 13 | return ; 14 | } 15 | } 16 | 17 | get url() { 18 | return `https://twitter.com/${this.props.tweet.retweeted_status.user.screen_name}/status/${this.props.tweet.retweeted_status.id_str}`; 19 | } 20 | 21 | onAnchorClicked(event) { 22 | event.preventDefault(); 23 | viewEventPublisher.emit('anchor-clicked', event.currentTarget.href); 24 | } 25 | 26 | render() { 27 | return( 28 |
  • 29 |
    30 |
    31 | 32 | 33 |
    34 |
    35 |
    36 |
    37 |
    38 | 39 | {this.props.tweet.retweeted_status.user.name} 40 | 41 | 42 | @{this.props.tweet.retweeted_status.user.screen_name} 43 | 44 | 45 | 46 | {' '} 47 | {this.props.tweet.user.name} 48 | 49 |
    50 | 51 | 53 |
    54 |
    55 | 56 |
    57 | {this.favoriteButton} 58 | 59 |
    60 |
    61 |
    62 |
  • 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/renderer/command-models/twitter-account.js: -------------------------------------------------------------------------------- 1 | import domainEventPublisher from '../singletons/domain-event-publisher' 2 | import TwitterClient from '../libraries/twitter-client' 3 | 4 | export default class TwitterAccount { 5 | constructor({ accessToken, accessTokenSecret, consumerKey, consumerSecret }) { 6 | this.twitterClient = new TwitterClient({ 7 | accessToken, 8 | accessTokenSecret, 9 | consumerKey, 10 | consumerSecret 11 | }); 12 | } 13 | 14 | favorite({ tweetId }) { 15 | this.twitterClient.favorite({ tweetId }).then(({ tweet }) => { 16 | domainEventPublisher.publish({ 17 | tweet, 18 | type: 'TWEET_FAVORITED' 19 | }); 20 | }); 21 | } 22 | 23 | fetchHomeTimelineTweets({ user }) { 24 | this.twitterClient.fetchHomeTimelineTweets({ screenName: user.screen_name }).then(({ tweets }) => { 25 | domainEventPublisher.publish({ 26 | tweets, 27 | type: 'HOME_TIMELINE_TWEETS_FETCHED' 28 | }); 29 | }); 30 | } 31 | 32 | fetchLists({ user }) { 33 | this.twitterClient.fetchLists().then(({ lists }) => { 34 | domainEventPublisher.publish({ 35 | lists, 36 | type: 'LISTS_FETCHED' 37 | }); 38 | }); 39 | } 40 | 41 | fetchListTweets({ listId }) { 42 | this.twitterClient.fetchListTweets({ listId }).then(({ tweets }) => { 43 | domainEventPublisher.publish({ 44 | tweets, 45 | type: 'LIST_TWEETS_FETCHED' 46 | }); 47 | }); 48 | } 49 | 50 | fetchUser() { 51 | return this.twitterClient.fetchUser().then(({ user }) => { 52 | domainEventPublisher.publish({ 53 | user, 54 | type: 'USER_FETCHED' 55 | }); 56 | return { user }; 57 | }); 58 | } 59 | 60 | postTweet(text) { 61 | this.twitterClient.postTweet({ text }).then(({ tweet }) => { 62 | domainEventPublisher.publish({ 63 | tweet, 64 | type: 'TWEET_POSTED' 65 | }); 66 | }); 67 | } 68 | 69 | searchTweets({ queryString }) { 70 | this.twitterClient.searchTweets({ queryString }).then(({ tweets }) => { 71 | domainEventPublisher.publish({ 72 | tweets, 73 | type: 'TWEETS_SEARCHED' 74 | }); 75 | }); 76 | } 77 | 78 | subscribeFilteredStream({ queryString }) { 79 | this.twitterClient.subscribeFilteredStream({ queryString }).on('tweet', (tweet) => { 80 | domainEventPublisher.publish({ 81 | tweet, 82 | type: 'FILTERED_TWEET_RECEIVED' 83 | }); 84 | }); 85 | } 86 | 87 | subscribeUserStream({ user }) { 88 | return this.twitterClient.subscribeUserStream({ 89 | user 90 | }).on('tweet', (tweet) => { 91 | domainEventPublisher.publish({ 92 | tweet, 93 | type: 'HOME_TIMELINE_TWEET_RECEIVED' 94 | }); 95 | }).on('favorite', (data) => { 96 | domainEventPublisher.publish({ 97 | data, 98 | type: 'FAVORITE_RECEIVED' 99 | }); 100 | }).on('retweet', (tweet) => { 101 | domainEventPublisher.publish({ 102 | tweet, 103 | type: 'RETWEET_RECEIVED' 104 | }); 105 | }); 106 | } 107 | 108 | unfavorite({ tweetId }) { 109 | this.twitterClient.unfavorite({ tweetId }).then(({ tweet }) => { 110 | domainEventPublisher.publish({ 111 | tweet, 112 | type: 'TWEET_UNFAVORITED' 113 | }); 114 | }); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/browser/application-menu.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | import Menu from 'menu' 3 | 4 | export default class ApplicationMenu extends EventEmitter { 5 | constructor() { 6 | super(); 7 | const self = this; 8 | Menu.setApplicationMenu( 9 | Menu.buildFromTemplate( 10 | [ 11 | { 12 | label: 'Application', 13 | submenu: [ 14 | { 15 | label: 'About', 16 | click() { 17 | openExternal('https://github.com/r7kamura/retro-twitter-client'); 18 | } 19 | }, 20 | { 21 | type: 'separator' 22 | }, 23 | { 24 | label: 'Quit', 25 | accelerator: 'Command+Q', 26 | click() { 27 | self.emit('quit'); 28 | } 29 | } 30 | ] 31 | }, 32 | { 33 | label: 'Edit', 34 | submenu: [ 35 | { 36 | label: 'Undo', 37 | accelerator: 'Command+Z', 38 | selector: 'undo:' 39 | }, 40 | { 41 | label: 'Redo', 42 | accelerator: 'Shift+Command+Z', 43 | selector: 'redo:' 44 | }, 45 | { 46 | type: 'separator' 47 | }, 48 | { 49 | label: 'Cut', 50 | accelerator: 'Command+X', 51 | selector: 'cut:' 52 | }, 53 | { 54 | label: 'Copy', 55 | accelerator: 'Command+C', 56 | selector: 'copy:' 57 | }, 58 | { 59 | label: 'Paste', 60 | accelerator: 'Command+V', 61 | selector: 'paste:' 62 | }, 63 | { 64 | label: 'Select All', 65 | accelerator: 'Command+A', 66 | selector: 'selectAll:' 67 | } 68 | ] 69 | }, 70 | { 71 | label: 'View', 72 | submenu: [ 73 | { 74 | label: 'Search', 75 | accelerator: 'Command+F', 76 | click() { 77 | self.emit('search') 78 | } 79 | }, 80 | { 81 | label: 'Select next channel', 82 | accelerator: 'Command+]', 83 | click() { 84 | self.emit('select-next-channel'); 85 | } 86 | }, 87 | { 88 | label: 'Select previous channel', 89 | accelerator: 'Command+[', 90 | click() { 91 | self.emit('select-previous-channel'); 92 | } 93 | }, 94 | { 95 | label: 'Reload', 96 | accelerator: 'Command+R', 97 | click() { 98 | self.emit('reload'); 99 | } 100 | }, 101 | { 102 | label: 'Open DevTools', 103 | accelerator: 'Alt+Command+I', 104 | click() { 105 | self.emit('open-dev-tools'); 106 | } 107 | } 108 | ] 109 | } 110 | ] 111 | ) 112 | ); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/renderer/libraries/application.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import applicationState from '../singletons/application-state' 3 | import ChannelSelector from '../command-models/channel-selector' 4 | import DefaultWebBrowser from '../command-models/default-web-browser' 5 | import DesktopNotifier from '../command-models/desktop-notifier' 6 | import domainEventPublisher from '../singletons/domain-event-publisher' 7 | import ipc from 'ipc' 8 | import KeyboardEventPublisher from '../libraries/keyboard-event-publisher' 9 | import SearchBoxSelector from '../command-models/search-box-selector' 10 | import TweetSelector from '../command-models/tweet-selector' 11 | import TwitterAccount from '../command-models/twitter-account' 12 | import twitterSignature from '../singletons/twitter-signature' 13 | import viewEventPublisher from '../singletons/view-event-publisher' 14 | import ViewRenderer from '../command-models/view-renderer' 15 | 16 | export default class Application { 17 | constructor() { 18 | this.channelSelector = new ChannelSelector(); 19 | this.defaultWebBrowser = new DefaultWebBrowser(); 20 | this.desktopNotifier = new DesktopNotifier(); 21 | this.keyboardEventPublisher = new KeyboardEventPublisher(); 22 | this.searchBoxSelector = new SearchBoxSelector(); 23 | this.tweetSelector = new TweetSelector(); 24 | this.twitterAccount = new TwitterAccount(twitterSignature); 25 | this.viewRenderer = new ViewRenderer(); 26 | } 27 | 28 | run() { 29 | this.subscribeDomainEvents(); 30 | this.subscribeIpcEvents(); 31 | this.subscribeKeyboardEvents(); 32 | this.subscribeViewEvents(); 33 | this.viewRenderer.render(); 34 | this.twitterAccount.fetchUser(); 35 | } 36 | 37 | subscribeDomainEvents() { 38 | domainEventPublisher.on('*', (domainEvent) => { 39 | applicationState.emit(domainEvent.type, domainEvent); 40 | }).on('FAVORITE_RECEIVED', ({ data }) => { 41 | this.desktopNotifier.notifyFavorite(data); 42 | }).on('LIST_CHANNEL_SELECTED', ({ listId }) => { 43 | this.twitterAccount.fetchListTweets({ listId }); 44 | }).on('RETWEET_RECEIVED', ({ tweet }) => { 45 | this.desktopNotifier.notifyRetweet({ tweet }); 46 | }).on('USER_FETCHED', ({ user }) => { 47 | this.twitterAccount.fetchHomeTimelineTweets({ user }); 48 | this.twitterAccount.fetchLists({ user }); 49 | this.twitterAccount.subscribeUserStream({ user }); 50 | }); 51 | } 52 | 53 | subscribeIpcEvents() { 54 | ipc.on('select-search-box', () => { 55 | this.searchBoxSelector.select(); 56 | }); 57 | ipc.on('select-next-channel', () => { 58 | this.channelSelector.selectNextChannel(); 59 | }); 60 | ipc.on('select-previous-channel', () => { 61 | this.channelSelector.selectPreviousChannel(); 62 | }); 63 | } 64 | 65 | subscribeKeyboardEvents() { 66 | this.keyboardEventPublisher.on('J', (event) => { 67 | event.preventDefault(); 68 | this.tweetSelector.selectNextTweet(); 69 | }).on('K', (event) => { 70 | event.preventDefault(); 71 | this.tweetSelector.selectPreviousTweet(); 72 | }); 73 | } 74 | 75 | subscribeViewEvents() { 76 | viewEventPublisher.on('anchor-clicked', (url) => { 77 | this.defaultWebBrowser.openUrl(url); 78 | }).on('channel-clicked', (channelId) => { 79 | this.channelSelector.selectChannel(channelId); 80 | }).on('favorite-button-clicked', (tweetId) => { 81 | this.twitterAccount.favorite({ tweetId }); 82 | }).on('search-query-string-submitted', (queryString) => { 83 | this.channelSelector.selectSearchChannel(); 84 | this.twitterAccount.searchTweets({ queryString }); 85 | this.twitterAccount.subscribeFilteredStream({ queryString }); 86 | }).on('tweet-submitted', (text) => { 87 | this.twitterAccount.postTweet(text); 88 | }).on('unfavorite-button-clicked', (tweetId) => { 89 | this.twitterAccount.unfavorite({ tweetId }); 90 | }); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/renderer/libraries/application-state.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import { EventEmitter } from 'events' 3 | import listRepository from '../singletons/list-repository' 4 | import tweetRepository from '../singletons/tweet-repository' 5 | import userRepository from '../singletons/user-repository' 6 | 7 | export default class ApplicationState extends EventEmitter { 8 | constructor() { 9 | super(); 10 | this.initializeState(); 11 | this.subscribeDomainEvents(); 12 | } 13 | 14 | get nextChannelId() { 15 | switch (this.channelId) { 16 | case 'HOME_TIMELINE_CHANNEL': 17 | return 'SEARCH_CHANNEL'; 18 | case 'SEARCH_CHANNEL': 19 | if (this.listIds.length > 0) { 20 | return this.listIds[0]; 21 | } else { 22 | return 'HOME_TIMELINE_CHANNEL'; 23 | } 24 | default: 25 | const index = _.findIndex(this.listIds, (listId) => listId === this.channelId); 26 | if (-1 < index && index < this.listIds.length - 1) { 27 | return this.listIds[index + 1]; 28 | } else { 29 | return 'HOME_TIMELINE_CHANNEL'; 30 | } 31 | } 32 | } 33 | 34 | get previousChannelId() { 35 | switch (this.channelId) { 36 | case 'HOME_TIMELINE_CHANNEL': 37 | if (this.listIds.length > 0) { 38 | return this.listIds[this.listIds.length - 1]; 39 | } else { 40 | return 'SEARCH_CHANNEL'; 41 | } 42 | case 'SEARCH_CHANNEL': 43 | return 'HOME_TIMELINE_CHANNEL'; 44 | default: 45 | const index = _.findIndex(this.listIds, (listId) => listId === this.channelId); 46 | if (index - 1 >= 0) { 47 | return this.listIds[index - 1]; 48 | } else { 49 | return 'SEARCH_CHANNEL'; 50 | } 51 | } 52 | } 53 | 54 | initializeState() { 55 | this.channelId = 'HOME_TIMELINE_CHANNEL'; 56 | this.homeTimelineTweetIds = []; 57 | this.listId = null; 58 | this.listIds = []; 59 | this.listTweetIds = []; 60 | this.searchedTweetIds = []; 61 | this.userId = {}; 62 | } 63 | 64 | subscribeDomainEvents() { 65 | this.on('CHANNEL_SELECTED', ({ channelId }) => { 66 | this.channelId = channelId; 67 | this.emit('changed'); 68 | }).on('HOME_TIMELINE_TWEET_RECEIVED', ({ tweet }) => { 69 | this.updateRepositoriesFromTweet(tweet); 70 | this.homeTimelineTweetIds = [tweet.id_str, ...this.homeTimelineTweetIds]; 71 | this.emit('changed'); 72 | }).on('HOME_TIMELINE_TWEETS_FETCHED', ({ tweets }) => { 73 | tweets.forEach((tweet) => this.updateRepositoriesFromTweet(tweet)); 74 | this.homeTimelineTweetIds = [...tweets.map((tweet) => tweet.id_str), ...this.homeTimelineTweetIds]; 75 | this.emit('changed'); 76 | }).on('LIST_CHANNEL_SELECTED', ({ listId }) => { 77 | this.listId = listId; 78 | this.emit('changed'); 79 | }).on('LISTS_FETCHED', ({ lists }) => { 80 | lists.forEach((list) => listRepository.update(list)); 81 | this.listIds = [...lists.map((list) => list.id_str), ...this.listIds]; 82 | this.emit('changed'); 83 | }).on('LIST_TWEETS_CLEARED', () => { 84 | this.listTweetIds = []; 85 | this.emit('changed'); 86 | }).on('LIST_TWEETS_FETCHED', ({ tweets }) => { 87 | tweets.forEach((tweet) => this.updateRepositoriesFromTweet(tweet)); 88 | this.listTweetIds = [...tweets.map((tweet) => tweet.id_str), ...this.listTweetIds]; 89 | this.emit('changed'); 90 | }).on('TWEET_FAVORITED', ({ tweet }) => { 91 | this.updateRepositoriesFromTweet(tweet); 92 | this.emit('changed'); 93 | }).on('TWEET_POSTED', ({ tweet }) => { 94 | this.updateRepositoriesFromTweet(tweet); 95 | this.homeTimelineTweetIds = [tweet.id_str, ...this.homeTimelineTweetIds]; 96 | this.emit('changed'); 97 | }).on('TWEET_UNFAVORITED', ({ tweet }) => { 98 | this.updateRepositoriesFromTweet(tweet); 99 | this.emit('changed'); 100 | }).on('TWEETS_SEARCHED', ({ tweets }) => { 101 | tweets.forEach((tweet) => this.updateRepositoriesFromTweet(tweet)); 102 | this.searchedTweetIds = [...tweets.map((tweet) => tweet.id_str), ...this.searchedTweetIds]; 103 | this.emit('changed'); 104 | }).on('USER_FETCHED', ({ user }) => { 105 | userRepository.update(user); 106 | this.userId = user.id_str; 107 | this.emit('changed'); 108 | }); 109 | } 110 | 111 | updateRepositoriesFromTweet(tweet) { 112 | tweetRepository.update(tweet); 113 | userRepository.update(tweet.user); 114 | if (tweet.retweeted_status) { 115 | this.updateRepositoriesFromTweet(tweet.retweeted_status); 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/renderer/libraries/twitter-client.js: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | const Twitter = require('twitter'); 3 | 4 | export default class TwitterClient { 5 | constructor({ accessToken, accessTokenSecret, consumerKey, consumerSecret }) { 6 | this.accessToken = accessToken; 7 | this.accessTokenSecret = accessTokenSecret; 8 | this.consumerKey = consumerKey; 9 | this.consumerSecret = consumerSecret; 10 | } 11 | 12 | favorite({ tweetId }) { 13 | return new Promise((resolve, reject) => { 14 | this.getTwitter().post( 15 | 'favorites/create', 16 | { id: tweetId }, 17 | (error, tweet, response) => { 18 | resolve({ response: response, tweet: tweet }); 19 | } 20 | ); 21 | }); 22 | } 23 | 24 | fetchUser() { 25 | return new Promise((resolve, reject) => { 26 | this.getTwitter().get( 27 | 'account/verify_credentials', 28 | (error, user, response) => { 29 | resolve({ user: user, response: response }); 30 | } 31 | ); 32 | }); 33 | } 34 | 35 | fetchLists() { 36 | return new Promise((resolve, reject) => { 37 | this.getTwitter().get( 38 | 'lists/list', 39 | (error, lists, response) => { 40 | resolve({ lists: lists, response: response }); 41 | } 42 | ); 43 | }); 44 | } 45 | 46 | fetchHomeTimelineTweets({ screenName }) { 47 | return new Promise((resolve, reject) => { 48 | this.getTwitter().get( 49 | 'statuses/home_timeline', 50 | { 51 | screen_name: screenName 52 | }, 53 | (error, tweets, response) => { 54 | resolve({ tweets: tweets, response: response }); 55 | } 56 | ); 57 | }); 58 | } 59 | 60 | fetchListTweets({ listId }) { 61 | return new Promise((resolve, reject) => { 62 | this.getTwitter().get( 63 | 'lists/statuses', 64 | { 65 | list_id: listId 66 | }, 67 | (error, tweets, response) => { 68 | resolve({ tweets: tweets, response: response }); 69 | } 70 | ); 71 | }); 72 | } 73 | 74 | getTwitter() { 75 | if (!this.twitter) { 76 | this.twitter = new Twitter({ 77 | access_token_key: this.accessToken, 78 | access_token_secret: this.accessTokenSecret, 79 | consumer_key: this.consumerKey, 80 | consumer_secret: this.consumerSecret 81 | }); 82 | } 83 | return this.twitter; 84 | } 85 | 86 | postTweet({ text }) { 87 | return new Promise((resolve, reject) => { 88 | this.getTwitter().post( 89 | 'statuses/update', 90 | { 91 | status: text 92 | }, 93 | (error, tweet, response) => { 94 | resolve({ tweet: tweet, response: response }); 95 | } 96 | ); 97 | }); 98 | } 99 | 100 | searchTweets({ queryString }) { 101 | return new Promise((resolve, reject) => { 102 | this.getTwitter().get( 103 | 'search/tweets', 104 | { 105 | q: queryString 106 | }, 107 | (error, data, response) => { 108 | resolve({ tweets: data.statuses, response: response }); 109 | } 110 | ); 111 | }); 112 | } 113 | 114 | /* 115 | * @return {EventEmitter} 116 | */ 117 | subscribeFilteredStream({ queryString }) { 118 | const eventEmitter = new EventEmitter(); 119 | this.getTwitter().stream( 120 | 'statuses/filter', 121 | { 122 | track: queryString 123 | }, 124 | (stream) => { 125 | stream.on('data', (data) => { 126 | eventEmitter.emit('tweet', data); 127 | }); 128 | } 129 | ); 130 | return eventEmitter; 131 | } 132 | 133 | /* 134 | * @param {Object} user User is used to detect retweet event 135 | * @return {EventEmitter} 136 | */ 137 | subscribeUserStream({ user }) { 138 | const eventEmitter = new EventEmitter(); 139 | this.getTwitter().stream( 140 | 'user', 141 | (stream) => { 142 | stream.on('follow', (data) => { 143 | eventEmitter.emit('follow', data); 144 | }); 145 | stream.on('block', (data) => { 146 | eventEmitter.emit('block', data); 147 | }); 148 | stream.on('favorite', (data) => { 149 | if (data.source.id_str !== user.id_str) { 150 | eventEmitter.emit('favorite', data); 151 | } 152 | }); 153 | stream.on('list_created', (data) => { 154 | eventEmitter.emit('list_created', data); 155 | }); 156 | stream.on('list_destroyed', (data) => { 157 | eventEmitter.emit('list_destroyed', data); 158 | }); 159 | stream.on('list_member_added', (data) => { 160 | eventEmitter.emit('list_member_added', data); 161 | }); 162 | stream.on('list_member_removed', (data) => { 163 | eventEmitter.emit('list_member_removed', data); 164 | }); 165 | stream.on('list_updated', (data) => { 166 | eventEmitter.emit('list_updated', data); 167 | }); 168 | stream.on('list_user_subscribed', (data) => { 169 | eventEmitter.emit('list_user_subscribed', data); 170 | }); 171 | stream.on('list_user_unsubscribed', (data) => { 172 | eventEmitter.emit('list_user_unsubscribed', data); 173 | }); 174 | stream.on('unblock', (data) => { 175 | eventEmitter.emit('unblock', data); 176 | }); 177 | stream.on('unfavorite', (data) => { 178 | eventEmitter.emit('unfavorite', data); 179 | }); 180 | stream.on('user_update', (data) => { 181 | eventEmitter.emit('user_update', data); 182 | }); 183 | stream.on('data', (data) => { 184 | if (data.friends) { 185 | eventEmitter.emit('friends', data); 186 | } else if (data.event) { 187 | } else if (data.delete) { 188 | eventEmitter.emit('delete', data); 189 | } else if (data.created_at) { 190 | if (data.retweeted_status && data.retweeted_status.user.id_str == user.id_str) { 191 | eventEmitter.emit('retweet', data); 192 | } 193 | eventEmitter.emit('tweet', data); 194 | } 195 | }); 196 | } 197 | ); 198 | return eventEmitter; 199 | } 200 | 201 | unfavorite({ tweetId }) { 202 | return new Promise((resolve, reject) => { 203 | this.getTwitter().post( 204 | 'favorites/destroy', 205 | { id: tweetId }, 206 | (error, tweet, response) => { 207 | resolve({ response: response, tweet: tweet }); 208 | } 209 | ); 210 | }); 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/renderer/components/tweet-body.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Time from './time' 3 | import twitterText from 'twitter-text' 4 | import viewEventPublisher from '../singletons/view-event-publisher' 5 | 6 | class Anchor extends React.Component { 7 | onClicked(event) { 8 | event.preventDefault(); 9 | viewEventPublisher.emit('anchor-clicked', event.currentTarget.href); 10 | } 11 | 12 | render() { 13 | return ; 21 | } 22 | } 23 | 24 | class AnchorToCashtag extends React.Component { 25 | getText() { 26 | return '$' + twitterText.htmlEscape(this.props.entity.cashtag); 27 | } 28 | 29 | getTitle() { 30 | return '$' + this.props.entity.cashtag; 31 | } 32 | 33 | getUrl() { 34 | return `https://twitter.com/#!/search?q=%24${this.props.entity.cashtag}`; 35 | } 36 | 37 | render() { 38 | return 39 | } 40 | } 41 | 42 | class AnchorToHashtag extends React.Component { 43 | getText() { 44 | return '#' + twitterText.htmlEscape(this.props.entity.hashtag); 45 | } 46 | 47 | getTitle() { 48 | return '#' + this.props.entity.hashtag; 49 | } 50 | 51 | getUrl() { 52 | return `https://twitter.com/#!/search?q=%23${this.props.entity.hashtag}`; 53 | } 54 | 55 | render() { 56 | return 57 | } 58 | } 59 | 60 | class AnchorToList extends React.Component { 61 | getIdentifier() { 62 | return this.props.entity.screenName + this.props.entity.listSlug; 63 | } 64 | 65 | getText() { 66 | return '@' + twitterText.htmlEscape(this.getIdentifier()); 67 | } 68 | 69 | getTitle() { 70 | return '@' + this.getIdentifier(); 71 | } 72 | 73 | getUrl() { 74 | return `https://twitter.com/${this.getIdentifier()}`; 75 | } 76 | 77 | render() { 78 | return 79 | } 80 | } 81 | 82 | class AnchorToMention extends React.Component { 83 | getIdentifier() { 84 | return this.props.entity.screenName; 85 | } 86 | 87 | getText() { 88 | return '@' + twitterText.htmlEscape(this.getIdentifier()); 89 | } 90 | 91 | getTitle() { 92 | return '@' + this.getIdentifier(); 93 | } 94 | 95 | getUrl() { 96 | return `https://twitter.com/${this.getIdentifier()}`; 97 | } 98 | 99 | render() { 100 | return 101 | } 102 | } 103 | 104 | class AnchorToUrl extends React.Component { 105 | getText() { 106 | if (this.props.urlEntity && this.props.urlEntity.display_url) { 107 | return twitterText.linkTextWithEntity(this.props.urlEntity, { invisibleTagAttrs: "style='position:absolute;left:-9999px;'" }); 108 | } else { 109 | return twitterText.htmlEscape(this.props.displayUrl); 110 | } 111 | } 112 | 113 | render() { 114 | return 115 | } 116 | } 117 | 118 | class Image extends React.Component { 119 | onClicked(event) { 120 | event.preventDefault(); 121 | viewEventPublisher.emit('anchor-clicked', event.currentTarget.href); 122 | } 123 | 124 | render() { 125 | return( 126 | 131 | ); 132 | } 133 | } 134 | 135 | class Text extends React.Component { 136 | getText() { 137 | return twitterText.htmlEscape(this.props.text); 138 | } 139 | 140 | render() { 141 | return ; 142 | } 143 | } 144 | 145 | export default class Tweet extends React.Component { 146 | getComponents() { 147 | const components = []; 148 | const text = this.props.tweet.text; 149 | let index = 0; 150 | this.getEntities().forEach((entity) => { 151 | components.push(); 152 | if (entity.url) { 153 | if (this.getImageUrls().indexOf(entity.url) === -1) { 154 | components.push(); 155 | } 156 | } else if (entity.hashtag) { 157 | components.push(); 158 | } else if (entity.listSlug) { 159 | components.push(); 160 | } else if (entity.screenName) { 161 | components.push(); 162 | } else if (entity.cashtag) { 163 | components.push(); 164 | } 165 | index = entity.indices[1]; 166 | }); 167 | components.push(); 168 | components.push(...this.getImages()); 169 | return components; 170 | } 171 | 172 | getEntities() { 173 | return twitterText.extractEntitiesWithIndices( 174 | this.props.tweet.text, 175 | { extractUrlsWithoutProtocol: false } 176 | ); 177 | } 178 | 179 | getImages() { 180 | if (this.props.tweet.extended_entities && this.props.tweet.extended_entities.media) { 181 | return this.props.tweet.extended_entities.media.filter((media) => { 182 | return media.type === 'photo'; 183 | }).map((media) => { 184 | return ; 185 | }); 186 | } else { 187 | return []; 188 | } 189 | } 190 | 191 | getImageUrls() { 192 | if (this.props.tweet.extended_entities && this.props.tweet.extended_entities.media) { 193 | return this.props.tweet.extended_entities.media.filter((media) => { 194 | return media.type === 'photo'; 195 | }).map((media) => { 196 | return media.url; 197 | }); 198 | } else { 199 | return []; 200 | } 201 | } 202 | 203 | getUrlEntities() { 204 | return this.props.tweet.entities.urls; 205 | } 206 | 207 | getUrlEntityFromUrl(url) { 208 | return this.getUrlEntities().filter((urlEntity) => { 209 | return urlEntity.url === url; 210 | })[0]; 211 | } 212 | 213 | render() { 214 | return( 215 |
    216 | {this.getComponents()} 217 |
    218 | ); 219 | } 220 | } 221 | --------------------------------------------------------------------------------