chiark / gitweb /
All the machinery is ready to try to post!
authorSimon Tatham <anakin@pobox.com>
Thu, 7 Dec 2023 18:52:56 +0000 (18:52 +0000)
committerSimon Tatham <anakin@pobox.com>
Thu, 7 Dec 2023 19:00:42 +0000 (19:00 +0000)
client.py
cursesclient.py

index ebdf714531928932fb8c67fa1b3324bae9e7e1d3..8046b5f898e67bc99f877bd06339b13f6384f147 100644 (file)
--- a/client.py
+++ b/client.py
@@ -291,6 +291,8 @@ class Status:
                 hp.feed(reply_status['content'])
             except HTTPError as ex:
                 hp.feed(f'[unavailable: {ex.response.status_code}]')
+            except KeyError: # returned 200 with an empty JSON object
+                hp.feed(f'[unavailable]')
             hp.done()
             yield text.InReplyToLine(hp.paras)
         yield text.BlankLine()
@@ -304,6 +306,8 @@ class Status:
         yield self.client.fq(self.account['acct'])
         for mention in self.mentions:
             yield self.client.fq(mention['acct'])
+    def get_reply_id(self):
+        return self.post_id
 
 class Notification:
     def __init__(self, data, client):
@@ -329,3 +333,5 @@ class Notification:
 
     def get_reply_recipients(self):
         yield self.client.fq(self.account['acct'])
+    def get_reply_id(self):
+        return None
index 1e9c1f6826f0c8cddb90d2a2d505145e5d115007..6cdbd8f85e89b55779aba3a96cbc5ba182473d7e 100644 (file)
@@ -158,8 +158,8 @@ class CursesUI(client.Client):
             self.composer = Composer(self)
         return self.composer
 
-    def new_composer(self, text):
-        self.composer = Composer(self, text)
+    def new_composer(self, text, reply_header, reply_id):
+        self.composer = Composer(self, text, reply_header, reply_id)
         return self.composer
 
     def run(self):
@@ -545,13 +545,32 @@ class ObjectFile(File):
         print("next ->", self.send_target, file=sys.stderr)
     def send_complete(self):
         self.mode = 'normal'
+
         recipients = collections.OrderedDict()
         for r in self.statuses[self.send_target].get_reply_recipients():
             if r == self.cc.fq_username or r in recipients:
                 continue
             recipients[r] = 1
-        self.push_to(self.cc.new_composer("".join(
-            f"@{r} " for r in recipients)))
+        initial_content = "".join(f"@{r} " for r in recipients)
+
+        reply_id = self.statuses[self.send_target].get_reply_id()
+        if reply_id is not None:
+            hp = text.HTMLParser()
+            try:
+                reply_status = self.cc.get_status_by_id(reply_id)
+                hp.feed(reply_status['content'])
+            except client.HTTPError as ex:
+                hp.feed(f'[unavailable: {ex.response.status_code}]')
+            except KeyError: # returned 200 with an empty JSON object
+                hp.feed(f'[unavailable]')
+            hp.done()
+            reply_header = text.InReplyToLine(hp.paras)
+        else:
+            reply_header = None
+
+        self.push_to(self.cc.new_composer(
+            initial_content, reply_header, reply_id))
+
 
     def move_to(self, pos):
         old_linepos = self.linepos
@@ -694,12 +713,20 @@ class Composer:
                     y += 1
                 pos = next_nl + 1
 
-    def __init__(self, cc, initial_text=""):
+    def __init__(self, cc, initial_text="", reply_header=None, reply_id=None):
         self.cc = cc
-        self.header = text.FileHeader("Compose a post")
+        self.reply_header = reply_header
+        self.reply_id = reply_id
+        if self.reply_header is None:
+            assert self.reply_id is None
+            self.header = text.FileHeader("Compose a post")
+        else:
+            assert self.reply_id is not None
+            self.header = text.FileHeader("Compose a reply")
         self.text = initial_text
         self.point = len(self.text)
         self.goal_column = None
+        self.mode = 'normal'
 
     def render(self):
         y = 0
@@ -708,6 +735,11 @@ class Composer:
             self.cc.print_at(y, 0, line)
             y += 1
 
+        if self.reply_header is not None:
+            for line in self.reply_header.render(self.cc.scr_w):
+                self.cc.print_at(y, 0, line)
+                y += 1
+
         # FIXME: here the real Mono editor has some keypress help of the form
         # [F1]:Options (or [^O])       [F3]:Mark Block     [F6]:Goto      [F8]:Read File
         # [F2]:Finish                                      [F7]:Find      [F9]:Write File
@@ -740,80 +772,105 @@ class Composer:
         if self.goal_column is None or not is_updown:
             self.goal_column = self.cx
 
-        # TODO:
-        #
-        # ^W,^T are Mono's keys for moving back/forward a word.
-        #
-        # Not sure what to do about ^K/^Y for one-line cut-paste, given
-        # that the autowrap makes the semantics a bit weird compared to
-        # the original setup.
-        #
-        # Probably don't want to exactly replicate the block
-        # operations either. Perhaps I should invent my own more sensible
-        # approach to cut, copy and paste.
-
-        if ch in {ctrl('b'), curses.KEY_LEFT}:
-            self.move_to(self.point - 1)
-        elif ch in {ctrl('f'), curses.KEY_RIGHT}:
-            self.move_to(self.point + 1)
-        elif ch in {ctrl('n'), curses.KEY_DOWN}:
-            try:
-                new_point = util.last(
-                    (i for i, yx in enumerate(self.dtext.yx[self.point:],
-                                              self.point)
-                     if yx <= (self.cy + 1, self.goal_column)))
-            except ValueError:
-                new_point = len(self.text)
-
-            self.move_to(new_point)
-            if self.dtext.yx[self.point][0] != self.cy + 1:
-                # Failed to go down; probably went to the end; reset
-                # the goal column.
-                self.goal_column = None
-        elif ch in {ctrl('p'), curses.KEY_UP}:
-            try:
+        if self.mode == 'normal':
+            # TODO:
+            #
+            # ^W,^T are Mono's keys for moving back/forward a word.
+            #
+            # Not sure what to do about ^K/^Y for one-line cut-paste, given
+            # that the autowrap makes the semantics a bit weird compared to
+            # the original setup.
+            #
+            # Probably don't want to exactly replicate the block
+            # operations either. Perhaps I should invent my own more sensible
+            # approach to cut, copy and paste.
+
+            if ch in {ctrl('b'), curses.KEY_LEFT}:
+                self.move_to(self.point - 1)
+            elif ch in {ctrl('f'), curses.KEY_RIGHT}:
+                self.move_to(self.point + 1)
+            elif ch in {ctrl('n'), curses.KEY_DOWN}:
+                try:
+                    new_point = util.last(
+                        (i for i, yx in enumerate(self.dtext.yx[self.point:],
+                                                  self.point)
+                         if yx <= (self.cy + 1, self.goal_column)))
+                except ValueError:
+                    new_point = len(self.text)
+
+                self.move_to(new_point)
+                if self.dtext.yx[self.point][0] != self.cy + 1:
+                    # Failed to go down; probably went to the end; reset
+                    # the goal column.
+                    self.goal_column = None
+            elif ch in {ctrl('p'), curses.KEY_UP}:
+                try:
+                    new_point = util.last(
+                        (i for i, yx in enumerate(self.dtext.yx[:self.point+1])
+                         if yx <= (self.cy - 1, self.goal_column)))
+                except ValueError:
+                    new_point = 0
+
+                self.move_to(new_point)
+                if self.dtext.yx[self.point][0] != self.cy - 1:
+                    # Failed to go down; probably went to the start; reset
+                    # the goal column.
+                    self.goal_column = None
+            elif ch in {ctrl('a'), curses.KEY_HOME}:
+                new_point = next(
+                    i for i, yx in enumerate(self.dtext.yx[:self.point+1])
+                    if yx[0] == self.cy)
+                self.move_to(new_point)
+            elif ch in {ctrl('e'), curses.KEY_END}:
                 new_point = util.last(
-                    (i for i, yx in enumerate(self.dtext.yx[:self.point+1])
-                     if yx <= (self.cy - 1, self.goal_column)))
-            except ValueError:
-                new_point = 0
-
-            self.move_to(new_point)
-            if self.dtext.yx[self.point][0] != self.cy - 1:
-                # Failed to go down; probably went to the start; reset
-                # the goal column.
-                self.goal_column = None
-        elif ch in {ctrl('a'), curses.KEY_HOME}:
-            new_point = next(
-                i for i, yx in enumerate(self.dtext.yx[:self.point+1])
-                if yx[0] == self.cy)
-            self.move_to(new_point)
-        elif ch in {ctrl('e'), curses.KEY_END}:
-            new_point = util.last(
-                i for i, yx in enumerate(self.dtext.yx[self.point:],
-                                         self.point)
-                if yx[0] == self.cy)
-            self.move_to(new_point)
-        elif ch in {ctrl('h'), '\x7F', curses.KEY_BACKSPACE}:
-            if self.point > 0:
-                self.text = self.text[:self.point - 1] + self.text[self.point:]
-                self.point -= 1
-        elif ch in {ctrl('d'), curses.KEY_DC}:
-            if self.point < len(self.text):
-                self.text = self.text[:self.point] + self.text[self.point + 1:]
-        elif ch in {'\r', '\n'}:
-            self.text = (self.text[:self.point] + '\n' +
-                         self.text[self.point:])
-            self.point += 1
-        elif isinstance(ch, str) and (' ' <= ch < '\x7F' or '\xA0' <= ch):
-            # TODO: overwrite mode
-            self.text = (self.text[:self.point] + ch +
-                         self.text[self.point:])
-            self.point += 1
+                    i for i, yx in enumerate(self.dtext.yx[self.point:],
+                                             self.point)
+                    if yx[0] == self.cy)
+                self.move_to(new_point)
+            elif ch in {ctrl('h'), '\x7F', curses.KEY_BACKSPACE}:
+                if self.point > 0:
+                    self.text = (self.text[:self.point - 1] +
+                                 self.text[self.point:])
+                    self.point -= 1
+            elif ch in {ctrl('d'), curses.KEY_DC}:
+                if self.point < len(self.text):
+                    self.text = (self.text[:self.point] +
+                                 self.text[self.point + 1:])
+            elif ch in {'\r', '\n'}:
+                self.text = (self.text[:self.point] + '\n' +
+                             self.text[self.point:])
+                self.point += 1
+            elif ch in {ctrl('o')}:
+                self.mode = 'ctrlo'
+            elif isinstance(ch, str) and (' ' <= ch < '\x7F' or '\xA0' <= ch):
+                # TODO: overwrite mode
+                self.text = (self.text[:self.point] + ch +
+                             self.text[self.point:])
+                self.point += 1
+        elif self.mode == 'ctrlo':
+            if ch == ' ':
+                self.post()
+                self.cc.composer = None
+                self.cc.activity_stack.pop()
+            elif ch == 'q':
+                self.cc.composer = None
+                self.cc.activity_stack.pop()
+            else:
+                self.mode = 'normal'
 
         if not is_updown:
             self.goal_column = None
 
+    def post(self):
+        params = {
+            "status": self.text,
+            "visibility": "public",
+            "language": "en", # FIXME
+        }
+        if self.reply_id is not None:
+            params["in_reply_to_id"] = self.reply_id
+        self.cc.post("statuses", **params)
+
 class testComposerLayout(unittest.TestCase):
     def testLayout(self):
         t = Composer.DisplayText("abc")