├── 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 | 
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 |
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 |
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 |
44 |
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 |
52 |
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 |
--------------------------------------------------------------------------------