Large commit to add support for browsing the inbox

This commit is contained in:
Michael Lazar
2019-02-27 02:04:45 -05:00
parent 3f7c9410a6
commit 7a71023a40
34 changed files with 23150 additions and 363 deletions

View File

@@ -227,11 +227,11 @@ def main():
# headers to avoid a 429 response from reddit.com
url = requests.head(config['link'], headers=reddit.http.headers,
allow_redirects=True).url
page.open_submission(url=url)
# Launch the subreddit page
page.loop()
while page:
page = page.loop()
except ConfigError as e:
_logger.exception(e)

View File

@@ -164,7 +164,7 @@ class Content(object):
data['saved'] = comment.saved
if comment.edited:
data['edited'] = '(edit {})'.format(
cls.humanize_timestamp(comment.edited))
cls.humanize_timestamp(comment.edited))
else:
data['edited'] = ''
else:
@@ -198,7 +198,7 @@ class Content(object):
data['hidden'] = False
if comment.edited:
data['edited'] = '(edit {})'.format(
cls.humanize_timestamp(comment.edited))
cls.humanize_timestamp(comment.edited))
else:
data['edited'] = ''
@@ -249,9 +249,9 @@ class Content(object):
data['saved'] = sub.saved
if sub.edited:
data['edited'] = '(edit {})'.format(
cls.humanize_timestamp(sub.edited))
cls.humanize_timestamp(sub.edited))
data['edited_long'] = '(edit {})'.format(
cls.humanize_timestamp(sub.edited, True))
cls.humanize_timestamp(sub.edited, True))
else:
data['edited'] = ''
data['edited_long'] = ''
@@ -295,6 +295,52 @@ class Content(object):
return data
@classmethod
def strip_praw_message(cls, msg):
"""
Parse through a message and return a dict with data ready to be
displayed through the terminal. Messages can be of either type
praw.objects.Message or praw.object.Comment. The comments returned will
contain special fields unique to messages and can't be parsed as normal
comment objects.
"""
author = getattr(msg, 'author', None)
data = {}
data['object'] = msg
if isinstance(msg, praw.objects.Message):
data['type'] = 'Message'
data['level'] = msg.nested_level
data['distinguished'] = msg.distinguished
data['permalink'] = None
data['submission_permalink'] = None
data['subreddit_name'] = None
data['link_title'] = None
data['context'] = None
else:
data['type'] = 'InboxComment'
data['level'] = 0
data['distinguished'] = None
data['permalink'] = msg._fast_permalink
data['submission_permalink'] = '/'.join(data['permalink'].split('/')[:-2])
data['subreddit_name'] = msg.subreddit_name_prefixed
data['link_title'] = msg.link_title
data['context'] = msg.context
data['id'] = msg.id
data['subject'] = msg.subject
data['body'] = msg.body
data['html'] = msg.body_html
data['created'] = cls.humanize_timestamp(msg.created_utc)
data['created_long'] = cls.humanize_timestamp(msg.created_utc, True)
data['recipient'] = msg.dest
data['distinguished'] = msg.distinguished
data['author'] = author.name if author else '[deleted]'
data['is_new'] = msg.new
data['was_comment'] = msg.was_comment
return data
@staticmethod
def humanize_timestamp(utc_timestamp, verbose=False):
"""
@@ -306,20 +352,50 @@ class Content(object):
seconds = int(timedelta.total_seconds())
if seconds < 60:
return 'moments ago' if verbose else '0min'
minutes = seconds // 60
if minutes < 60:
return '%d minutes ago' % minutes if verbose else '%dmin' % minutes
if verbose and minutes == 1:
return '1 minutes ago'
elif verbose:
return '%d minutes ago' % minutes
else:
return '%dmin' % minutes
hours = minutes // 60
if hours < 24:
return '%d hours ago' % hours if verbose else '%dhr' % hours
if verbose and hours == 1:
return '1 hour ago'
elif verbose:
return '%d hours ago' % hours
else:
return '%dhr' % hours
days = hours // 24
if days < 30:
return '%d days ago' % days if verbose else '%dday' % days
if verbose and days == 1:
return '1 day ago'
elif verbose:
return '%d days ago' % days
else:
return '%dday' % days
months = days // 30.4
if months < 12:
return '%d months ago' % months if verbose else '%dmonth' % months
if verbose and months == 1:
return '1 month ago'
elif verbose:
return '%d months ago' % months
else:
return '%dmonth' % months
years = months // 12
return '%d years ago' % years if verbose else '%dyr' % years
if verbose and years == 1:
return '1 year ago'
elif verbose:
return '%d years ago' % years
else:
return '%dyr' % years
@staticmethod
def wrap_text(text, width):
@@ -380,10 +456,18 @@ class SubmissionContent(Content):
def from_url(cls, reddit, url, loader, indent_size=2, max_indent_level=8,
order=None, max_comment_cols=120):
url = url.replace('http:', 'https:') # Reddit forces SSL
# Reddit forces SSL
url = url.replace('http:', 'https:')
# Sometimes reddit will return a 403 FORBIDDEN when trying to access an
# np link while using OAUTH. Cause is unknown.
url = url.replace('https://np.', 'https://www.')
# Sometimes reddit will return internal links like "context" as
# relative URLs.
if url.startswith('/'):
url = 'https://www.reddit.com' + url
submission = reddit.get_submission(url, comment_sort=order)
return cls(submission, loader, indent_size, max_indent_level, order,
max_comment_cols)
@@ -854,6 +938,92 @@ class SubscriptionContent(Content):
return data
class InboxContent(Content):
def __init__(self, order, content_generator, loader,
indent_size=2, max_indent_level=8):
self.name = 'My Inbox'
self.order = order
self.query = None
self.indent_size = indent_size
self.max_indent_level = max_indent_level
self._loader = loader
self._content_generator = content_generator
self._content_data = []
try:
self.get(0)
except IndexError:
if order == 'all':
raise exceptions.InboxError('Empty Inbox')
else:
raise exceptions.InboxError('Empty Inbox [%s]' % order)
@classmethod
def from_user(cls, reddit, loader, order='all'):
if order == 'all':
items = reddit.get_inbox(limit=None)
elif order == 'unread':
items = reddit.get_unread(limit=None)
elif order == 'messages':
items = reddit.get_messages(limit=None)
elif order == 'comments':
items = reddit.get_comment_replies(limit=None)
elif order == 'posts':
items = reddit.get_post_replies(limit=None)
elif order == 'mentions':
items = reddit.get_mentions(limit=None)
elif order == 'sent':
items = reddit.get_sent(limit=None)
else:
raise exceptions.InboxError('Invalid order %s' % order)
return cls(order, items, loader)
@property
def range(self):
return 0, len(self._content_data) - 1
def get(self, index, n_cols=70):
"""
Grab the `i`th object, with the title field formatted to fit
inside of a window of width `n_cols`
"""
if index < 0:
raise IndexError
while index >= len(self._content_data):
try:
with self._loader('Loading content'):
item = next(self._content_generator)
if self._loader.exception:
raise IndexError
except StopIteration:
raise IndexError
else:
if isinstance(item, praw.objects.Message):
# Message chains can be treated like comment trees
for child_message in self.flatten_comments([item]):
data = self.strip_praw_message(child_message)
self._content_data.append(data)
else:
# Comments also return children, but we don't display them
# in the Inbox page so they don't need to be parsed here.
data = self.strip_praw_message(item)
self._content_data.append(data)
data = self._content_data[index]
indent_level = min(data['level'], self.max_indent_level)
data['h_offset'] = indent_level * self.indent_size
width = n_cols - data['h_offset']
data['split_body'] = self.wrap_text(data['body'], width=width)
data['n_rows'] = len(data['split_body']) + 2
return data
class RequestHeaderRateLimiter(DefaultHandler):
"""Custom PRAW request handler for rate-limiting requests.
@@ -952,7 +1122,6 @@ class RequestHeaderRateLimiter(DefaultHandler):
"""Remove items from cache matching URLs.
Return the number of items removed.
"""
if isinstance(urls, six.text_type):
urls = [urls]

View File

@@ -55,20 +55,21 @@ https://github.com/michael-lazar/rtv
Q : Force quit
a : Upvote
z : Downvote
c : Compose a new submission/comment
c : Compose a new submission/comment/reply
C : Compose a new private message
e : Edit a submission/comment
d : Delete a submission/comment
i : Display new messages
i : View inbox
s : Show subscribed subreddits
S : Show subscribed multireddits
w : Save a submission/comment
w : Save submission/comment, or mark message as read
l : View comments, or open comment in pager
h : Return to subreddit
o : Open the submission or comment url
SPACE : Hide a submission, or fold/expand the selected comment tree
b : Display urls with urlview
y : Copy submission permalink to clipboard
Y : Copy submission or comment urls to clipboard
h : Return to the previous page
o : Open the submission/comment URL
SPACE : Hide submission, or fold/expand the selected comment tree
b : Send submission/comment URLs to a urlview program
y : Copy submission permalink to the clipboard
Y : Copy submission/comment URLs to the clipboard
F2 : Cycle to previous theme
F3 : Cycle to next theme
@@ -105,6 +106,10 @@ BANNER_SEARCH = """
[1]relevance [2]top [3]comments [4]new
"""
BANNER_INBOX = """
[1]all [2]unread [3]messages [4]comments [5]posts [6]mentions [7]sent
"""
FOOTER_SUBREDDIT = """
[?]Help [q]Quit [l]Comments [/]Prompt [u]Login [o]Open [c]Post [a/z]Vote [r]Refresh
"""
@@ -114,12 +119,16 @@ FOOTER_SUBMISSION = """
"""
FOOTER_SUBSCRIPTION = """
[?]Help [q]Quit [h]Return [l]Select
[?]Help [q]Quit [h]Return [l]Select Subreddit [r]Refresh
"""
FOOTER_INBOX = """
[?]Help [l]View Context [o]Open Submission [c]Reply [w]Mark Read [r]Refresh
"""
TOKEN = "INSTRUCTIONS"
COMMENT_FILE = """<!--{token}
REPLY_FILE = """<!--{token}
Replying to {{author}}'s {{type}}:
{{content}}
@@ -154,6 +163,16 @@ The submission is shown below, update it and save the file.
{{content}}
""".format(token=TOKEN)
MESSAGE_FILE = """<!--{token}
Compose a new private message
Enter your message below this instruction block:
- The first line should contain the recipient's reddit name
- The second line should contain the message subject
- Subsequent lines will be interpreted as the message body
{token}-->
""".format(token=TOKEN)
OAUTH_ACCESS_DENIED = """\
<h1 style="color: red">Access Denied</h1><hr>
<p><span style="font-weight: bold">Reddit Terminal Viewer</span> was

View File

@@ -39,6 +39,10 @@ class SubscriptionError(RTVError):
"Content could not be fetched"
class InboxError(RTVError):
"Content could not be fetched"
class ProgramError(RTVError):
"Problem executing an external program"

204
rtv/inbox_page.py Normal file
View File

@@ -0,0 +1,204 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from . import docs
from .content import InboxContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
class InboxController(PageController):
character_map = {}
class InboxPage(Page):
BANNER = docs.BANNER_INBOX
FOOTER = docs.FOOTER_INBOX
name = 'inbox'
def __init__(self, reddit, term, config, oauth, content_type='all'):
super(InboxPage, self).__init__(reddit, term, config, oauth)
self.controller = InboxController(self, keymap=config.keymap)
self.content = InboxContent.from_user(reddit, term.loader, content_type)
self.nav = Navigator(self.content.get)
self.content_type = content_type
def handle_selected_page(self):
"""
Open the subscription and submission pages subwindows, but close the
current page if any other type of page is selected.
"""
if not self.selected_page:
pass
if self.selected_page.name in ('subscription', 'submission'):
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name in ('subreddit', 'inbox'):
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
@logged_in
def refresh_content(self, order=None, name=None):
"""
Re-download all inbox content and reset the page index
"""
self.content_type = order or self.content_type
with self.term.loader():
self.content = InboxContent.from_user(
self.reddit, self.term.loader, self.content_type)
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@InboxController.register(Command('SORT_1'))
def load_content_inbox(self):
self.refresh_content(order='all')
@InboxController.register(Command('SORT_2'))
def load_content_unread_messages(self):
self.refresh_content(order='unread')
@InboxController.register(Command('SORT_3'))
def load_content_messages(self):
self.refresh_content(order='messages')
@InboxController.register(Command('SORT_4'))
def load_content_comment_replies(self):
self.refresh_content(order='comments')
@InboxController.register(Command('SORT_5'))
def load_content_post_replies(self):
self.refresh_content(order='posts')
@InboxController.register(Command('SORT_6'))
def load_content_username_mentions(self):
self.refresh_content(order='mentions')
@InboxController.register(Command('SORT_7'))
def load_content_sent_messages(self):
self.refresh_content(order='sent')
@InboxController.register(Command('INBOX_MARK_READ'))
@logged_in
def mark_seen(self):
"""
Mark the selected message or comment as seen.
"""
data = self.get_selected_item()
if data['is_new']:
with self.term.loader('Marking as read'):
data['object'].mark_as_read()
if not self.term.loader.exception:
data['is_new'] = False
else:
with self.term.loader('Marking as unread'):
data['object'].mark_as_unread()
if not self.term.loader.exception:
data['is_new'] = True
@InboxController.register(Command('INBOX_REPLY'))
@logged_in
def inbox_reply(self):
"""
Reply to the selected private message or comment from the inbox.
"""
self.reply()
@InboxController.register(Command('INBOX_EXIT'))
def close_inbox(self):
"""
Close inbox and return to the previous page.
"""
self.active = False
@InboxController.register(Command('INBOX_VIEW_CONTEXT'))
@logged_in
def view_context(self):
"""
View the context surrounding the selected comment.
"""
url = self.get_selected_item().get('context')
if url:
self.selected_page = self.open_submission_page(url)
@InboxController.register(Command('INBOX_OPEN_SUBMISSION'))
@logged_in
def open_submission(self):
"""
Open the full submission and comment tree for the selected comment.
"""
url = self.get_selected_item().get('submission_permalink')
if url:
self.selected_page = self.open_submission_page(url)
def _draw_item(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
n_cols -= 1 # Leave space for the cursor in the first column
# Handle the case where the window is not large enough to fit the data.
valid_rows = range(0, n_rows)
offset = 0 if not inverted else -(data['n_rows'] - n_rows)
row = offset
if row in valid_rows:
if data['is_new']:
attr = self.term.attr('New')
self.term.add_line(win, '[new]', row, 1, attr)
self.term.add_space(win)
attr = self.term.attr('MessageSubject')
self.term.add_line(win, '{subject}'.format(**data), attr=attr)
self.term.add_space(win)
else:
attr = self.term.attr('MessageSubject')
self.term.add_line(win, '{subject}'.format(**data), row, 1, attr)
self.term.add_space(win)
if data['link_title']:
attr = self.term.attr('MessageLink')
self.term.add_line(win, '{link_title}'.format(**data), attr=attr)
row = offset + 1
if row in valid_rows:
# reddit.user might be ``None`` if the user logs out while viewing
# this page
if data['author'] == getattr(self.reddit.user, 'name', None):
self.term.add_line(win, 'to ', row, 1)
text = '{recipient}'.format(**data)
else:
self.term.add_line(win, 'from ', row, 1)
text = '{author}'.format(**data)
attr = self.term.attr('MessageAuthor')
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
if data['distinguished']:
attr = self.term.attr('Distinguished')
text = '[{distinguished}]'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
attr = self.term.attr('Created')
text = 'sent {created_long}'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
if data['subreddit_name']:
attr = self.term.attr('MessageSubreddit')
text = 'via {subreddit_name}'.format(**data)
self.term.add_line(win, text, attr=attr)
self.term.add_space(win)
attr = self.term.attr('MessageText')
for row, text in enumerate(data['split_body'], start=offset + 2):
if row in valid_rows:
self.term.add_line(win, text, row, 1, attr=attr)
attr = self.term.attr('CursorBlock')
for y in range(n_rows):
self.term.addch(win, y, 0, str(' '), attr)

View File

@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import os
import sys
import time
@@ -43,7 +44,6 @@ class Page(object):
FOOTER = None
def __init__(self, reddit, term, config, oauth):
self.reddit = reddit
self.term = term
self.config = config
@@ -54,6 +54,7 @@ class Page(object):
self.copy_to_clipboard = copy
self.active = True
self.selected_page = None
self._row = 0
self._subwindows = None
@@ -64,6 +65,9 @@ class Page(object):
raise NotImplementedError
def get_selected_item(self):
"""
Return the content dictionary that is currently selected by the cursor.
"""
return self.content.get(self.nav.absolute_index)
def loop(self):
@@ -72,34 +76,91 @@ class Page(object):
1. Re-draw the screen
2. Wait for user to press a key (includes terminal resizing)
3. Trigger the method registered to the input key
4. Check if there are any nested pages that need to be looped over
The loop will run until self.active is set to False from within one of
the methods.
"""
self.active = True
# This needs to be called once before the main loop, in case a subpage
# was pre-selected before the loop started. This happens in __main__.py
# with ``page.open_submission(url=url)``
while self.selected_page and self.active:
self.handle_selected_page()
while self.active:
self.draw()
ch = self.term.stdscr.getch()
self.controller.trigger(ch)
while self.selected_page and self.active:
self.handle_selected_page()
return self.selected_page
def handle_selected_page(self):
"""
Some commands will result in an action that causes a new page to open.
Examples include selecting a submission, viewing subscribed subreddits,
or opening the user's inbox. With these commands, the newly selected
page will be pre-loaded and stored in ``self.selected_page`` variable.
It's up to each page type to determine what to do when another page is
selected.
- It can start a nested page.loop(). This would allow the user to
return to their previous screen after exiting the sub-page. For
example, this is what happens when opening an individual submission
from within a subreddit page. When the submission is closed, the
user resumes the subreddit that they were previously viewing.
- It can close the current self.loop() and bubble the selected page up
one level in the loop stack. For example, this is what happens when
the user opens their subscriptions and selects a subreddit. The
subscription page loop is closed and the selected subreddit is
bubbled up to the root level loop.
Care should be taken to ensure the user can never enter an infinite
nested loop, as this could lead to memory leaks and recursion errors.
# Example of an unsafe nested loop
subreddit_page.loop()
-> submission_page.loop()
-> subreddit_page.loop()
-> submission_page.loop()
...
"""
raise NotImplementedError
@PageController.register(Command('REFRESH'))
def reload_page(self):
"""
Clear the PRAW cache to force the page the re-fetch content from reddit.
"""
self.reddit.handler.clear_cache()
self.refresh_content()
@PageController.register(Command('EXIT'))
def exit(self):
"""
Prompt and exit the application.
"""
if self.term.prompt_y_or_n('Do you really want to quit? (y/n): '):
sys.exit()
@PageController.register(Command('FORCE_EXIT'))
def force_exit(self):
"""
Immediately exit the application.
"""
sys.exit()
@PageController.register(Command('PREVIOUS_THEME'))
def previous_theme(self):
"""
Cycle to preview the previous theme from the internal list of themes.
"""
theme = self.term.theme_list.previous(self.term.theme)
while not self.term.check_theme(theme):
theme = self.term.theme_list.previous(theme)
@@ -111,7 +172,9 @@ class Page(object):
@PageController.register(Command('NEXT_THEME'))
def next_theme(self):
"""
Cycle to preview the next theme from the internal list of themes.
"""
theme = self.term.theme_list.next(self.term.theme)
while not self.term.check_theme(theme):
theme = self.term.theme_list.next(theme)
@@ -123,36 +186,57 @@ class Page(object):
@PageController.register(Command('HELP'))
def show_help(self):
"""
Open the help documentation in the system pager.
"""
self.term.open_pager(docs.HELP.strip())
@PageController.register(Command('MOVE_UP'))
def move_cursor_up(self):
"""
Move the cursor up one selection.
"""
self._move_cursor(-1)
self.clear_input_queue()
@PageController.register(Command('MOVE_DOWN'))
def move_cursor_down(self):
"""
Move the cursor down one selection.
"""
self._move_cursor(1)
self.clear_input_queue()
@PageController.register(Command('PAGE_UP'))
def move_page_up(self):
"""
Move the cursor up approximately the number of entries on the page.
"""
self._move_page(-1)
self.clear_input_queue()
@PageController.register(Command('PAGE_DOWN'))
def move_page_down(self):
"""
Move the cursor down approximately the number of entries on the page.
"""
self._move_page(1)
self.clear_input_queue()
@PageController.register(Command('PAGE_TOP'))
def move_page_top(self):
"""
Move the cursor to the first item on the page.
"""
self.nav.page_index = self.content.range[0]
self.nav.cursor_index = 0
self.nav.inverted = False
@PageController.register(Command('PAGE_BOTTOM'))
def move_page_bottom(self):
"""
Move the cursor to the last item on the page.
"""
self.nav.page_index = self.content.range[1]
self.nav.cursor_index = 0
self.nav.inverted = True
@@ -160,6 +244,9 @@ class Page(object):
@PageController.register(Command('UPVOTE'))
@logged_in
def upvote(self):
"""
Upvote the currently selected item.
"""
data = self.get_selected_item()
if 'likes' not in data:
self.term.flash()
@@ -179,6 +266,9 @@ class Page(object):
@PageController.register(Command('DOWNVOTE'))
@logged_in
def downvote(self):
"""
Downvote the currently selected item.
"""
data = self.get_selected_item()
if 'likes' not in data:
self.term.flash()
@@ -198,6 +288,9 @@ class Page(object):
@PageController.register(Command('SAVE'))
@logged_in
def save(self):
"""
Mark the currently selected item as saved through the reddit API.
"""
data = self.get_selected_item()
if 'saved' not in data:
self.term.flash()
@@ -218,7 +311,6 @@ class Page(object):
Prompt to log into the user's account, or log out of the current
account.
"""
if self.reddit.is_oauth_session():
ch = self.term.show_notification('Log out? (y/n)')
if ch in (ord('y'), ord('Y')):
@@ -227,13 +319,64 @@ class Page(object):
else:
self.oauth.authorize()
def reply(self):
"""
Reply to the selected item. This is a utility method and should not
be bound to a key directly.
Item type:
Submission - add a top level comment
Comment - add a comment reply
Message - reply to a private message
"""
data = self.get_selected_item()
if data['type'] == 'Submission':
body = data['text']
description = 'submission'
reply = data['object'].add_comment
elif data['type'] in ('Comment', 'InboxComment'):
body = data['body']
description = 'comment'
reply = data['object'].reply
elif data['type'] == 'Message':
body = data['body']
description = 'private message'
reply = data['object'].reply
else:
self.term.flash()
return
# Construct the text that will be displayed in the editor file.
# The post body will be commented out and added for reference
lines = [' |' + line for line in body.split('\n')]
content = '\n'.join(lines)
comment_info = docs.REPLY_FILE.format(
author=data['author'],
type=description,
content=content)
with self.term.open_editor(comment_info) as comment:
if not comment:
self.term.show_notification('Canceled')
return
with self.term.loader('Posting {}'.format(description), delay=0):
reply(comment)
# Give reddit time to process the submission
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
else:
raise TemporaryFileError()
@PageController.register(Command('DELETE'))
@logged_in
def delete_item(self):
"""
Delete a submission or comment.
"""
data = self.get_selected_item()
if data.get('author') != self.reddit.user.name:
self.term.flash()
@@ -248,6 +391,7 @@ class Page(object):
data['object'].delete()
# Give reddit time to process the request
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
@@ -257,7 +401,6 @@ class Page(object):
"""
Edit a submission or comment.
"""
data = self.get_selected_item()
if data.get('author') != self.reddit.user.name:
self.term.flash()
@@ -289,20 +432,52 @@ class Page(object):
else:
raise TemporaryFileError()
@PageController.register(Command('INBOX'))
@PageController.register(Command('PRIVATE_MESSAGE'))
@logged_in
def get_inbox(self):
def send_private_message(self):
"""
Checks the inbox for unread messages and displays a notification.
Send a new private message to another user.
"""
message_info = docs.MESSAGE_FILE
with self.term.open_editor(message_info) as text:
if not text:
self.term.show_notification('Canceled')
return
with self.term.loader('Loading'):
messages = self.reddit.get_unread(limit=1)
inbox = len(list(messages))
parts = text.split('\n', 2)
if len(parts) == 1:
self.term.show_notification('Missing message subject')
return
elif len(parts) == 2:
self.term.show_notification('Missing message body')
return
if self.term.loader.exception is None:
message = 'New Messages' if inbox > 0 else 'No New Messages'
self.term.show_notification(message)
recipient, subject, message = parts
recipient = recipient.strip()
subject = subject.strip()
message = message.rstrip()
if not recipient:
self.term.show_notification('Missing recipient')
return
elif not subject:
self.term.show_notification('Missing message subject')
return
elif not message:
self.term.show_notification('Missing message body')
return
with self.term.loader('Sending message', delay=0):
self.reddit.send_message(
recipient, subject, message, raise_captcha_exception=True)
# Give reddit time to process the message
time.sleep(2.0)
if self.term.loader.exception:
raise TemporaryFileError()
else:
self.term.show_notification('Message sent!')
self.selected_page = self.open_inbox_page('sent')
def prompt_and_select_link(self):
"""
@@ -340,31 +515,23 @@ class Page(object):
@PageController.register(Command('COPY_PERMALINK'))
def copy_permalink(self):
"""
Copies submission permalink to OS clipboard
Copy the submission permalink to OS clipboard
"""
data = self.get_selected_item()
url = data.get('permalink')
if url is None:
self.term.flash()
return
try:
self.copy_to_clipboard(url)
except (ProgramError, OSError) as e:
_logger.exception(e)
self.term.show_notification(
'Failed to copy permalink: {0}'.format(e))
else:
self.term.show_notification(
'Copied permalink to clipboard', timeout=1)
url = self.get_selected_item().get('permalink')
self.copy_to_clipboard(url)
@PageController.register(Command('COPY_URL'))
def copy_url(self):
"""
Copies link to OS clipboard
Copy a link to OS clipboard
"""
url = self.prompt_and_select_link()
self.copy_to_clipboard(url)
def copy_to_clipboard(self, url):
"""
Attempt to copy the selected URL to the user's clipboard
"""
if url is None:
self.term.flash()
return
@@ -379,11 +546,104 @@ class Page(object):
self.term.show_notification(
['Copied to clipboard:', url], timeout=1)
@PageController.register(Command('SUBSCRIPTIONS'))
@logged_in
def subscriptions(self):
"""
View a list of the user's subscribed subreddits
"""
self.selected_page = self.open_subscription_page('subreddit')
@PageController.register(Command('MULTIREDDITS'))
@logged_in
def multireddits(self):
"""
View a list of the user's subscribed multireddits
"""
self.selected_page = self.open_subscription_page('multireddit')
@PageController.register(Command('PROMPT'))
def prompt(self):
"""
Open a prompt to navigate to a different subreddit or comment"
"""
name = self.term.prompt_input('Enter page: /')
if name:
# Check if opening a submission url or a subreddit url
# Example patterns for submissions:
# comments/571dw3
# /comments/571dw3
# /r/pics/comments/571dw3/
# https://www.reddit.com/r/pics/comments/571dw3/at_disneyland
submission_pattern = re.compile(r'(^|/)comments/(?P<id>.+?)($|/)')
match = submission_pattern.search(name)
if match:
url = 'https://www.reddit.com/comments/{0}'.format(match.group('id'))
self.selected_page = self.open_submission_page(url)
else:
self.selected_page = self.open_subreddit_page(name)
@PageController.register(Command('INBOX'))
@logged_in
def inbox(self):
"""
View the user's inbox.
"""
self.selected_page = self.open_inbox_page('all')
def open_inbox_page(self, content_type):
"""
Open an instance of the inbox page for the logged in user.
"""
from .inbox_page import InboxPage
with self.term.loader('Loading inbox'):
page = InboxPage(self.reddit, self.term, self.config, self.oauth,
content_type=content_type)
if not self.term.loader.exception:
return page
def open_subscription_page(self, content_type):
"""
Open an instance of the subscriptions page with the selected content.
"""
from .subscription_page import SubscriptionPage
with self.term.loader('Loading {0}s'.format(content_type)):
page = SubscriptionPage(self.reddit, self.term, self.config,
self.oauth, content_type=content_type)
if not self.term.loader.exception:
return page
def open_submission_page(self, url=None, submission=None):
"""
Open an instance of the submission page for the given submission URL.
"""
from .submission_page import SubmissionPage
with self.term.loader('Loading submission'):
page = SubmissionPage(self.reddit, self.term, self.config,
self.oauth, url=url, submission=submission)
if not self.term.loader.exception:
return page
def open_subreddit_page(self, name):
"""
Open an instance of the subreddit page for the given subreddit name.
"""
from .subreddit_page import SubredditPage
with self.term.loader('Loading subreddit'):
page = SubredditPage(self.reddit, self.term, self.config,
self.oauth, name)
if not self.term.loader.exception:
return page
def clear_input_queue(self):
"""
Clear excessive input caused by the scroll wheel or holding down a key
"""
with self.term.no_delay():
while self.term.getch() != -1:
continue

View File

@@ -1,14 +1,10 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import time
from . import docs
from .content import SubmissionContent, SubredditContent
from .content import SubmissionContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
from .exceptions import TemporaryFileError
class SubmissionController(PageController):
@@ -16,10 +12,11 @@ class SubmissionController(PageController):
class SubmissionPage(Page):
BANNER = docs.BANNER_SUBMISSION
FOOTER = docs.FOOTER_SUBMISSION
name = 'submission'
def __init__(self, reddit, term, config, oauth, url=None, submission=None):
super(SubmissionPage, self).__init__(reddit, term, config, oauth)
@@ -33,9 +30,44 @@ class SubmissionPage(Page):
self.content = SubmissionContent(
submission, term.loader,
max_comment_cols=config['max_comment_cols'])
# Start at the submission post, which is indexed as -1
self.nav = Navigator(self.content.get, page_index=-1)
self.selected_subreddit = None
def handle_selected_page(self):
"""
Open the subscription page in a subwindow, but close the current page
if any other type of page is selected.
"""
if not self.selected_page:
pass
elif self.selected_page.name == 'subscription':
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name in ('subreddit', 'submission', 'inbox'):
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
def refresh_content(self, order=None, name=None):
"""
Re-download comments and reset the page index
"""
order = order or self.content.order
url = name or self.content.name
# Hack to allow an order specified in the name by prompt_subreddit() to
# override the current default
if order == 'ignore':
order = None
with self.term.loader('Refreshing page'):
self.content = SubmissionContent.from_url(
self.reddit, url, self.term.loader, order=order,
max_comment_cols=self.config['max_comment_cols'])
if not self.term.loader.exception:
self.nav = Navigator(self.content.get, page_index=-1)
@SubmissionController.register(Command('SORT_1'))
def sort_content_hot(self):
@@ -62,7 +94,6 @@ class SubmissionPage(Page):
"""
Toggle the selected comment tree between visible and hidden
"""
current_index = self.nav.absolute_index
self.content.toggle(current_index)
@@ -84,57 +115,8 @@ class SubmissionPage(Page):
"""
Close the submission and return to the subreddit page
"""
self.active = False
def refresh_content(self, order=None, name=None):
"""
Re-download comments and reset the page index
"""
order = order or self.content.order
url = name or self.content.name
# Hack to allow an order specified in the name by prompt_subreddit() to
# override the current default
if order == 'ignore':
order = None
with self.term.loader('Refreshing page'):
self.content = SubmissionContent.from_url(
self.reddit, url, self.term.loader, order=order,
max_comment_cols=self.config['max_comment_cols'])
if not self.term.loader.exception:
self.nav = Navigator(self.content.get, page_index=-1)
@SubmissionController.register(Command('PROMPT'))
def prompt_subreddit(self):
"""
Open a prompt to navigate to a different subreddit
"""
name = self.term.prompt_input('Enter page: /')
if name is not None:
# Check if opening a submission url or a subreddit url
# Example patterns for submissions:
# comments/571dw3
# /comments/571dw3
# /r/pics/comments/571dw3/
# https://www.reddit.com/r/pics/comments/571dw3/at_disneyland
submission_pattern = re.compile(r'(^|/)comments/(?P<id>.+?)($|/)')
match = submission_pattern.search(name)
if match:
url = 'https://www.reddit.com/comments/{0}'
self.refresh_content('ignore', url.format(match.group('id')))
else:
with self.term.loader('Loading page'):
content = SubredditContent.from_name(
self.reddit, name, self.term.loader)
if not self.term.loader.exception:
self.selected_subreddit = content
self.active = False
@SubmissionController.register(Command('SUBMISSION_OPEN_IN_BROWSER'))
def open_link(self):
"""
@@ -161,7 +143,6 @@ class SubmissionPage(Page):
"""
Open the selected item with the system's pager
"""
n_rows, n_cols = self.term.stdscr.getmaxyx()
if self.config['max_pager_cols'] is not None:
@@ -182,46 +163,8 @@ class SubmissionPage(Page):
def add_comment(self):
"""
Submit a reply to the selected item.
Selected item:
Submission - add a top level comment
Comment - add a comment reply
"""
data = self.get_selected_item()
if data['type'] == 'Submission':
body = data['text']
reply = data['object'].add_comment
elif data['type'] == 'Comment':
body = data['body']
reply = data['object'].reply
else:
self.term.flash()
return
# Construct the text that will be displayed in the editor file.
# The post body will be commented out and added for reference
lines = [' |' + line for line in body.split('\n')]
content = '\n'.join(lines)
comment_info = docs.COMMENT_FILE.format(
author=data['author'],
type=data['type'].lower(),
content=content)
with self.term.open_editor(comment_info) as comment:
if not comment:
self.term.show_notification('Canceled')
return
with self.term.loader('Posting', delay=0):
reply(comment)
# Give reddit time to process the submission
time.sleep(2.0)
if self.term.loader.exception is None:
self.reload_page()
else:
raise TemporaryFileError()
self.reply()
@SubmissionController.register(Command('DELETE'))
@logged_in
@@ -229,7 +172,6 @@ class SubmissionPage(Page):
"""
Delete the selected comment
"""
if self.get_selected_item()['type'] == 'Comment':
self.delete_item()
else:
@@ -240,7 +182,6 @@ class SubmissionPage(Page):
"""
Open the selected comment with the URL viewer
"""
data = self.get_selected_item()
comment = data.get('body') or data.get('text') or data.get('url_full')
if comment:
@@ -254,7 +195,6 @@ class SubmissionPage(Page):
Move the cursor up to the comment's parent. If the comment is
top-level, jump to the previous top-level comment.
"""
cursor = self.nav.absolute_index
if cursor > 0:
level = max(self.content.get(cursor)['level'], 1)
@@ -273,7 +213,6 @@ class SubmissionPage(Page):
Jump to the next comment that's at the same level as the selected
comment and shares the same parent.
"""
cursor = self.nav.absolute_index
if cursor >= 0:
level = self.content.get(cursor)['level']

View File

@@ -1,15 +1,12 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import re
import time
from . import docs
from .content import SubredditContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
from .submission_page import SubmissionPage
from .subscription_page import SubscriptionPage
from .exceptions import TemporaryFileError
@@ -18,10 +15,11 @@ class SubredditController(PageController):
class SubredditPage(Page):
BANNER = docs.BANNER_SUBREDDIT
FOOTER = docs.FOOTER_SUBREDDIT
name = 'subreddit'
def __init__(self, reddit, term, config, oauth, name):
"""
Params:
@@ -34,11 +32,25 @@ class SubredditPage(Page):
self.nav = Navigator(self.content.get)
self.toggled_subreddit = None
def handle_selected_page(self):
"""
Open all selected pages in subwindows except other subreddit pages.
"""
if not self.selected_page:
pass
elif self.selected_page.name in ('subscription', 'submission', 'inbox'):
# Launch page in a subwindow
self.selected_page = self.selected_page.loop()
elif self.selected_page.name == 'subreddit':
# Replace the current page
self.active = False
else:
raise RuntimeError(self.selected_page.name)
def refresh_content(self, order=None, name=None):
"""
Re-download all submissions and reset the page index
"""
order = order or self.content.order
# Preserve the query if staying on the current page
@@ -113,7 +125,6 @@ class SubredditPage(Page):
"""
Open a prompt to search the given subreddit
"""
name = name or self.content.name
query = self.term.prompt_input('Search {0}: '.format(name))
@@ -126,29 +137,6 @@ class SubredditPage(Page):
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubredditController.register(Command('PROMPT'))
def prompt_subreddit(self):
"""
Open a prompt to navigate to a different subreddit"
"""
name = self.term.prompt_input('Enter page: /')
if name is not None:
# Check if opening a submission url or a subreddit url
# Example patterns for submissions:
# comments/571dw3
# /comments/571dw3
# /r/pics/comments/571dw3/
# https://www.reddit.com/r/pics/comments/571dw3/at_disneyland
submission_pattern = re.compile(r'(^|/)comments/(?P<id>.+?)($|/)')
match = submission_pattern.search(name)
if match:
submission_url = 'https://www.reddit.com/comments/{0}'
self.open_submission(submission_url.format(match.group('id')))
else:
self.refresh_content(order='ignore', name=name)
@SubredditController.register(Command('SUBREDDIT_FRONTPAGE'))
def show_frontpage(self):
"""
@@ -171,26 +159,13 @@ class SubredditPage(Page):
"""
Select the current submission to view posts.
"""
data = {}
if url is None:
data = self.get_selected_item()
url = data['permalink']
if data.get('url_type') == 'selfpost':
self.config.history.add(data['url_full'])
with self.term.loader('Loading submission'):
page = SubmissionPage(
self.reddit, self.term, self.config, self.oauth, url=url)
if self.term.loader.exception:
return
page.loop()
if data.get('url_type') == 'selfpost':
self.config.history.add(data['url_full'])
if page.selected_subreddit is not None:
self.content = page.selected_subreddit
self.nav = Navigator(self.content.get)
self.selected_page = self.open_submission_page(url)
@SubredditController.register(Command('SUBREDDIT_OPEN_IN_BROWSER'))
def open_link(self):
@@ -214,9 +189,8 @@ class SubredditPage(Page):
@logged_in
def post_submission(self):
"""
Post a new submission to the given subreddit
Post a new submission to the given subreddit.
"""
# Check that the subreddit can be submitted to
name = self.content.name
if '+' in name or name in ('/r/all', '/r/front', '/r/me', '/u/saved'):
@@ -242,63 +216,8 @@ class SubredditPage(Page):
raise TemporaryFileError()
if not self.term.loader.exception:
# Open the newly created post
with self.term.loader('Loading submission'):
page = SubmissionPage(
self.reddit, self.term, self.config, self.oauth,
submission=submission)
if self.term.loader.exception:
return
page.loop()
if page.selected_subreddit is not None:
self.content = page.selected_subreddit
self.nav = Navigator(self.content.get)
else:
self.reload_page()
@SubredditController.register(Command('SUBREDDIT_OPEN_SUBSCRIPTIONS'))
@logged_in
def open_subscriptions(self):
"""
Open user subscriptions page
"""
with self.term.loader('Loading subscriptions'):
page = SubscriptionPage(self.reddit, self.term, self.config,
self.oauth, content_type='subreddit')
if self.term.loader.exception:
return
page.loop()
# When the user has chosen a subreddit in the subscriptions list,
# refresh content with the selected subreddit
if page.selected_subreddit is not None:
self.content = page.selected_subreddit
self.nav = Navigator(self.content.get)
@SubredditController.register(Command('SUBREDDIT_OPEN_MULTIREDDITS'))
@logged_in
def open_multireddit_subscriptions(self):
"""
Open user multireddit subscriptions page
"""
with self.term.loader('Loading multireddits'):
page = SubscriptionPage(self.reddit, self.term, self.config,
self.oauth, content_type='multireddit')
if self.term.loader.exception:
return
page.loop()
# When the user has chosen a subreddit in the subscriptions list,
# refresh content with the selected subreddit
if page.selected_subreddit is not None:
self.content = page.selected_subreddit
self.nav = Navigator(self.content.get)
# Open the newly created submission
self.selected_page = self.open_submission_page(submission=submission)
@SubredditController.register(Command('SUBREDDIT_HIDE'))
@logged_in
@@ -314,7 +233,7 @@ class SubredditPage(Page):
with self.term.loader('Hiding'):
data['object'].hide()
data['hidden'] = True
def _draw_item(self, win, data, inverted):
n_rows, n_cols = win.getmaxyx()
@@ -353,8 +272,7 @@ class SubredditPage(Page):
self.term.add_space(win)
attr = self.term.attr('Created')
self.term.add_line(win, '{created}{edited}'.format(**data),
attr=attr)
self.term.add_line(win, '{created}{edited}'.format(**data), attr=attr)
if data['comments'] is not None:
attr = self.term.attr('Separator')

View File

@@ -2,8 +2,8 @@
from __future__ import unicode_literals
from . import docs
from .page import Page, PageController
from .content import SubscriptionContent, SubredditContent
from .content import SubscriptionContent
from .page import Page, PageController, logged_in
from .objects import Navigator, Command
@@ -12,10 +12,11 @@ class SubscriptionController(PageController):
class SubscriptionPage(Page):
BANNER = None
FOOTER = docs.FOOTER_SUBSCRIPTION
name = 'subscription'
def __init__(self, reddit, term, config, oauth, content_type='subreddit'):
super(SubscriptionPage, self).__init__(reddit, term, config, oauth)
@@ -24,13 +25,18 @@ class SubscriptionPage(Page):
reddit, term.loader, content_type)
self.nav = Navigator(self.content.get)
self.content_type = content_type
self.selected_subreddit = None
def handle_selected_page(self):
"""
Always close the current page when another page is selected.
"""
if self.selected_page:
self.active = False
def refresh_content(self, order=None, name=None):
"""
Re-download all subscriptions and reset the page index
"""
# reddit.get_my_subreddits() does not support sorting by order
if order:
self.term.flash()
@@ -42,41 +48,19 @@ class SubscriptionPage(Page):
if not self.term.loader.exception:
self.nav = Navigator(self.content.get)
@SubscriptionController.register(Command('PROMPT'))
def prompt_subreddit(self):
"""
Open a prompt to navigate to a different subreddit
"""
name = self.term.prompt_input('Enter page: /')
if name is not None:
with self.term.loader('Loading page'):
content = SubredditContent.from_name(
self.reddit, name, self.term.loader)
if not self.term.loader.exception:
self.selected_subreddit = content
self.active = False
@SubscriptionController.register(Command('SUBSCRIPTION_SELECT'))
def select_subreddit(self):
"""
Store the selected subreddit and return to the subreddit page
"""
name = self.get_selected_item()['name']
with self.term.loader('Loading page'):
content = SubredditContent.from_name(
self.reddit, name, self.term.loader)
if not self.term.loader.exception:
self.selected_subreddit = content
self.active = False
self.selected_page = self.open_subreddit_page(name)
@SubscriptionController.register(Command('SUBSCRIPTION_EXIT'))
def close_subscriptions(self):
"""
Close subscriptions and return to the subreddit page
"""
self.active = False
def _draw_banner(self):

View File

@@ -143,6 +143,9 @@ PROMPT = /
SAVE = w
COPY_PERMALINK = y
COPY_URL = Y
PRIVATE_MESSAGE = C
SUBSCRIPTIONS = s
MULTIREDDITS = S
; Submission page
SUBMISSION_TOGGLE_COMMENT = 0x20
@@ -159,11 +162,16 @@ SUBREDDIT_SEARCH = f
SUBREDDIT_POST = c
SUBREDDIT_OPEN = l, <KEY_RIGHT>
SUBREDDIT_OPEN_IN_BROWSER = o, <LF>, <KEY_ENTER>
SUBREDDIT_OPEN_SUBSCRIPTIONS = s
SUBREDDIT_OPEN_MULTIREDDITS = S
SUBREDDIT_FRONTPAGE = p
SUBREDDIT_HIDE = 0x20
; Subscription page
SUBSCRIPTION_SELECT = l, <LF>, <KEY_ENTER>, <KEY_RIGHT>
SUBSCRIPTION_EXIT = h, s, S, <ESC>, <KEY_LEFT>
; Inbox page
INBOX_VIEW_CONTEXT = l, <KEY_RIGHT>
INBOX_OPEN_SUBMISSION = o, <LF>, <KEY_ENTER>
INBOX_REPLY = c
INBOX_MARK_READ = w
INBOX_EXIT = h, <ESC>, <KEY_LEFT>

View File

@@ -602,8 +602,8 @@ class Terminal(object):
webbrowser.open_new_tab(url)
finally:
try:
null.close()
except AttributeError:
os.close(null)
except OSError:
pass
os.dup2(stdout, 1)
os.dup2(stderr, 2)