twterm 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +88 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +10 -0
  6. data/Rakefile +1 -0
  7. data/bin/twterm +5 -0
  8. data/lib/extentions.rb +51 -0
  9. data/lib/twterm/app.rb +45 -0
  10. data/lib/twterm/auth.rb +23 -0
  11. data/lib/twterm/client.rb +214 -0
  12. data/lib/twterm/color_manager.rb +50 -0
  13. data/lib/twterm/config.rb +37 -0
  14. data/lib/twterm/list.rb +20 -0
  15. data/lib/twterm/notification/base.rb +24 -0
  16. data/lib/twterm/notification/error.rb +19 -0
  17. data/lib/twterm/notification/message.rb +19 -0
  18. data/lib/twterm/notifier.rb +68 -0
  19. data/lib/twterm/screen.rb +53 -0
  20. data/lib/twterm/status.rb +102 -0
  21. data/lib/twterm/tab/auto_reloadable.rb +14 -0
  22. data/lib/twterm/tab/base.rb +41 -0
  23. data/lib/twterm/tab/conversation_tab.rb +31 -0
  24. data/lib/twterm/tab/exceptions.rb +6 -0
  25. data/lib/twterm/tab/favorites.rb +7 -0
  26. data/lib/twterm/tab/list_tab.rb +33 -0
  27. data/lib/twterm/tab/mentions_tab.rb +36 -0
  28. data/lib/twterm/tab/new/list.rb +88 -0
  29. data/lib/twterm/tab/new/search.rb +52 -0
  30. data/lib/twterm/tab/new/start.rb +67 -0
  31. data/lib/twterm/tab/scrollable.rb +130 -0
  32. data/lib/twterm/tab/search_tab.rb +29 -0
  33. data/lib/twterm/tab/statuses_tab.rb +235 -0
  34. data/lib/twterm/tab/timeline_tab.rb +33 -0
  35. data/lib/twterm/tab/user_tab.rb +29 -0
  36. data/lib/twterm/tab_manager.rb +111 -0
  37. data/lib/twterm/tweetbox.rb +53 -0
  38. data/lib/twterm/user.rb +40 -0
  39. data/lib/twterm/user_window.rb +71 -0
  40. data/lib/twterm/version.rb +3 -0
  41. data/lib/twterm.rb +50 -0
  42. data/twterm.gemspec +28 -0
  43. metadata +184 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: e40202371311a827db240194d0b8bf6557116ae1
4
+ data.tar.gz: 626203d268f804937122696f04ab2200e1f8dbd5
5
+ SHA512:
6
+ metadata.gz: 59b2bceeb082f670e52f639dca1ffc1411cb1ccc6b6a8371806c8acdb259d0af5474bbf5d09d38706dbe821e1d0d2f90518726682d860db8c49901c8977a067c
7
+ data.tar.gz: 2489774aa2fabd526a67aa15b440cb0befa2f01372991ead8267d24bcfa1f19cf42739a67ac09ed35dd69dfad93acf351865526be6fc4080bb3c2b6760cdf2f9
data/.gitignore ADDED
@@ -0,0 +1,88 @@
1
+ # Created by https://www.gitignore.io
2
+
3
+ ### OSX ###
4
+ .DS_Store
5
+ .AppleDouble
6
+ .LSOverride
7
+
8
+ # Icon must end with two \r
9
+ Icon
10
+
11
+
12
+ # Thumbnails
13
+ ._*
14
+
15
+ # Files that might appear on external disk
16
+ .Spotlight-V100
17
+ .Trashes
18
+
19
+ # Directories potentially created on remote AFP share
20
+ .AppleDB
21
+ .AppleDesktop
22
+ Network Trash Folder
23
+ Temporary Items
24
+ .apdisk
25
+
26
+
27
+ ### Ruby ###
28
+ *.gem
29
+ *.rbc
30
+ /.config
31
+ /coverage/
32
+ /InstalledFiles
33
+ /pkg/
34
+ /spec/reports/
35
+ /test/tmp/
36
+ /test/version_tmp/
37
+ /tmp/
38
+
39
+ ## Specific to RubyMotion:
40
+ .dat*
41
+ .repl_history
42
+ build/
43
+
44
+ ## Documentation cache and generated files:
45
+ /.yardoc/
46
+ /_yardoc/
47
+ /doc/
48
+ /rdoc/
49
+
50
+ ## Environment normalisation:
51
+ /.bundle/
52
+ /vendor/bundle/
53
+ /lib/bundler/man/
54
+
55
+ # for a library or gem, you might want to ignore these files since the code is
56
+ # intended to run in multiple environments; otherwise, check them in:
57
+ # Gemfile.lock
58
+ # .ruby-version
59
+ # .ruby-gemset
60
+
61
+ # unless supporting rvm < 1.11.0 or doing something fancy, ignore this:
62
+ .rvmrc
63
+
64
+
65
+ ### vim ###
66
+ [._]*.s[a-w][a-z]
67
+ [._]s[a-w][a-z]
68
+ *.un~
69
+ Session.vim
70
+ .netrwhist
71
+ *~
72
+
73
+
74
+ ### Tags ###
75
+ # Ignore tags created by etags, ctags, gtags (GNU global) and cscope
76
+ TAGS
77
+ !TAGS/
78
+ tags
79
+ !tags/
80
+ gtags.files
81
+ GTAGS
82
+ GRTAGS
83
+ GPATH
84
+ cscope.files
85
+ cscope.out
86
+ cscope.in.out
87
+ cscope.po.out
88
+
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source 'https://rubygems.org'
2
+
3
+ # Specify your gem's dependencies in twterm.gemspec
4
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2015 Ryota Kameoka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # Twterm
2
+
3
+ - A full-featured CLI Twitter client
4
+ - http://twterm.ryota-ka.me/
5
+
6
+ ## Installation
7
+
8
+ ```
9
+ gem install twterm
10
+ ```
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
data/bin/twterm ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'twterm'
4
+
5
+ Twterm::App.instance.run
data/lib/extentions.rb ADDED
@@ -0,0 +1,51 @@
1
+ class String
2
+ def width
3
+ each_char.map { |c| c.bytesize == 1 ? 1 : 2 }.reduce(0, &:+)
4
+ end
5
+
6
+ def split_by_width(width)
7
+ cnt = 0
8
+ str = ''
9
+ chunks = []
10
+
11
+ each_char do |c|
12
+ if c == "\n"
13
+ chunks << str
14
+ str = ''
15
+ cnt = 0
16
+ next
17
+ end
18
+
19
+ cnt += c.width
20
+ if cnt > width
21
+ chunks << str
22
+ str = ''
23
+ cnt = 0
24
+ end
25
+ str << c unless str.empty? && c == ' '
26
+ end
27
+ chunks << str unless str.empty?
28
+ chunks
29
+ end
30
+ end
31
+
32
+ class Integer
33
+ def format
34
+ to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\1,')
35
+ end
36
+ end
37
+
38
+ class Curses::Window
39
+ def bold(&block)
40
+ attron(Curses::A_BOLD)
41
+ block.call
42
+ attroff(Curses::A_BOLD)
43
+ end
44
+
45
+ def with_color(fg, bg = :black, &block)
46
+ color_pair_index = Twterm::ColorManager.instance.get_color_pair_index(fg, bg)
47
+ attron(Curses.color_pair(color_pair_index))
48
+ block.call
49
+ attroff(Curses.color_pair(color_pair_index))
50
+ end
51
+ end
data/lib/twterm/app.rb ADDED
@@ -0,0 +1,45 @@
1
+ module Twterm
2
+ class App
3
+ include Singleton
4
+
5
+ def initialize
6
+ Config.load
7
+ Auth.authenticate_user if Config[:screen_name].nil?
8
+
9
+ Screen.instance
10
+
11
+ client = Client.new(Config[:user_id], Config[:screen_name], Config[:access_token], Config[:access_token_secret])
12
+
13
+ timeline = Tab::TimelineTab.new(client)
14
+ TabManager.instance.add_and_show(timeline)
15
+
16
+ mentions_tab = Tab::MentionsTab.new(client)
17
+ TabManager.instance.add(mentions_tab)
18
+
19
+ Screen.instance.refresh
20
+
21
+ client.stream
22
+ UserWindow.instance
23
+
24
+ reset_interruption_handler
25
+ end
26
+
27
+ def run
28
+ Screen.instance.wait
29
+ Screen.instance.refresh
30
+ end
31
+
32
+ def register_interruption_handler(&block)
33
+ fail ArgumentError, 'no block given' unless block_given?
34
+ Signal.trap(:INT) do
35
+ block.call
36
+ end
37
+ end
38
+
39
+ def reset_interruption_handler
40
+ Signal.trap(:INT) do
41
+ exit
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,23 @@
1
+ module Twterm
2
+ module Auth
3
+ def authenticate_user
4
+ consumer = OAuth::Consumer.new(
5
+ 'vLNSVFgXclBJQJRZ7VLMxL9lA',
6
+ 'OFLKzrepRG2p1hq0nUB9j2S9ndFQoNTPheTpmOY0GYw55jGgS5',
7
+ site: 'https://api.twitter.com'
8
+ )
9
+ request_token = consumer.get_request_token
10
+ Launchy.open request_token.authorize_url
11
+ print 'input PIN: '
12
+ pin = (STDIN.gets || '').strip
13
+ access_token = request_token.get_access_token(oauth_verifier: pin)
14
+
15
+ Config[:access_token] = access_token.token
16
+ Config[:access_token_secret] = access_token.secret
17
+ Config[:screen_name] = access_token.params[:screen_name]
18
+ Config[:user_id] = access_token.params[:user_id]
19
+ end
20
+
21
+ module_function :authenticate_user
22
+ end
23
+ end
@@ -0,0 +1,214 @@
1
+ module Twterm
2
+ class Client
3
+ attr_reader :user_id, :screen_name
4
+
5
+ CREATE_STATUS_PROC = -> (s) { Status.new(s) }
6
+
7
+ @@instances = []
8
+
9
+ def initialize(user_id, screen_name, token, secret)
10
+ @user_id = user_id
11
+ @screen_name = screen_name
12
+
13
+ @rest_client = Twitter::REST::Client.new do |config|
14
+ config.consumer_key = 'vLNSVFgXclBJQJRZ7VLMxL9lA'
15
+ config.consumer_secret = 'OFLKzrepRG2p1hq0nUB9j2S9ndFQoNTPheTpmOY0GYw55jGgS5'
16
+ config.access_token = token
17
+ config.access_token_secret = secret
18
+ end
19
+
20
+ TweetStream.configure do |config|
21
+ config.consumer_key = 'vLNSVFgXclBJQJRZ7VLMxL9lA'
22
+ config.consumer_secret = 'OFLKzrepRG2p1hq0nUB9j2S9ndFQoNTPheTpmOY0GYw55jGgS5'
23
+ config.oauth_token = token
24
+ config.oauth_token_secret = secret
25
+ config.auth_method = :oauth
26
+ end
27
+
28
+ @stream_client = TweetStream::Client.new
29
+
30
+ @callbacks = {}
31
+ @@instances << self
32
+ end
33
+
34
+ def stream
35
+ @stream_client.on_timeline_status do |tweet|
36
+ status = Status.new(tweet)
37
+ invoke_callbacks(:timeline_status, status)
38
+ invoke_callbacks(:mention, status) if status.text.include? "@#{@screen_name}"
39
+ end
40
+
41
+ @stream_client.on_delete do |status_id|
42
+ timeline.delete_status(status_id)
43
+ end
44
+
45
+ @stream_client.on_event(:favorite) do |event|
46
+ break if event[:source][:screen_name] == @screen_name
47
+ message = "@#{event[:source][:screen_name]} has favorited your tweet: #{event[:target_object][:text]}"
48
+ Notifier.instance.show_message(message)
49
+ end
50
+
51
+ @stream_client.on_no_data_received do
52
+ connect_stream
53
+ end
54
+
55
+ connect_stream
56
+ end
57
+
58
+ def connect_stream
59
+ @stream_client.stop_stream
60
+ @streaming_thread.kill if @streaming_thread.is_a? Thread
61
+
62
+ Notifier.instance.show_message 'Trying to connect to Twitter...'
63
+ @streaming_thread = Thread.new do
64
+ begin
65
+ @stream_client.userstream
66
+ rescue EventMachine::ConnectionError
67
+ Notifier.instance.show_error 'Connection failed'
68
+ sleep 30
69
+ retry
70
+ end
71
+ Notifier.instance.show_message 'Connection established'
72
+ end
73
+ end
74
+
75
+ def post(text, in_reply_to = nil)
76
+ send_request do
77
+ if in_reply_to.is_a? Status
78
+ text = "@#{in_reply_to.user.screen_name} #{text}"
79
+ @rest_client.update(text, in_reply_to_status_id: in_reply_to.id)
80
+ else
81
+ @rest_client.update(text)
82
+ end
83
+ end
84
+ end
85
+
86
+ def home_timeline
87
+ send_request do
88
+ yield @rest_client.home_timeline(count: 200).map(&CREATE_STATUS_PROC)
89
+ end
90
+ end
91
+
92
+ def mentions
93
+ send_request do
94
+ yield @rest_client.mentions(count: 200).map(&CREATE_STATUS_PROC)
95
+ end
96
+ end
97
+
98
+ def user_timeline(user_id)
99
+ send_request do
100
+ yield @rest_client.user_timeline(user_id, count: 200).map(&CREATE_STATUS_PROC)
101
+ end
102
+ end
103
+
104
+ def lists
105
+ send_request do
106
+ yield @rest_client.lists.map { |list| List.new(list) }
107
+ end
108
+ end
109
+
110
+ def list(list)
111
+ fail ArgumentError, 'argument must be an instance of List class' unless list.is_a? List
112
+ send_request do
113
+ yield @rest_client.list_timeline(list.id, count: 200).map(&CREATE_STATUS_PROC)
114
+ end
115
+ end
116
+
117
+ def search(query)
118
+ send_request do
119
+ yield @rest_client.search(query, count: 100).map(&CREATE_STATUS_PROC)
120
+ end
121
+ end
122
+
123
+ def show_status(status_id)
124
+ send_request do
125
+ yield Status.new(@rest_client.status(status_id))
126
+ end
127
+ end
128
+
129
+ def favorite(status)
130
+ return false unless status.is_a? Status
131
+
132
+ send_request do
133
+ @rest_client.favorite(status.id)
134
+ status.favorite!
135
+ yield status if block_given?
136
+ end
137
+
138
+ self
139
+ end
140
+
141
+ def unfavorite(status)
142
+ fail ArgumentError, 'argument must be an instance of Status class' unless status.is_a? Status
143
+
144
+ send_request do
145
+ @rest_client.unfavorite(status.id)
146
+ status.unfavorite!
147
+ yield status if block_given?
148
+ end
149
+ end
150
+
151
+ def retweet(status)
152
+ return false unless status.is_a? Status
153
+
154
+ send_request do
155
+ begin
156
+ @rest_client.retweet!(status.id)
157
+ status.retweet!
158
+ yield status if block_given?
159
+ rescue Twitter::Error::AlreadyRetweeted, Twitter::Error::NotFound, Twitter::Error::Forbidden
160
+ Notifier.instance.show_error 'Retweet attempt failed'
161
+ end
162
+ end
163
+ end
164
+
165
+ def on_timeline_status(&block)
166
+ fail ArgumentError, 'no block given' unless block_given?
167
+ on(:timeline_status, &block)
168
+ end
169
+
170
+ def on_mention(&block)
171
+ fail ArgumentError, 'no block given' unless block_given?
172
+ on(:mention, &block)
173
+ end
174
+
175
+ class << self
176
+ def new(user_id, screen_name, token, secret)
177
+ detector = -> (instance) { instance.user_id == user_id }
178
+ instance = @@instances.find(&detector)
179
+ instance.nil? ? super : instance
180
+ end
181
+
182
+ def current
183
+ @@instances[0]
184
+ end
185
+ end
186
+
187
+ private
188
+
189
+ def on(event, &block)
190
+ @callbacks[event] ||= []
191
+ @callbacks[event] << block
192
+ self
193
+ end
194
+
195
+ def invoke_callbacks(event, data = nil)
196
+ return if @callbacks[event].nil?
197
+
198
+ @callbacks[event].each { |cb| cb.call(data) }
199
+ self
200
+ end
201
+
202
+ def send_request(&block)
203
+ Thread.new do
204
+ begin
205
+ block.call
206
+ rescue Twitter::Error => e
207
+ Notifier.instance.show_error 'Failed to send request'
208
+ sleep 10
209
+ retry if e.message == 'getaddrinfo: nodename nor servname provided, or not known'
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,50 @@
1
+ module Twterm
2
+ class ColorManager
3
+ include Singleton
4
+ include Curses
5
+
6
+ COLORS = [:black, :white, :red, :green, :blue, :yellow, :cyan, :magenta]
7
+ CURSES_COLORS = {
8
+ black: COLOR_BLACK,
9
+ white: COLOR_WHITE,
10
+ red: COLOR_RED,
11
+ green: COLOR_GREEN,
12
+ blue: COLOR_BLUE,
13
+ yellow: COLOR_YELLOW,
14
+ cyan: COLOR_CYAN,
15
+ magenta: COLOR_MAGENTA
16
+ }
17
+
18
+ def initialize
19
+ @colors = {
20
+ black: {}, white: {}, red: {}, green: {},
21
+ blue: {}, yellow: {}, cyan: {}, magenta: {}
22
+ }
23
+ @count = 0
24
+ end
25
+
26
+ def get_color_pair_index(fg, bg)
27
+ fail ArgumentError, 'invalid color name' unless COLORS.include? fg
28
+ fail ArgumentError, 'invalid color name' unless COLORS.include? bg
29
+
30
+ return @colors[bg][fg] unless @colors[bg][fg].nil?
31
+
32
+ add_color(fg, bg)
33
+ end
34
+
35
+ private
36
+
37
+ def add_color(fg, bg)
38
+ fail ArgumentError, 'invalid color name' unless COLORS.include? fg
39
+ fail ArgumentError, 'invalid color name' unless COLORS.include? bg
40
+
41
+ @count += 1
42
+ index = @count
43
+
44
+ Curses.init_pair(index, CURSES_COLORS[fg], CURSES_COLORS[bg])
45
+ @colors[bg][fg] = index
46
+
47
+ index
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ module Twterm
2
+ module Config
3
+ CONFIG_FILE = "#{ENV['HOME']}/.twterm.yml"
4
+
5
+ def [](key)
6
+ @config[key]
7
+ end
8
+
9
+ def []=(key, value)
10
+ @config[key] = value
11
+ save
12
+ end
13
+
14
+ def load
15
+ unless File.exist? CONFIG_FILE
16
+ @config = {}
17
+ return
18
+ end
19
+ @config = YAML.load(File.read(CONFIG_FILE)) || {}
20
+ end
21
+
22
+ private
23
+
24
+ def save
25
+ begin
26
+ file = File.open(CONFIG_FILE, 'w', 0600)
27
+ file.write @config.to_yaml
28
+ rescue
29
+ puts 'exception raised'
30
+ ensure
31
+ file.close
32
+ end
33
+ end
34
+
35
+ module_function :[], :[]=, :load, :save
36
+ end
37
+ end
@@ -0,0 +1,20 @@
1
+ module Twterm
2
+ class List
3
+ attr_reader :id, :name, :slug, :full_name, :mode, :description, :member_count, :subscriber_count
4
+
5
+ def initialize(list)
6
+ @id = list.id
7
+ @name = list.name
8
+ @slug = list.slug
9
+ @full_name = list.full_name
10
+ @mode = list.mode
11
+ @description = list.description.is_a?(Twitter::NullObject) ? '' : list.description
12
+ @member_count = list.member_count
13
+ @subscriber_count = list.subscriber_count
14
+ end
15
+
16
+ def ==(other)
17
+ other.is_a?(self.class) && id == other.id
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,24 @@
1
+ module Twterm
2
+ module Notification
3
+ module Base
4
+ attr_reader :time, :fg_color, :bg_color
5
+
6
+ def initialize(message)
7
+ @message = message
8
+ @time = Time.now
9
+ end
10
+
11
+ def show_with_width(width)
12
+ @message.gsub("\n", ' ')
13
+ end
14
+
15
+ def fg_color
16
+ fail NotImplementedError, 'fg_color method must be implemented'
17
+ end
18
+
19
+ def bg_color
20
+ fail NotImplementedError, 'bg_color method must be implemented'
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ module Twterm
2
+ module Notification
3
+ class Error
4
+ include Base
5
+
6
+ def initialize(message)
7
+ super
8
+ end
9
+
10
+ def fg_color
11
+ :white
12
+ end
13
+
14
+ def bg_color
15
+ :red
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ module Twterm
2
+ module Notification
3
+ class Message
4
+ include Base
5
+
6
+ def initialize(message)
7
+ super
8
+ end
9
+
10
+ def fg_color
11
+ :white
12
+ end
13
+
14
+ def bg_color
15
+ :blue
16
+ end
17
+ end
18
+ end
19
+ end