diff --git a/conversation.py b/conversation.py index 784d991d8..c448ccd8e 100644 --- a/conversation.py +++ b/conversation.py @@ -34,7 +34,7 @@ def _get_conversation_filename(base_dir: str, nickname: str, domain: str, def update_conversation(base_dir: str, nickname: str, domain: str, post_json_object: {}) -> bool: - """Ads a post to a conversation index in the /conversation subdirectory + """Adds a post to a conversation index in the /conversation subdirectory """ conversation_filename = \ _get_conversation_filename(base_dir, nickname, domain, diff --git a/daemon.py b/daemon.py index ac6958631..d2ec5e5f0 100644 --- a/daemon.py +++ b/daemon.py @@ -221,6 +221,7 @@ from webapp_suspended import html_suspended from webapp_tos import html_terms_of_service from webapp_confirm import html_confirm_follow from webapp_confirm import html_confirm_unfollow +from webapp_post import html_conversation_thread from webapp_post import html_emoji_reaction_picker from webapp_post import html_post_replies from webapp_post import html_individual_post @@ -11801,6 +11802,72 @@ class PubServer(BaseHTTPRequestHandler): self._redirect_headers(actor_absolute, cookie, calling_domain) return True + def _show_conversation_thread(self, authorized: bool, + calling_domain: str, path: str, + base_dir: str, http_prefix: str, + domain: str, port: int, + debug: str, curr_session) -> bool: + """get conversation thread from the date link on a post + """ + if not authorized: + return False + if not path.startswith('/users/'): + return False + if '?convthread=' not in path: + return False + post_id = path.split('?convthread=')[1].strip() + post_id = post_id.replace('--', '/') + nickname = path.split('/users/')[1] + if '?convthread=' in nickname: + nickname = nickname.split('?convthread=')[0] + if '/' in nickname: + nickname = nickname.split('/')[0] + timezone = None + if self.server.account_timezone.get(nickname): + timezone = \ + self.server.account_timezone.get(nickname) + bold_reading = False + if self.server.bold_reading.get(nickname): + bold_reading = True + conv_str = \ + html_conversation_thread(post_id, self.server.translate, + base_dir, + http_prefix, + nickname, + domain, + self.server.project_version, + self.server.recent_posts_cache, + self.server.max_recent_posts, + curr_session, + self.server.cached_webfingers, + self.server.person_cache, + port, + self.server.yt_replace_domain, + self.server.twitter_replacement_domain, + self.server.show_published_date_only, + self.server.peertube_instances, + self.server.allow_local_network_access, + self.server.theme_name, + self.server.system_language, + self.server.max_like_count, + self.server.signing_priv_key_pem, + self.server.cw_lists, + self.server.lists_enabled, + timezone, bold_reading, + self.server.dogwhistles, + self.server.access_keys, + self.server.min_images_for_accounts, + self.server.debug) + if conv_str: + msg = conv_str.encode('utf-8') + msglen = len(msg) + self._login_headers('text/html', + msglen, calling_domain) + self._write(msg) + self._404() + self.server.getreq_busy = False + return True + def _show_individual_at_post(self, ssml_getreq: bool, authorized: bool, calling_domain: str, referer_domain: str, path: str, @@ -16419,6 +16486,19 @@ class PubServer(BaseHTTPRequestHandler): self.server.debug) return + if self._show_conversation_thread(authorized, + calling_domain, self.path, + self.server.base_dir, + self.server.http_prefix, + self.server.domain, + self.server.port, + self.server.debug, + self.server.session): + fitness_performance(getreq_start_time, self.server.fitness, + '_GET', '_show_conversation_thread', + self.server.debug) + return + # shared items catalog for this instance # this is only accessible to instance members or to # other instances which present an authorization token diff --git a/epicyon.py b/epicyon.py index aea29be4f..a5871e25e 100644 --- a/epicyon.py +++ b/epicyon.py @@ -26,6 +26,7 @@ from roles import set_role from webfinger import webfinger_handle from bookmarks import send_bookmark_via_server from bookmarks import send_undo_bookmark_via_server +from posts import download_conversation_posts from posts import set_post_expiry_days from posts import get_instance_actor_key from posts import send_mute_via_server @@ -183,6 +184,9 @@ def _command_options() -> None: parser.add_argument('-n', '--nickname', dest='nickname', type=str, default=None, help='Nickname of the account to use') + parser.add_argument('--conversation', dest='conversation', type=str, + default=None, + help='Download a conversation for the given post id') parser.add_argument('--screenreader', dest='screenreader', type=str, default=None, help='Name of the screen reader: ' + @@ -1112,7 +1116,12 @@ def _command_options() -> None: sys.exit() if argb.json: - session = create_session(None) + proxy_type = None + if '.onion/' in argb.json: + proxy_type = 'tor' + elif '.i2p/' in argb.json: + proxy_type = 'i2p' + session = create_session(proxy_type) profile_str = 'https://www.w3.org/ns/activitystreams' as_header = { 'Accept': 'application/ld+json; profile="' + profile_str + '"' @@ -1136,6 +1145,38 @@ def _command_options() -> None: pprint(test_json) sys.exit() + if argb.conversation: + post_id = argb.conversation + if '://' not in post_id: + print('--conversation should be the url of a post') + sys.exit() + proxy_type = None + if '.onion/' in post_id: + proxy_type = 'tor' + elif '.i2p/' in post_id: + proxy_type = 'i2p' + session = create_session(proxy_type) + if not argb.domain: + argb.domain = get_config_param(base_dir, 'domain') + domain = '' + if argb.domain: + domain = argb.domain + if not domain: + print('Please specify a domain with the --domain option') + sys.exit() + nickname = '' + if argb.nickname: + nickname = argb.nickname + if not nickname: + print('Please specify a nickname with the --nickname option') + sys.exit() + conv_json = download_conversation_posts(session, http_prefix, + base_dir, nickname, domain, + post_id, argb.debug) + if conv_json: + pprint(conv_json) + sys.exit() + if argb.ssml: session = create_session(None) profile_str = 'https://www.w3.org/ns/activitystreams' diff --git a/posts.py b/posts.py index 7db8e8824..74ded58df 100644 --- a/posts.py +++ b/posts.py @@ -6035,3 +6035,86 @@ def set_max_profile_posts(base_dir: str, nickname: str, domain: str, max_posts_filename) return False return True + + +def download_conversation_posts(session, http_prefix: str, base_dir: str, + nickname: str, domain: str, + post_id: str, debug: bool) -> []: + """Downloads all posts for a conversation and returns a list of the + json objects + """ + if '://' not in post_id: + return [] + profile_str = 'https://www.w3.org/ns/activitystreams' + as_header = { + 'Accept': 'application/ld+json; profile="' + profile_str + '"' + } + conversation_thread = [] + signing_priv_key_pem = get_instance_actor_key(base_dir, domain) + post_id = remove_id_ending(post_id) + post_filename = \ + locate_post(base_dir, nickname, domain, post_id) + if post_filename: + post_json = load_json(post_filename) + else: + post_json = get_json(signing_priv_key_pem, session, post_id, + as_header, None, debug, __version__, + http_prefix, domain) + if debug: + if not post_json: + print(post_id + ' returned no json') + while post_json: + if not has_object_dict(post_json): + if not post_json.get('attributedTo'): + print(str(post_json)) + if debug: + print(post_id + ' has no attributedTo') + break + if not isinstance(post_json['attributedTo'], str): + break + if not post_json.get('published'): + if debug: + print(post_id + ' has no published date') + break + if not post_json.get('to'): + if debug: + print(post_id + ' has no "to" list') + break + if not isinstance(post_json['to'], list): + break + if 'cc' not in post_json: + if debug: + print(post_id + ' has no "cc" list') + break + if not isinstance(post_json['cc'], list): + break + wrapped_post = { + "@context": "https://www.w3.org/ns/activitystreams", + 'id': post_id + '/activity', + 'type': 'Create', + 'actor': post_json['attributedTo'], + 'published': post_json['published'], + 'to': post_json['to'], + 'cc': post_json['cc'], + 'object': post_json + } + post_json = wrapped_post + conversation_thread = [post_json] + conversation_thread + if not post_json['object'].get('inReplyTo'): + if debug: + print(post_id + ' is not a reply') + break + post_id = post_json['object']['inReplyTo'] + post_id = remove_id_ending(post_id) + post_filename = \ + locate_post(base_dir, nickname, domain, post_id) + if post_filename: + post_json = load_json(post_filename) + else: + post_json = get_json(signing_priv_key_pem, session, post_id, + as_header, None, debug, __version__, + http_prefix, domain) + if debug: + if not post_json: + print(post_id + ' returned no json') + return conversation_thread diff --git a/webapp_post.py b/webapp_post.py index 5a45f7618..476db3adb 100644 --- a/webapp_post.py +++ b/webapp_post.py @@ -21,6 +21,7 @@ from announce import no_of_announces from like import liked_by_person from like import no_of_likes from follow import is_following_actor +from posts import download_conversation_posts from posts import post_is_muted from posts import get_person_box from posts import download_announce @@ -91,6 +92,7 @@ from webapp_utils import get_post_attachments_as_html from webapp_utils import html_header_with_external_style from webapp_utils import html_footer from webapp_utils import get_broken_link_substitute +from webapp_utils import html_post_separator from webapp_media import add_embedded_elements from webapp_question import insert_question from devices import e2e_edecrypt_message_from_device @@ -1541,7 +1543,8 @@ def _get_footer_with_icons(show_icons: bool, bookmark_str: str, delete_str: str, mute_str: str, edit_str: str, post_json_object: {}, published_link: str, - time_class: str, published_str: str) -> str: + time_class: str, published_str: str, + nickname: str) -> str: """Returns the html for a post footer containing icons """ if not show_icons: @@ -1553,7 +1556,11 @@ def _get_footer_with_icons(show_icons: bool, reply_str + announce_str + like_str + bookmark_str + reaction_str footer_str += delete_str + mute_str + edit_str if not is_news_post(post_json_object): - footer_str += ' ' + \ published_str + '\n' else: @@ -2289,7 +2296,7 @@ def individual_post_as_html(signing_priv_key_pem: str, like_str, reaction_str, bookmark_str, delete_str, mute_str, edit_str, post_json_object, published_link, - time_class, published_str) + time_class, published_str, nickname) if new_footer_str: footer_str = new_footer_str @@ -2841,3 +2848,90 @@ def html_emoji_reaction_picker(recent_posts_cache: {}, max_recent_posts: int, '\n' return header_str + reacted_to_post_str + emoji_picks_str + html_footer() + + +def html_conversation_thread(post_id: str, + translate: {}, base_dir: str, + http_prefix: str, + nickname: str, domain: str, + project_version: str, + recent_posts_cache: {}, + max_recent_posts: int, + session, + cached_webfingers, + person_cache: {}, + port: int, + yt_replace_domain: str, + twitter_replacement_domain: str, + show_published_date_only: bool, + peertube_instances: [], + allow_local_network_access: bool, + theme_name: str, + system_language: str, + max_like_count: int, + signing_priv_key_pem: str, + cw_lists: {}, + lists_enabled: str, + timezone: str, bold_reading: bool, + dogwhistles: {}, access_keys: {}, + min_images_for_accounts: [], + debug: bool) -> str: + """Show a page containing a conversation thread + """ + conv_posts = \ + download_conversation_posts(session, http_prefix, base_dir, + nickname, domain, + post_id, debug) + + css_filename = base_dir + '/epicyon-profile.css' + if os.path.isfile(base_dir + '/epicyon.css'): + css_filename = base_dir + '/epicyon.css' + + instance_title = \ + get_config_param(base_dir, 'instanceTitle') + conv_str = \ + html_header_with_external_style(css_filename, instance_title, None) + + if not conv_posts: + conv_str += html_footer() + return conv_str + + separator_str = html_post_separator(base_dir, None) + + minimize_all_images = False + if nickname in min_images_for_accounts: + minimize_all_images = True + for post_json_object in conv_posts: + show_individual_post_icons = False + allow_deletion = False + post_str = \ + individual_post_as_html(signing_priv_key_pem, + True, recent_posts_cache, + max_recent_posts, + translate, None, + base_dir, session, cached_webfingers, + person_cache, + nickname, domain, port, + post_json_object, + None, True, allow_deletion, + http_prefix, project_version, + 'search', + yt_replace_domain, + twitter_replacement_domain, + show_published_date_only, + peertube_instances, + allow_local_network_access, + theme_name, system_language, + max_like_count, + show_individual_post_icons, + show_individual_post_icons, + False, False, False, False, + cw_lists, lists_enabled, + timezone, False, bold_reading, + dogwhistles, + minimize_all_images) + if post_str: + conv_str += separator_str + post_str + + conv_str += html_footer() + return conv_str diff --git a/webapp_search.py b/webapp_search.py index 97be7ba07..723b716fe 100644 --- a/webapp_search.py +++ b/webapp_search.py @@ -749,6 +749,7 @@ def html_history_search(translate: {}, base_dir: str, history_search_form += \ '