chiark / gitweb /
First cut at Examine User.
authorSimon Tatham <anakin@pobox.com>
Thu, 14 Dec 2023 18:20:24 +0000 (18:20 +0000)
committerSimon Tatham <anakin@pobox.com>
Thu, 14 Dec 2023 18:50:33 +0000 (18:50 +0000)
Currently you can only type in names to examine by hand.

client.py
cursesclient.py
text.py

index 8206af6f7e05db9ae6df267ad19650481abc6934..79d5966642657de83381e1fc32e0134e044af07d 100644 (file)
--- 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):
index 87f64ded9843c249234528051baf7766ad9026ee..0f209edcea10f24bad68027d010c469af4ccc311 100644 (file)
@@ -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 8257f688caa92b6c3dc7c584d7d89f663f18f4a3..b75cac0745908353184a72149506de1fafb8ecec 100644 (file)
--- 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 = {}