Only mention tab-completed usernames
This commit is contained in:
		| @@ -3,7 +3,7 @@ | |||||||
| """HTML and Markdown processing tools.""" | """HTML and Markdown processing tools.""" | ||||||
|  |  | ||||||
| import re | import re | ||||||
| from typing import DefaultDict, Dict, List, Tuple | from typing import DefaultDict, Dict, List, Optional, Tuple | ||||||
| from urllib.parse import unquote | from urllib.parse import unquote | ||||||
|  |  | ||||||
| import html_sanitizer.sanitizer as sanitizer | import html_sanitizer.sanitizer as sanitizer | ||||||
| @@ -174,9 +174,6 @@ class HTMLProcessor: | |||||||
|  |  | ||||||
|     extra_newlines_regex = re.compile(r"\n(\n*)") |     extra_newlines_regex = re.compile(r"\n(\n*)") | ||||||
|  |  | ||||||
|     # {room_id: {user_id: username}} |  | ||||||
|     rooms_user_id_names: DefaultDict[str, Dict[str, str]] = DefaultDict(dict) |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     def __init__(self) -> None: |     def __init__(self) -> None: | ||||||
|         # The whitespace remover doesn't take <pre> into account |         # The whitespace remover doesn't take <pre> into account | ||||||
| @@ -214,7 +211,7 @@ class HTMLProcessor: | |||||||
|  |  | ||||||
|  |  | ||||||
|     def user_id_link_in_html(self, html: str, user_id: str) -> bool: |     def user_id_link_in_html(self, html: str, user_id: str) -> bool: | ||||||
|         """Return whether html contains a mention link for user_id.""" |         """Return whether html contains a mention link for `user_id`.""" | ||||||
|  |  | ||||||
|         regex = re.compile(rf"https?://matrix.to/#/{user_id}", re.IGNORECASE) |         regex = re.compile(rf"https?://matrix.to/#/{user_id}", re.IGNORECASE) | ||||||
|  |  | ||||||
| @@ -230,7 +227,7 @@ class HTMLProcessor: | |||||||
|         text:                  str, |         text:                  str, | ||||||
|         inline:                bool                     = False, |         inline:                bool                     = False, | ||||||
|         outgoing:              bool                     = False, |         outgoing:              bool                     = False, | ||||||
|         room_id:  str  = "", |         display_name_mentions: Optional[Dict[str, str]] = None, | ||||||
|     ) -> str: |     ) -> str: | ||||||
|         """Return filtered HTML from Markdown text.""" |         """Return filtered HTML from Markdown text.""" | ||||||
|  |  | ||||||
| @@ -238,7 +235,7 @@ class HTMLProcessor: | |||||||
|             self._markdown_to_html(text), |             self._markdown_to_html(text), | ||||||
|             inline, |             inline, | ||||||
|             outgoing, |             outgoing, | ||||||
|             room_id, |             display_name_mentions, | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|  |  | ||||||
| @@ -247,11 +244,13 @@ class HTMLProcessor: | |||||||
|         html:                  str, |         html:                  str, | ||||||
|         inline:                bool                     = False, |         inline:                bool                     = False, | ||||||
|         outgoing:              bool                     = False, |         outgoing:              bool                     = False, | ||||||
|         room_id:  str  = "", |         display_name_mentions: Optional[Dict[str, str]] = None, | ||||||
|     ) -> str: |     ) -> str: | ||||||
|         """Filter and return HTML.""" |         """Filter and return HTML.""" | ||||||
|  |  | ||||||
|         sanit = Sanitizer(self.sanitize_settings(inline, outgoing, room_id)) |         mentions = display_name_mentions | ||||||
|  |  | ||||||
|  |         sanit = Sanitizer(self.sanitize_settings(inline, outgoing, mentions)) | ||||||
|         html  = sanit.sanitize(html).rstrip("\n") |         html  = sanit.sanitize(html).rstrip("\n") | ||||||
|  |  | ||||||
|         if not html.strip(): |         if not html.strip(): | ||||||
| @@ -262,7 +261,7 @@ class HTMLProcessor: | |||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         for a_tag in tree.iterdescendants("a"): |         for a_tag in tree.iterdescendants("a"): | ||||||
|             self._mentions_to_matrix_to_links(a_tag, room_id, outgoing) |             self._mentions_to_matrix_to_links(a_tag, mentions, outgoing) | ||||||
|  |  | ||||||
|             if not outgoing: |             if not outgoing: | ||||||
|                 self._matrix_to_links_add_classes(a_tag) |                 self._matrix_to_links_add_classes(a_tag) | ||||||
| @@ -286,7 +285,10 @@ class HTMLProcessor: | |||||||
|  |  | ||||||
|  |  | ||||||
|     def sanitize_settings( |     def sanitize_settings( | ||||||
|         self, inline: bool = False, outgoing: bool = False, room_id: str = "", |         self, | ||||||
|  |         inline:                bool                     = False, | ||||||
|  |         outgoing:              bool                     = False, | ||||||
|  |         display_name_mentions: Optional[Dict[str, str]] = None, | ||||||
|     ) -> dict: |     ) -> dict: | ||||||
|         """Return an html_sanitizer configuration.""" |         """Return an html_sanitizer configuration.""" | ||||||
|  |  | ||||||
| @@ -309,12 +311,9 @@ class HTMLProcessor: | |||||||
|             }, |             }, | ||||||
|         }} |         }} | ||||||
|  |  | ||||||
|         username_link_regexes = [] |  | ||||||
|  |  | ||||||
|         if outgoing: |  | ||||||
|         username_link_regexes = [re.compile(r) for r in [ |         username_link_regexes = [re.compile(r) for r in [ | ||||||
|                 rf"(?<!\w)(?P<body>{re.escape(username)})(?!\w)(?P<host>)" |             rf"(?<!\w)(?P<body>{re.escape(name)})(?!\w)(?P<host>)" | ||||||
|                 for username in self.rooms_user_id_names[room_id].values() |             for name in (display_name_mentions or {}) | ||||||
|         ]] |         ]] | ||||||
|  |  | ||||||
|         return { |         return { | ||||||
| @@ -472,11 +471,14 @@ class HTMLProcessor: | |||||||
|  |  | ||||||
|  |  | ||||||
|     def _mentions_to_matrix_to_links( |     def _mentions_to_matrix_to_links( | ||||||
|         self, el: HtmlElement, room_id: str = "", outgoing: bool = False, |         self, | ||||||
|  |         el:                    HtmlElement, | ||||||
|  |         display_name_mentions: Optional[Dict[str, str]] = None, | ||||||
|  |         outgoing:              bool                     = False, | ||||||
|     ) -> HtmlElement: |     ) -> HtmlElement: | ||||||
|         """Turn user ID/names and room ID/aliases into matrix.to URL. |         """Turn user ID, usernames and room ID/aliases into matrix.to URL. | ||||||
|  |  | ||||||
|         After the HTML sanitizer autolinks these, the links's hrefs will be the |         After the HTML sanitizer autolinks these, the links's hrefs are the | ||||||
|         link text, e.g. `<a href="@foo:bar.com">@foo:bar.com</a>`. |         link text, e.g. `<a href="@foo:bar.com">@foo:bar.com</a>`. | ||||||
|         We turn them into proper matrix.to URL in this function. |         We turn them into proper matrix.to URL in this function. | ||||||
|         """ |         """ | ||||||
| @@ -493,11 +495,8 @@ class HTMLProcessor: | |||||||
|                 el.attrib["href"] = f"https://matrix.to/#/{el.attrib['href']}" |                 el.attrib["href"] = f"https://matrix.to/#/{el.attrib['href']}" | ||||||
|                 return el |                 return el | ||||||
|  |  | ||||||
|         if not outgoing or room_id not in self.rooms_user_id_names: |         for name, user_id in (display_name_mentions or {}).items(): | ||||||
|             return el |             if unquote(el.attrib["href"]) == name: | ||||||
|  |  | ||||||
|         for user_id, username in self.rooms_user_id_names[room_id].items(): |  | ||||||
|             if unquote(el.attrib["href"]) == username: |  | ||||||
|                 el.attrib["href"] = f"https://matrix.to/#/{user_id}" |                 el.attrib["href"] = f"https://matrix.to/#/{user_id}" | ||||||
|                 return el |                 return el | ||||||
|  |  | ||||||
| @@ -512,7 +511,6 @@ class HTMLProcessor: | |||||||
|         if not href or not el.text: |         if not href or not el.text: | ||||||
|             return el |             return el | ||||||
|  |  | ||||||
|  |  | ||||||
|         # This must be first, or link will be mistaken by room ID/alias regex |         # This must be first, or link will be mistaken by room ID/alias regex | ||||||
|         if self.link_is_message_id_regex.match(href): |         if self.link_is_message_id_regex.match(href): | ||||||
|             el.attrib["class"]        = "mention message-id-mention" |             el.attrib["class"]        = "mention message-id-mention" | ||||||
|   | |||||||
| @@ -509,11 +509,17 @@ class MatrixClient(nio.AsyncClient): | |||||||
|  |  | ||||||
|  |  | ||||||
|     async def send_text( |     async def send_text( | ||||||
|         self, room_id: str, text: str, reply_to_event_id: Optional[str] = None, |         self, | ||||||
|  |         room_id:               str, | ||||||
|  |         text:                  str, | ||||||
|  |         display_name_mentions: Optional[Dict[str, str]] = None,  # {name: id} | ||||||
|  |         reply_to_event_id:     Optional[str]            = None, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         """Send a markdown `m.text` or `m.notice` (with `/me`) message .""" |         """Send a markdown `m.text` or `m.notice` (with `/me`) message .""" | ||||||
|  |  | ||||||
|         from_md = partial(HTML.from_markdown, room_id=room_id) |         from_md = partial( | ||||||
|  |             HTML.from_markdown, display_name_mentions=display_name_mentions, | ||||||
|  |         ) | ||||||
|  |  | ||||||
|         escape = False |         escape = False | ||||||
|         if text.startswith("//") or text.startswith(r"\/"): |         if text.startswith("//") or text.startswith(r"\/"): | ||||||
| @@ -909,9 +915,7 @@ class MatrixClient(nio.AsyncClient): | |||||||
|         content = event_fields.get("content", "").strip() |         content = event_fields.get("content", "").strip() | ||||||
|  |  | ||||||
|         if content and "inline_content" not in event_fields: |         if content and "inline_content" not in event_fields: | ||||||
|             event_fields["inline_content"] = HTML.filter( |             event_fields["inline_content"] = HTML.filter(content, inline=True) | ||||||
|                 content, inline=True, room_id=room_id, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         event = Event( |         event = Event( | ||||||
|             id            = f"echo-{transaction_id}", |             id            = f"echo-{transaction_id}", | ||||||
| @@ -1798,7 +1802,6 @@ class MatrixClient(nio.AsyncClient): | |||||||
|             topic          = HTML.filter( |             topic          = HTML.filter( | ||||||
|                 utils.plain2html(room.topic or ""), |                 utils.plain2html(room.topic or ""), | ||||||
|                 inline = True, |                 inline = True, | ||||||
|                 room_id = room.room_id, |  | ||||||
|             ), |             ), | ||||||
|             inviter_id     = inviter, |             inviter_id     = inviter, | ||||||
|             inviter_name   = room.user_name(inviter) if inviter else "", |             inviter_name   = room.user_name(inviter) if inviter else "", | ||||||
| @@ -1869,16 +1872,11 @@ class MatrixClient(nio.AsyncClient): | |||||||
|         self.models[self.user_id, room.room_id, "members"][user_id] = \ |         self.models[self.user_id, room.room_id, "members"][user_id] = \ | ||||||
|             member_item |             member_item | ||||||
|  |  | ||||||
|         if member.display_name: |  | ||||||
|             HTML.rooms_user_id_names[room.room_id][user_id] = \ |  | ||||||
|                 member.display_name |  | ||||||
|  |  | ||||||
|  |  | ||||||
|     async def remove_member(self, room: nio.MatrixRoom, user_id: str) -> None: |     async def remove_member(self, room: nio.MatrixRoom, user_id: str) -> None: | ||||||
|         """Remove a room member from our models.""" |         """Remove a room member from our models.""" | ||||||
|  |  | ||||||
|         self.models[self.user_id, room.room_id, "members"].pop(user_id, None) |         self.models[self.user_id, room.room_id, "members"].pop(user_id, None) | ||||||
|         HTML.rooms_user_id_names[room.room_id].pop(user_id, None) |  | ||||||
|  |  | ||||||
|         room_item = self.models[self.user_id, "rooms"].get(room.room_id) |         room_item = self.models[self.user_id, "rooms"].get(room.room_id) | ||||||
|  |  | ||||||
| @@ -1972,9 +1970,7 @@ class MatrixClient(nio.AsyncClient): | |||||||
|         content = fields.get("content", "").strip() |         content = fields.get("content", "").strip() | ||||||
|  |  | ||||||
|         if content and "inline_content" not in fields: |         if content and "inline_content" not in fields: | ||||||
|             fields["inline_content"] = HTML.filter( |             fields["inline_content"] = HTML.filter(content, inline=True) | ||||||
|                 content, inline=True, room_id=room.room_id, |  | ||||||
|             ) |  | ||||||
|  |  | ||||||
|         # Create Event ModelItem |         # Create Event ModelItem | ||||||
|  |  | ||||||
|   | |||||||
| @@ -154,8 +154,6 @@ class NioCallbacks: | |||||||
|             ev.formatted_body |             ev.formatted_body | ||||||
|             if ev.format == "org.matrix.custom.html" else |             if ev.format == "org.matrix.custom.html" else | ||||||
|             plain2html(ev.body), |             plain2html(ev.body), | ||||||
|  |  | ||||||
|             room_id = room.room_id, |  | ||||||
|         ) |         ) | ||||||
|  |  | ||||||
|         mention_list = HTML_PROCESSOR.mentions_in_html(co) |         mention_list = HTML_PROCESSOR.mentions_in_html(co) | ||||||
| @@ -627,9 +625,7 @@ class NioCallbacks: | |||||||
|         self, room: nio.MatrixRoom, ev: nio.RoomTopicEvent, |         self, room: nio.MatrixRoom, ev: nio.RoomTopicEvent, | ||||||
|     ) -> None: |     ) -> None: | ||||||
|         if ev.topic: |         if ev.topic: | ||||||
|             topic = HTML_PROCESSOR.filter( |             topic = HTML_PROCESSOR.filter(plain2html(ev.topic), inline=True) | ||||||
|                 plain2html(ev.topic), inline=True, room_id=room.room_id, |  | ||||||
|             ) |  | ||||||
|             co    = f"%1 changed the room's topic to \"{topic}\"" |             co    = f"%1 changed the room's topic to \"{topic}\"" | ||||||
|         else: |         else: | ||||||
|             co = "%1 removed the room's topic" |             co = "%1 removed the room's topic" | ||||||
|   | |||||||
| @@ -50,6 +50,7 @@ Rectangle { | |||||||
|                 MessageArea { |                 MessageArea { | ||||||
|                     id: messageArea |                     id: messageArea | ||||||
|                     autoCompletionOpen: userCompletion.open |                     autoCompletionOpen: userCompletion.open | ||||||
|  |                     usersCompleted: userCompletion.usersCompleted | ||||||
|  |  | ||||||
|                     onAutoCompletePrevious: userCompletion.previous() |                     onAutoCompletePrevious: userCompletion.previous() | ||||||
|                     onAutoCompleteNext: userCompletion.next() |                     onAutoCompleteNext: userCompletion.next() | ||||||
| @@ -57,7 +58,7 @@ Rectangle { | |||||||
|                     onExtraCharacterCloseAutoCompletion: |                     onExtraCharacterCloseAutoCompletion: | ||||||
|                         ! userCompletion.autoOpen || |                         ! userCompletion.autoOpen || | ||||||
|                         userCompletion.autoOpenCompleted ? |                         userCompletion.autoOpenCompleted ? | ||||||
|                         userCompletion.open = false : |                         userCompletion.accept() : | ||||||
|                         null |                         null | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
|   | |||||||
| @@ -9,7 +9,9 @@ HTextArea { | |||||||
|     id: area |     id: area | ||||||
|  |  | ||||||
|     property HListView eventList |     property HListView eventList | ||||||
|     property bool autoCompletionOpen |  | ||||||
|  |     property bool autoCompletionOpen: false | ||||||
|  |     property var usersCompleted: ({}) | ||||||
|  |  | ||||||
|     property string indent: "    " |     property string indent: "    " | ||||||
|  |  | ||||||
| @@ -88,7 +90,10 @@ HTextArea { | |||||||
|     function sendText() { |     function sendText() { | ||||||
|         if (! toSend) return |         if (! toSend) return | ||||||
|  |  | ||||||
|         const args = [chat.roomId, toSend, chat.replyToEventId] |         // Need to copy usersCompleted because the completion UI closing will | ||||||
|  |         // clear it before it reaches Python. | ||||||
|  |         const mentions = Object.assign({}, usersCompleted) | ||||||
|  |         const args     = [chat.roomId, toSend, mentions, chat.replyToEventId] | ||||||
|         py.callClientCoro(writingUserId, "send_text", args) |         py.callClientCoro(writingUserId, "send_text", args) | ||||||
|  |  | ||||||
|         area.clear() |         area.clear() | ||||||
| @@ -184,7 +189,7 @@ HTextArea { | |||||||
|         autoCompletionOpen ? cancelAutoCompletion() : clearReplyTo() |         autoCompletionOpen ? cancelAutoCompletion() : clearReplyTo() | ||||||
|  |  | ||||||
|     Keys.onReturnPressed: ev => { |     Keys.onReturnPressed: ev => { | ||||||
|         extraCharacterCloseAutoCompletion() |         if (autoCompletionOpen) extraCharacterCloseAutoCompletion() | ||||||
|         ev.accepted = true |         ev.accepted = true | ||||||
|  |  | ||||||
|         ev.modifiers & Qt.ShiftModifier || |         ev.modifiers & Qt.ShiftModifier || | ||||||
| @@ -197,7 +202,7 @@ HTextArea { | |||||||
|     Keys.onEnterPressed: ev => Keys.returnPressed(ev) |     Keys.onEnterPressed: ev => Keys.returnPressed(ev) | ||||||
|  |  | ||||||
|     Keys.onMenuPressed: ev => { |     Keys.onMenuPressed: ev => { | ||||||
|         extraCharacterCloseAutoCompletion() |         if (autoCompletionOpen) extraCharacterCloseAutoCompletion() | ||||||
|  |  | ||||||
|         if (eventList && eventList.currentItem) |         if (eventList && eventList.currentItem) | ||||||
|             eventList.currentItem.openContextMenu() |             eventList.currentItem.openContextMenu() | ||||||
| @@ -230,7 +235,7 @@ HTextArea { | |||||||
|     } |     } | ||||||
|  |  | ||||||
|     Keys.onPressed: ev => { |     Keys.onPressed: ev => { | ||||||
|         if (ev.text) extraCharacterCloseAutoCompletion() |         if (ev.text && autoCompletionOpen) extraCharacterCloseAutoCompletion() | ||||||
|  |  | ||||||
|         if (ev.matches(StandardKey.Copy) && |         if (ev.matches(StandardKey.Copy) && | ||||||
|             ! area.selectedText && |             ! area.selectedText && | ||||||
|   | |||||||
| @@ -6,14 +6,17 @@ import "../../.." | |||||||
| import "../../../Base" | import "../../../Base" | ||||||
| import "../../../Base/HTile" | import "../../../Base/HTile" | ||||||
|  |  | ||||||
|  | // FIXME: a b -> a @p b → @p doesn't trigger completion | ||||||
|  | // FIXME: close autocomplete when cursor moves away | ||||||
| HListView { | HListView { | ||||||
|     id: listView |     id: root | ||||||
|  |  | ||||||
|     property HTextArea textArea |     property HTextArea textArea | ||||||
|     property bool open: false |     property bool open: false | ||||||
|  |  | ||||||
|     property string originalText: "" |     property string originalText: "" | ||||||
|     property bool autoOpenCompleted: false |     property bool autoOpenCompleted: false | ||||||
|  |     property var usersCompleted: ({})  // {displayName: userId} | ||||||
|  |  | ||||||
|     readonly property bool autoOpen: |     readonly property bool autoOpen: | ||||||
|         autoOpenCompleted || textArea.text.match(/.*(^|\W)@[^\s]+$/) |         autoOpenCompleted || textArea.text.match(/.*(^|\W)@[^\s]+$/) | ||||||
| @@ -26,11 +29,12 @@ HListView { | |||||||
|         "" |         "" | ||||||
|  |  | ||||||
|     function replaceLastWord(withText) { |     function replaceLastWord(withText) { | ||||||
|         const lastWordStart = /(?:^|\s)[^\s]+$/.exec(textArea.text).index |         let lastWordStart = /(?:^|\s)[^\s]+$/.exec(textArea.text).index | ||||||
|         const isTextStart   = |  | ||||||
|             lastWordStart === 0 && ! textArea.text[0].match(/\s/) |  | ||||||
|  |  | ||||||
|         textArea.remove(lastWordStart + (isTextStart ? 0 : 1), textArea.length) |         if (! (lastWordStart === 0 && ! textArea.text[0].match(/\s/))) | ||||||
|  |             lastWordStart += 1 | ||||||
|  |  | ||||||
|  |         textArea.remove(lastWordStart, textArea.length) | ||||||
|         textArea.insertAtCursor(withText) |         textArea.insertAtCursor(withText) | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -56,10 +60,21 @@ HListView { | |||||||
|         py.callCoro("set_string_filter", args, incrementCurrentIndex) |         py.callCoro("set_string_filter", args, incrementCurrentIndex) | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     function accept() { | ||||||
|  |         if (currentIndex !== -1) { | ||||||
|  |             const member = model.get(currentIndex) | ||||||
|  |             usersCompleted[member.display_name] = member.id | ||||||
|  |             usersCompletedChanged() | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         open = false | ||||||
|  |     } | ||||||
|  |  | ||||||
|     function cancel() { |     function cancel() { | ||||||
|         if (originalText) |         if (originalText) | ||||||
|             replaceLastWord(originalText.split(/\s/).splice(-1)[0]) |             replaceLastWord(originalText.split(/\s/).splice(-1)[0]) | ||||||
|  |  | ||||||
|  |         currentIndex = -1 | ||||||
|         open         = false |         open         = false | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -70,11 +85,11 @@ HListView { | |||||||
|     model: ModelStore.get(chat.userId, chat.roomId, "autocompleted_members") |     model: ModelStore.get(chat.userId, chat.roomId, "autocompleted_members") | ||||||
|  |  | ||||||
|     delegate: HTile { |     delegate: HTile { | ||||||
|         width: listView.width |         width: root.width | ||||||
|         contentItem: HLabel { text: model.display_name + " (" + model.id + ")"} |         contentItem: HLabel { text: model.display_name + " (" + model.id + ")"} | ||||||
|         onClicked: { |         onClicked: { | ||||||
|             currentIndex = model.index |             currentIndex = model.index | ||||||
|             listView.open = false |             root.open    = false | ||||||
|         } |         } | ||||||
|     } |     } | ||||||
|  |  | ||||||
| @@ -102,4 +117,21 @@ HListView { | |||||||
|  |  | ||||||
|     Behavior on opacity { HNumberAnimation {} } |     Behavior on opacity { HNumberAnimation {} } | ||||||
|     Behavior on implicitHeight { HNumberAnimation {} } |     Behavior on implicitHeight { HNumberAnimation {} } | ||||||
|  |  | ||||||
|  |     Connections { | ||||||
|  |         target: root.textArea | ||||||
|  |  | ||||||
|  |         function onTextChanged() { | ||||||
|  |             let changed = false | ||||||
|  |  | ||||||
|  |             for (const displayName of Object.keys(root.usersCompleted)) { | ||||||
|  |                 if (! root.textArea.text.includes(displayName)) { | ||||||
|  |                     delete root.usersCompleted[displayName] | ||||||
|  |                     changed = true | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             if (changed) root.usersCompletedChanged() | ||||||
|  |         } | ||||||
|  |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	