From: Simon Tatham Date: Thu, 14 Dec 2023 18:20:24 +0000 (+0000) Subject: First cut at Examine User. X-Git-Url: https://www.chiark.greenend.org.uk/ucgi/~ian/git?a=commitdiff_plain;h=8595271e5e97ba0bdc8acbba128ba05b3b5ec4ba;p=mastodonochrome.git First cut at Examine User. Currently you can only type in names to examine by hand. --- diff --git a/client.py b/client.py index 8206af6..79d5966 100644 --- a/client.py +++ b/client.py @@ -295,6 +295,22 @@ class StatusInfoFeed(Feed): def __getitem__(self, n): return self.data[n] +class UserInfoFeed(Feed): + def __init__(self, client, account): + self.client = client + self.account = account + self.data = [UserInfo(account, client)] + + def start(self): + pass + + def min_index(self): + return 0 + def max_index(self): + return len(self.data) + def __getitem__(self, n): + return self.data[n] + class EgoFeed(IncrementalServerFeed): def __init__(self, client): super().__init__(client, "notifications", { @@ -429,6 +445,8 @@ class Status: yield text.Paragraph("Client website: " + client_url) yield text.BlankLine() + def get_account_id(self): + return self.account['id'] def get_reply_recipients(self): yield self.client.fq(self.account['acct']) for mention in self.mentions: @@ -458,6 +476,158 @@ class Notification: self.datestamp, self.client.fq(self.account['acct']), self.account['display_name'], self.ntype, self.content) + def get_account_id(self): + return self.account['id'] + def get_reply_recipients(self): + yield self.client.fq(self.account['acct']) + def get_reply_id(self): + return None + +class UserInfo: + def __init__(self, data, client): + self.account = data + self.client = client + + self.relationship = None + try: + for rel in self.client.get("accounts/relationships", id=data['id']): + if rel['id'] == data['id']: + self.relationship = rel + except HTTPError: + pass + + hp = text.HTMLParser() + hp.feed(data['note']) + hp.done() + self.bio = [] + for p in hp.paras: + ip = text.IndentedParagraph(2, 2) + ip.add_para(p) + self.bio.append(ip) + + self.info = [] + for field in self.account['fields']: + hp = text.HTMLParser() + hp.feed(field['value']) + hp.done() + ip = text.IndentedParagraph(2, 4) + name = field['name'] + if not name.endswith(":"): + name += ":" + ip.add(text.ColouredString( + name, 'f' if field['verified_at'] else ' ')) + it = iter(hp.paras) + try: + ip.add_para(next(it)) + except StopIteration: + pass + self.info.append(ip) + + for p in it: + ip = text.IndentedParagraph(4, 4) + ip.add_para(p) + self.bio.append(ip) + + def text(self): + yield text.Paragraph("Account name: " + text.ColouredString( + self.client.fq(self.account['acct']), 'f')) + url = self.account['url'] + yield text.Paragraph("On the web: " + text.ColouredString(url, 'u')) + yield text.BlankLine() + yield text.Paragraph("Display name: " + self.account['display_name']) + yield text.Paragraph("Bio:") + yield from self.bio + yield text.BlankLine() + if len(self.info) > 0: + yield text.Paragraph("Information:") + yield from self.info + yield text.BlankLine() + flags = list(self.flags_text()) + if len(flags) > 0: + yield from flags + yield text.BlankLine() + rel = list(self.relationship_text()) + if len(rel) > 0: + yield text.Paragraph("Relationships to this user:") + yield from rel + yield text.BlankLine() + aid = self.account['id'] + yield text.Paragraph(f"Account id: {aid}") + created = noneify(self.account['created_at']) + yield text.Paragraph(f"Account created: " + created) + posted = noneify(self.account['last_status_at']) + yield text.Paragraph(f"Latest post: " + created) + n = self.account['statuses_count'] + yield text.Paragraph(f"Number of posts: {n}") + yield text.BlankLine() + n = self.account['followers_count'] + yield text.Paragraph(f"Number of followers: {n}") + n = self.account['following_count'] + yield text.Paragraph(f"Number of users followed: {n}") + yield text.BlankLine() + + def flags_text(self): + if self.account['locked']: + yield text.Paragraph( + "This account is " + text.ColouredString("locked", 'r') + + " (you can't follow it without its permission).") + if self.account.get('suspended'): + yield text.Paragraph(text.ColouredString( + "This account is suspended.", 'r')) + if self.account.get('limited'): + yield text.Paragraph(text.ColouredString( + "This account is silenced.", 'r')) + if self.account['bot']: + yield text.Paragraph("This account identifies as a bot.") + if self.account['group']: + yield text.Paragraph("This account identifies as a group.") + moved_to = self.account.get('moved') + if moved_to is not None: + yield text.Paragraph( + text.ColouredString("This account has moved to:", 'r') + " " + + text.ColouredString(self.client.fq(moved_to['acct']), 'f')) + + def relationship_text(self): + if self.relationship is None: + return + if self.account['id'] == self.client.account_id: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "You are this user!", + " ___ ")) + if self.relationship['following']: + if self.relationship['showing_reblogs']: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "You follow this user.", 'f')) + else: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "You follow this user (but without boosts).", 'f')) + if self.relationship['followed_by']: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "This user follows you.", 'f')) + if self.relationship['requested']: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "This user has requested to follow you!", 'F')) + if self.relationship['notifying']: + yield text.IndentedParagraph( + 2, 4, "You have enabled notifications for this user.") + if self.relationship['blocking']: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "You have blocked this user.", 'r')) + if self.relationship['blocked_by']: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "This user has blocked you.", 'r')) + if self.relationship['muting']: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "You have muted this user.", 'r')) + if self.relationship['muting_notifications']: + yield text.IndentedParagraph(2, 4, text.ColouredString( + "You have muted notifications from this user.", 'r')) + if self.relationship['domain_blocking']: + yield text.IndentedParagraph( + 2, 4, "You have blocked this user's domain.") + + def get_account_id(self): + return self.account['id'] def get_reply_recipients(self): yield self.client.fq(self.account['acct']) def get_reply_id(self): diff --git a/cursesclient.py b/cursesclient.py index 87f64de..0f209ed 100644 --- a/cursesclient.py +++ b/cursesclient.py @@ -7,6 +7,7 @@ import select import signal import sys import threading +import time import unittest import client @@ -244,6 +245,7 @@ class CursesUI(client.Client): if ch == ctrl('['): if self.activity_stack[-1] is not self.escape_menu: + self.escape_menu.activation = time.monotonic() self.activity_stack.append(self.escape_menu) elif ch == curses.KEY_RESIZE: self.scr_h, self.scr_w = self.scr.getmaxyx() @@ -270,6 +272,12 @@ class Activity: def push_to(self, activity): self.cc.activity_stack.append(activity) + def optionally_chain_to(self, activity): + if self.cc.activity_stack[-1] is self: + self.chain_to(activity) + else: + self.push_to(activity) + class Menu(Activity): status_extra_text = None @@ -360,6 +368,13 @@ class EscMenu(Menu): self.title = text.ColouredString( "Utilities [ESC]", "HHHHHHHHHHHKKKH") + self.items.append(text.MenuKeypressLine( + 'E', text.ColouredString("Examine User", + "K "))) + self.items.append(text.MenuKeypressLine( + 'Y', text.ColouredString("Examine Yourself", + " K "))) + self.items.append(text.BlankLine()) self.items.append(text.MenuKeypressLine( 'L', text.ColouredString("Logs menu", "K "))) @@ -368,6 +383,18 @@ class EscMenu(Menu): 'X', text.ColouredString("EXit Mastodonochrome", " K "))) + self.activation = None + + def recently_activated(self): + return (self.activation is not None and + time.monotonic() - self.activation < 0.5) + + def prompt_to(self, prompt): + if self.recently_activated(): + self.chain_to(prompt) + else: + self.push_to(prompt) + def handle_key(self, ch): if ch in {'r', 'R'}: self.chain_to(self.cc.mentions_timeline) @@ -377,9 +404,30 @@ class EscMenu(Menu): self.chain_to(self.cc.exit_menu) elif ch in {'g', 'G'}: self.cc.activity_stack[:] = [self.cc.main_menu] + elif ch in {'e', 'E'}: + self.prompt_to(BottomLinePrompt( + self.cc, self.got_user_to_examine, + "Examine User: ")) + elif ch in {'y', 'Y'}: + self.chain_to(UserInfoFile.by_id(self.cc, self.cc.account_id)) else: return super().handle_key(ch) + def got_user_to_examine(self, text): + text = text.strip().lstrip("@") + if text == "": + return + + try: + acct = self.cc.get("accounts/lookup", acct=text) + except client.HTTPError: + curses.beep() # FIXME: better error report? + return + if "id" not in acct: + curses.beep() # FIXME: better error report? + return + self.optionally_chain_to(UserInfoFile(self.cc, acct)) + class LogMenu(Menu): def __init__(self, cc): super().__init__(cc) @@ -469,6 +517,8 @@ class File(Activity): self.thread_mode() elif ch in {'i', 'I'}: self.info_mode() + elif ch in {'e', 'E'}: + self.examine_mode() elif ch in {'\\'}: self.search_direction = -1 self.push_to(BottomLinePrompt( @@ -514,6 +564,9 @@ class File(Activity): elif (self.select_type == 'info' and ch in {' '}): self.info_complete(False) + elif (self.select_type == 'examine' and + ch in {' '}): + self.examine_complete(False) def got_search_text(self, text): if len(text) != 0: @@ -540,6 +593,10 @@ class File(Activity): pass # not supported def thread_mode(self): pass # not supported + def info_mode(self): + pass # not supported + def examine_mode(self): + pass # not supported class ObjectFile(File): def __init__(self, cc, constructor, feed, title): @@ -665,6 +722,8 @@ class ObjectFile(File): elif self.select_type == 'thread': sl.keys.append(('SPACE', 'Show Thread Context')) sl.keys.append(('F', 'Show Full Thread')) + elif self.select_type == 'examine': + sl.keys.append(('SPACE', 'Examine')) elif self.select_type == 'info': sl.keys.append(('SPACE', 'Show Post Info')) sl.keys.append(('-', None)) @@ -713,6 +772,11 @@ class ObjectFile(File): self.mode = 'select' self.select_type = 'info' self.select_target = self.index_by_line[self.linepos-1] + def examine_mode(self): + if self.items_have_authors: + self.mode = 'select' + self.select_type = 'examine' + self.select_target = self.index_by_line[self.linepos-1] def prev_select_target(self): self.select_target = max(self.minpos, self.select_target-1) def next_select_target(self): @@ -786,6 +850,12 @@ class ObjectFile(File): reply_id = target.get_reply_id() self.push_to(StatusInfoFile(self.cc, reply_id)) + def examine_complete(self, full): + self.mode = 'normal' + target = self.statuses[self.select_target] + account_id = target.get_account_id() + self.push_to(UserInfoFile.by_id(self.cc, account_id)) + def move_to(self, pos): old_linepos = self.linepos self.linepos = pos @@ -846,6 +916,21 @@ class StatusInfoFile(ObjectFile): super().__init__( cc, lambda x,cc:x, client.StatusInfoFeed(cc, self.data), title) +class UserInfoFile(ObjectFile): + items_are_statuses = False + items_have_authors = True + def __init__(self, cc, account): + self.account = account + name = cc.fq(account['acct']) + title = text.ColouredString(f"Information about user {name}", 'H') + super().__init__( + cc, lambda x,cc:x, client.UserInfoFeed(cc, self.account), title) + + @classmethod + def by_id(cls, cc, account_id): + account = cc.get("accounts/" + account_id) + return cls(cc, account) + class EditorCommon: # Common editing operations between the post editor and the line editor. # Expects self.text to be the editor buffer, and self.point to be @@ -928,8 +1013,8 @@ class BottomLinePrompt(Activity, EditorCommon): self.ctrl_k_paste_buffer = self.text[self.point:] self.text = (self.text[:self.point]) elif ch in {'\r', '\n'}: - self.callback(self.text) self.chain_to(self.parent_activity) + self.callback(self.text) else: self.handle_common_editing_keys(ch) diff --git a/text.py b/text.py index 8257f68..b75cac0 100644 --- a/text.py +++ b/text.py @@ -34,6 +34,7 @@ colourmap = { 'k': [0, 1], # keypresses in file status lines '-': [0, 7, 40, 36], # separator line between editor header and content '0': [0, 34], # something really boring, like 'none' in place of data + 'r': [0, 31], # red nastinesses like blocking/muting in Examine User } wcswidth_cache = {}