matterhorn 50200.10.3 → 50200.11.0
raw patch · 217 files changed
+20640/−19671 lines, 217 filesdep +matterhorndep −quickcheck-textdep −string-conversionsdep ~brickdep ~bytestringdep ~cheapskate
Dependencies added: matterhorn
Dependencies removed: quickcheck-text, string-conversions
Dependency ranges changed: brick, bytestring, cheapskate, containers, mattermost-api, mattermost-api-qc, text, time, uuid, vty
Files
- CHANGELOG.md +110/−1
- README.md +3/−2
- client-scripts/cowsay +7/−0
- client-scripts/figlet +7/−0
- client-scripts/rot13 +4/−0
- client-scripts/talky +38/−0
- docs/commands.md +5/−4
- docs/keybindings.md +52/−51
- matterhorn.cabal +134/−147
- programs/Main.hs +60/−0
- scripts/cowsay +0/−7
- scripts/figlet +0/−7
- scripts/rot13 +0/−4
- src/App.hs +0/−114
- src/Clipboard.hs +0/−31
- src/Command.hs +0/−335
- src/Command.hs-boot +0/−9
- src/Config.hs +0/−351
- src/Config/Schema.hs +0/−203
- src/Connection.hs +0/−104
- src/Constants.hs +0/−35
- src/Draw.hs +0/−46
- src/Draw/Autocomplete.hs +0/−188
- src/Draw/ChannelList.hs +0/−208
- src/Draw/ChannelListOverlay.hs +0/−54
- src/Draw/DeleteChannelConfirm.hs +0/−33
- src/Draw/LeaveChannelConfirm.hs +0/−33
- src/Draw/ListOverlay.hs +0/−134
- src/Draw/Main.hs +0/−655
- src/Draw/ManageAttachments.hs +0/−65
- src/Draw/Messages.hs +0/−204
- src/Draw/NotifyPrefs.hs +0/−35
- src/Draw/PostListOverlay.hs +0/−117
- src/Draw/ReactionEmojiListOverlay.hs +0/−40
- src/Draw/RichText.hs +0/−525
- src/Draw/ShowHelp.hs +0/−567
- src/Draw/TabbedWindow.hs +0/−77
- src/Draw/ThemeListOverlay.hs +0/−53
- src/Draw/URLList.hs +0/−57
- src/Draw/UserListOverlay.hs +0/−78
- src/Draw/Util.hs +0/−100
- src/Emoji.hs +0/−108
- src/Events.hs +0/−383
- src/Events/ChannelListOverlay.hs +0/−35
- src/Events/ChannelSelect.hs +0/−45
- src/Events/DeleteChannelConfirm.hs +0/−20
- src/Events/EditNotifyPrefs.hs +0/−48
- src/Events/Keybindings.hs +0/−322
- src/Events/LeaveChannelConfirm.hs +0/−19
- src/Events/Main.hs +0/−148
- src/Events/ManageAttachments.hs +0/−182
- src/Events/MessageSelect.hs +0/−85
- src/Events/PostListOverlay.hs +0/−28
- src/Events/ReactionEmojiListOverlay.hs +0/−35
- src/Events/ShowHelp.hs +0/−44
- src/Events/TabbedWindow.hs +0/−56
- src/Events/ThemeListOverlay.hs +0/−30
- src/Events/UrlSelect.hs +0/−40
- src/Events/UserListOverlay.hs +0/−30
- src/FilePaths.hs +0/−167
- src/HelpTopics.hs +0/−49
- src/IOUtil.hs +0/−20
- src/InputHistory.hs +0/−76
- src/KeyMap.hs +0/−28
- src/LastRunState.hs +0/−99
- src/Login.hs +0/−594
- src/Main.hs +0/−60
- src/Matterhorn/App.hs +115/−0
- src/Matterhorn/Clipboard.hs +31/−0
- src/Matterhorn/Command.hs +338/−0
- src/Matterhorn/Command.hs-boot +9/−0
- src/Matterhorn/Config.hs +365/−0
- src/Matterhorn/Config/Schema.hs +203/−0
- src/Matterhorn/Connection.hs +104/−0
- src/Matterhorn/Constants.hs +35/−0
- src/Matterhorn/Draw.hs +52/−0
- src/Matterhorn/Draw/Autocomplete.hs +205/−0
- src/Matterhorn/Draw/Buttons.hs +29/−0
- src/Matterhorn/Draw/ChannelList.hs +239/−0
- src/Matterhorn/Draw/ChannelListOverlay.hs +53/−0
- src/Matterhorn/Draw/ChannelTopicWindow.hs +56/−0
- src/Matterhorn/Draw/DeleteChannelConfirm.hs +28/−0
- src/Matterhorn/Draw/LeaveChannelConfirm.hs +28/−0
- src/Matterhorn/Draw/ListOverlay.hs +134/−0
- src/Matterhorn/Draw/Main.hs +721/−0
- src/Matterhorn/Draw/ManageAttachments.hs +64/−0
- src/Matterhorn/Draw/Messages.hs +209/−0
- src/Matterhorn/Draw/NotifyPrefs.hs +35/−0
- src/Matterhorn/Draw/PostListOverlay.hs +109/−0
- src/Matterhorn/Draw/ReactionEmojiListOverlay.hs +39/−0
- src/Matterhorn/Draw/RichText.hs +619/−0
- src/Matterhorn/Draw/ShowHelp.hs +566/−0
- src/Matterhorn/Draw/TabbedWindow.hs +77/−0
- src/Matterhorn/Draw/ThemeListOverlay.hs +52/−0
- src/Matterhorn/Draw/URLList.hs +64/−0
- src/Matterhorn/Draw/UserListOverlay.hs +77/−0
- src/Matterhorn/Draw/Util.hs +103/−0
- src/Matterhorn/Emoji.hs +108/−0
- src/Matterhorn/Events.hs +387/−0
- src/Matterhorn/Events/ChannelListOverlay.hs +35/−0
- src/Matterhorn/Events/ChannelSelect.hs +45/−0
- src/Matterhorn/Events/ChannelTopicWindow.hs +48/−0
- src/Matterhorn/Events/DeleteChannelConfirm.hs +20/−0
- src/Matterhorn/Events/EditNotifyPrefs.hs +48/−0
- src/Matterhorn/Events/Keybindings.hs +323/−0
- src/Matterhorn/Events/LeaveChannelConfirm.hs +19/−0
- src/Matterhorn/Events/Main.hs +151/−0
- src/Matterhorn/Events/ManageAttachments.hs +182/−0
- src/Matterhorn/Events/MessageSelect.hs +85/−0
- src/Matterhorn/Events/PostListOverlay.hs +28/−0
- src/Matterhorn/Events/ReactionEmojiListOverlay.hs +35/−0
- src/Matterhorn/Events/ShowHelp.hs +44/−0
- src/Matterhorn/Events/TabbedWindow.hs +56/−0
- src/Matterhorn/Events/ThemeListOverlay.hs +30/−0
- src/Matterhorn/Events/UrlSelect.hs +39/−0
- src/Matterhorn/Events/UserListOverlay.hs +30/−0
- src/Matterhorn/FilePaths.hs +167/−0
- src/Matterhorn/HelpTopics.hs +49/−0
- src/Matterhorn/IOUtil.hs +20/−0
- src/Matterhorn/InputHistory.hs +76/−0
- src/Matterhorn/KeyMap.hs +28/−0
- src/Matterhorn/LastRunState.hs +99/−0
- src/Matterhorn/Login.hs +594/−0
- src/Matterhorn/Options.hs +157/−0
- src/Matterhorn/Prelude.hs +115/−0
- src/Matterhorn/Scripts.hs +81/−0
- src/Matterhorn/State/Async.hs +161/−0
- src/Matterhorn/State/Attachments.hs +53/−0
- src/Matterhorn/State/Autocomplete.hs +373/−0
- src/Matterhorn/State/ChannelListOverlay.hs +82/−0
- src/Matterhorn/State/ChannelSelect.hs +155/−0
- src/Matterhorn/State/ChannelTopicWindow.hs +16/−0
- src/Matterhorn/State/Channels.hs +1134/−0
- src/Matterhorn/State/Common.hs +374/−0
- src/Matterhorn/State/Editing.hs +525/−0
- src/Matterhorn/State/Editing.hs-boot +10/−0
- src/Matterhorn/State/Flagging.hs +66/−0
- src/Matterhorn/State/Help.hs +21/−0
- src/Matterhorn/State/Links.hs +23/−0
- src/Matterhorn/State/ListOverlay.hs +145/−0
- src/Matterhorn/State/Logging.hs +33/−0
- src/Matterhorn/State/MessageSelect.hs +294/−0
- src/Matterhorn/State/Messages.hs +929/−0
- src/Matterhorn/State/Messages.hs-boot +11/−0
- src/Matterhorn/State/NotifyPrefs.hs +88/−0
- src/Matterhorn/State/PostListOverlay.hs +148/−0
- src/Matterhorn/State/ReactionEmojiListOverlay.hs +125/−0
- src/Matterhorn/State/Reactions.hs +52/−0
- src/Matterhorn/State/Setup.hs +281/−0
- src/Matterhorn/State/Setup/Threads.hs +367/−0
- src/Matterhorn/State/Setup/Threads/Logging.hs +317/−0
- src/Matterhorn/State/ThemeListOverlay.hs +86/−0
- src/Matterhorn/State/UrlSelect.hs +48/−0
- src/Matterhorn/State/UserListOverlay.hs +174/−0
- src/Matterhorn/State/Users.hs +99/−0
- src/Matterhorn/TeamSelect.hs +90/−0
- src/Matterhorn/Themes.hs +808/−0
- src/Matterhorn/TimeUtils.hs +70/−0
- src/Matterhorn/Types.hs +2165/−0
- src/Matterhorn/Types/Channels.hs +420/−0
- src/Matterhorn/Types/Common.hs +41/−0
- src/Matterhorn/Types/DirectionalSeq.hs +97/−0
- src/Matterhorn/Types/KeyEvents.hs +437/−0
- src/Matterhorn/Types/Messages.hs +750/−0
- src/Matterhorn/Types/Posts.hs +231/−0
- src/Matterhorn/Types/RichText.hs +486/−0
- src/Matterhorn/Types/Users.hs +196/−0
- src/Matterhorn/Util.hs +25/−0
- src/Matterhorn/Windows/ViewMessage.hs +218/−0
- src/Matterhorn/Zipper.hs +119/−0
- src/Options.hs +0/−156
- src/Prelude/MH.hs +0/−115
- src/Scripts.hs +0/−81
- src/State/Async.hs +0/−161
- src/State/Attachments.hs +0/−53
- src/State/Autocomplete.hs +0/−263
- src/State/ChannelListOverlay.hs +0/−82
- src/State/ChannelSelect.hs +0/−155
- src/State/Channels.hs +0/−1082
- src/State/Common.hs +0/−383
- src/State/Editing.hs +0/−524
- src/State/Editing.hs-boot +0/−10
- src/State/Flagging.hs +0/−66
- src/State/Help.hs +0/−21
- src/State/ListOverlay.hs +0/−144
- src/State/Logging.hs +0/−33
- src/State/MessageSelect.hs +0/−294
- src/State/Messages.hs +0/−871
- src/State/Messages.hs-boot +0/−11
- src/State/NotifyPrefs.hs +0/−87
- src/State/PostListOverlay.hs +0/−160
- src/State/ReactionEmojiListOverlay.hs +0/−125
- src/State/Reactions.hs +0/−52
- src/State/Setup.hs +0/−281
- src/State/Setup/Threads.hs +0/−372
- src/State/Setup/Threads/Logging.hs +0/−317
- src/State/ThemeListOverlay.hs +0/−86
- src/State/UrlSelect.hs +0/−48
- src/State/UserListOverlay.hs +0/−174
- src/State/Users.hs +0/−100
- src/TeamSelect.hs +0/−90
- src/Themes.hs +0/−772
- src/TimeUtils.hs +0/−70
- src/Types.hs +0/−2068
- src/Types/Channels.hs +0/−420
- src/Types/Common.hs +0/−41
- src/Types/DirectionalSeq.hs +0/−97
- src/Types/KeyEvents.hs +0/−434
- src/Types/Messages.hs +0/−739
- src/Types/Posts.hs +0/−228
- src/Types/RichText.hs +0/−409
- src/Types/Users.hs +0/−196
- src/Util.hs +0/−25
- src/Windows/ViewMessage.hs +0/−217
- src/Zipper.hs +0/−119
- test/Message_QCA.hs +7/−6
- test/test_messages.hs +12/−8
CHANGELOG.md view
@@ -1,4 +1,113 @@ +50200.11.0+==========++New features:+ * Matterhorn now opens post links itself rather than invoking the+ configured URL open command. This means that if a user obtains a link+ to a post in the web client and pastes it into a message, Matterhorn+ will detect it, and if the user chooses to open that link, Matterhorn+ will switch to the post's channel and show the post. This change+ comes a new theme attribute, `permalink`, that is used to style post+ permalinks. Post links with no labels appear as `<post link>`; post+ links with labels are rendered with their labels. Post links also+ appear in the URL list accessed with `C-o`, and they are opened by+ the message selection mode `o` binding.+ * Message timestamp visibility can now be controlled with the+ configuration setting `showMessageTimestamps` (default: `True`) and+ can be toggled at runtime with the new `/toggle-message-timestamps`+ command.+ * The channel list can now be displayed on either the left side of+ the screen or the right. This is controlled with a new configuration+ setting, `channelListOrientation`, set to either `left` (the default+ and previous behavior) or `right`.+ * Matterhorn now supports strikethrough syntax in messages. This change+ adds a new theme attribute, `markdownStrikethrough`, that+ is used to style strikethrough text. This change also adds+ support for `strikethrough` as a valid style specifier in theme+ customization files. Bear in mind that your terminal might+ not support strikethrough; for details, see the FAQ entry on+ strikethrough support.+ * Matterhorn now has improved handling for channel topics (headers)+ with multiple lines of text. Previously Matterhorn displayed all+ lines in a multi-line channel topic but replaced newlines with+ spaces. That resulted in broken layouts for multi-line topics where+ layout was important. Now it renders the topics as originally+ intended with user control over how multi-line topics are displayed+ in order to let the user control how the topic affects screen real+ estate use.+ * Added a new key event, `toggle-expanded-channel-topics`, to toggle+ whether channel topics show only their first line or all lines.+ * Added a new configuration setting, `showExpandedChannelTopics`, to+ set this behavior on startup (default: `True`).+ * Added a new command, `/toggle-expanded-topics`, and a new default+ keybinding, `F3`, to control this behavior at runtime.+ * Added a `/topic` command that opens an interactive topic editor when+ run with no arguments. The editor provides a live Markdown preview+ of the channel topic. As part of the UI for this feature, new theme+ attributes were added: `button` and `button.focused` for rendering+ unfocused and focused buttons, respectively.+ * Command autocompletion now queries the server for available commands+ and includes those in the command list. Server commands are also+ indicated as being provided by the server to help distinguish them+ from Matterhorn-specific commands.++Other changes:+ * Channel selection mode (`C-g`) now displays entries more consistently+ with the normal channel sidebar, including:+ * Channel mention counts and visual styles+ * The previous channel sigil (`<`) and first-unread channel sigil+ (`~`)+ * Channel mute status (`(m)`)+ * Unread status and visual style+ * When composing replies, Mattehorn now displays the threat root+ message of the thread being replied to, rather than the message+ the user selected for reply (#670). This is intended to be less+ surprising than the old behavior since showing the root post in the+ preview is consistent with the display of the post in the message+ list once it is posted, where the root post is shown above the reply.+ * Matterhorn no longer considers external program output to constitute+ an error, instead only reporting external program errors on non-zero+ exit status (#665).+ * Matterhorn now decorates all hyperlinks with angle brackets. This+ change is intended to get around issues caused when a hyperlink label+ has a style or color that prevents the normal hyperlink color from+ being used, thus concealing the fact that it is a link. This makes+ all links visible regardless of link label content. In particular,+ links labeled `foo` now get rendered as `<foo>`. Links with no labels+ also get decorated this way.+ * The total unread channel count is now included in the window title+ bar updates.+ * The total unread channel count is now included in the channel list+ header.+ * Long lines in code blocks with no syntax highlighting are now wrapped+ to improve readability.+ * Command autocompletion now sorts command completions to prefer prefix+ matches first.+ * Command autocomplete now makes only one request for the server+ command list per completion attempt.+ * There is now a new `/shortcuts` command to stand in for+ server-provided version which did nothing in Matterhorn.+ * Message selection mode now remains active when:+ * Opening message URLs (`C-s`/`o`),+ * Exiting the message view window (`C-s`/`v`), and+ * Exiting the emoji reaction list window (`C-s`/`a`).+ * The channel list now includes an unread indicator in the DM channel+ section header when any DM channels are unread.+ * The visual style for channel selection match text now includes the+ underline style.+ * All unknown websocket events are now logged rather than triggering+ messages to ask the user to report issues.++Bug fixes:+ * The URL list (`C-o`) now has a fixed header when the channel in+ question is a DM channel.+ * The URL list no longer treats URLs with the same target but different+ labels as duplicates.+ * The message editor's multi-line toggle UI hint now looks up the+ active keybinding rather than hard-coding it.+ * The channel sidebar's unread logic was improved (#655).+ 50200.10.3 ========== @@ -128,7 +237,7 @@ followed by a space. While this is often desirable behavior, it also meant that if the very next input character was a punctuation character such as a period, the final text would be `@user .` Based- on the observation that usally the intention is to end up with+ on the observation that usually the intention is to end up with `@user.`, the editor now automatically replaces the trailing space when such a punctuation character is entered. This behavior is configurable with the `smartediting` configuration setting which
README.md view
@@ -24,8 +24,9 @@ When you run Matterhorn you'll be prompted for your server URL and credentials. To connect, just paste your web client's Mattermost URL-into the Server URL box and enter your credentials. See the next section-on the details for providing each kind of supported credentials.+into the Server URL box and enter your credentials. See the [Matterhorn+User Guide](docs/UserGuide.md) on the details for providing each kind of+supported credentials. Note: Version `ABBCC.X.Y` matches Mattermost server version `A.BB.CC`. For example, if your Mattermost server version is `3.6.0` then you
+ client-scripts/cowsay view
@@ -0,0 +1,7 @@+#!/bin/bash -e++# We want the fences so that the cowsay block gets rendered+# in Markdown as a code block. Otherwise, we just call cowsay.+echo '~~~~~~~~~~'+cowsay+echo '~~~~~~~~~~'
+ client-scripts/figlet view
@@ -0,0 +1,7 @@+#!/bin/bash -e++# We want the fences so that the cowsay block gets rendered+# in Markdown as a code block. Otherwise, we just call cowsay.+echo '~~~~~~~~~~'+figlet+echo '~~~~~~~~~~'
+ client-scripts/rot13 view
@@ -0,0 +1,4 @@+#!/bin/bash -e++# This should be a reasonably portable ROT-13-izer across Unixes+tr '[A-Za-z]' '[N-ZA-Mn-za-m]'
+ client-scripts/talky view
@@ -0,0 +1,38 @@+#!/bin/bash -e++BASE_URL="https://talky.io"++base64enc() {+ if [[ -x $(which shasum) ]] ; then+ retval=$(echo $1 | shasum | sed 's/ .*//g')+ elif [[ -x $(which openssl) ]] ; then+ retval=$(echo $1 | openssl enc -base64 | sed 's/\//x/g' | sed 's/+/x/g' | sed 's/=/x/g')+ elif [[ -x $(which base64) ]] ; then+ retval=$(echo $1 | base64 | sed 's/\//x/g' | sed 's/+/x/g' | sed 's/=/x/g')+ else+ echo "Unable to base64 encode, install base64?"+ exit+ fi+ echo $retval+}++getroom() {+if [[ -z "$1" ]]; then+ if [[ ! -a /dev/urandom ]] ; then+ echo "No urandom! Can not make random room name."+ exit+ elif [[ ! -x $(which dd) ]] ; then+ echo "No `dd` utility, can not read random file."+ exit+ else+ ROOM_RAND=$(dd if=/dev/urandom bs=1 count=32 2>/dev/null)+ ROOM_NAME=$( base64enc ${ROOM_RAND} )+ fi+else+ ROOM_NAME=$1+fi+}++getroom++echo "${BASE_URL}/${ROOM_NAME}"
docs/commands.md view
@@ -11,7 +11,7 @@ | `/focus <~channel>` | Focus on a channel or user | | `/focus` | Select from available channels | | `/group-msg <@user [@user ...]>` | Create a group chat |-| `/help` | Show this help screen |+| `/help` | Show the main help screen | | `/help <topic>` | Show help about a particular topic | | `/hide` | Hide the current DM or group channel from the channel list | | `/join` | Find a channel to join |@@ -23,7 +23,6 @@ | `/log-start <path>` | Begin logging debug information to the specified path | | `/log-status` | Show current debug logging status | | `/log-stop` | Stop logging |-| `/me <message>` | Send an emote message | | `/members` | Show the current channel's members | | `/message-preview` | Toggle preview of the current message | | `/msg` | Search for a user to enter a private chat |@@ -31,7 +30,6 @@ | `/msg <@user> <message or command>` | Go to a user's channel and send the specified message or command | | `/notify-prefs` | Edit the current channel's notification preferences | | `/pinned-posts` | Open a window of this channel's pinned posts |-| `/purpose <purpose>` | Set the current channel's purpose | | `/quit` | Exit Matterhorn | | `/reconnect` | Force a reconnection attempt to the server | | `/remove-user <@user>` | Remove a user from the current channel |@@ -39,10 +37,13 @@ | `/search <terms>` | Search for posts with given terms | | `/sh` | List the available shell scripts | | `/sh <script> <message>` | Run a prewritten shell script |-| `/shrug <message>` | Send a message followed by a shrug emoticon |+| `/shortcuts` | Show keyboard shortcuts | | `/theme` | List the available themes | | `/theme <theme>` | Set the color theme | | `/toggle-channel-list` | Toggle channel list visibility |+| `/toggle-expanded-topics` | Toggle expanded channel topics |+| `/toggle-message-timestamps` | Toggle message timestamps |+| `/topic` | Set the current channel's topic (header) interactively | | `/topic <topic>` | Set the current channel's topic (header) | | `/user` | Show users to initiate a private DM chat channel | | `/username-attribute <@user>` | Display the attribute used to color the specified username |
docs/keybindings.md view
@@ -23,6 +23,7 @@ | `C-r` | `reply-recent` | Reply to the most recent message | | `M-p` | `toggle-message-preview` | Toggle message preview | | `F2` | `toggle-channel-list-visibility` | Toggle channel list visibility |+| `F3` | `toggle-expanded-channel-topics` | Toggle display of expanded channel topics | | `M-k` | `invoke-editor` | Invoke `$EDITOR` to edit the current message | | `C-g` | `enter-fast-select` | Enter fast channel selection mode | | `C-q` | `quit` | Quit |@@ -45,6 +46,25 @@ | `Esc`, `C-c` | `cancel` | Cancel autocomplete, message reply, or edit, in that order | | `M-8` | `show-flagged-posts` | View currently flagged posts | +# Text Editing+| Keybinding | Event Name | Description |+| ---------- | ---------- | ----------- |+| `C-t` | `editor-transpose-chars` | Transpose the final two characters |+| `C-a` | `editor-beginning-of-line` | Go to the start of the current line |+| `C-e` | `editor-end-of-line` | Go to the end of the current line |+| `C-d` | `editor-delete-char` | Delete the character at the cursor |+| `C-u` | `editor-kill-to-beginning-of-line` | Delete from the cursor to the start of the current line |+| `C-k` | `editor-kill-to-end-of-line` | Kill the line to the right of the current position and copy it |+| `C-f` | `editor-next-char` | Move one character to the right |+| `C-b` | `editor-prev-char` | Move one character to the left |+| `M-f` | `editor-next-word` | Move one word to the right |+| `M-b` | `editor-prev-word` | Move one word to the left |+| `C-w`, `M-Backspace` | `editor-delete-prev-word` | Delete the word to the left of the cursor |+| `M-d` | `editor-delete-next-word` | Delete the word to the right of the cursor |+| `Home` | `editor-home` | Move the cursor to the beginning of the input |+| `End` | `editor-end` | Move the cursor to the end of the input |+| `C-y` | `editor-yank` | Paste the current buffer contents at the cursor |+ # Channel Select Mode | Keybinding | Event Name | Description | | ---------- | ---------- | ----------- |@@ -55,15 +75,6 @@ | `Down` | `focus-next-channel-alternate` | Select next match (alternate binding) | | `Up` | `focus-prev-channel-alternate` | Select previous match (alternate binding) | -# URL Select Mode-| Keybinding | Event Name | Description |-| ---------- | ---------- | ----------- |-| `Enter` | (non-customizable key) | Open the selected URL, if any |-| `Esc`, `C-c` | `cancel` | Cancel URL selection |-| `k`, `Up` | `select-up` | Move cursor up |-| `j`, `Down` | `select-down` | Move cursor down |-| `q` | (non-customizable key) | Cancel URL selection |- # Message Select Mode | Keybinding | Event Name | Description | | ---------- | ---------- | ----------- |@@ -86,33 +97,24 @@ | `Enter` | `fetch-for-gap` | Fetch messages for the selected gap | | `a` | `react-to-message` | Post a reaction to the selected message | -# Text Editing+# User Listings | Keybinding | Event Name | Description | | ---------- | ---------- | ----------- |-| `C-t` | `editor-transpose-chars` | Transpose the final two characters |-| `C-a` | `editor-beginning-of-line` | Go to the start of the current line |-| `C-e` | `editor-end-of-line` | Go to the end of the current line |-| `C-d` | `editor-delete-char` | Delete the character at the cursor |-| `C-u` | `editor-kill-to-beginning-of-line` | Delete from the cursor to the start of the current line |-| `C-k` | `editor-kill-to-end-of-line` | Kill the line to the right of the current position and copy it |-| `C-f` | `editor-next-char` | Move one character to the right |-| `C-b` | `editor-prev-char` | Move one character to the left |-| `M-f` | `editor-next-word` | Move one word to the right |-| `M-b` | `editor-prev-word` | Move one word to the left |-| `C-w`, `M-Backspace` | `editor-delete-prev-word` | Delete the word to the left of the cursor |-| `M-d` | `editor-delete-next-word` | Delete the word to the right of the cursor |-| `Home` | `editor-home` | Move the cursor to the beginning of the input |-| `End` | `editor-end` | Move the cursor to the end of the input |-| `C-y` | `editor-yank` | Paste the current buffer contents at the cursor |+| `Esc`, `C-c` | `cancel` | Close the user search list |+| `C-p`, `Up` | `search-select-up` | Select the previous user |+| `C-n`, `Down` | `search-select-down` | Select the next user |+| `PgDown` | `page-down` | Page down in the user list |+| `PgUp` | `page-up` | Page up in the user list |+| `Enter` | `activate-list-item` | Interact with the selected user | -# Flagged Messages+# URL Select Mode | Keybinding | Event Name | Description | | ---------- | ---------- | ----------- |-| `Esc`, `C-c` | `cancel` | Exit post browsing |-| `k`, `Up` | `select-up` | Select the previous message |-| `j`, `Down` | `select-down` | Select the next message |-| `f` | `flag-message` | Toggle the selected message flag |-| `Enter` | `activate-list-item` | Jump to and select current message |+| `Enter` | (non-customizable key) | Open the selected URL, if any |+| `Esc`, `C-c` | `cancel` | Cancel URL selection |+| `k`, `Up` | `select-up` | Move cursor up |+| `j`, `Down` | `select-down` | Move cursor down |+| `q` | (non-customizable key) | Cancel URL selection | # Theme List Window | Keybinding | Event Name | Description |@@ -124,16 +126,6 @@ | `PgUp` | `page-up` | Page up in the theme list | | `Enter` | `activate-list-item` | Switch to the selected color theme | -# User Listings-| Keybinding | Event Name | Description |-| ---------- | ---------- | ----------- |-| `Esc`, `C-c` | `cancel` | Close the user search list |-| `C-p`, `Up` | `search-select-up` | Select the previous user |-| `C-n`, `Down` | `search-select-down` | Select the next user |-| `PgDown` | `page-down` | Page down in the user list |-| `PgUp` | `page-up` | Page up in the user list |-| `Enter` | `activate-list-item` | Interact with the selected user |- # Channel Search Window | Keybinding | Event Name | Description | | ---------- | ---------- | ----------- |@@ -144,16 +136,6 @@ | `PgUp` | `page-up` | Page up in the channel list | | `Enter` | `activate-list-item` | Join the selected channel | -# Reaction Emoji Search Window-| Keybinding | Event Name | Description |-| ---------- | ---------- | ----------- |-| `Esc`, `C-c` | `cancel` | Close the emoji search window |-| `C-p`, `Up` | `search-select-up` | Select the previous emoji |-| `C-n`, `Down` | `search-select-down` | Select the next emoji |-| `PgDown` | `page-down` | Page down in the emoji list |-| `PgUp` | `page-up` | Page up in the emoji list |-| `Enter` | `activate-list-item` | Post the selected emoji reaction |- # Message Viewer: Common | Keybinding | Event Name | Description | | ---------- | ---------- | ----------- |@@ -200,4 +182,23 @@ | ---------- | ---------- | ----------- | | `Esc`, `C-c` | `cancel` | Cancel attachment file browse | | `o` | `open-attachment` | Open the selected file using the URL open command |++# Flagged Messages+| Keybinding | Event Name | Description |+| ---------- | ---------- | ----------- |+| `Esc`, `C-c` | `cancel` | Exit post browsing |+| `k`, `Up` | `select-up` | Select the previous message |+| `j`, `Down` | `select-down` | Select the next message |+| `f` | `flag-message` | Toggle the selected message flag |+| `Enter` | `activate-list-item` | Jump to and select current message |++# Reaction Emoji Search Window+| Keybinding | Event Name | Description |+| ---------- | ---------- | ----------- |+| `Esc`, `C-c` | `cancel` | Close the emoji search window |+| `C-p`, `Up` | `search-select-up` | Select the previous emoji |+| `C-n`, `Down` | `search-select-down` | Select the next emoji |+| `PgDown` | `page-down` | Page down in the emoji list |+| `PgUp` | `page-up` | Page up in the emoji list |+| `Enter` | `activate-list-item` | Post the selected emoji reaction |
matterhorn.cabal view
@@ -1,5 +1,5 @@ name: matterhorn-version: 50200.10.3+version: 50200.11.0 synopsis: Terminal client for the Mattermost chat system description: This is a terminal client for the Mattermost chat system. Please see the README for a list of@@ -25,9 +25,10 @@ docs/commands.md docs/AUTHORS.txt docs/PRACTICES.md- scripts/cowsay- scripts/figlet- scripts/rot13+ client-scripts/cowsay+ client-scripts/figlet+ client-scripts/rot13+ client-scripts/talky notification-scripts/notify emoji/LICENSE.txt emoji/emoji.json@@ -37,115 +38,119 @@ type: git location: https://github.com/matterhorn-chat/matterhorn.git -executable matterhorn+library hs-source-dirs: src- main-is: Main.hs- other-modules: Config- Config.Schema- Command- Connection- App- Clipboard- Constants- State.Async- State.Attachments- State.Autocomplete- State.Channels- State.ChannelSelect- State.Common- State.Editing- State.Flagging- State.Help- State.Logging- State.Messages- State.MessageSelect- State.NotifyPrefs- State.PostListOverlay- State.ThemeListOverlay- State.ReactionEmojiListOverlay- State.Reactions- State.Setup- State.Setup.Threads- State.Setup.Threads.Logging- State.UrlSelect- State.ListOverlay- State.UserListOverlay- State.ChannelListOverlay- State.Users- Zipper- Themes- Draw- Emoji- Draw.RichText- Draw.ManageAttachments- Draw.Autocomplete- Draw.Main- Draw.Messages- Draw.ChannelList- Draw.ChannelListOverlay- Draw.ReactionEmojiListOverlay- Draw.ShowHelp- Draw.LeaveChannelConfirm- Draw.DeleteChannelConfirm- Draw.PostListOverlay- Draw.ThemeListOverlay- Draw.UserListOverlay- Draw.ListOverlay- Draw.URLList- Draw.Util- Draw.TabbedWindow- Draw.NotifyPrefs- InputHistory- IOUtil- Events- Events.ManageAttachments- Events.Keybindings- Events.ShowHelp- Events.MessageSelect- Events.Main- Events.ChannelSelect- Events.PostListOverlay- Events.ThemeListOverlay- Events.UrlSelect- Events.UserListOverlay- Events.ChannelListOverlay- Events.ReactionEmojiListOverlay- Events.LeaveChannelConfirm- Events.DeleteChannelConfirm- Events.TabbedWindow- Events.EditNotifyPrefs- Windows.ViewMessage- HelpTopics- KeyMap- LastRunState- Scripts- TimeUtils- Prelude.MH- Types- Types.Channels- Types.Common- Types.DirectionalSeq- Types.KeyEvents- Types.Messages- Types.RichText- Types.Users- Types.Posts- FilePaths- TeamSelect- Login- Options- Util+ exposed-modules: Matterhorn.App+ Matterhorn.Clipboard+ Matterhorn.Command+ Matterhorn.Config+ Matterhorn.Config.Schema+ Matterhorn.Connection+ Matterhorn.Constants+ Matterhorn.Draw+ Matterhorn.Draw.Autocomplete+ Matterhorn.Draw.Buttons+ Matterhorn.Draw.ChannelList+ Matterhorn.Draw.ChannelListOverlay+ Matterhorn.Draw.ChannelTopicWindow+ Matterhorn.Draw.DeleteChannelConfirm+ Matterhorn.Draw.LeaveChannelConfirm+ Matterhorn.Draw.ListOverlay+ Matterhorn.Draw.Main+ Matterhorn.Draw.ManageAttachments+ Matterhorn.Draw.Messages+ Matterhorn.Draw.NotifyPrefs+ Matterhorn.Draw.PostListOverlay+ Matterhorn.Draw.ReactionEmojiListOverlay+ Matterhorn.Draw.RichText+ Matterhorn.Draw.ShowHelp+ Matterhorn.Draw.TabbedWindow+ Matterhorn.Draw.ThemeListOverlay+ Matterhorn.Draw.URLList+ Matterhorn.Draw.UserListOverlay+ Matterhorn.Draw.Util+ Matterhorn.Emoji+ Matterhorn.Events+ Matterhorn.Events.ChannelListOverlay+ Matterhorn.Events.ChannelSelect+ Matterhorn.Events.ChannelTopicWindow+ Matterhorn.Events.DeleteChannelConfirm+ Matterhorn.Events.EditNotifyPrefs+ Matterhorn.Events.Keybindings+ Matterhorn.Events.LeaveChannelConfirm+ Matterhorn.Events.Main+ Matterhorn.Events.ManageAttachments+ Matterhorn.Events.MessageSelect+ Matterhorn.Events.PostListOverlay+ Matterhorn.Events.ReactionEmojiListOverlay+ Matterhorn.Events.ShowHelp+ Matterhorn.Events.TabbedWindow+ Matterhorn.Events.ThemeListOverlay+ Matterhorn.Events.UrlSelect+ Matterhorn.Events.UserListOverlay+ Matterhorn.FilePaths+ Matterhorn.HelpTopics+ Matterhorn.IOUtil+ Matterhorn.InputHistory+ Matterhorn.KeyMap+ Matterhorn.LastRunState+ Matterhorn.Login+ Matterhorn.Options+ Matterhorn.Prelude+ Matterhorn.Scripts+ Matterhorn.State.Async+ Matterhorn.State.Attachments+ Matterhorn.State.Autocomplete+ Matterhorn.State.ChannelListOverlay+ Matterhorn.State.ChannelSelect+ Matterhorn.State.Channels+ Matterhorn.State.ChannelTopicWindow+ Matterhorn.State.Common+ Matterhorn.State.Editing+ Matterhorn.State.Flagging+ Matterhorn.State.Help+ Matterhorn.State.Links+ Matterhorn.State.ListOverlay+ Matterhorn.State.Logging+ Matterhorn.State.MessageSelect+ Matterhorn.State.Messages+ Matterhorn.State.NotifyPrefs+ Matterhorn.State.PostListOverlay+ Matterhorn.State.ReactionEmojiListOverlay+ Matterhorn.State.Reactions+ Matterhorn.State.Setup+ Matterhorn.State.Setup.Threads+ Matterhorn.State.Setup.Threads.Logging+ Matterhorn.State.ThemeListOverlay+ Matterhorn.State.UrlSelect+ Matterhorn.State.UserListOverlay+ Matterhorn.State.Users+ Matterhorn.TeamSelect+ Matterhorn.Themes+ Matterhorn.TimeUtils+ Matterhorn.Types+ Matterhorn.Types.Channels+ Matterhorn.Types.Common+ Matterhorn.Types.DirectionalSeq+ Matterhorn.Types.KeyEvents+ Matterhorn.Types.Messages+ Matterhorn.Types.Posts+ Matterhorn.Types.RichText+ Matterhorn.Types.Users+ Matterhorn.Util+ Matterhorn.Windows.ViewMessage+ Matterhorn.Zipper Paths_matterhorn default-extensions: OverloadedStrings, ScopedTypeVariables, NoImplicitPrelude - ghc-options: -Wall -threaded -with-rtsopts=-I0 -Wcompat+ ghc-options: -Wall -Wcompat if impl(ghc >= 8.2) ghc-options: -fhide-source-paths build-depends: base >=4.8 && <5- , mattermost-api == 50200.8.0+ , mattermost-api == 50200.9.0 , base-compat >= 0.9 && < 0.12 , unordered-containers >= 0.2 && < 0.3 , containers >= 0.5.7 && < 0.7@@ -159,9 +164,9 @@ , config-ini >= 0.2.2.0 && < 0.3 , process >= 1.4 && < 1.7 , microlens-platform >= 0.3 && < 0.5- , brick >= 0.55 && < 0.56+ , brick >= 0.57 && < 0.58 , brick-skylighting >= 0.2 && < 0.4- , vty >= 5.30 && < 5.31+ , vty >= 5.31 && < 5.32 , word-wrap >= 0.4.0 && < 0.5 , transformers >= 0.4 && < 0.6 , text-zipper >= 0.10 && < 0.11@@ -191,19 +196,24 @@ , network-uri >= 2.6 && < 2.7 default-language: Haskell2010 +executable matterhorn+ hs-source-dirs: programs+ main-is: Main.hs++ ghc-options: -Wall -threaded -with-rtsopts=-I0 -Wcompat+ if impl(ghc >= 8.2)+ ghc-options: -fhide-source-paths++ build-depends: base >=4.8 && <5+ , matterhorn+ , text+ default-language: Haskell2010+ test-suite test_messages type: exitcode-stdio-1.0 main-is: test_messages.hs other-modules: Cheapskate_QCA , Message_QCA- , TimeUtils- , Types.Messages- , Types.Posts- , Types.Common- , Types.DirectionalSeq- , Types.RichText- , Constants- , Prelude.MH default-language: Haskell2010 default-extensions: OverloadedStrings , ScopedTypeVariables@@ -211,42 +221,19 @@ if impl(ghc >= 8.2) ghc-options: -fhide-source-paths - hs-source-dirs: src, test+ hs-source-dirs: test build-depends: base >=4.7 && <5- , base-compat >= 0.9 && < 0.12- , brick >= 0.55 && < 0.56- , bytestring >= 0.10 && < 0.11- , cheapskate >= 0.1 && < 0.2+ , Unique >= 0.4 && < 0.5 , checkers >= 0.4 && < 0.6- , config-ini >= 0.2.2.0 && < 0.3- , connection >= 0.2 && < 0.4- , containers >= 0.5.7 && < 0.7- , directory >= 1.3 && < 1.4- , filepath >= 1.4 && < 1.5- , hashable >= 1.2 && < 1.4- , Hclip >= 3.0 && < 3.1- , mattermost-api == 50200.8.0- , mattermost-api-qc == 50200.8.0- , microlens-platform >= 0.3 && < 0.5- , mtl >= 2.2 && < 2.3- , process >= 1.4 && < 1.7- , quickcheck-text >= 0.1 && < 0.2- , stm >= 2.4 && < 2.6- , strict >= 0.3 && < 0.4- , string-conversions >= 0.4 && < 0.5 , tasty >= 0.11 && < 1.3 , tasty-hunit >= 0.9 && < 0.12 , tasty-quickcheck >= 0.8 && < 0.12- , text >= 1.2 && < 1.3- , text-zipper >= 0.10 && < 0.11- , time >= 1.6 && < 2.0- , timezone-olson >= 0.2 && < 0.3- , timezone-series >= 0.1.6.1 && < 0.2- , transformers >= 0.4 && < 0.6- , Unique >= 0.4 && < 0.5- , unordered-containers >= 0.2 && < 0.3- , vector < 0.13- , vty >= 5.30 && < 5.31- , xdg-basedir >= 0.2 && < 0.3- , semigroups >= 0.18 && < 0.20- , uuid >= 1.3 && < 1.4+ , bytestring+ , cheapskate+ , containers+ , matterhorn+ , mattermost-api+ , mattermost-api-qc+ , text+ , time+ , uuid
+ programs/Main.hs view
@@ -0,0 +1,60 @@+module Main where++import Prelude ()+import qualified Data.Text.IO as T++import System.Exit ( exitFailure, exitSuccess )++import Matterhorn.Prelude+import Matterhorn.Config+import Matterhorn.Options+import Matterhorn.App+import Matterhorn.Events.Keybindings ( ensureKeybindingConsistency )+import Matterhorn.KeyMap ( keybindingModeMap )+import Matterhorn.Draw.ShowHelp ( keybindingMarkdownTable, keybindingTextTable+ , commandMarkdownTable, commandTextTable )+++main :: IO ()+main = do+ opts <- grabOptions++ configResult <- if optIgnoreConfig opts+ then return $ Right defaultConfig+ else fmap snd <$> findConfig (optConfLocation opts)++ config <- case configResult of+ Left err -> do+ putStrLn $ "Error loading config: " <> err+ exitFailure+ Right c -> return c++ let keyConfig = configUserKeys config+ format = optPrintFormat opts++ printedCommands <- case optPrintCommands opts of+ False -> return False+ True -> do+ case format of+ Markdown -> T.putStrLn commandMarkdownTable+ Plain -> T.putStrLn commandTextTable+ return True++ printedKeybindings <- case optPrintKeybindings opts of+ False -> return False+ True -> do+ case format of+ Markdown -> T.putStrLn $ keybindingMarkdownTable keyConfig+ Plain -> T.putStrLn $ keybindingTextTable keyConfig+ return True++ when (printedKeybindings || printedCommands) exitSuccess++ case ensureKeybindingConsistency keyConfig keybindingModeMap of+ Right () -> return ()+ Left err -> do+ putStrLn $ "Configuration error: " <> err+ exitFailure++ finalSt <- runMatterhorn opts config+ closeMatterhorn finalSt
− scripts/cowsay
@@ -1,7 +0,0 @@-#!/bin/bash -e--# We want the fences so that the cowsay block gets rendered-# in Markdown as a code block. Otherwise, we just call cowsay.-echo '~~~~~~~~~~'-cowsay-echo '~~~~~~~~~~'
− scripts/figlet
@@ -1,7 +0,0 @@-#!/bin/bash -e--# We want the fences so that the cowsay block gets rendered-# in Markdown as a code block. Otherwise, we just call cowsay.-echo '~~~~~~~~~~'-figlet-echo '~~~~~~~~~~'
− scripts/rot13
@@ -1,4 +0,0 @@-#!/bin/bash -e--# This should be a reasonably portable ROT-13-izer across Unixes-tr '[A-Za-z]' '[N-ZA-Mn-za-m]'
− src/App.hs
@@ -1,114 +0,0 @@-module App- ( runMatterhorn- , closeMatterhorn- )-where--import Prelude ()-import Prelude.MH--import Brick-import Control.Monad.Trans.Except ( runExceptT )-import qualified Graphics.Vty as Vty-import Text.Aspell ( stopAspell )-import GHC.Conc (getNumProcessors, setNumCapabilities)-import System.Posix.IO ( stdInput )--import Network.Mattermost--import Config-import Draw-import qualified Events-import IOUtil-import InputHistory-import LastRunState-import Options hiding ( ShowHelp )-import State.Setup-import State.Setup.Threads.Logging ( shutdownLogManager )-import Types---app :: App ChatState MHEvent Name-app = App- { appDraw = draw- , appChooseCursor = \s cs -> case appMode s of- Main -> showFirstCursor s cs- ChannelSelect -> showFirstCursor s cs- UserListOverlay -> showFirstCursor s cs- ReactionEmojiListOverlay -> showFirstCursor s cs- ChannelListOverlay -> showFirstCursor s cs- ManageAttachmentsBrowseFiles -> showFirstCursor s cs- ThemeListOverlay -> showFirstCursor s cs- LeaveChannelConfirm -> Nothing- DeleteChannelConfirm -> Nothing- MessageSelect -> Nothing- MessageSelectDeleteConfirm -> Nothing- PostListOverlay _ -> Nothing- ManageAttachments -> Nothing- ViewMessage -> Nothing- ShowHelp _ _ -> Nothing- UrlSelect -> Nothing- EditNotifyPrefs -> Nothing- , appHandleEvent = Events.onEvent- , appStartEvent = return- , appAttrMap = (^.csResources.crTheme)- }--applicationMaxCPUs :: Int-applicationMaxCPUs = 2--setupCpuUsage :: Config -> IO ()-setupCpuUsage config = do- actualNumCpus <- getNumProcessors-- let requestedCPUs = case configCpuUsagePolicy config of- SingleCPU -> 1- MultipleCPUs -> min applicationMaxCPUs actualNumCpus-- setNumCapabilities requestedCPUs--runMatterhorn :: Options -> Config -> IO ChatState-runMatterhorn opts config = do- setupCpuUsage config-- let mkVty = do- mEraseChar <- Vty.getTtyEraseChar stdInput- let addEraseChar cfg = case mEraseChar of- Nothing -> cfg- Just ch -> cfg { Vty.inputMap = (Nothing, [ch], Vty.EvKey Vty.KBS []) : Vty.inputMap cfg }-- vty <- Vty.mkVty $ addEraseChar Vty.defaultConfig- let output = Vty.outputIface vty- Vty.setMode output Vty.BracketedPaste True- Vty.setMode output Vty.Hyperlink $ configHyperlinkingMode config- return vty-- (st, vty) <- setupState mkVty (optLogLocation opts) config- finalSt <- customMain vty mkVty (Just $ st^.csResources.crEventQueue) app st-- case finalSt^.csEditState.cedSpellChecker of- Nothing -> return ()- Just (s, _) -> stopAspell s-- return finalSt---- | Cleanup resources and save data for restoring on program restart.-closeMatterhorn :: ChatState -> IO ()-closeMatterhorn finalSt = do- logIfError (mmCloseSession $ getResourceSession $ finalSt^.csResources)- "Error in closing session"-- logIfError (writeHistory (finalSt^.csEditState.cedInputHistory))- "Error in writing history"-- logIfError (writeLastRunState finalSt)- "Error in writing last run state"-- shutdownLogManager $ finalSt^.csResources.crLogManager-- where- logIfError action msg = do- done <- runExceptT $ convertIOException $ action- case done of- Left err -> putStrLn $ msg <> ": " <> err- Right _ -> return ()
− src/Clipboard.hs
@@ -1,31 +0,0 @@-module Clipboard- ( copyToClipboard- )-where--import Prelude ()-import Prelude.MH--import Control.Exception ( try )-import qualified Data.Text as T-import System.Hclip ( setClipboard, ClipboardException(..) )--import Types---copyToClipboard :: Text -> MH ()-copyToClipboard txt = do- result <- liftIO (try (setClipboard (T.unpack txt)))- case result of- Left e -> do- let errMsg = case e of- UnsupportedOS _ ->- "Matterhorn does not support yanking on this operating system."- NoTextualData ->- "Textual data is required to set the clipboard."- MissingCommands cmds ->- "Could not set clipboard due to missing one of the " <>- "required program(s): " <> (T.pack $ show cmds)- mhError $ ClipboardError errMsg- Right () ->- return ()
− src/Command.hs
@@ -1,335 +0,0 @@-{-# LANGUAGE GADTs #-}-{-# LANGUAGE OverloadedStrings #-}-module Command- ( commandList- , dispatchCommand- , printArgSpec- , toggleMessagePreview- )-where--import Prelude ()-import Prelude.MH--import Brick.Main ( invalidateCache )-import qualified Control.Exception as Exn-import qualified Data.Char as Char-import qualified Data.Text as T-import Lens.Micro.Platform ( (%=) )--import qualified Network.Mattermost.Endpoints as MM-import qualified Network.Mattermost.Exceptions as MM-import qualified Network.Mattermost.Types as MM--import Connection ( connectWebsockets )-import Constants ( userSigil, normalChannelSigil )-import HelpTopics-import Scripts-import State.Help-import State.Editing-import State.Channels-import State.ChannelSelect-import State.Logging-import State.PostListOverlay-import State.UserListOverlay-import State.ChannelListOverlay-import State.ThemeListOverlay-import State.NotifyPrefs-import State.Common ( postInfoMessage )-import State.Users-import Themes ( attrForUsername )-import Types----- | This function skips any initial whitespace and returns the first--- 'token' (i.e. any sequence of non-whitespace characters) as well as--- the trailing rest of the string, after any whitespace. This is used--- for tokenizing the first bits of command input while leaving the--- subsequent chunks unchanged, preserving newlines and other--- important formatting.-unwordHead :: Text -> Maybe (Text, Text)-unwordHead t =- let t' = T.dropWhile Char.isSpace t- (w, rs) = T.break Char.isSpace t'- in if T.null w- then Nothing- else Just (w, T.dropWhile Char.isSpace rs)--printArgSpec :: CmdArgs a -> Text-printArgSpec NoArg = ""-printArgSpec (LineArg ts) = "<" <> ts <> ">"-printArgSpec (TokenArg t NoArg) = "<" <> t <> ">"-printArgSpec (UserArg rs) = "<" <> userSigil <> "user>" <> addSpace (printArgSpec rs)-printArgSpec (ChannelArg rs) = "<" <> normalChannelSigil <> "channel>" <> addSpace (printArgSpec rs)-printArgSpec (TokenArg t rs) = "<" <> t <> ">" <> addSpace (printArgSpec rs)--addSpace :: Text -> Text-addSpace "" = ""-addSpace t = " " <> t--matchArgs :: CmdArgs a -> Text -> Either Text a-matchArgs NoArg t = case unwordHead t of- Nothing -> return ()- Just (a, as)- | not (T.all Char.isSpace as) -> Left ("Unexpected arguments '" <> t <> "'")- | otherwise -> Left ("Unexpected argument '" <> a <> "'")-matchArgs (LineArg _) t = return t-matchArgs spec@(UserArg rs) t = case unwordHead t of- Nothing -> case rs of- NoArg -> Left ("Missing argument: " <> printArgSpec spec)- _ -> Left ("Missing arguments: " <> printArgSpec spec)- Just (a, as) -> (,) <$> pure a <*> matchArgs rs as-matchArgs spec@(ChannelArg rs) t = case unwordHead t of- Nothing -> case rs of- NoArg -> Left ("Missing argument: " <> printArgSpec spec)- _ -> Left ("Missing arguments: " <> printArgSpec spec)- Just (a, as) -> (,) <$> pure a <*> matchArgs rs as-matchArgs spec@(TokenArg _ rs) t = case unwordHead t of- Nothing -> case rs of- NoArg -> Left ("Missing argument: " <> printArgSpec spec)- _ -> Left ("Missing arguments: " <> printArgSpec spec)- Just (a, as) -> (,) <$> pure a <*> matchArgs rs as--commandList :: [Cmd]-commandList =- [ Cmd "quit" "Exit Matterhorn" NoArg $ \ () -> requestQuit-- , Cmd "right" "Focus on the next channel" NoArg $ \ () ->- nextChannel-- , Cmd "left" "Focus on the previous channel" NoArg $ \ () ->- prevChannel-- , Cmd "create-channel" "Create a new public channel"- (LineArg "channel name") $ \ name ->- createOrdinaryChannel True name-- , Cmd "create-private-channel" "Create a new private channel"- (LineArg "channel name") $ \ name ->- createOrdinaryChannel False name-- , Cmd "delete-channel" "Delete the current channel"- NoArg $ \ () ->- beginCurrentChannelDeleteConfirm-- , Cmd "hide" "Hide the current DM or group channel from the channel list"- NoArg $ \ () -> do- hideDMChannel =<< use csCurrentChannelId-- , Cmd "reconnect" "Force a reconnection attempt to the server"- NoArg $ \ () ->- connectWebsockets-- , Cmd "members" "Show the current channel's members"- NoArg $ \ () ->- enterChannelMembersUserList-- , Cmd "leave" "Leave a normal channel or hide a DM channel" NoArg $ \ () ->- startLeaveCurrentChannel-- , Cmd "join" "Find a channel to join" NoArg $ \ () ->- enterChannelListOverlayMode-- , Cmd "join" "Join the specified channel" (ChannelArg NoArg) $ \(n, ()) ->- joinChannelByName n-- , Cmd "theme" "List the available themes" NoArg $ \ () ->- enterThemeListMode-- , Cmd "theme" "Set the color theme"- (TokenArg "theme" NoArg) $ \ (themeName, ()) ->- setTheme themeName-- , Cmd "topic" "Set the current channel's topic (header)"- (LineArg "topic") $ \ p ->- if not (T.null p) then setChannelTopic p else return ()-- , Cmd "purpose" "Set the current channel's purpose"- (LineArg "purpose") $ \ p ->- if not (T.null p) then setChannelPurpose p else return ()-- , Cmd "add-user" "Search for a user to add to the current channel"- NoArg $ \ () ->- enterChannelInviteUserList-- , Cmd "msg" "Search for a user to enter a private chat"- NoArg $ \ () ->- enterDMSearchUserList-- , Cmd "msg" "Chat with the specified user"- (UserArg NoArg) $ \ (name, ()) ->- changeChannelByName name-- , Cmd "username-attribute" "Display the attribute used to color the specified username"- (UserArg NoArg) $ \ (name, ()) ->- displayUsernameAttribute name-- , Cmd "msg" "Go to a user's channel and send the specified message or command"- (UserArg $ LineArg "message or command") $ \ (name, msg) -> do- withFetchedUserMaybe (UserFetchByUsername name) $ \foundUser -> do- case foundUser of- Just user -> createOrFocusDMChannel user $ Just $ \cId ->- handleInputSubmission cId msg- Nothing -> mhError $ NoSuchUser name-- , Cmd "log-start" "Begin logging debug information to the specified path"- (TokenArg "path" NoArg) $ \ (path, ()) ->- startLogging $ T.unpack path-- , Cmd "log-snapshot" "Dump the current debug log buffer to the specified path"- (TokenArg "path" NoArg) $ \ (path, ()) ->- logSnapshot $ T.unpack path-- , Cmd "log-stop" "Stop logging"- NoArg $ \ () ->- stopLogging-- , Cmd "log-mark" "Add a custom marker message to the Matterhorn debug log"- (LineArg "message") $ \ markMsg ->- mhLog LogUserMark markMsg-- , Cmd "log-status" "Show current debug logging status"- NoArg $ \ () ->- getLogDestination-- , Cmd "add-user" "Add a user to the current channel"- (UserArg NoArg) $ \ (uname, ()) ->- addUserByNameToCurrentChannel uname-- , Cmd "remove-user" "Remove a user from the current channel"- (UserArg NoArg) $ \ (uname, ()) ->- removeUserFromCurrentChannel uname-- , Cmd "user" "Show users to initiate a private DM chat channel"- -- n.b. this is identical to "msg", but is provided as an- -- alternative mental model for useability.- NoArg $ \ () ->- enterDMSearchUserList-- , Cmd "message-preview" "Toggle preview of the current message" NoArg $ \_ ->- toggleMessagePreview-- , Cmd "toggle-channel-list" "Toggle channel list visibility" NoArg $ \_ ->- toggleChannelListVisibility-- , Cmd "focus" "Focus on a channel or user"- (ChannelArg NoArg) $ \ (name, ()) ->- changeChannelByName name-- , Cmd "focus" "Select from available channels" NoArg $ \ () ->- beginChannelSelect-- , Cmd "help" "Show this help screen" NoArg $ \ _ ->- showHelpScreen mainHelpTopic-- , Cmd "help" "Show help about a particular topic"- (TokenArg "topic" NoArg) $ \ (topicName, ()) ->- case lookupHelpTopic topicName of- Nothing -> mhError $ NoSuchHelpTopic topicName- Just topic -> showHelpScreen topic-- , Cmd "sh" "List the available shell scripts" NoArg $ \ () ->- listScripts-- , Cmd "group-msg" "Create a group chat"- (LineArg (userSigil <> "user [" <> userSigil <> "user ...]")) createGroupChannel-- , Cmd "sh" "Run a prewritten shell script"- (TokenArg "script" (LineArg "message")) $ \ (script, text) -> do- cId <- use csCurrentChannelId- findAndRunScript cId script text-- , Cmd "me" "Send an emote message"- (LineArg "message") $- \msg -> execMMCommand "me" msg-- , Cmd "shrug" "Send a message followed by a shrug emoticon"- (LineArg "message") $- \msg -> execMMCommand "shrug" msg-- , Cmd "flags" "Open a window of your flagged posts" NoArg $ \ () ->- enterFlaggedPostListMode-- , Cmd "pinned-posts" "Open a window of this channel's pinned posts" NoArg $ \ () ->- enterPinnedPostListMode-- , Cmd "search" "Search for posts with given terms" (LineArg "terms") $- enterSearchResultPostListMode-- , Cmd "notify-prefs" "Edit the current channel's notification preferences" NoArg $ \_ ->- enterEditNotifyPrefsMode-- ]--displayUsernameAttribute :: Text -> MH ()-displayUsernameAttribute name = do- let an = attrForUsername trimmed- trimmed = trimUserSigil name- postInfoMessage $ "The attribute used for " <> userSigil <> trimmed <>- " is " <> (attrNameToConfig an)--execMMCommand :: Text -> Text -> MH ()-execMMCommand name rest = do- cId <- use csCurrentChannelId- session <- getSession- em <- use (csEditState.cedEditMode)- tId <- gets myTeamId- let mc = MM.MinCommand- { MM.minComChannelId = cId- , MM.minComCommand = "/" <> name <> " " <> rest- , MM.minComParentId = case em of- Replying _ p -> Just $ MM.getId p- Editing p _ -> MM.postRootId p- _ -> Nothing- , MM.minComRootId = case em of- Replying _ p -> MM.postRootId p <|> (Just $ MM.postId p)- Editing p _ -> MM.postRootId p- _ -> Nothing- , MM.minComTeamId = tId- }- runCmd = liftIO $ do- void $ MM.mmExecuteCommand mc session- handleHTTP (MM.HTTPResponseException err) =- return (Just (T.pack err))- -- XXX: this might be a bit brittle in the future, because it- -- assumes the shape of an error message. We might want to- -- think about a better way of discovering this error and- -- reporting it accordingly?- handleCmdErr (MM.MattermostServerError err) =- let (_, msg) = T.breakOn ": " err in- return (Just (T.drop 2 msg))- handleMMErr (MM.MattermostError- { MM.mattermostErrorMessage = msg }) =- return (Just msg)- errMsg <- liftIO $ (runCmd >> return Nothing) `Exn.catch` handleHTTP- `Exn.catch` handleCmdErr- `Exn.catch` handleMMErr- case errMsg of- Nothing -> return ()- Just err ->- mhError $ GenericError ("Error running command: " <> err)--dispatchCommand :: Text -> MH ()-dispatchCommand cmd =- case unwordHead cmd of- Just (x, xs)- | matchingCmds <- [ c- | c@(Cmd name _ _ _) <- commandList- , name == x- ] -> go [] matchingCmds- where go [] [] = do- execMMCommand x xs- go errs [] = do- let msg = ("error running command /" <> x <> ":\n" <>- mconcat [ " " <> e | e <- errs ])- mhError $ GenericError msg- go errs (Cmd _ _ spec exe : cs) =- case matchArgs spec xs of- Left e -> go (e:errs) cs- Right args -> exe args- _ -> return ()--toggleMessagePreview :: MH ()-toggleMessagePreview = do- mh invalidateCache- csShowMessagePreview %= not
− src/Command.hs-boot
@@ -1,9 +0,0 @@-module Command where--import Data.Text ( Text )--import Types ( MH, Cmd, CmdArgs )--commandList :: [Cmd]-printArgSpec :: CmdArgs a -> Text-dispatchCommand :: Text -> MH ()
− src/Config.hs
@@ -1,351 +0,0 @@-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE TupleSections #-}-{-# LANGUAGE MultiWayIf #-}-module Config- ( Config(..)- , PasswordSource(..)- , findConfig- , defaultConfig- , configConnectionType- )-where--import Prelude ()-import Prelude.MH--import Control.Monad.Trans.Except-import Control.Monad.Trans.Class ( lift )-import Config.Schema-import Data.Char ( isDigit, isAlpha )-import Data.List ( isPrefixOf )-import Data.List.Split ( splitOn )-import qualified Data.Map.Strict as M-import qualified Data.Text as T-import qualified Data.Text.IO as T-import System.Directory ( makeAbsolute, getHomeDirectory )-import System.Environment ( getExecutablePath )-import System.FilePath ( (</>), takeDirectory, splitPath, joinPath )-import System.Process ( readProcess )-import Network.Mattermost.Types (ConnectionType(..))-import Network.URI ( isIPv4address, isIPv6address )--import FilePaths-import IOUtil-import Types-import Types.KeyEvents---defaultPort :: Int-defaultPort = 443--bundledSyntaxPlaceholderName :: String-bundledSyntaxPlaceholderName = "BUNDLED_SYNTAX"--userSyntaxPlaceholderName :: String-userSyntaxPlaceholderName = "USER_SYNTAX"--defaultSkylightingPaths :: IO [FilePath]-defaultSkylightingPaths = do- xdg <- xdgSyntaxDir- adjacent <- getBundledSyntaxPath- return [xdg, adjacent]--getBundledSyntaxPath :: IO FilePath-getBundledSyntaxPath = do- selfPath <- getExecutablePath- let distDir = "dist-newstyle/"- pathBits = splitPath selfPath-- return $ if distDir `elem` pathBits- then- -- We're in development, so use the development- -- executable path to locate the XML path in the- -- development tree.- (joinPath $ takeWhile (/= distDir) pathBits) </> syntaxDirName- else- -- In this case we assume the binary is being run from- -- a release, in which case the syntax directory is a- -- sibling of the executable path.- takeDirectory selfPath </> syntaxDirName--fromIni :: IniParser Config-fromIni = do- conf <- section "mattermost" $ do- configUser <- fieldMbOf "user" stringField- configHost <- fieldMbOf "host" hostField- configTeam <- fieldMbOf "team" stringField- configPort <- fieldDefOf "port" number (configPort defaultConfig)- configUrlPath <- fieldMbOf "urlPath" stringField- configChannelListWidth <- fieldDefOf "channelListWidth" number- (configChannelListWidth defaultConfig)- configCpuUsagePolicy <- fieldDefOf "cpuUsagePolicy" cpuUsagePolicy- (configCpuUsagePolicy defaultConfig)- configLogMaxBufferSize <- fieldDefOf "logMaxBufferSize" number- (configLogMaxBufferSize defaultConfig)- configTimeFormat <- fieldMbOf "timeFormat" stringField- configDateFormat <- fieldMbOf "dateFormat" stringField- configTheme <- fieldMbOf "theme" stringField- configThemeCustomizationFile <- fieldMbOf "themeCustomizationFile" stringField- configAspellDictionary <- fieldMbOf "aspellDictionary" stringField- configURLOpenCommand <- fieldMbOf "urlOpenCommand" stringField- configURLOpenCommandInteractive <- fieldFlagDef "urlOpenCommandIsInteractive" False- configSmartBacktick <- fieldFlagDef "smartbacktick"- (configSmartBacktick defaultConfig)- configSmartEditing <- fieldFlagDef "smartediting"- (configSmartEditing defaultConfig)- configShowOlderEdits <- fieldFlagDef "showOlderEdits"- (configShowOlderEdits defaultConfig)- configShowBackground <- fieldDefOf "showBackgroundActivity" backgroundField- (configShowBackground defaultConfig)- configShowMessagePreview <- fieldFlagDef "showMessagePreview"- (configShowMessagePreview defaultConfig)- configShowChannelList <- fieldFlagDef "showChannelList"- (configShowChannelList defaultConfig)- configShowTypingIndicator <- fieldFlagDef "showTypingIndicator"- (configShowTypingIndicator defaultConfig)- configEnableAspell <- fieldFlagDef "enableAspell"- (configEnableAspell defaultConfig)- configSyntaxDirs <- fieldDefOf "syntaxDirectories" syntaxDirsField []- configActivityNotifyCommand <- fieldMb "activityNotifyCommand"- configActivityBell <- fieldFlagDef "activityBell"- (configActivityBell defaultConfig)- configHyperlinkingMode <- fieldFlagDef "hyperlinkURLs"- (configHyperlinkingMode defaultConfig)- configMessageSelectAfterURLOpen <- fieldFlagDef "messageSelectAfterURLOpen"- (configMessageSelectAfterURLOpen defaultConfig)- configPass <- (Just . PasswordCommand <$> field "passcmd") <!>- (Just . PasswordString <$> field "pass") <!>- pure Nothing- configToken <- (Just . TokenCommand <$> field "tokencmd") <!>- pure Nothing- configUnsafeUseHTTP <-- fieldFlagDef "unsafeUseUnauthenticatedConnection" False- configValidateServerCertificate <-- fieldFlagDef "validateServerCertificate" True- configDirectChannelExpirationDays <- fieldDefOf "directChannelExpirationDays" number- (configDirectChannelExpirationDays defaultConfig)- configDefaultAttachmentPath <- fieldMbOf "defaultAttachmentPath" filePathField-- let configAbsPath = Nothing- configUserKeys = mempty- return Config { .. }- keys <- sectionMb "keybindings" $ do- fmap (M.fromList . catMaybes) $ forM allEvents $ \ ev -> do- kb <- fieldMbOf (keyEventName ev) parseBindingList- case kb of- Nothing -> return Nothing- Just binding -> return (Just (ev, binding))- return conf { configUserKeys = fromMaybe mempty keys }--syntaxDirsField :: Text -> Either String [FilePath]-syntaxDirsField = listWithSeparator ":" string--validHostnameFragmentChar :: Char -> Bool-validHostnameFragmentChar c = isAlpha c || isDigit c || c == '-'--isHostnameFragment :: String -> Bool-isHostnameFragment "" = False-isHostnameFragment s = all validHostnameFragmentChar s--isHostname :: String -> Bool-isHostname "" = False-isHostname s =- let parts@(h:_) = splitOn "." s- in all isHostnameFragment parts && not ("-" `isPrefixOf` h)--hostField :: Text -> Either String Text-hostField t =- let s = T.unpack t- valid = or [ isIPv4address s- , isIPv6address s- , isHostname s- ]- in if valid- then Right t- else Left "Invalid 'host' value, must be a hostname or IPv4/IPv6 address"--expandTilde :: FilePath -> FilePath -> FilePath-expandTilde homeDir p =- let parts = splitPath p- f part | part == "~/" = homeDir <> "/"- | otherwise = part- in joinPath $ f <$> parts--backgroundField :: Text -> Either String BackgroundInfo-backgroundField t =- case t of- "Disabled" -> Right Disabled- "Active" -> Right Active- "ActiveCount" -> Right ActiveCount- _ -> Left ("Invalid value " <> show t- <> "; must be one of: Disabled, Active, ActiveCount")--cpuUsagePolicy :: Text -> Either String CPUUsagePolicy-cpuUsagePolicy t =- case T.toLower t of- "single" -> return SingleCPU- "multiple" -> return MultipleCPUs- _ -> Left $ "Invalid CPU usage policy value: " <> show t--stringField :: Text -> Either e Text-stringField t =- case isQuoted t of- True -> Right $ parseQuotedString t- False -> Right t--filePathField :: Text -> Either e FilePath-filePathField t = let path = T.unpack t in Right path--parseQuotedString :: Text -> Text-parseQuotedString t =- let body = T.drop 1 $ T.init t- unescapeQuotes s | T.null s = s- | "\\\"" `T.isPrefixOf` s = "\"" <> unescapeQuotes (T.drop 2 s)- | otherwise = (T.singleton $ T.head s) <> unescapeQuotes (T.drop 1 s)- in unescapeQuotes body--isQuoted :: Text -> Bool-isQuoted t =- let quote = "\""- in (quote `T.isPrefixOf` t) &&- (quote `T.isSuffixOf` t)--defaultConfig :: Config-defaultConfig =- Config { configAbsPath = Nothing- , configUser = Nothing- , configHost = Nothing- , configTeam = Nothing- , configPort = defaultPort- , configUrlPath = Nothing- , configPass = Nothing- , configToken = Nothing- , configTimeFormat = Nothing- , configDateFormat = Nothing- , configTheme = Nothing- , configThemeCustomizationFile = Nothing- , configSmartBacktick = True- , configSmartEditing = True- , configURLOpenCommand = Nothing- , configURLOpenCommandInteractive = False- , configActivityNotifyCommand = Nothing- , configActivityBell = False- , configShowBackground = Disabled- , configShowMessagePreview = False- , configShowChannelList = True- , configEnableAspell = False- , configAspellDictionary = Nothing- , configUnsafeUseHTTP = False- , configValidateServerCertificate = True- , configChannelListWidth = 20- , configLogMaxBufferSize = 200- , configShowOlderEdits = True- , configUserKeys = mempty- , configShowTypingIndicator = False- , configHyperlinkingMode = True- , configMessageSelectAfterURLOpen = False- , configSyntaxDirs = []- , configDirectChannelExpirationDays = 7- , configCpuUsagePolicy = MultipleCPUs- , configDefaultAttachmentPath = Nothing- }--findConfig :: Maybe FilePath -> IO (Either String ([String], Config))-findConfig Nothing = runExceptT $ do- cfg <- lift $ locateConfig configFileName- (warns, config) <-- case cfg of- Nothing -> return ([], defaultConfig)- Just path -> getConfig path- config' <- fixupPaths config- return (warns, config')-findConfig (Just path) =- runExceptT $ do (warns, config) <- getConfig path- config' <- fixupPaths config- return (warns, config')---- | Fix path references in the configuration:------ * Rewrite the syntax directory path list with 'fixupSyntaxDirs'--- * Expand "~" encountered in any setting that contains a path value-fixupPaths :: Config -> ExceptT String IO Config-fixupPaths initial = do- new <- fixupSyntaxDirs initial- homeDir <- liftIO getHomeDirectory- let fixP = expandTilde homeDir- fixPText = T.pack . expandTilde homeDir . T.unpack- return $ new { configThemeCustomizationFile = fixPText <$> configThemeCustomizationFile new- , configSyntaxDirs = fixP <$> configSyntaxDirs new- , configURLOpenCommand = fixPText <$> configURLOpenCommand new- , configActivityNotifyCommand = fixPText <$> configActivityNotifyCommand new- , configDefaultAttachmentPath = fixP <$> configDefaultAttachmentPath new- }---- | If the configuration has no syntax directories specified (the--- default if the user did not provide the setting), fill in the--- list with the defaults. Otherwise replace any bundled directory--- placeholders in the config's syntax path list.-fixupSyntaxDirs :: Config -> ExceptT String IO Config-fixupSyntaxDirs c =- if configSyntaxDirs c == []- then do- dirs <- liftIO defaultSkylightingPaths- return $ c { configSyntaxDirs = dirs }- else do- newDirs <- forM (configSyntaxDirs c) $ \dir ->- if | dir == bundledSyntaxPlaceholderName -> liftIO getBundledSyntaxPath- | dir == userSyntaxPlaceholderName -> liftIO xdgSyntaxDir- | otherwise -> return dir-- return $ c { configSyntaxDirs = newDirs }--getConfig :: FilePath -> ExceptT String IO ([String], Config)-getConfig fp = do- absPath <- convertIOException $ makeAbsolute fp- t <- (convertIOException $ T.readFile absPath) `catchE`- (\e -> throwE $ "Could not read " <> show absPath <> ": " <> e)-- -- HACK ALERT FIXME:- --- -- The config parser library we use, config-ini (as of 0.2.4.0)- -- cannot handle configuration files without trailing newlines.- -- Since that's not a really good reason for this function to raise- -- an exception (and is fixable on the fly), we have the following- -- check. This check is admittedly not a great thing to have to do,- -- and we should definitely get rid of it when config-ini fixes this- -- issue.- let t' = if "\n" `T.isSuffixOf` t then t else t <> "\n"-- case parseIniFile t' fromIni of- Left err -> do- throwE $ "Unable to parse " ++ absPath ++ ":" ++ fatalString err- Right (warns, conf) -> do- actualPass <- case configPass conf of- Just (PasswordCommand cmdString) -> do- let (cmd:rest) = T.unpack <$> T.words cmdString- output <- convertIOException (readProcess cmd rest "") `catchE`- (\e -> throwE $ "Could not execute password command: " <> e)- return $ Just $ T.pack (takeWhile (/= '\n') output)- Just (PasswordString pass) -> return $ Just pass- Nothing -> return Nothing-- actualToken <- case configToken conf of- Just (TokenCommand cmdString) -> do- let (cmd:rest) = T.unpack <$> T.words cmdString- output <- convertIOException (readProcess cmd rest "") `catchE`- (\e -> throwE $ "Could not execute token command: " <> e)- return $ Just $ T.pack (takeWhile (/= '\n') output)- Just (TokenString _) -> error $ "BUG: getConfig: token in the Config was already a TokenString"- Nothing -> return Nothing-- let conf' = conf- { configPass = PasswordString <$> actualPass- , configToken = TokenString <$> actualToken- , configAbsPath = Just absPath- }- return (map warningString warns, conf')--configConnectionType :: Config -> ConnectionType-configConnectionType config- | configUnsafeUseHTTP config = ConnectHTTP- | otherwise = ConnectHTTPS (configValidateServerCertificate config)
− src/Config/Schema.hs
@@ -1,203 +0,0 @@-{- |--This module provides an INI schema validator that is able to track unused-sections and fields in order to report warning messages to the user.--- -}-module Config.Schema- ( IniParser- , parseIniFile- , (<!>)-- , Fatal(..)- , fatalString-- , Warning(..)- , warningString-- , section- , sectionMb- , fieldMbOf- , fieldMb- , field- , fieldDefOf- , fieldFlagDef-- -- * Re-exports- , number- , string- , listWithSeparator- ) where--import Prelude ()-import Prelude.MH--import Data.Map (Map)-import qualified Data.Map as Map-import Data.List.NonEmpty (NonEmpty)-import qualified Data.List.NonEmpty as NonEmpty-import qualified Data.Text as T-import Control.Monad-import Data.Ini.Config (flag, number, listWithSeparator, string)-import Data.Ini.Config.Raw-----------------------------------------------------------------------------newtype Parser e t a = Parser { unParser :: e -> Map NormalizedText (NonEmpty t) -> Either Fatal (Map NormalizedText (NonEmpty t), [Warning], a) }--instance Functor (Parser e t) where- fmap = liftM--instance Applicative (Parser e t) where- pure x = Parser $ \_ s -> Right (s, [], x)- (<*>) = ap--instance Monad (Parser e t) where- m >>= f = Parser $ \e s0 ->- do (s1, ws1, x1) <- unParser m e s0- (s2, ws2, x2) <- unParser (f x1) e s1- Right (s2, ws1++ws2, x2)----(<!>) :: Parser e t a -> Parser e t a -> Parser e t a-p <!> q = Parser $ \e s ->- case unParser p e s of- Right r -> Right r- Left {} -> unParser q e s--getenv :: Parser e t e-getenv = Parser $ \e s -> Right (s, [], e)--request :: Text -> Parser e t (Maybe t)-request name = Parser $ \_ s ->- let name' = normalize name in- Right $!- case Map.lookup name' s of- Nothing -> (s , [], Nothing)- Just (x NonEmpty.:| xs) -> (s', [], Just x)- where- s' = case NonEmpty.nonEmpty xs of- Nothing -> Map.delete name' s- Just ne -> Map.insert name' ne s--fatal :: Fatal -> Parser e t a-fatal e = Parser $ \_ _ -> Left e--warnings :: [Warning] -> IniParser ()-warnings ws = Parser $ \_ s -> Right (s, ws, ())----------------------------------------------------------------------------type IniParser = Parser RawIni IniSection--type SectionParser = Parser IniSection IniValue--data Fatal- = NoSection Text- | MissingField IniSection Text- | BadField IniSection IniValue String- | ParseError String- deriving Show--data Warning- = UnusedSection IniSection- | UnusedField IniSection IniValue- deriving Show----------------------------------------------------------------------------fatalString :: Fatal -> String-fatalString (NoSection name) = "No top-level section named " ++ show name-fatalString (MissingField sec name) = "Missing field " ++ show name ++ " in section " ++ show (isName sec)-fatalString (BadField sec val err) =- "Line " ++ show (vLineNo val) ++- " in section " ++ show (isName sec) ++- ": " ++ err-fatalString (ParseError err) = err--warningString :: Warning -> String-warningString (UnusedSection sec) = "Unused section " ++ show (isName sec)-warningString (UnusedField sec val) =- "Line " ++ show (vLineNo val) ++- " in section " ++ show (isName sec) ++- ": unused field"----------------------------------------------------------------------------parseIniFile :: Text -> IniParser a -> Either Fatal ([Warning], a)-parseIniFile text parser =- case parseRawIni text of- Left e -> Left (ParseError e)- Right ini ->- let entries = Map.fromListWith (<>)- [ (k, pure v) | (k,v) <- toList (fromRawIni ini) ]- in- case unParser parser ini entries of- Left e -> Left e- Right (entries', ws, x) -> Right (ws ++ unused, x)- where- unused = [ UnusedSection e | e <- concatMap toList entries' ]----------------------------------------------------------------------------section :: Text -> SectionParser a -> IniParser a-section name parser =- do mb <- sectionMb name parser- case mb of- Nothing -> fatal (NoSection name)- Just x -> pure x--sectionMb :: Text -> SectionParser a -> IniParser (Maybe a)-sectionMb name parser =- do mb <- request name- case mb of- Nothing -> pure Nothing- Just sec ->- let entries = Map.fromListWith (<>)- [ (k, pure v) | (k,v) <- toList (isVals sec) ]- in- case unParser parser sec entries of- Left e -> fatal e- Right (entries', ws, result) ->- do warnings (ws ++ [ UnusedField sec v | v <- concatMap toList entries' ])- pure (Just result)----------------------------------------------------------------------------field :: Text -> SectionParser Text-field name =- do mb <- fieldMb name- case mb of- Just x -> pure x- Nothing ->- do s <- getenv- fatal (MissingField s name)--fieldMbOf :: Text -> (Text -> Either String a) -> SectionParser (Maybe a)-fieldMbOf name validate =- do mb <- request name- case mb of- Nothing -> pure Nothing- Just val ->- case validate (getVal val) of- Left e ->- do sec <- getenv- fatal (BadField sec val e)- Right x -> pure (Just x)--fieldMb :: Text -> SectionParser (Maybe Text)-fieldMb name = fmap getVal <$> request name--fieldDefOf :: Text -> (Text -> Either String a) -> a -> SectionParser a-fieldDefOf name validate def = fromMaybe def <$> fieldMbOf name validate--fieldFlagDef :: Text -> Bool -> SectionParser Bool-fieldFlagDef name def = fieldDefOf name flag def----------------------------------------------------------------------------getVal :: IniValue -> Text-getVal = T.strip . vValue
− src/Connection.hs
@@ -1,104 +0,0 @@-module Connection where--import Prelude ()-import Prelude.MH--import Control.Concurrent ( forkIO, threadDelay, killThread )-import qualified Control.Concurrent.STM as STM-import Control.Exception ( SomeException, catch, AsyncException(..), throwIO )-import qualified Data.HashMap.Strict as HM-import Data.Int (Int64)-import Data.Semigroup ( Max(..) )-import qualified Data.Text as T-import Data.Time ( UTCTime(..), secondsToDiffTime, getCurrentTime- , diffUTCTime )-import Data.Time.Calendar ( Day(..) )-import Lens.Micro.Platform ( (.=) )--import Network.Mattermost.Types ( ChannelId )-import qualified Network.Mattermost.WebSocket as WS--import Constants-import Types---connectWebsockets :: MH ()-connectWebsockets = do- logger <- mhGetIOLogger-- -- If we have an old websocket thread, kill it.- mOldTid <- use (csResources.crWebsocketThreadId)- case mOldTid of- Nothing -> return ()- Just oldTid -> liftIO $ do- logger LogWebsocket "Terminating previous websocket thread"- killThread oldTid-- st <- use id- session <- getSession-- tid <- liftIO $ do- let shunt (Left msg) = writeBChan (st^.csResources.crEventQueue) (WebsocketParseError msg)- shunt (Right (Right e)) = writeBChan (st^.csResources.crEventQueue) (WSEvent e)- shunt (Right (Left e)) = writeBChan (st^.csResources.crEventQueue) (WSActionResponse e)- runWS = WS.mmWithWebSocket session shunt $ \ws -> do- writeBChan (st^.csResources.crEventQueue) WebsocketConnect- processWebsocketActions st ws 1 HM.empty- logger LogWebsocket "Starting new websocket thread"- forkIO $ runWS `catch` ignoreThreadKilled- `catch` handleTimeout logger 1 st- `catch` handleError logger 5 st-- csResources.crWebsocketThreadId .= Just tid--ignoreThreadKilled :: AsyncException -> IO ()-ignoreThreadKilled ThreadKilled = return ()-ignoreThreadKilled e = throwIO e---- | Take websocket actions from the websocket action channel in the--- ChatState and send them to the server over the websocket.------ Takes and propagates the action sequence number which is incremented--- for each successful send.------ Keeps and propagates a map of channel id to last user_typing--- notification send time so that the new user_typing actions are--- throttled to be send only once in two seconds.-processWebsocketActions :: ChatState -> WS.MMWebSocket -> Int64 -> HashMap ChannelId (Max UTCTime) -> IO ()-processWebsocketActions st ws s userTypingLastNotifTimeMap = do- action <- STM.atomically $ STM.readTChan (st^.csResources.crWebsocketActionChan)- if (shouldSendAction action)- then do- WS.mmSendWSAction (st^.csResources.crConn) ws $ convert action- now <- getCurrentTime- processWebsocketActions st ws (s + 1) $ userTypingLastNotifTimeMap' action now- else do- processWebsocketActions st ws s userTypingLastNotifTimeMap- where- convert (UserTyping _ cId pId) = WS.UserTyping s cId pId-- shouldSendAction (UserTyping ts cId _) =- diffUTCTime ts (userTypingLastNotifTime cId) >= (userTypingExpiryInterval / 2 - 0.5)-- userTypingLastNotifTime cId = getMax $ HM.lookupDefault (Max zeroTime) cId userTypingLastNotifTimeMap-- zeroTime = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)-- userTypingLastNotifTimeMap' (UserTyping _ cId _) now =- HM.insertWith (<>) cId (Max now) userTypingLastNotifTimeMap--handleTimeout :: (LogCategory -> Text -> IO ()) -> Int -> ChatState -> WS.MMWebSocketTimeoutException -> IO ()-handleTimeout logger seconds st e = do- logger LogWebsocket $ T.pack $ "Websocket timeout exception: " <> show e- reconnectAfter seconds st--handleError :: (LogCategory -> Text -> IO ()) -> Int -> ChatState -> SomeException -> IO ()-handleError logger seconds st e = do- logger LogWebsocket $ T.pack $ "Websocket error: " <> show e- reconnectAfter seconds st--reconnectAfter :: Int -> ChatState -> IO ()-reconnectAfter seconds st = do- writeBChan (st^.csResources.crEventQueue) WebsocketDisconnect- threadDelay (seconds * 1000 * 1000)- writeBChan (st^.csResources.crEventQueue) RefreshWebsocketEvent
− src/Constants.hs
@@ -1,35 +0,0 @@-module Constants- ( pageAmount- , userTypingExpiryInterval- , numScrollbackPosts- , previewMaxHeight- , normalChannelSigil- , userSigil- )-where--import Prelude ()-import Prelude.MH----- | The number of rows to consider a "page" when scrolling-pageAmount :: Int-pageAmount = 15---- | The expiry interval in seconds for user typing notifications.-userTypingExpiryInterval :: NominalDiffTime-userTypingExpiryInterval = 5--numScrollbackPosts :: Int-numScrollbackPosts = 100---- | The maximum height of the message preview, in lines.-previewMaxHeight :: Int-previewMaxHeight = 5---- Sigils-normalChannelSigil :: Text-normalChannelSigil = "~"--userSigil :: Text-userSigil = "@"
− src/Draw.hs
@@ -1,46 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE PackageImports #-}-module Draw (draw) where--import Prelude ()-import Prelude.MH--import Brick--import Lens.Micro.Platform ( _2, singular, _Just )--import Draw.DeleteChannelConfirm-import Draw.LeaveChannelConfirm-import Draw.Main-import Draw.ThemeListOverlay-import Draw.PostListOverlay-import Draw.ShowHelp-import Draw.UserListOverlay-import Draw.ChannelListOverlay-import Draw.ReactionEmojiListOverlay-import Draw.TabbedWindow-import Draw.ManageAttachments-import Draw.NotifyPrefs-import Types---draw :: ChatState -> [Widget Name]-draw st =- case appMode st of- Main -> drawMain True st- UrlSelect -> drawMain True st- ShowHelp topic _ -> drawShowHelp topic st- ChannelSelect -> drawMain True st- LeaveChannelConfirm -> drawLeaveChannelConfirm st- MessageSelect -> drawMain True st- MessageSelectDeleteConfirm -> drawMain True st- DeleteChannelConfirm -> drawDeleteChannelConfirm st- ThemeListOverlay -> drawThemeListOverlay st- PostListOverlay contents -> drawPostListOverlay contents st- UserListOverlay -> drawUserListOverlay st- ChannelListOverlay -> drawChannelListOverlay st- ReactionEmojiListOverlay -> drawReactionEmojiListOverlay st- ViewMessage -> drawTabbedWindow (st^.csViewedMessage.singular _Just._2) st : drawMain False st- ManageAttachments -> drawManageAttachments st- ManageAttachmentsBrowseFiles -> drawManageAttachments st- EditNotifyPrefs -> drawNotifyPrefs st : drawMain False st
− src/Draw/Autocomplete.hs
@@ -1,188 +0,0 @@-module Draw.Autocomplete- ( autocompleteLayer- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.List ( renderList, listElementsL, listSelectedFocusedAttr- , listSelectedElement- )-import qualified Data.Text as T--import Network.Mattermost.Types ( User(..), Channel(..) )--import Constants ( normalChannelSigil )-import Draw.Util-import Themes-import Types-import Types.Common ( sanitizeUserText )---autocompleteLayer :: ChatState -> Widget Name-autocompleteLayer st =- case st^.csEditState.cedAutocomplete of- Nothing ->- emptyWidget- Just ac ->- renderAutocompleteBox st ac--userNotInChannelMarker :: T.Text-userNotInChannelMarker = "*"--elementTypeLabel :: AutocompletionType -> Text-elementTypeLabel ACUsers = "Users"-elementTypeLabel ACChannels = "Channels"-elementTypeLabel ACCodeBlockLanguage = "Languages"-elementTypeLabel ACEmoji = "Emoji"-elementTypeLabel ACCommands = "Commands"--renderAutocompleteBox :: ChatState -> AutocompleteState -> Widget Name-renderAutocompleteBox st ac =- let matchList = _acCompletionList ac- maxListHeight = 5- visibleHeight = min maxListHeight numResults- numResults = length elements- elements = matchList^.listElementsL- label = withDefAttr clientMessageAttr $- txt $ elementTypeLabel (ac^.acType) <> ": " <> (T.pack $ show numResults) <>- " match" <> (if numResults == 1 then "" else "es") <>- " (Tab/Shift-Tab to select)"-- selElem = snd <$> listSelectedElement matchList- curChan = st^.csCurrentChannel- footer = case renderAutocompleteFooterFor curChan =<< selElem of- Just w -> hBorderWithLabel w- _ -> hBorder- curUser = myUsername st-- in if numResults == 0- then emptyWidget- else Widget Greedy Greedy $ do- ctx <- getContext- let rowOffset = ctx^.availHeightL - 3 - editorOffset - visibleHeight- editorOffset = if st^.csEditState.cedEphemeral.eesMultiline- then multilineHeightLimit- else 0- render $ translateBy (Location (0, rowOffset)) $- vBox [ hBorderWithLabel label- , vLimit visibleHeight $- renderList (renderAutocompleteAlternative curUser) True matchList- , footer- ]--renderAutocompleteFooterFor :: ClientChannel -> AutocompleteAlternative -> Maybe (Widget Name)-renderAutocompleteFooterFor _ (SpecialMention MentionChannel) = Nothing-renderAutocompleteFooterFor _ (SpecialMention MentionAll) = Nothing-renderAutocompleteFooterFor ch (UserCompletion _ False) =- Just $ hBox [ txt $ "("- , withDefAttr clientEmphAttr (txt userNotInChannelMarker)- , txt ": not a member of "- , withDefAttr channelNameAttr (txt $ normalChannelSigil <> ch^.ccInfo.cdName)- , txt ")"- ]-renderAutocompleteFooterFor _ (ChannelCompletion False ch) =- Just $ hBox [ txt $ "("- , withDefAttr clientEmphAttr (txt userNotInChannelMarker)- , txt ": you are not a member of "- , withDefAttr channelNameAttr (txt $ normalChannelSigil <> sanitizeUserText (channelName ch))- , txt ")"- ]-renderAutocompleteFooterFor _ _ = Nothing--renderAutocompleteAlternative :: Text -> Bool -> AutocompleteAlternative -> Widget Name-renderAutocompleteAlternative _ sel (EmojiCompletion e) =- padRight Max $ renderEmojiCompletion sel e-renderAutocompleteAlternative _ sel (SpecialMention m) =- padRight Max $ renderSpecialMention m sel-renderAutocompleteAlternative curUser sel (UserCompletion u inChan) =- padRight Max $ renderUserCompletion curUser u inChan sel-renderAutocompleteAlternative _ sel (ChannelCompletion inChan c) =- padRight Max $ renderChannelCompletion c inChan sel-renderAutocompleteAlternative _ _ (SyntaxCompletion t) =- padRight Max $ txt t-renderAutocompleteAlternative _ _ (CommandCompletion n args desc) =- padRight Max $ renderCommandCompletion n args desc--renderSpecialMention :: SpecialMention -> Bool -> Widget Name-renderSpecialMention m sel =- let usernameWidth = 18- padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')- maybeForce = if sel- then forceAttr listSelectedFocusedAttr- else id- t = autocompleteAlternativeReplacement $ SpecialMention m- desc = case m of- MentionChannel -> "Notifies all users in this channel"- MentionAll -> "Mentions all users in this channel"- in maybeForce $- hBox [ txt " "- , padTo usernameWidth $ withDefAttr clientEmphAttr $ txt t- , txt desc- ]--renderEmojiCompletion :: Bool -> T.Text -> Widget Name-renderEmojiCompletion sel e =- let maybeForce = if sel- then forceAttr listSelectedFocusedAttr- else id- in maybeForce $- padLeft (Pad 2) $- withDefAttr emojiAttr $- txt $- autocompleteAlternativeReplacement $ EmojiCompletion e--renderUserCompletion :: Text -> User -> Bool -> Bool -> Widget Name-renderUserCompletion curUser u inChan selected =- let usernameWidth = 18- fullNameWidth = 25- padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')- username = userUsername u- fullName = (sanitizeUserText $ userFirstName u) <> " " <>- (sanitizeUserText $ userLastName u)- nickname = sanitizeUserText $ userNickname u- maybeForce = if selected- then forceAttr listSelectedFocusedAttr- else id- memberDisplay = if inChan- then txt " "- else withDefAttr clientEmphAttr $- txt $ userNotInChannelMarker <> " "- in maybeForce $- hBox [ memberDisplay- , padTo usernameWidth $ colorUsername curUser username ("@" <> username)- , padTo fullNameWidth $ txt fullName- , txt nickname- ]--renderChannelCompletion :: Channel -> Bool -> Bool -> Widget Name-renderChannelCompletion c inChan selected =- let urlNameWidth = 30- displayNameWidth = 30- padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')- maybeForce = if selected- then forceAttr listSelectedFocusedAttr- else id- memberDisplay = if inChan- then txt " "- else withDefAttr clientEmphAttr $- txt $ userNotInChannelMarker <> " "- in maybeForce $- hBox [ memberDisplay- , padTo urlNameWidth $- withDefAttr channelNameAttr $- txt $ normalChannelSigil <> (sanitizeUserText $ channelName c)- , padTo displayNameWidth $- withDefAttr channelNameAttr $- txt $ sanitizeUserText $ channelDisplayName c- , txt $ sanitizeUserText $ channelPurpose c- ]--renderCommandCompletion :: Text -> Text -> Text -> Widget Name-renderCommandCompletion name args desc =- withDefAttr clientMessageAttr- (txt $ "/" <> name <> if T.null args then "" else " " <> args) <+>- (txt $ " - " <> desc)
− src/Draw/ChannelList.hs
@@ -1,208 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE RankNTypes #-}---- | This module provides the Drawing functionality for the--- ChannelList sidebar. The sidebar is divided vertically into groups--- and each group is rendered separately.------ There are actually two UI modes handled by this code:------ * Normal display of the channels, with various markers to--- indicate the current channel, channels with unread messages,--- user state (for Direct Message channels), etc.------ * ChannelSelect display where the user is typing match characters--- into a prompt at the ChannelList sidebar is showing only those--- channels matching the entered text (and highlighting the--- matching portion).--module Draw.ChannelList (renderChannelList) where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center (hCenter)-import qualified Data.Text as T-import Lens.Micro.Platform (non)--import qualified Network.Mattermost.Types as MM--import Constants ( userSigil )-import Draw.Util-import State.Channels-import Themes-import Types-import Types.Common ( sanitizeUserText )-import qualified Zipper as Z---- | Internal record describing each channel entry and its associated--- attributes. This is the object passed to the rendering function so--- that it can determine how to render each channel.-data ChannelListEntryData =- ChannelListEntryData { entrySigil :: Text- , entryLabel :: Text- , entryHasUnread :: Bool- , entryMentions :: Int- , entryIsRecent :: Bool- , entryIsReturn :: Bool- , entryIsCurrent :: Bool- , entryIsMuted :: Bool- , entryUserStatus :: Maybe UserStatus- }--renderChannelList :: ChatState -> Widget Name-renderChannelList st =- viewport ChannelList Vertical body- where- teamHeader = hCenter $- withDefAttr clientEmphAttr $- txt $ "Team: " <> teamNameStr- myUsername_ = myUsername st- me = userById (myUserId st) st- statusSigil = maybe ' ' userSigilFromInfo me- selfHeader = hCenter $- colorUsername myUsername_ myUsername_ (T.singleton statusSigil <> " " <> userSigil <> myUsername_)- teamNameStr = sanitizeUserText $ MM.teamDisplayName $ st^.csMyTeam- body = case appMode st of- ChannelSelect ->- let zipper = st^.csChannelSelectState.channelSelectMatches- matches = if Z.isEmpty zipper- then [hCenter $ txt "No matches"]- else (renderChannelListGroup st (renderChannelSelectListEntry (Z.focus zipper)) <$>- Z.toList zipper)- in vBox $- teamHeader :- selfHeader :- matches- _ ->- cached ChannelSidebar $- vBox $- teamHeader :- selfHeader :- (renderChannelListGroup st (\s e -> renderChannelListEntry myUsername_ $ mkChannelEntryData s e) <$>- Z.toList (st^.csFocus))--renderChannelListGroupHeading :: ChannelListGroup -> Widget Name-renderChannelListGroupHeading g =- let (anyUnread, label) = case g of- ChannelGroupPublicChannels u -> (u, "Public Channels")- ChannelGroupPrivateChannels u -> (u, "Private Channels")- ChannelGroupDirectMessages u -> (u, "Direct Messages")- addUnread = if anyUnread- then (<+> (withDefAttr unreadGroupMarkerAttr $ txt "*"))- else id- labelWidget = addUnread $ withDefAttr channelListHeaderAttr $ txt label- in hBorderWithLabel labelWidget--renderChannelListGroup :: ChatState- -> (ChatState -> e -> Widget Name)- -> (ChannelListGroup, [e])- -> Widget Name-renderChannelListGroup st renderEntry (group, es) =- let heading = renderChannelListGroupHeading group- entryWidgets = renderEntry st <$> es- in if null entryWidgets- then emptyWidget- else vBox (heading : entryWidgets)--mkChannelEntryData :: ChatState- -> ChannelListEntry- -> ChannelListEntryData-mkChannelEntryData st e =- ChannelListEntryData sigilWithSpace name unread mentions recent ret current muted status- where- cId = channelListEntryChannelId e- Just chan = findChannelById cId (st^.csChannels)- unread = hasUnread' chan- recent = isRecentChannel st cId- ret = isReturnChannel st cId- current = isCurrentChannel st cId- muted = isMuted chan- (name, normalSigil, addSpace, status) = case e of- CLChannel _ ->- (chan^.ccInfo.cdDisplayName, Nothing, False, Nothing)- CLGroupDM _ ->- (chan^.ccInfo.cdDisplayName, Just " ", True, Nothing)- CLUserDM _ uId ->- let Just u = userById uId st- uname = if useNickname st- then u^.uiNickName.non (u^.uiName)- else u^.uiName- in (uname, Just $ T.singleton $ userSigilFromInfo u, True, Just $ u^.uiStatus)- sigilWithSpace = sigil <> if addSpace then " " else ""- prevEditSigil = "»"- sigil = if current- then fromMaybe "" normalSigil- else case chan^.ccEditState.eesInputHistoryPosition of- Just _ -> prevEditSigil- Nothing ->- case chan^.ccEditState.eesLastInput of- ("", _) -> fromMaybe "" normalSigil- _ -> prevEditSigil- mentions = chan^.ccInfo.cdMentionCount---- | Render an individual Channel List entry (in Normal mode) with--- appropriate visual decorations.-renderChannelListEntry :: Text -> ChannelListEntryData -> Widget Name-renderChannelListEntry myUName entry = body- where- body = decorate $ decorateEntry entry $ decorateMentions $ padRight Max $- entryWidget $ entrySigil entry <> entryLabel entry- decorate = if | entryIsCurrent entry ->- visible . forceAttr currentChannelNameAttr- | entryMentions entry > 0 && not (entryIsMuted entry) ->- forceAttr mentionsChannelAttr- | entryHasUnread entry ->- forceAttr unreadChannelAttr- | otherwise -> id- entryWidget = case entryUserStatus entry of- Just Offline -> withDefAttr clientMessageAttr . txt- Just _ -> colorUsername myUName (entryLabel entry)- Nothing -> txt- decorateMentions- | entryMentions entry > 9 =- (<+> str "(9+)")- | entryMentions entry > 0 =- (<+> str ("(" <> show (entryMentions entry) <> ")"))- | entryIsMuted entry =- (<+> str "(m)")- | otherwise = id---- | Render an individual entry when in Channel Select mode,--- highlighting the matching portion, or completely suppressing the--- entry if it doesn't match.-renderChannelSelectListEntry :: Maybe ChannelSelectMatch -> ChatState -> ChannelSelectMatch -> Widget Name-renderChannelSelectListEntry curMatch st match =- let ChannelSelectMatch preMatch inMatch postMatch _ entry = match- maybeSelect = if (Just entry) == (matchEntry <$> curMatch)- then visible . withDefAttr currentChannelNameAttr- else id- entryData = mkChannelEntryData st entry- in maybeSelect $- decorateEntry entryData $- padRight Max $- hBox [ txt $ entrySigil entryData <> preMatch- , forceAttr channelSelectMatchAttr $ txt inMatch- , txt postMatch- ]---- If this channel is the return channel, add a decoration to denote--- that.------ Otherwise, if this channel is the most recently viewed channel (prior--- to the currently viewed channel), add a decoration to denote that.-decorateEntry :: ChannelListEntryData -> Widget n -> Widget n-decorateEntry entry =- if entryIsReturn entry- then (<+> (withDefAttr recentMarkerAttr $ str returnChannelSigil))- else if entryIsRecent entry- then (<+> (withDefAttr recentMarkerAttr $ str recentChannelSigil))- else id--recentChannelSigil :: String-recentChannelSigil = "<"--returnChannelSigil :: String-returnChannelSigil = "~"
− src/Draw/ChannelListOverlay.hs
@@ -1,54 +0,0 @@-module Draw.ChannelListOverlay- ( drawChannelListOverlay- )-where--import Prelude ()-import Prelude.MH--import Brick-import qualified Data.Text as T-import Text.Wrap ( defaultWrapSettings, preserveIndentation )--import Network.Mattermost.Types-import Network.Mattermost.Lenses--import Draw.Main-import Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )-import Types-import Types.Common ( sanitizeUserText )-import Themes---drawChannelListOverlay :: ChatState -> [Widget Name]-drawChannelListOverlay st =- let overlay = drawListOverlay (st^.csChannelListOverlay) channelSearchScopeHeader- channelSearchScopeNoResults channelSearchScopePrompt- renderChannel- Nothing- OverlayCenter- 80- in joinBorders overlay : drawMain False st--channelSearchScopePrompt :: ChannelSearchScope -> Widget Name-channelSearchScopePrompt scope =- txt $ case scope of- AllChannels -> "Search channels:"--channelSearchScopeNoResults :: ChannelSearchScope -> Widget Name-channelSearchScopeNoResults scope =- txt $ case scope of- AllChannels -> "No matching channels found."--channelSearchScopeHeader :: ChannelSearchScope -> Widget Name-channelSearchScopeHeader scope =- withDefAttr clientEmphAttr $ txt $ case scope of- AllChannels -> "Join a Channel"--renderChannel :: Bool -> Channel -> Widget Name-renderChannel _ chan =- let baseStr = (sanitizeUserText $ chan^.channelNameL) <> " (" <>- (sanitizeUserText $ chan^.channelDisplayNameL) <> ")"- s = " " <> (T.strip $ sanitizeUserText $ chan^.channelPurposeL)- in (vLimit 1 $ padRight Max $ withDefAttr clientEmphAttr $ txt baseStr) <=>- (vLimit 1 $ txtWrapWith (defaultWrapSettings { preserveIndentation = True }) s)
− src/Draw/DeleteChannelConfirm.hs
@@ -1,33 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module Draw.DeleteChannelConfirm- ( drawDeleteChannelConfirm- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center--import Draw.Main-import Themes-import Types---drawDeleteChannelConfirm :: ChatState -> [Widget Name]-drawDeleteChannelConfirm st =- confirmBox st : (drawMain False st)--confirmBox :: ChatState -> Widget Name-confirmBox st =- let cName = st^.csCurrentChannel.ccInfo.cdName- in centerLayer $ hLimit 50 $ vLimit 15 $- withDefAttr dialogAttr $- borderWithLabel (txt "Confirm Delete Channel") $- vBox [ padBottom (Pad 1) $ hCenter $ txt "Are you sure you want to delete this channel?"- , padBottom (Pad 1) $ hCenter $ withDefAttr dialogEmphAttr $ txt cName- , hCenter $ txt "Press " <+> (withDefAttr dialogEmphAttr $ txt "Y") <+> txt " to delete the channel"- , hCenter $ txt "or any other key to cancel."- ]
− src/Draw/LeaveChannelConfirm.hs
@@ -1,33 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module Draw.LeaveChannelConfirm- ( drawLeaveChannelConfirm- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center--import Draw.Main-import Themes-import Types---drawLeaveChannelConfirm :: ChatState -> [Widget Name]-drawLeaveChannelConfirm st =- confirmBox st : (drawMain False st)--confirmBox :: ChatState -> Widget Name-confirmBox st =- let cName = st^.csCurrentChannel.ccInfo.cdName- in centerLayer $ hLimit 50 $ vLimit 15 $- withDefAttr dialogAttr $- borderWithLabel (txt "Confirm Leave Channel") $- vBox [ padBottom (Pad 1) $ hCenter $ txt "Are you sure you want to leave this channel?"- , padBottom (Pad 1) $ hCenter $ withDefAttr dialogEmphAttr $ txt cName- , hCenter $ txt "Press " <+> (withDefAttr dialogEmphAttr $ txt "Y") <+> txt " to leave the channel"- , hCenter $ txt "or any other key to cancel."- ]
− src/Draw/ListOverlay.hs
@@ -1,134 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--module Draw.ListOverlay- ( drawListOverlay- , OverlayPosition(..)- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center-import Brick.Widgets.Edit-import qualified Brick.Widgets.List as L-import Control.Monad.Trans.Reader ( withReaderT )-import qualified Data.Foldable as F-import qualified Data.Text as T-import Graphics.Vty ( imageWidth, translateX)-import Lens.Micro.Platform ( (%~), (.~), to )--import Themes-import Types---hLimitWithPadding :: Int -> Widget n -> Widget n-hLimitWithPadding pad contents = Widget- { hSize = Fixed- , vSize = (vSize contents)- , render =- withReaderT (& availWidthL %~ (\ n -> n - (2 * pad))) $ render $ cropToContext contents- }--data OverlayPosition =- OverlayCenter- | OverlayUpperRight- deriving (Eq, Show)---- | Draw a ListOverlayState. This draws a bordered box with the--- overlay's search input and results list inside the box. The provided--- functions determine how to render the overlay in various states.-drawListOverlay :: ListOverlayState a b- -- ^ The overlay state- -> (b -> Widget Name)- -- ^ The function to build the window title from the- -- current search scope- -> (b -> Widget Name)- -- ^ The function to generate a message for the search- -- scope indicating that no results were found- -> (b -> Widget Name)- -- ^ The function to generate the editor prompt for the- -- search scope- -> (Bool -> a -> Widget Name)- -- ^ The function to render an item from the overlay's- -- list- -> Maybe (Widget Name)- -- ^ The footer widget to render underneath the search- -- results- -> OverlayPosition- -- ^ How to position the overlay layer- -> Int- -- ^ The maximum window width in columns- -> Widget Name-drawListOverlay st scopeHeader scopeNoResults scopePrompt renderItem footer layerPos maxWinWidth =- positionLayer $ hLimitWithPadding 10 $ vLimit 25 $- hLimit maxWinWidth $- borderWithLabel title body- where- title = withDefAttr clientEmphAttr $- hBox [ scopeHeader scope- , case st^.listOverlayRecordCount of- Nothing -> emptyWidget- Just c -> txt " (" <+> str (show c) <+> txt ")"- ]- positionLayer = case layerPos of- OverlayCenter -> centerLayer- OverlayUpperRight -> upperRightLayer- body = vBox [ (padRight (Pad 1) promptMsg) <+>- renderEditor (txt . T.unlines) True (st^.listOverlaySearchInput)- , cursorPositionBorder- , showResults- , fromMaybe emptyWidget footer- ]- plural 1 = ""- plural _ = "s"- cursorPositionBorder =- if st^.listOverlaySearching- then hBorderWithLabel $ txt "[Searching...]"- else case st^.listOverlaySearchResults.L.listSelectedL of- Nothing -> hBorder- Just _ ->- let showingFirst = "Showing first " <> show numSearchResults <>- " result" <> plural numSearchResults- showingAll = "Showing all " <> show numSearchResults <>- " result" <> plural numSearchResults- showing = "Showing " <> show numSearchResults <>- " result" <> plural numSearchResults- msg = case getEditContents (st^.listOverlaySearchInput) of- [""] ->- case st^.listOverlayRecordCount of- Nothing -> showing- Just total -> if numSearchResults < total- then showingFirst- else showingAll- _ -> showing- in hBorderWithLabel $ str $ "[" <> msg <> "]"-- scope = st^.listOverlaySearchScope- promptMsg = scopePrompt scope-- showMessage = center . withDefAttr clientEmphAttr-- showResults- | numSearchResults == 0 = showMessage $ scopeNoResults scope- | otherwise = renderedUserList-- renderedUserList = L.renderList renderItem True (st^.listOverlaySearchResults)- numSearchResults = F.length $ st^.listOverlaySearchResults.L.listElementsL--upperRightLayer :: Widget a -> Widget a-upperRightLayer w =- Widget (hSize w) (vSize w) $ do- result <- render w- c <- getContext- let rWidth = result^.imageL.to imageWidth- leftPaddingAmount = max 0 $ c^.availWidthL - rWidth- paddedImage = translateX leftPaddingAmount $ result^.imageL- off = Location (leftPaddingAmount, 0)- if leftPaddingAmount == 0 then- return result else- return $ addResultOffset off- $ result & imageL .~ paddedImage
− src/Draw/Main.hs
@@ -1,655 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-module Draw.Main (drawMain) where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Border.Style-import Brick.Widgets.Center ( hCenter )-import Brick.Widgets.List ( listElements )-import Brick.Widgets.Edit ( editContentsL, renderEditor, getEditContents )-import Control.Arrow ( (>>>) )-import Data.Char ( isSpace, isPunctuation )-import qualified Data.Map as M-import qualified Data.Sequence as Seq-import qualified Data.Set as S-import qualified Data.Text as T-import Data.Text.Zipper ( cursorPosition, insertChar, getText, gotoEOL )-import Data.Time.Calendar ( fromGregorian )-import Data.Time.Clock ( UTCTime(..) )-import qualified Graphics.Vty as Vty-import Lens.Micro.Platform ( (.~), (^?!), to, view, folding )--import Network.Mattermost.Types ( ChannelId, Type(Direct, Private, Group)- , ServerTime(..), UserId- )---import Constants-import Draw.ChannelList ( renderChannelList )-import Draw.Messages-import Draw.Autocomplete-import Draw.URLList-import Draw.Util-import Draw.RichText-import Events.Keybindings-import Events.MessageSelect-import State.MessageSelect-import Themes-import TimeUtils ( justAfter, justBefore )-import Types-import Types.RichText ( parseMarkdown )-import Types.KeyEvents---previewFromInput :: Maybe MessageType -> UserId -> Text -> Maybe Message-previewFromInput _ _ s | s == T.singleton cursorSentinel = Nothing-previewFromInput overrideTy uId s =- -- If it starts with a slash but not /me, this has no preview- -- representation- let isCommand = "/" `T.isPrefixOf` s- isEmoteCmd = "/me " `T.isPrefixOf` s- content = if isEmoteCmd- then T.stripStart $ T.drop 3 s- else s- msgTy = fromMaybe (if isEmoteCmd then CP Emote else CP NormalPost) overrideTy- in if isCommand && not isEmoteCmd- then Nothing- else Just $ Message { _mText = parseMarkdown content- , _mMarkdownSource = content- , _mUser = UserI False uId- , _mDate = ServerTime $ UTCTime (fromGregorian 1970 1 1) 0- -- The date is not used for preview- -- rendering, but we need to provide one.- -- Ideally we'd just use today's date, but- -- the rendering function is pure so we- -- can't.- , _mType = msgTy- , _mPending = False- , _mDeleted = False- , _mAttachments = mempty- , _mInReplyToMsg = NotAReply- , _mMessageId = Nothing- , _mReactions = mempty- , _mOriginalPost = Nothing- , _mFlagged = False- , _mPinned = False- , _mChannelId = Nothing- }---- | Tokens in spell check highlighting.-data Token =- Ignore Text- -- ^ This bit of text is to be ignored for the purposes of- -- spell-checking.- | Check Text- -- ^ This bit of text should be checked against the spell checker's- -- misspelling list.- deriving (Show)--drawEditorContents :: ChatState -> HighlightSet -> [Text] -> Widget Name-drawEditorContents st hs =- let noHighlight = txt . T.unlines- in case st^.csEditState.cedSpellChecker of- Nothing -> noHighlight- Just _ ->- case S.null (st^.csEditState.cedMisspellings) of- True -> noHighlight- False -> doHighlightMisspellings- hs- (st^.csEditState.cedMisspellings)---- | This function takes a set of misspellings from the spell--- checker, the editor lines, and builds a rendering of the text with--- misspellings highlighted.------ This function processes each line of text from the editor as follows:------ * Tokenize the line based on our rules for what constitutes--- whitespace. We do this because we need to check "words" in the--- user's input against the list of misspellings returned by the spell--- checker. But to do this we need to ignore the same things that--- Aspell ignores, and it ignores whitespace and lots of puncutation.--- We also do this because once we have identified the misspellings--- present in the input, we need to reconstruct the user's input and--- that means preserving whitespace so that the input looks as it was--- originally typed.------ * Once we have a list of tokens -- the whitespace tokens to be--- preserved but ignored and the tokens to be checked -- we check--- each non-whitespace token for presence in the list of misspellings--- reported by the checker.------ * Having indicated which tokens correspond to misspelled words, we--- then need to coallesce adjacent tokens that are of the same--- "misspelling status", i.e., two neighboring tokens (of whitespace--- or check type) need to be coallesced if they both correspond to--- text that is a misspelling or if they both are NOT a misspelling.--- We do this so that the final Brick widget is optimal in that it--- uses a minimal number of box cells to display substrings that have--- the same attribute.------ * Finally we build a widget out of these coallesced tokens and apply--- the misspellingAttr attribute to the misspelled tokens.------ Note that since we have to come to our own conclusion about which--- words are worth checking in the checker's output, sometimes our--- algorithm will differ from aspell in what is considered "part of a--- word" and what isn't. In particular, Aspell is smart about sometimes--- noticing that "'" is an apostrophe and at other times that it is--- a single quote as part of a quoted string. As a result there will--- be cases where Markdown formatting characters interact poorly--- with Aspell's checking to result in misspellings that are *not*--- highlighted.------ One way to deal with this would be to *not* parse the user's input--- as done here, complete with all its Markdown metacharacters, but to--- instead 1) parse the input as Markdown, 2) traverse the Markdown AST--- and extract the words from the relevant subtrees, and 3) spell-check--- those words. The reason we don't do it that way in the first place is--- because 1) the user's input might not be valid markdown and 2) even--- if we did that, we'd still have to do this tokenization operation to--- annotate misspellings and reconstruct the user's raw input.-doHighlightMisspellings :: HighlightSet -> S.Set Text -> [Text] -> Widget Name-doHighlightMisspellings hSet misspellings contents =- -- Traverse the input, gathering non-whitespace into tokens and- -- checking if they appear in the misspelling collection- let whitelist = S.union (hUserSet hSet) (hChannelSet hSet)-- handleLine t | t == "" = txt " "- handleLine t =- -- For annotated tokens, coallesce tokens of the same type- -- and add attributes for misspellings.- let mkW (Left tok) =- let s = getTokenText tok- in if T.null s- then emptyWidget- else withDefAttr misspellingAttr $ txt $ getTokenText tok- mkW (Right tok) =- let s = getTokenText tok- in if T.null s- then emptyWidget- else txt s-- go :: Either Token Token -> [Either Token Token] -> [Either Token Token]- go lst [] = [lst]- go lst (tok:toks) =- case (lst, tok) of- (Left a, Left b) -> go (Left $ combineTokens a b) toks- (Right a, Right b) -> go (Right $ combineTokens a b) toks- _ -> lst : go tok toks-- in hBox $ mkW <$> (go (Right $ Ignore "") $ annotatedTokens t)-- combineTokens (Ignore a) (Ignore b) = Ignore $ a <> b- combineTokens (Check a) (Check b) = Check $ a <> b- combineTokens (Ignore a) (Check b) = Check $ a <> b- combineTokens (Check a) (Ignore b) = Check $ a <> b-- getTokenText (Ignore a) = a- getTokenText (Check a) = a-- annotatedTokens t =- -- For every token, check on whether it is a misspelling.- -- The result is Either Token Token where the Left is a- -- misspelling and the Right is not.- checkMisspelling <$> tokenize t (Ignore "")-- checkMisspelling t@(Ignore _) = Right t- checkMisspelling t@(Check s) =- if s `S.member` whitelist- then Right t- else if s `S.member` misspellings- then Left t- else Right t-- ignoreChar c = isSpace c || isPunctuation c || c == '`' || c == '/' ||- T.singleton c == userSigil || T.singleton c == normalChannelSigil-- tokenize t curTok- | T.null t = [curTok]- | ignoreChar $ T.head t =- case curTok of- Ignore s -> tokenize (T.tail t) (Ignore $ s <> (T.singleton $ T.head t))- Check s -> Check s : tokenize (T.tail t) (Ignore $ T.singleton $ T.head t)- | otherwise =- case curTok of- Ignore s -> Ignore s : tokenize (T.tail t) (Check $ T.singleton $ T.head t)- Check s -> tokenize (T.tail t) (Check $ s <> (T.singleton $ T.head t))-- in vBox $ handleLine <$> contents--renderUserCommandBox :: ChatState -> HighlightSet -> Widget Name-renderUserCommandBox st hs =- let prompt = txt $ case st^.csEditState.cedEditMode of- Replying _ _ -> "reply> "- Editing _ _ -> "edit> "- NewPost -> "> "- inputBox = renderEditor (drawEditorContents st hs) True (st^.csEditState.cedEditor)- curContents = getEditContents $ st^.csEditState.cedEditor- multilineContent = length curContents > 1- multilineHints =- hBox [ borderElem bsHorizontal- , str $ "[" <> (show $ (+1) $ fst $ cursorPosition $- st^.csEditState.cedEditor.editContentsL) <>- "/" <> (show $ length curContents) <> "]"- , hBorderWithLabel $ withDefAttr clientEmphAttr $- txt $ "In multi-line mode. Press " <>- (ppBinding (getFirstDefaultBinding ToggleMultiLineEvent)) <>- " to finish."- ]-- replyDisplay = case st^.csEditState.cedEditMode of- Replying msg _ ->- let msgWithoutParent = msg & mInReplyToMsg .~ NotAReply- in hBox [ replyArrow- , addEllipsis $ renderMessage MessageData- { mdMessage = msgWithoutParent- , mdUserName = msgWithoutParent^.mUser.to (nameForUserRef st)- , mdParentMessage = Nothing- , mdParentUserName = Nothing- , mdHighlightSet = hs- , mdEditThreshold = Nothing- , mdShowOlderEdits = False- , mdRenderReplyParent = True- , mdIndentBlocks = False- , mdThreadState = NoThread- , mdShowReactions = True- , mdMessageWidthLimit = Nothing- , mdMyUsername = myUsername st- }- ]- _ -> emptyWidget-- commandBox = case st^.csEditState.cedEphemeral.eesMultiline of- False ->- let linesStr = "line" <> if numLines == 1 then "" else "s"- numLines = length curContents- in vLimit 1 $ hBox $- prompt : if multilineContent- then [ withDefAttr clientEmphAttr $- str $ "[" <> show numLines <> " " <> linesStr <>- "; Enter: send, M-e: edit, Backspace: cancel] "- , txt $ head curContents- , showCursor MessageInput (Location (0,0)) $ str " "- ]- else [inputBox]- True -> vLimit multilineHeightLimit inputBox <=> multilineHints- in replyDisplay <=> commandBox--renderChannelHeader :: ChatState -> HighlightSet -> ClientChannel -> Widget Name-renderChannelHeader st hs chan =- let chnType = chan^.ccInfo.cdType- topicStr = chan^.ccInfo.cdHeader- userHeader u = let s = T.intercalate " " $ filter (not . T.null) parts- parts = [ userSigil <> u^.uiName- , if (all T.null names)- then mempty- else "is"- ] <> names <> [- if T.null (u^.uiEmail)- then mempty- else "(" <> u^.uiEmail <> ")"- ]- names = [ u^.uiFirstName- , nick- , u^.uiLastName- ]- quote n = "\"" <> n <> "\""- nick = maybe "" quote $ u^.uiNickName- in s- maybeTopic = if T.null topicStr- then ""- else " - " <> topicStr- channelNameString = case chnType of- Direct ->- case chan^.ccInfo.cdDMUserId >>= flip userById st of- Nothing -> mkChannelName (chan^.ccInfo)- Just u -> userHeader u- Private ->- channelNamePair <> " (Private)"- Group ->- channelNamePair <> " (Private group)"- _ ->- channelNamePair- newlineToSpace '\n' = ' '- newlineToSpace c = c- channelNamePair = mkChannelName (chan^.ccInfo) <> " - " <> (chan^.ccInfo.cdDisplayName)-- in renderText' (myUsername st)- hs- (T.map newlineToSpace (channelNameString <> maybeTopic))--renderCurrentChannelDisplay :: ChatState -> HighlightSet -> Widget Name-renderCurrentChannelDisplay st hs = header <=> messages- where- header = withDefAttr channelHeaderAttr $- padRight Max $- renderChannelHeader st hs chan- messages = padTop Max $ padRight (Pad 1) body-- body = chatText-- chatText = case appMode st of- MessageSelect ->- renderMessagesWithSelect (st^.csMessageSelect) channelMessages- MessageSelectDeleteConfirm ->- renderMessagesWithSelect (st^.csMessageSelect) channelMessages- _ ->- cached (ChannelMessages cId) $- renderLastMessages st hs editCutoff $- retrogradeMsgsWithThreadStates $- reverseMessages channelMessages-- renderMessagesWithSelect (MessageSelectState selMsgId) msgs =- -- In this case, we want to fill the message list with messages- -- but use the post ID as a cursor. To do this efficiently we- -- only want to render enough messages to fill the screen.- --- -- If the message area is H rows high, this actually renders at- -- most 2H rows' worth of messages and then does the appropriate- -- cropping. This way we can simplify the math needed to figure- -- out how to crop while bounding the number of messages we- -- render around the cursor.- --- -- First, we sanity-check the application state because under- -- some conditions, the selected message might be gone (e.g.- -- deleted).- let (s, (before, after)) = splitDirSeqOn (\(m, _) -> m^.mMessageId == selMsgId) msgsWithStates- msgsWithStates = chronologicalMsgsWithThreadStates msgs- in case s of- Nothing ->- renderLastMessages st hs editCutoff before- Just m ->- unsafeRenderMessageSelection (m, (before, after)) (renderSingleMessage st hs Nothing)-- cutoff = getNewMessageCutoff cId st- editCutoff = getEditedMessageCutoff cId st- channelMessages =- insertTransitions (getMessageListing cId st)- cutoff- (getDateFormat st)- (st ^. timeZone)-- cId = st^.csCurrentChannelId- chan = st^.csCurrentChannel--getMessageListing :: ChannelId -> ChatState -> Messages-getMessageListing cId st =- st ^?! csChannels.folding (findChannelById cId) . ccContents . cdMessages . to (filterMessages isShown)- where isShown m- | st^.csResources.crUserPreferences.userPrefShowJoinLeave = True- | otherwise = not $ isJoinLeave m--insertTransitions :: Messages -> Maybe NewMessageIndicator -> Text -> TimeZoneSeries -> Messages-insertTransitions ms cutoff = insertDateMarkers $ foldr addMessage ms newMessagesT- where anyNondeletedNewMessages t =- isJust $ findLatestUserMessage (not . view mDeleted) (messagesAfter t ms)- newMessagesT = case cutoff of- Nothing -> []- Just Hide -> []- Just (NewPostsAfterServerTime t)- | anyNondeletedNewMessages t -> [newMessagesMsg $ justAfter t]- | otherwise -> []- Just (NewPostsStartingAt t)- | anyNondeletedNewMessages (justBefore t) -> [newMessagesMsg $ justBefore t]- | otherwise -> []- newMessagesMsg d = newMessageOfType (T.pack "New Messages")- (C NewMessagesTransition) d--renderChannelSelectPrompt :: ChatState -> Widget Name-renderChannelSelectPrompt st =- let e = st^.csChannelSelectState.channelSelectInput- in withDefAttr channelSelectPromptAttr $- (txt "Switch to channel [use ^ and $ to anchor]: ") <+>- (renderEditor (txt . T.concat) True e)--drawMain :: Bool -> ChatState -> [Widget Name]-drawMain useColor st =- let maybeColor = if useColor then id else forceAttr "invalid"- in maybeColor <$>- [ connectionLayer st- , autocompleteLayer st- , joinBorders $ mainInterface st- ]--connectionLayer :: ChatState -> Widget Name-connectionLayer st =- case st^.csConnectionStatus of- Connected -> emptyWidget- Disconnected ->- Widget Fixed Fixed $ do- ctx <- getContext- let aw = ctx^.availWidthL- w = length msg + 2- msg = "NOT CONNECTED"- render $ translateBy (Location (max 0 (aw - w), 0)) $- withDefAttr errorMessageAttr $- border $ str msg--messageSelectBottomBar :: ChatState -> Widget Name-messageSelectBottomBar st =- let optionStr = if null usableOptions- then "(no actions available for this message)"- else T.intercalate " " usableOptions- usableOptions = catMaybes $ mkOption <$> options- mkOption (f, k, desc) = if f postMsg- then Just $ k <> ":" <> desc- else Nothing- numURLs = Seq.length $ msgURLs postMsg- s = if numURLs == 1 then "" else "s"- hasURLs = numURLs > 0- openUrlsMsg = "open " <> (T.pack $ show numURLs) <> " URL" <> s- hasVerb = isJust (findVerbatimChunk (postMsg^.mText))- -- make sure these keybinding pieces are up-to-date!- ev e =- let keyconf = st^.csResources.crConfiguration.to configUserKeys- KeyHandlerMap keymap = messageSelectKeybindings keyconf- in T.intercalate ","- [ ppBinding (eventToBinding k)- | KH { khKey = k- , khHandler = h- } <- M.elems keymap- , kehEventTrigger h == ByEvent e- ]- options = [ ( not . isGap- , ev YankWholeMessageEvent- , "yank-all"- )- , ( \m -> isFlaggable m && not (m^.mFlagged)- , ev FlagMessageEvent- , "flag"- )- , ( \m -> isFlaggable m && m^.mFlagged- , ev FlagMessageEvent- , "unflag"- )- , ( \m -> isPinnable m && not (m^.mPinned)- , ev PinMessageEvent- , "pin"- )- , ( \m -> isPinnable m && m^.mPinned- , ev PinMessageEvent- , "unpin"- )- , ( isReplyable- , ev ReplyMessageEvent- , "reply"- )- , ( not . isGap- , ev ViewMessageEvent- , "view"- )- , ( isGap- , ev FillGapEvent- , "load messages"- )- , ( \m -> isMine st m && isEditable m- , ev EditMessageEvent- , "edit"- )- , ( \m -> isMine st m && isDeletable m- , ev DeleteMessageEvent- , "delete"- )- , ( const hasURLs- , ev OpenMessageURLEvent- , openUrlsMsg- )- , ( const hasVerb- , ev YankMessageEvent- , "yank-code"- )- , ( isReactable- , ev ReactToMessageEvent- , "react"- )- ]- Just postMsg = getSelectedMessage st-- in hBox [ borderElem bsHorizontal- , txt "["- , withDefAttr messageSelectStatusAttr $- txt $ "Message select: " <> optionStr- , txt "]"- , hBorder- ]--maybePreviewViewport :: Widget Name -> Widget Name-maybePreviewViewport w =- Widget Greedy Fixed $ do- result <- render w- case (Vty.imageHeight $ result^.imageL) > previewMaxHeight of- False -> return result- True ->- render $ vLimit previewMaxHeight $ viewport MessagePreviewViewport Vertical $- (Widget Fixed Fixed $ return result)--inputPreview :: ChatState -> HighlightSet -> Widget Name-inputPreview st hs | not $ st^.csShowMessagePreview = emptyWidget- | otherwise = thePreview- where- uId = myUserId st- -- Insert a cursor sentinel into the input text just before- -- rendering the preview. We use the inserted sentinel (which is- -- not rendered) to get brick to ensure that the line the cursor is- -- on is visible in the preview viewport. We put the sentinel at- -- the *end* of the line because it will still influence markdown- -- parsing and can create undesirable/confusing churn in the- -- rendering while the cursor moves around. If the cursor is at the- -- end of whatever line the user is editing, that is very unlikely- -- to be a problem.- curContents = getText $ (gotoEOL >>> insertChar cursorSentinel) $- st^.csEditState.cedEditor.editContentsL- curStr = T.intercalate "\n" curContents- overrideTy = case st^.csEditState.cedEditMode of- Editing _ ty -> Just ty- _ -> Nothing- previewMsg = previewFromInput overrideTy uId curStr- thePreview = let noPreview = str "(No preview)"- msgPreview = case previewMsg of- Nothing -> noPreview- Just pm -> if T.null curStr- then noPreview- else prview pm $ getParentMessage st pm- prview m p = renderMessage MessageData- { mdMessage = m- , mdUserName = m^.mUser.to (nameForUserRef st)- , mdParentMessage = p- , mdParentUserName = p >>= (^.mUser.to (nameForUserRef st))- , mdHighlightSet = hs- , mdEditThreshold = Nothing- , mdShowOlderEdits = False- , mdRenderReplyParent = True- , mdIndentBlocks = True- , mdThreadState = NoThread- , mdShowReactions = True- , mdMessageWidthLimit = Nothing- , mdMyUsername = myUsername st- }- in (maybePreviewViewport msgPreview) <=>- hBorderWithLabel (withDefAttr clientEmphAttr $ str "[Preview ↑]")--userInputArea :: ChatState -> HighlightSet -> Widget Name-userInputArea st hs =- case appMode st of- ChannelSelect -> renderChannelSelectPrompt st- UrlSelect -> hCenter $ hBox [ txt "Press "- , withDefAttr clientEmphAttr $ txt "Enter"- , txt " to open the selected URL or "- , withDefAttr clientEmphAttr $ txt "Escape"- , txt " to cancel."- ]- MessageSelectDeleteConfirm -> renderDeleteConfirm- _ -> renderUserCommandBox st hs--renderDeleteConfirm :: Widget Name-renderDeleteConfirm =- hCenter $ txt "Are you sure you want to delete the selected message? (y/n)"--mainInterface :: ChatState -> Widget Name-mainInterface st =- vBox [ if st^.csShowChannelList || appMode st == ChannelSelect- then hBox [hLimit channelListWidth (renderChannelList st), vBorder, mainDisplay]- else mainDisplay- , bottomBorder- , inputPreview st hs- , userInputArea st hs- ]- where- hs = getHighlightSet st- channelListWidth = configChannelListWidth $ st^.csResources.crConfiguration- mainDisplay = case appMode st of- UrlSelect -> renderUrlList st- _ -> maybeSubdue $ renderCurrentChannelDisplay st hs-- bottomBorder = case appMode st of- MessageSelect -> messageSelectBottomBar st- _ -> maybeSubdue $ hBox- [ showAttachmentCount- , hBorder- , showTypingUsers- , showBusy- ]-- showAttachmentCount =- let count = length $ listElements $ st^.csEditState.cedAttachmentList- in if count == 0- then emptyWidget- else hBox [ borderElem bsHorizontal- , withDefAttr clientMessageAttr $- txt $ "(" <> (T.pack $ show count) <> " attachment" <>- (if count == 1 then "" else "s") <> "; "- , withDefAttr clientEmphAttr $- txt $ ppBinding (getFirstDefaultBinding ShowAttachmentListEvent)- , txt " to manage)"- ]-- showTypingUsers =- let format = renderText' (myUsername st) hs- in case allTypingUsers (st^.csCurrentChannel.ccInfo.cdTypingUsers) of- [] -> emptyWidget- [uId] | Just un <- usernameForUserId uId st ->- format $ "[" <> userSigil <> un <> " is typing]"- [uId1, uId2] | Just un1 <- usernameForUserId uId1 st- , Just un2 <- usernameForUserId uId2 st ->- format $ "[" <> userSigil <> un1 <> " and " <> userSigil <> un2 <> " are typing]"- _ -> format "[several people are typing]"-- showBusy = case st^.csWorkerIsBusy of- Just (Just n) -> hLimit 2 hBorder <+> txt (T.pack $ "*" <> show n)- Just Nothing -> hLimit 2 hBorder <+> txt "*"- Nothing -> emptyWidget-- maybeSubdue = if appMode st == ChannelSelect- then forceAttr ""- else id--replyArrow :: Widget a-replyArrow =- Widget Fixed Fixed $ do- ctx <- getContext- let bs = ctx^.ctxBorderStyleL- render $ str [' ', bsCornerTL bs, '▸']
− src/Draw/ManageAttachments.hs
@@ -1,65 +0,0 @@-module Draw.ManageAttachments- ( drawManageAttachments- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center-import qualified Brick.Widgets.FileBrowser as FB-import Brick.Widgets.List-import Data.Maybe ( fromJust )--import Types-import Types.KeyEvents-import Events.Keybindings ( getFirstDefaultBinding )-import Themes-import Draw.Main ( drawMain )---drawManageAttachments :: ChatState -> [Widget Name]-drawManageAttachments st =- topLayer : drawMain False st- where- topLayer = case appMode st of- ManageAttachments -> drawAttachmentList st- ManageAttachmentsBrowseFiles -> drawFileBrowser st- _ -> error "BUG: drawManageAttachments called in invalid mode"--drawAttachmentList :: ChatState -> Widget Name-drawAttachmentList st =- let addBinding = ppBinding $ getFirstDefaultBinding AttachmentListAddEvent- delBinding = ppBinding $ getFirstDefaultBinding AttachmentListDeleteEvent- escBinding = ppBinding $ getFirstDefaultBinding CancelEvent- openBinding = ppBinding $ getFirstDefaultBinding AttachmentOpenEvent- in centerLayer $- hLimit 60 $- vLimit 15 $- joinBorders $- borderWithLabel (withDefAttr clientEmphAttr $ txt "Attachments") $- vBox [ renderList renderAttachmentItem True (st^.csEditState.cedAttachmentList)- , hBorder- , hCenter $ withDefAttr clientMessageAttr $- txt $ addBinding <> ":add " <>- delBinding <> ":delete " <>- openBinding <> ":open " <>- escBinding <> ":close"- ]--renderAttachmentItem :: Bool -> AttachmentData -> Widget Name-renderAttachmentItem _ d =- padRight Max $ str $ FB.fileInfoSanitizedFilename $ attachmentDataFileInfo d--drawFileBrowser :: ChatState -> Widget Name-drawFileBrowser st =- centerLayer $- hLimit 60 $- vLimit 20 $- borderWithLabel (withDefAttr clientEmphAttr $ txt "Attach File") $- -- invariant: cedFileBrowser is not Nothing if appMode is- -- ManageAttachmentsBrowseFiles, and that is the only way to reach- -- this code, ergo the fromJust.- FB.renderFileBrowser True $ fromJust (st^.csEditState.cedFileBrowser)
− src/Draw/Messages.hs
@@ -1,204 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-module Draw.Messages- ( nameForUserRef- , renderSingleMessage- , unsafeRenderMessageSelection- , renderLastMessages- )-where--import Brick-import Brick.Widgets.Border-import Control.Monad.Trans.Reader ( withReaderT )-import qualified Data.Foldable as F-import qualified Graphics.Vty as Vty-import Lens.Micro.Platform ( (.~), to )-import Network.Mattermost.Types ( ServerTime(..), userUsername )-import Prelude ()-import Prelude.MH--import Draw.Util-import Draw.RichText-import Themes-import Types-import Types.DirectionalSeq--maxMessageHeight :: Int-maxMessageHeight = 200---- | nameForUserRef converts the UserRef into a printable name, based--- on the current known user data.-nameForUserRef :: ChatState -> UserRef -> Maybe Text-nameForUserRef st uref =- case uref of- NoUser -> Nothing- UserOverride _ t -> Just t- UserI _ uId -> displayNameForUserId uId st---- | renderSingleMessage is the main message drawing function.------ The `ind` argument specifies an "indicator boundary". Showing--- various indicators (e.g. "edited") is not typically done for--- messages that are older than this boundary value.-renderSingleMessage :: ChatState- -> HighlightSet- -> Maybe ServerTime- -> Message- -> ThreadState- -> Widget Name-renderSingleMessage st hs ind m threadState =- renderChatMessage st hs ind threadState (withBrackets . renderTime st . withServerTime) m--renderChatMessage :: ChatState- -> HighlightSet- -> Maybe ServerTime- -> ThreadState- -> (ServerTime -> Widget Name)- -> Message- -> Widget Name-renderChatMessage st hs ind threadState renderTimeFunc msg =- let showOlderEdits = configShowOlderEdits $ st^.csResources.crConfiguration- parent = case msg^.mInReplyToMsg of- NotAReply -> Nothing- InReplyTo pId -> getMessageForPostId st pId- m = renderMessage MessageData- { mdMessage = msg- , mdUserName = msg^.mUser.to (nameForUserRef st)- , mdParentMessage = parent- , mdParentUserName = parent >>= (^.mUser.to (nameForUserRef st))- , mdEditThreshold = ind- , mdHighlightSet = hs- , mdShowOlderEdits = showOlderEdits- , mdRenderReplyParent = True- , mdIndentBlocks = True- , mdThreadState = threadState- , mdShowReactions = True- , mdMessageWidthLimit = Nothing- , mdMyUsername = userUsername $ myUser st- }- fullMsg =- case msg^.mUser of- NoUser- | isGap msg -> withDefAttr gapMessageAttr m- | otherwise ->- case msg^.mType of- C DateTransition ->- withDefAttr dateTransitionAttr (hBorderWithLabel m)- C NewMessagesTransition ->- withDefAttr newMessageTransitionAttr (hBorderWithLabel m)- C Error ->- withDefAttr errorMessageAttr m- _ ->- withDefAttr clientMessageAttr m- _ | isJoinLeave msg -> withDefAttr clientMessageAttr m- | otherwise -> m- maybeRenderTime w =- let maybePadTime = if threadState == InThreadShowParent- then (txt " " <=>) else id- in hBox [maybePadTime $ renderTimeFunc (msg^.mDate), txt " ", w]- maybeRenderTimeWith f = if isTransition msg then id else f- in maybeRenderTimeWith maybeRenderTime fullMsg---- | Render a selected message with focus, including the messages--- before and the messages after it. The foldable parameters exist--- because (depending on the situation) we might use either of the--- message list types for the 'before' and 'after' (i.e. the--- chronological or retrograde message sequences).-unsafeRenderMessageSelection :: (Foldable f, Foldable g)- => ((Message, ThreadState), (f (Message, ThreadState), g (Message, ThreadState)))- -> (Message -> ThreadState -> Widget Name)- -> Widget Name-unsafeRenderMessageSelection ((curMsg, curThreadState), (before, after)) doMsgRender =- Widget Greedy Greedy $ do- ctx <- getContext- curMsgResult <- withReaderT relaxHeight $ render $- forceAttr messageSelectAttr $- padRight Max $ doMsgRender curMsg curThreadState-- let targetHeight = ctx^.availHeightL- upperHeight = targetHeight `div` 2- lowerHeight = targetHeight - upperHeight-- lowerRender img (m, tState) = render1HLimit doMsgRender Vty.vertJoin targetHeight img tState m- upperRender img (m, tState) = render1HLimit doMsgRender (flip Vty.vertJoin) targetHeight img tState m-- lowerHalf <- foldM lowerRender Vty.emptyImage after- upperHalf <- foldM upperRender Vty.emptyImage before-- let curHeight = Vty.imageHeight $ curMsgResult^.imageL- uncropped = upperHalf Vty.<-> curMsgResult^.imageL Vty.<-> lowerHalf- img = if | Vty.imageHeight lowerHalf < (lowerHeight - curHeight) ->- Vty.cropTop targetHeight uncropped- | Vty.imageHeight upperHalf < upperHeight ->- Vty.cropBottom targetHeight uncropped- | otherwise ->- Vty.cropTop upperHeight upperHalf Vty.<-> curMsgResult^.imageL Vty.<->- (if curHeight < lowerHeight- then Vty.cropBottom (lowerHeight - curHeight) lowerHalf- else Vty.cropBottom lowerHeight lowerHalf)- return $ emptyResult & imageL .~ img--renderLastMessages :: ChatState- -> HighlightSet- -> Maybe ServerTime- -> DirectionalSeq Retrograde (Message, ThreadState)- -> Widget Name-renderLastMessages st hs editCutoff msgs =- Widget Greedy Greedy $ do- ctx <- getContext- let targetHeight = ctx^.availHeightL- doMsgRender = renderSingleMessage st hs editCutoff-- newMessagesTransitions = filterMessages (isNewMessagesTransition . fst) msgs- newMessageTransition = fst <$> (listToMaybe $ F.toList newMessagesTransitions)-- isBelow m transition = m^.mDate > transition^.mDate-- go :: Vty.Image -> DirectionalSeq Retrograde (Message, ThreadState) -> RenderM Name Vty.Image- go img ms | messagesLength ms == 0 = return img- go img ms = do- let Just (m, threadState) = messagesHead ms- newMessagesAbove = maybe False (isBelow m) newMessageTransition- newImg <- render1HLimit doMsgRender (flip Vty.vertJoin) targetHeight img threadState m- -- If the new message fills the window, check whether- -- there is still a "New Messages" transition that is- -- not displayed. If there is, then we need to replace- -- the top line of the new image with a "New Messages"- -- indicator.- if Vty.imageHeight newImg >= targetHeight && newMessagesAbove- then do- transitionResult <- render $ withDefAttr newMessageTransitionAttr $- hBorderWithLabel (txt "New Messages ↑")- let newImg2 = Vty.vertJoin (transitionResult^.imageL)- (Vty.cropTop (targetHeight - 1) newImg)- return newImg2- else go newImg $ messagesDrop 1 ms-- img <- go Vty.emptyImage msgs- return $ emptyResult & imageL .~ (Vty.cropTop targetHeight img)--relaxHeight :: Context -> Context-relaxHeight c = c & availHeightL .~ (max maxMessageHeight (c^.availHeightL))--render1HLimit :: (Message -> ThreadState -> Widget Name)- -> (Vty.Image -> Vty.Image -> Vty.Image)- -> Int- -> Vty.Image- -> ThreadState- -> Message- -> RenderM Name Vty.Image-render1HLimit doMsgRender fjoin lim img threadState msg- | Vty.imageHeight img >= lim = return img- | otherwise = fjoin img <$> render1 doMsgRender threadState msg--render1 :: (Message -> ThreadState -> Widget Name)- -> ThreadState- -> Message- -> RenderM Name Vty.Image-render1 doMsgRender threadState msg = case msg^.mDeleted of- True -> return Vty.emptyImage- False -> do- r <- withReaderT relaxHeight $- render $ padRight Max $- doMsgRender msg threadState- return $ r^.imageL
− src/Draw/NotifyPrefs.hs
@@ -1,35 +0,0 @@-module Draw.NotifyPrefs- ( drawNotifyPrefs- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center-import Brick.Forms (renderForm)-import Data.List (intersperse)--import Draw.Util (renderKeybindingHelp)-import Types-import Types.KeyEvents-import Themes--drawNotifyPrefs :: ChatState -> Widget Name-drawNotifyPrefs st =- let Just form = st^.csNotifyPrefs- label = forceAttr clientEmphAttr $ str "Notification Preferences"- formKeys = withDefAttr clientEmphAttr <$> txt <$> ["Tab", "BackTab"]- bindings = vBox $ hCenter <$> [ renderKeybindingHelp "Save" [FormSubmitEvent] <+> txt " " <+>- renderKeybindingHelp "Cancel" [CancelEvent]- , hBox ((intersperse (txt "/") formKeys) <> [txt (":Cycle form fields")])- , hBox [withDefAttr clientEmphAttr $ txt "Space", txt ":Toggle form field"]- ]- in centerLayer $- vLimit 25 $- hLimit 39 $- joinBorders $- borderWithLabel label $- (padAll 1 $ renderForm form) <=> hBorder <=> bindings
− src/Draw/PostListOverlay.hs
@@ -1,117 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE TupleSections #-}--module Draw.PostListOverlay where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center-import Control.Monad.Trans.Reader ( withReaderT )-import qualified Data.Text as T-import Lens.Micro.Platform ( (^?), (%~), to )--import Network.Mattermost.Lenses-import Network.Mattermost.Types--import Constants ( userSigil )-import Draw.Main-import Draw.Messages-import Draw.Util-import Themes-import Types---hLimitWithPadding :: Int -> Widget n -> Widget n-hLimitWithPadding pad contents = Widget- { hSize = Fixed- , vSize = (vSize contents)- , render =- withReaderT (& availWidthL %~ (\ n -> n - (2 * pad))) $ render $ cropToContext contents- }--drawPostListOverlay :: PostListContents -> ChatState -> [Widget Name]-drawPostListOverlay contents st =- (joinBorders $ drawPostsBox contents st) : (drawMain False st)---- | Draw a PostListOverlay as a floating overlay on top of whatever--- is rendered beneath it-drawPostsBox :: PostListContents -> ChatState -> Widget Name-drawPostsBox contents st =- centerLayer $ hLimitWithPadding 10 $ borderWithLabel contentHeader $- padRight (Pad 1) messageListContents- where -- The 'window title' of the overlay- hs = getHighlightSet st- contentHeader = withAttr channelListHeaderAttr $ txt $ case contents of- PostListFlagged -> "Flagged posts"- PostListPinned cId ->- let cName = case findChannelById cId (st^.csChannels) of- Nothing -> "<UNKNOWN>"- Just cc ->- case cc^.ccInfo.cdType of- Direct ->- case cc^.ccInfo.cdDMUserId >>= flip userById st of- Nothing -> mkChannelName (cc^.ccInfo)- Just u -> userSigil <> u^.uiName- _ -> mkChannelName (cc^.ccInfo)- in "Posts pinned in " <> cName- PostListSearch terms searching -> "Search results" <> if searching- then ": " <> terms- else " (" <> (T.pack . show . length) messages <> "): " <> terms-- messages = insertDateMarkers- (filterMessages knownChannel $ st^.csPostListOverlay.postListPosts)- (getDateFormat st)- (st^.timeZone)-- knownChannel msg =- case msg^.mChannelId of- Just cId | Nothing <- st^?csChannels.channelByIdL(cId) -> False- _ -> True-- -- The overall contents, with a sensible default even if there- -- are no messages- messageListContents- | null messages =- padTopBottom 1 $- hCenter $- withDefAttr clientEmphAttr $- str $ case contents of- PostListFlagged -> "You have no flagged messages."- PostListPinned _ -> "This channel has no pinned messages."- PostListSearch _ searching ->- if searching- then "Searching ..."- else "No search results found"- | otherwise = vBox renderedMessageList-- -- The render-message function we're using- renderMessageForOverlay msg tState =- let renderedMsg = renderSingleMessage st hs Nothing msg tState- in case msg^.mOriginalPost of- -- We should factor out some of the channel name logic at- -- some point, but we can do that later- Just post- | Just chan <- st^?csChannels.channelByIdL(post^.postChannelIdL) ->- case chan^.ccInfo.cdType of- Direct- | Just u <- flip userById st =<< chan^.ccInfo.cdDMUserId ->- (forceAttr channelNameAttr (txt (userSigil <> u^.uiName)) <=>- (str " " <+> renderedMsg))- _ -> (forceAttr channelNameAttr (txt (chan^.ccInfo.to mkChannelName)) <=>- (str " " <+> renderedMsg))- _ | CP _ <- msg^.mType -> str "[BUG: unknown channel]"- | otherwise -> renderedMsg-- -- The full message list, rendered with the current selection- renderedMessageList =- let (s, (before, after)) = splitDirSeqOn matchesMessage messagesWithStates- matchesMessage (m, _) = m^.mMessageId == (MessagePostId <$> st^.csPostListOverlay.postListSelected)- messagesWithStates = (, InThreadShowParent) <$> messages- in case s of- Nothing ->- map (uncurry renderMessageForOverlay) (reverse (toList messagesWithStates))- Just curMsg ->- [unsafeRenderMessageSelection (curMsg, (after, before)) renderMessageForOverlay]
− src/Draw/ReactionEmojiListOverlay.hs
@@ -1,40 +0,0 @@-module Draw.ReactionEmojiListOverlay- ( drawReactionEmojiListOverlay- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.List ( listSelectedFocusedAttr )-import qualified Data.Text as T--import Draw.Main-import Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )-import Types-import Themes---drawReactionEmojiListOverlay :: ChatState -> [Widget Name]-drawReactionEmojiListOverlay st =- let overlay = drawListOverlay (st^.csReactionEmojiListOverlay)- (const $ txt "Search Emoji")- (const $ txt "No matching emoji found.")- (const $ txt "Search emoji:")- renderEmoji- Nothing- OverlayCenter- 80- in joinBorders overlay : drawMain False st--renderEmoji :: Bool -> (Bool, T.Text) -> Widget Name-renderEmoji sel (mine, e) =- let maybeForce = if sel- then forceAttr listSelectedFocusedAttr- else id- in maybeForce $- padRight Max $- hBox [ if mine then txt " * " else txt " "- , withDefAttr emojiAttr $ txt $ ":" <> e <> ":"- ]
− src/Draw/RichText.hs
@@ -1,525 +0,0 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE ViewPatterns #-}-{-# LANGUAGE ParallelListComp #-}-{-# LANGUAGE RecordWildCards #-}-module Draw.RichText- ( MessageData(..)- , renderRichText- , renderMessage- , renderText- , renderText'- , renderElementSeq- , cursorSentinel- , addEllipsis- , findVerbatimChunk- )-where--import Prelude ()-import Prelude.MH--import Brick ( (<+>), Widget, hLimit, imageL- , raw, render, Size(..), Widget(..)- )-import qualified Brick as B-import qualified Brick.Widgets.Border as B-import qualified Brick.Widgets.Skylighting as BS-import qualified Data.Foldable as F-import qualified Data.Map.Strict as Map-import qualified Data.Sequence as Seq-import Data.Sequence ( ViewL(..)- , ViewR(..)- , (<|)- , (|>)- , viewl- , viewr)-import qualified Data.Sequence as S-import qualified Data.Set as Set-import qualified Data.Text as T-import qualified Graphics.Vty as V-import qualified Skylighting.Core as Sky--import Network.Mattermost.Lenses ( postEditAtL, postCreateAtL )-import Network.Mattermost.Types ( ServerTime(..) )--import Constants ( normalChannelSigil, userSigil )-import Themes-import Types ( Name, HighlightSet(..) )-import Types.Messages-import Types.Posts-import Types.RichText---emptyHSet :: HighlightSet-emptyHSet = HighlightSet Set.empty Set.empty mempty--omittedUsernameType :: MessageType -> Bool-omittedUsernameType = \case- CP Join -> True- CP Leave -> True- CP TopicChange -> True- _ -> False---- Add the edit sentinel to the end of the last block in the sequence.--- If the last block is a paragraph, append it to that paragraph.--- Otherwise, append a new block so it appears beneath the last--- block-level element.-addEditSentinel :: ElementData -> Seq RichTextBlock -> Seq RichTextBlock-addEditSentinel d bs =- case viewr bs of- EmptyR -> bs- (rest :> b) -> rest <> appendEditSentinel d b--appendEditSentinel :: ElementData -> RichTextBlock -> Seq RichTextBlock-appendEditSentinel sentinel b =- let s = Para (S.singleton m)- m = Element Normal sentinel- in case b of- Para is -> S.singleton $ Para (is |> Element Normal ESpace |> m)- _ -> S.fromList [b, s]---- | A bundled structure that includes all the information necessary--- to render a given message-data MessageData =- MessageData { mdEditThreshold :: Maybe ServerTime- -- ^ If specified, any messages edited before this point- -- in time are not indicated as edited.- , mdShowOlderEdits :: Bool- -- ^ Indicates whether "edited" markers should be shown- -- for old messages (i.e., ignore the mdEditThreshold- -- value).- , mdShowReactions :: Bool- -- ^ Whether to render reactions.- , mdMessage :: Message- -- ^ The message to render.- , mdUserName :: Maybe Text- -- ^ The username of the message's author, if any. This- -- is passed here rather than obtaining from the message- -- because we need to do lookups in the ChatState to- -- compute this, and we don't pass the ChatState into- -- renderMessage.- , mdParentMessage :: Maybe Message- -- ^ The parent message of this message, if any.- , mdParentUserName :: Maybe Text- -- ^ The author of the parent message, if any.- , mdThreadState :: ThreadState- -- ^ The thread state of this message.- , mdRenderReplyParent :: Bool- -- ^ Whether to render the parent message.- , mdHighlightSet :: HighlightSet- -- ^ The highlight set to use to highlight usernames,- -- channel names, etc.- , mdIndentBlocks :: Bool- -- ^ Whether to indent the message underneath the- -- author's name (True) or just display it to the right- -- of the author's name (False).- , mdMessageWidthLimit :: Maybe Int- -- ^ A width override to use to wrap non-code blocks. If- -- unspecified, all blocks in the message will be- -- wrapped and truncated at the width specified by the- -- rendering context. If specified, all non-code blocks- -- will be wrapped at this width and code blocks will be- -- rendered using the context's width.- , mdMyUsername :: Text- -- ^ The username of the user running Matterhorn.- }---- | renderMessage performs markdown rendering of the specified message.-renderMessage :: MessageData -> Widget Name-renderMessage md@MessageData { mdMessage = msg, .. } =- let msgUsr = case mdUserName of- Just u -> if omittedUsernameType (msg^.mType) then Nothing else Just u- Nothing -> Nothing- botElem = if isBotMessage msg then B.txt "[BOT]" else B.emptyWidget- nameElems = case msgUsr of- Just un- | isEmote msg ->- [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN]" else ""- , B.txt $ (if msg^.mFlagged then "[!] " else "") <> "*"- , colorUsername mdMyUsername un un- , botElem- , B.txt " "- ]- | otherwise ->- [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN] " else ""- , colorUsername mdMyUsername un un- , botElem- , B.txt $ (if msg^.mFlagged then "[!]" else "") <> ": "- ]- Nothing -> []-- -- Use the editing threshold to determine whether to append an- -- editing indication to this message.- maybeAugment bs = case msg^.mOriginalPost of- Nothing -> bs- Just p ->- if p^.postEditAtL > p^.postCreateAtL- then case mdEditThreshold of- Just cutoff | p^.postEditAtL >= cutoff ->- addEditSentinel (EEditSentinel True) bs- _ -> if mdShowOlderEdits- then addEditSentinel (EEditSentinel False) bs- else bs- else bs-- augmentedText = maybeAugment $ msg^.mText- msgWidget =- vBox $ (layout mdHighlightSet mdMessageWidthLimit nameElems augmentedText . viewl) augmentedText :- catMaybes [msgAtch, msgReac]- replyIndent = B.Widget B.Fixed B.Fixed $ do- ctx <- B.getContext- -- NB: The amount subtracted here must be the total padding- -- added below (pad 1 + vBorder)- w <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) msgWidget- B.render $ B.vLimit (V.imageHeight $ w^.B.imageL) $- B.padRight (B.Pad 1) B.vBorder B.<+> (B.Widget B.Fixed B.Fixed $ return w)- msgAtch = if Seq.null (msg^.mAttachments)- then Nothing- else Just $ B.withDefAttr clientMessageAttr $ vBox- [ B.txt (" [attached: `" <> a^.attachmentName <> "`]")- | a <- toList (msg^.mAttachments)- ]- msgReac = if Map.null (msg^.mReactions) || (not mdShowReactions)- then Nothing- else let renderR e us =- let n = Set.size us- in if | n == 1 -> " [" <> e <> "]"- | n > 1 -> " [" <> e <> " " <> T.pack (show n) <> "]"- | otherwise -> ""- reactionMsg = Map.foldMapWithKey renderR (msg^.mReactions)- in Just $ B.withDefAttr emojiAttr $ B.txt (" " <> reactionMsg)- withParent p =- case mdThreadState of- NoThread -> msgWidget- InThreadShowParent -> p B.<=> replyIndent- InThread -> replyIndent- in if not mdRenderReplyParent- then msgWidget- else case msg^.mInReplyToMsg of- NotAReply -> msgWidget- InReplyTo _ ->- case mdParentMessage of- Nothing -> withParent (B.str "[loading...]")- Just pm ->- let parentMsg = renderMessage md- { mdShowOlderEdits = False- , mdMessage = pm- , mdUserName = mdParentUserName- , mdParentMessage = Nothing- , mdRenderReplyParent = False- , mdIndentBlocks = False- }- in withParent (addEllipsis $ B.forceAttr replyParentAttr parentMsg)-- where- layout :: HighlightSet -> Maybe Int -> [Widget Name] -> Seq RichTextBlock- -> ViewL RichTextBlock -> Widget Name- layout hs w nameElems bs xs | length xs > 1 = multiLnLayout hs w nameElems bs- layout hs w nameElems bs (Blockquote {} :< _) = multiLnLayout hs w nameElems bs- layout hs w nameElems bs (CodeBlock {} :< _) = multiLnLayout hs w nameElems bs- layout hs w nameElems bs (HTMLBlock {} :< _) = multiLnLayout hs w nameElems bs- layout hs w nameElems bs (List {} :< _) = multiLnLayout hs w nameElems bs- layout hs w nameElems bs (Para inlns :< _)- | F.any breakCheck inlns = multiLnLayout hs w nameElems bs- layout hs w nameElems bs _ = nameNextToMessage hs w nameElems bs-- multiLnLayout hs w nameElems bs =- if mdIndentBlocks- then vBox [ hBox nameElems- , hBox [B.txt " ", renderRichText mdMyUsername hs ((subtract 2) <$> w) bs]- ]- else nameNextToMessage hs w nameElems bs-- nameNextToMessage hs w nameElems bs =- Widget Fixed Fixed $ do- nameResult <- render $ hBox nameElems- let newW = subtract (V.imageWidth (nameResult^.imageL)) <$> w- render $ hBox [raw (nameResult^.imageL), renderRichText mdMyUsername hs newW bs]-- breakCheck e = eData e `elem` [ELineBreak, ESoftBreak]--addEllipsis :: Widget a -> Widget a-addEllipsis w = B.Widget (B.hSize w) (B.vSize w) $ do- ctx <- B.getContext- let aw = ctx^.B.availWidthL- result <- B.render w- let withEllipsis = (B.hLimit (aw - 3) $ B.vLimit 1 $ (B.Widget B.Fixed B.Fixed $ return result)) <+>- B.str "..."- if (V.imageHeight (result^.B.imageL) > 1) || (V.imageWidth (result^.B.imageL) == aw) then- B.render withEllipsis else- return result---- Cursor sentinel for tracking the user's cursor position in previews.-cursorSentinel :: Char-cursorSentinel = '‸'---- Render markdown with username highlighting-renderRichText :: Text -> HighlightSet -> Maybe Int -> Seq RichTextBlock -> Widget a-renderRichText curUser hSet w =- B.vBox . toList . fmap (blockToWidget curUser hSet w) . addBlankLines---- Add blank lines only between adjacent elements of the same type, to--- save space-addBlankLines :: Seq RichTextBlock -> Seq RichTextBlock-addBlankLines = go' . viewl- where go' EmptyL = S.empty- go' (x :< xs) = go x (viewl xs)- go a@Para {} (b@Para {} :< rs) =- a <| blank <| go b (viewl rs)- go a@Header {} (b@Header {} :< rs) =- a <| blank <| go b (viewl rs)- go a@Blockquote {} (b@Blockquote {} :< rs) =- a <| blank <| go b (viewl rs)- go a@List {} (b@List {} :< rs) =- a <| blank <| go b (viewl rs)- go a@CodeBlock {} (b@CodeBlock {} :< rs) =- a <| blank <| go b (viewl rs)- go a@HTMLBlock {} (b@HTMLBlock {} :< rs) =- a <| blank <| go b (viewl rs)- go x (y :< rs) = x <| go y (viewl rs)- go x (EmptyL) = S.singleton x- blank = Para (S.singleton (Element Normal ESpace))---- Render text to markdown without username highlighting-renderText :: Text -> Widget a-renderText txt = renderText' "" emptyHSet txt--renderText' :: Text -> HighlightSet -> Text -> Widget a-renderText' curUser hSet t = renderRichText curUser hSet Nothing rtBs- where rtBs = parseMarkdown t--vBox :: F.Foldable f => f (Widget a) -> Widget a-vBox = B.vBox . toList--hBox :: F.Foldable f => f (Widget a) -> Widget a-hBox = B.hBox . toList--header :: Int -> Widget a-header n = B.txt (T.replicate n "#")--maybeHLimit :: Maybe Int -> Widget a -> Widget a-maybeHLimit Nothing w = w-maybeHLimit (Just i) w = hLimit i w--blockToWidget :: Text -> HighlightSet -> Maybe Int -> RichTextBlock -> Widget a-blockToWidget curUser hSet w (Para is) =- toElementChunk curUser w is hSet-blockToWidget curUser hSet w (Header n is) =- B.withDefAttr clientHeaderAttr $- hBox [B.padRight (B.Pad 1) $ header n, toElementChunk curUser (subtract 1 <$> w) is hSet]-blockToWidget curUser hSet w (Blockquote is) =- maybeHLimit w $- addQuoting (vBox $ fmap (blockToWidget curUser hSet w) is)-blockToWidget curUser hSet w (List _ l bs) =- maybeHLimit w $- blocksToList curUser l w bs hSet-blockToWidget _ hSet _ (CodeBlock ci tx) =- let f = maybe rawCodeBlockToWidget (codeBlockToWidget (hSyntaxMap hSet)) mSyntax- mSyntax = do- lang <- codeBlockLanguage ci- Sky.lookupSyntax lang (hSyntaxMap hSet)- in f tx-blockToWidget _ _ w (HTMLBlock t) =- maybeHLimit w $- textWithCursor t-blockToWidget _ _ w (HRule) =- maybeHLimit w $- B.vLimit 1 (B.fill '*')--quoteChar :: Char-quoteChar = '>'--addQuoting :: B.Widget n -> B.Widget n-addQuoting w =- B.Widget B.Fixed (B.vSize w) $ do- ctx <- B.getContext- childResult <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) w-- let quoteBorder = B.raw $ V.charFill (ctx^.B.attrL) quoteChar 1 height- height = V.imageHeight $ childResult^.B.imageL-- B.render $ B.hBox [ B.padRight (B.Pad 1) quoteBorder- , B.Widget B.Fixed B.Fixed $ return childResult- ]--codeBlockToWidget :: Sky.SyntaxMap -> Sky.Syntax -> Text -> Widget a-codeBlockToWidget syntaxMap syntax tx =- let result = Sky.tokenize cfg syntax tx- cfg = Sky.TokenizerConfig syntaxMap False- in case result of- Left _ -> rawCodeBlockToWidget tx- Right tokLines ->- let padding = B.padLeftRight 1 (B.vLimit (length tokLines) B.vBorder)- in (B.txt $ "[" <> Sky.sName syntax <> "]") B.<=>- (padding <+> BS.renderRawSource textWithCursor tokLines)--rawCodeBlockToWidget :: Text -> Widget a-rawCodeBlockToWidget tx =- B.withDefAttr codeAttr $- let padding = B.padLeftRight 1 (B.vLimit (length theLines) B.vBorder)- theLines = expandEmpty <$> T.lines tx- expandEmpty "" = " "- expandEmpty s = s- in padding <+> (B.vBox $ textWithCursor <$> theLines)--toElementChunk :: Text -> Maybe Int -> Seq Element -> HighlightSet -> Widget a-toElementChunk curUser w es hSet = B.Widget B.Fixed B.Fixed $ do- ctx <- B.getContext- let width = fromMaybe (ctx^.B.availWidthL) w- ws = fmap (renderElementSeq curUser) (wrapLine width hSet es)- B.render (vBox (fmap hBox ws))--blocksToList :: Text -> ListType -> Maybe Int -> Seq (Seq RichTextBlock) -> HighlightSet -> Widget a-blocksToList curUser lt w bs hSet = vBox- [ B.txt i <+> (vBox (fmap (blockToWidget curUser hSet w) b))- | b <- F.toList bs- | i <- is ]- where is = case lt of- Bullet _ -> repeat ("• ")- Numbered Period s ->- [ T.pack (show (n :: Int)) <> ". " | n <- [s..] ]- Numbered Paren s ->- [ T.pack (show (n :: Int)) <> ") " | n <- [s..] ]--data SplitState = SplitState- { splitChunks :: Seq (Seq Element)- , splitCurrCol :: Int- }--wrapLine :: Int -> HighlightSet -> Seq Element -> Seq (Seq Element)-wrapLine maxCols hSet = splitChunks . go (SplitState (S.singleton S.empty) 0)- where go st (viewl-> e :< es) = go st' es- where- HighlightSet { hUserSet = uSet, hChannelSet = cSet } = hSet-- -- Right before we check the width of the token, we see- -- if the token is a user or channel reference. If so, we- -- check if it is valid. If it is valid, we leave it in- -- place; otherwise we translate it into an ordinary text- -- element so that it does not render highlighted as a- -- valid user or channel reference.- newElement = e { eData = newEData }- newEData = case eData e of- EUser u ->- if u `Set.member` uSet- then EUser u- else EText $ userSigil <> u- EChannel c ->- if c `Set.member` cSet- then EChannel c- else EText $ normalChannelSigil <> c- d -> d-- addHyperlink url el = setElementStyle (Hyperlink url (eStyle el)) el-- st' =- case newEData of- EHyperlink url (Just labelEs) ->- go st $ addHyperlink url <$> labelEs- EImage url (Just labelEs) ->- go st $ addHyperlink url <$> labelEs- _ ->- if | newEData == ESoftBreak || newEData == ELineBreak ->- st { splitChunks = splitChunks st |> S.empty- , splitCurrCol = 0- }- | available >= eWidth ->- st { splitChunks = addElement newElement (splitChunks st)- , splitCurrCol = splitCurrCol st + eWidth- }- | newEData == ESpace ->- st { splitChunks = splitChunks st |> S.empty- , splitCurrCol = 0- }- | otherwise ->- st { splitChunks = splitChunks st |> S.singleton newElement- , splitCurrCol = eWidth- }- available = maxCols - splitCurrCol st- eWidth = elementWidth newElement- addElement x (viewr-> ls :> l) = ( ls |> (l |> x))- addElement _ _ = error "[unreachable]"- go st _ = st--renderElementSeq :: Text -> Seq Element -> Seq (Widget a)-renderElementSeq curUser es = renderElement curUser <$> es--renderElement :: Text -> Element -> Widget a-renderElement curUser e = addStyle sty widget- where- sty = eStyle e- dat = eData e- addStyle s = case s of- Normal -> id- Emph -> B.withDefAttr clientEmphAttr- Strong -> B.withDefAttr clientStrongAttr- Code -> B.withDefAttr codeAttr- Hyperlink (URL url) innerSty ->- B.hyperlink url . B.withDefAttr urlAttr . addStyle innerSty- rawText = B.txt . removeCursor- widget = case dat of- -- Cursor sentinels get parsed as individual text nodes by- -- Cheapskate, meaning that we get a text node with exactly- -- one character in it. That's why we compare accordingly- -- above. In addition, since that character has no width, we- -- have to replace it with something that has a size (such- -- as a space) to get Brick's visibility logic to scroll the- -- viewport to get it into view.- EText t -> if t == T.singleton (cursorSentinel)- then B.visible $ B.txt " "- else rawText t-- ESpace -> B.txt " "- ERawHtml t -> rawText t- EEditSentinel recent -> let attr = if recent- then editedRecentlyMarkingAttr- else editedMarkingAttr- in B.withDefAttr attr $ B.txt editMarking- EUser u -> colorUsername curUser u $ userSigil <> u- EChannel c -> B.withDefAttr channelNameAttr $- B.txt $ normalChannelSigil <> c- EHyperlink (URL url) Nothing -> rawText url- EImage (URL url) Nothing -> rawText url- EEmoji em -> B.withDefAttr emojiAttr $- B.txt $ ":" <> em <> ":"-- -- Hyperlink and image nodes with labels should not appear- -- at this point because line-wrapping should break them up- -- into normal text node sequences with hyperlink styles- -- attached.- EHyperlink {} -> rawText "(Report renderElement bug #1)"- EImage {} -> rawText "(Report renderElement bug #2)"-- -- Line breaks should never need to get rendered since the- -- line-wrapping algorithm removes them.- ESoftBreak -> B.emptyWidget- ELineBreak -> B.emptyWidget--textWithCursor :: Text -> Widget a-textWithCursor t- | T.any (== cursorSentinel) t = B.visible $ B.txt $ removeCursor t- | otherwise = B.txt t--removeCursor :: Text -> Text-removeCursor = T.filter (/= cursorSentinel)--editMarking :: Text-editMarking = "(edited)"--elementWidth :: Element -> Int-elementWidth e =- case eData e of- EText t -> B.textWidth t- ERawHtml t -> B.textWidth t- EUser t -> T.length userSigil + B.textWidth t- EChannel t -> T.length normalChannelSigil + B.textWidth t- EEditSentinel _ -> B.textWidth editMarking- EImage (URL url) Nothing -> B.textWidth url- EImage _ (Just is) -> sum $ elementWidth <$> is- EHyperlink (URL url) Nothing -> B.textWidth url- EHyperlink _ (Just is) -> sum $ elementWidth <$> is- EEmoji t -> B.textWidth t + 2- ESpace -> 1- ELineBreak -> 0- ESoftBreak -> 0
− src/Draw/ShowHelp.hs
@@ -1,567 +0,0 @@-module Draw.ShowHelp- ( drawShowHelp- , keybindingMarkdownTable- , keybindingTextTable- , commandTextTable- , commandMarkdownTable- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Themes ( themeDescriptions )-import Brick.Widgets.Border-import Brick.Widgets.Center ( hCenter )-import Brick.Widgets.List ( listSelectedFocusedAttr )-import qualified Data.Map as M-import qualified Data.Text as T-import qualified Graphics.Vty as Vty-import Lens.Micro.Platform ( singular, _Just, _2 )--import Network.Mattermost.Version ( mmApiVersion )--import Command-import Events-import Events.ChannelSelect-import Events.Keybindings-import Events.Main-import Events.MessageSelect-import Events.ThemeListOverlay-import Events.PostListOverlay-import Events.ShowHelp-import Events.UrlSelect-import Events.UserListOverlay-import Events.ChannelListOverlay-import Events.ReactionEmojiListOverlay-import Events.ManageAttachments-import Events.TabbedWindow-import Windows.ViewMessage-import HelpTopics ( helpTopics )-import Draw.RichText ( renderText )-import Options ( mhVersion )-import State.Editing ( editingKeyHandlers )-import Themes-import Types-import Types.KeyEvents ( BindingState(..), Binding(..)- , ppBinding, nonCharKeys, eventToBinding )---drawShowHelp :: HelpTopic -> ChatState -> [Widget Name]-drawShowHelp topic st =- [helpBox (helpTopicViewportName topic) $ helpTopicDraw topic st]--helpTopicDraw :: HelpTopic -> ChatState -> Widget Name-helpTopicDraw topic st =- overrideAttr codeAttr helpEmphAttr $- hCenter $- hLimit helpContentWidth $- case helpTopicScreen topic of- MainHelp -> mainHelp (configUserKeys (st^.csResources.crConfiguration))- ScriptHelp -> scriptHelp- ThemeHelp -> themeHelp- SyntaxHighlightHelp -> syntaxHighlightHelp (configSyntaxDirs $ st^.csResources.crConfiguration)- KeybindingHelp -> keybindingHelp (configUserKeys (st^.csResources.crConfiguration))--mainHelp :: KeyConfig -> Widget Name-mainHelp kc = summary- where- summary = vBox entries- entries = [ heading $ T.pack mhVersion- , headingNoPad $ T.pack mmApiVersion- , heading "Help Topics"- , drawHelpTopics- , heading "Commands"- , padTop (Pad 1) mkCommandHelpText- , heading "Keybindings"- ] <>- (mkKeybindingHelp kc <$> keybindSections)-- mkCommandHelpText :: Widget Name- mkCommandHelpText =- let commandNameWidth = 2 + (maximum $ T.length <$> fst <$> commandHelpInfo)- in vBox [ (emph $ txt $ padTo commandNameWidth info) <+> renderText desc- | (info, desc) <- commandHelpInfo- ]--commandHelpInfo :: [(T.Text, T.Text)]-commandHelpInfo = pairs- where- pairs = [ (info, desc)- | Cmd cmd desc args _ <- cs- , let argSpec = printArgSpec args- spc = if T.null argSpec then "" else " "- info = T.cons '/' cmd <> spc <> argSpec- ]- cs = sortWith commandName commandList--commandTextTable :: T.Text-commandTextTable =- let commandNameWidth = 4 + (maximum $ T.length <$> fst <$> commandHelpInfo)- in T.intercalate "\n" $- [ padTo commandNameWidth info <> desc- | (info, desc) <- commandHelpInfo- ]--commandMarkdownTable :: T.Text-commandMarkdownTable =- T.intercalate "\n" $- [ "# Commands"- , ""- , "| Command | Description |"- , "| ------- | ----------- |"- ] <>- [ "| `" <> info <> "` | " <> desc <> " |"- | (info, desc) <- commandHelpInfo- ]--drawHelpTopics :: Widget Name-drawHelpTopics =- let allHelpTopics = drawTopic <$> helpTopics- topicNameWidth = 4 + (maximum $ T.length <$> helpTopicName <$> helpTopics)- drawTopic t = (emph $ txt (padTo topicNameWidth $ helpTopicName t)) <+>- txt (helpTopicDescription t)- in vBox $ (padBottom (Pad 1) $- para "Learn more about these topics with `/help <topic>`:")- : allHelpTopics--helpContentWidth :: Int-helpContentWidth = 72--scriptHelp :: Widget Name-scriptHelp = heading "Using Scripts" <=> vBox scriptHelpText- where scriptHelpText = map paraL- [ [ "Matterhorn has a special feature that allows you to use "- , "prewritten shell scripts to preprocess messages. "- , "For example, this can allow you to run various filters over "- , "your written text, do certain kinds of automated formatting, "- , "or just automatically cowsay-ify a message." ]- , [ "These scripts can be any kind of executable file, "- , "as long as the file lives in "- , "*~/.config/matterhorn/scripts* (unless you've explicitly "- , "moved your XDG configuration directory elsewhere). "- , "Those executables are given no arguments "- , "on the command line and are passed your typed message on "- , "*stdin*; whatever they produce on *stdout* is sent "- , "as a message. If the script exits successfully, then everything "- , "that appeared on *stderr* is discarded; if it instead exits with "- , "a failing exit code, your message is *not* sent, and you are "- , "presented with whatever was printed on stderr as a "- , "local error message." ]- , [ "To run a script, simply type" ]- , [ "> *> /sh [script-name] [my-message]*" ]- , [ "And the script named *[script-name]* will be invoked with "- , "the text of *[my-message]*. If the script does not exist, "- , "or if it exists but is not marked as executable, you'll be "- , "presented with an appropriate error message." ]- , [ "For example, if you want to use a basic script to "- , "automatically ROT13 your message, you can write a shell "- , "script using the standard Unix *tr* utility, like this:" ]- , [ "> *#!/bin/bash -e*"- , "> *tr '[A-Za-z]' '[N-ZA-Mn-za-m]'*" ]- , [ "Move this script to *~/.config/matterhorn/scripts/rot13* "- , "and be sure it's executable with" ]- , [ "> *$ chmod u+x ~/.config/matterhorn/scripts/rot13*" ]- , [ "after which you can send ROT13 messages with the "- , "Matterhorn command " ]- , [ "> *> /sh rot13 Hello, world!*" ]- ]--keybindingMarkdownTable :: KeyConfig -> Text-keybindingMarkdownTable kc = title <> keybindSectionStrings- where title = "# Keybindings\n"- keybindSectionStrings = T.concat $ sectionText <$> keybindSections- sectionText = mkKeybindEventSectionHelp kc keybindEventHelpMarkdown T.unlines mkHeading- mkHeading n =- "\n# " <> n <>- "\n| Keybinding | Event Name | Description |" <>- "\n| ---------- | ---------- | ----------- |"--keybindingTextTable :: KeyConfig -> Text-keybindingTextTable kc = title <> keybindSectionStrings- where title = "Keybindings\n===========\n"- keybindSectionStrings = T.concat $ sectionText <$> keybindSections- sectionText = mkKeybindEventSectionHelp kc (keybindEventHelpText keybindingWidth eventNameWidth) T.unlines mkHeading- keybindingWidth = 15- eventNameWidth = 30- mkHeading n =- "\n" <> n <>- "\n" <> (T.replicate (T.length n) "=")--keybindingHelp :: KeyConfig -> Widget Name-keybindingHelp kc = vBox $- [ heading "Configurable Keybindings"- , padBottom (Pad 1) $ vBox keybindingHelpText- ] ++ keybindSectionWidgets- ++- [ headingNoPad "Keybinding Syntax"- , vBox validKeys- ]- where keybindSectionWidgets = sectionWidget <$> keybindSections- sectionWidget = mkKeybindEventSectionHelp kc keybindEventHelpWidget vBox headingNoPad-- keybindingHelpText = map paraL- [ [ "Many of the keybindings used in Matterhorn can be "- , "modified from within Matterhorn's **config.ini** file. "- , "To do this, include a section called **[KEYBINDINGS]** "- , "in your config file and use the event names listed below as "- , "keys and the desired key sequence as values. "- , "See the end of this page for documentation on the valid "- , "syntax for key sequences."- ]- , [ "For example, by default, the keybinding to move to the next "- , "channel in the public channel list is **"- , nextChanBinding- , "**, and the corresponding "- , "previous channel binding is **"- , prevChanBinding- , "**. You might want to remap these "- , "to other keys: say, **C-j** and **C-k**. We can do this with the following "- , "configuration snippet:"- ]- , [ "```ini\n"- , "[KEYBINDINGS]\n"- , "focus-next-channel = C-j\n"- , "focus-prev-channel = C-k\n"- , "```"- ]- , [ "You can remap a command to more than one key sequence, in which "- , "case any one of the key sequences provided can be used to invoke "- , "the relevant command. To do this, provide the desired bindings as "- , "a comma-separated list. Additionally, some key combinations are "- , "used in multiple modes (such as URL select or help viewing) and "- , "therefore share the same name, such as **cancel** or **scroll-up**."- ]- , [ "Additionally, some keys simply cannot be remapped, mostly in the "- , "case of editing keybindings. If you feel that a particular key "- , "event should be rebindable and isn't, then please feel free to "- , "let us know by posting an issue in the Matterhorn issue tracker."- ]- , [ "It is also possible to entirely unbind a key event by setting its "- , "key to **unbound**, thus avoiding conflicts between default bindings "- , "and new ones:"- ]- , [ "```ini\n"- , "[KEYBINDINGS]\n"- , "focus-next-channel = unbound\n"- , "```"- ]- , [ "The rebindable key events, along with their **current** "- , "values, are as follows:"- ]- ]- nextChanBinding = ppBinding (getFirstDefaultBinding NextChannelEvent)- prevChanBinding = ppBinding (getFirstDefaultBinding PrevChannelEvent)- validKeys = map paraL- [ [ "The syntax used for key sequences consists of zero or more "- , "single-character modifier characters followed by a keystroke, "- , "all separated by dashes. The available modifier keys are "- , "**S** for Shift, **C** for Ctrl, **A** for Alt, and **M** for "- , "Meta. So, for example, **"- , ppBinding (Binding [] (Vty.KFun 2))- , "** is the F2 key pressed with no "- , "modifier keys; **"- , ppBinding (Binding [Vty.MCtrl] (Vty.KChar 'x'))- , "** is Ctrl and X pressed together, "- , "and **"- , ppBinding (Binding [Vty.MShift, Vty.MCtrl] (Vty.KChar 'x'))- , "** is Shift, Ctrl, and X all pressed together. "- , "Although Matterhorn will pretty-print all key combinations "- , "with specific capitalization, the parser is **not** case-sensitive "- , "and will ignore any capitalization."- ]- , [ "Your terminal emulator might not recognize some particular "- , "keypress combinations, or it might reserve certain combinations of "- , "keys for some terminal-specific operation. Matterhorn does not have a "- , "reliable way of testing this, so it is up to you to avoid setting "- , "keybindings that your terminal emulator does not deliver to applications."- ]- , [ "Letter keys, number keys, and function keys are specified with "- , "their obvious name, such as **x** for the X key, **8** for the 8 "- , "key, and **f5** for the F5 key. Other valid keys include: "- , T.intercalate ", " [ "**" <> key <> "**" | key <- nonCharKeys ]- , "."- ]- ]--emph :: Widget a -> Widget a-emph = withDefAttr helpEmphAttr--para :: Text -> Widget a-para t = padTop (Pad 1) $ renderText t--paraL :: [Text] -> Widget a-paraL = para . mconcat--heading :: Text -> Widget a-heading = padTop (Pad 1) . headingNoPad--headingNoPad :: Text -> Widget a-headingNoPad t = hCenter $ emph $ renderText t--syntaxHighlightHelp :: [FilePath] -> Widget a-syntaxHighlightHelp dirs = vBox- [ heading "Syntax Highlighting"-- , para $ "Matterhorn supports syntax highlighting in Markdown code blocks when the " <>- "name of the code block language follows the block opening sytnax:"- , para $ "```<language>"- , para $ "The possible values of `language` are determined by the available syntax " <>- "definitions. The available definitions are loaded from the following " <>- "directories according to the configuration setting `syntaxDirectories`. " <>- "If the setting is omitted, it defaults to the following sequence of directories:"- , para $ T.pack $ intercalate "\n" $ (\d -> "`" <> d <> "`") <$> dirs- , para $ "Syntax definitions are in the Kate XML format. Files with an " <>- "`xml` extension are loaded from each directory, with directories earlier " <>- "in the list taking precedence over later directories when more than one " <>- "directory provides a definition file for the same syntax."- , para $ "To place custom definitions in a directory, place a Kate " <>- "XML syntax definition in the directory and ensure that a copy of " <>- "`language.dtd` is also present. The file `language.dtd` can be found in " <>- "the `syntax/` directory of your Matterhorn distribution."- ]--themeHelp :: Widget a-themeHelp = vBox- [ heading "Using Themes"- , para "Matterhorn provides these built-in color themes:"- , padTop (Pad 1) $ vBox $ hCenter <$> emph <$>- txt <$> internalThemeName <$> internalThemes- , para $- "These themes can be selected with the */theme* command. To automatically " <>- "select a theme at startup, set the *theme* configuration file option to one " <>- "of the themes listed above."-- , heading "Customizing the Theme"- , para $- "Theme customization is also supported. To customize the selected theme, " <>- "create a theme customization file and set the `themeCustomizationFile` " <>- "configuration option to the path to the customization file. If the path " <>- "to the file is relative, Matterhorn will look for it in the same directory " <>- "as the Matterhorn configuration file."-- , para $- "Theme customization files are INI-style files that can customize any " <>- "foreground color, background color, or style of any aspect of the " <>- "Matterhorn user interface. Here is an example:"-- , para $- "```\n" <>- "[default]\n" <>- "default.fg = blue\n" <>- "default.bg = black\n" <>- "\n" <>- "[other]\n" <>- attrNameToConfig codeAttr <> ".fg = magenta\n" <>- attrNameToConfig codeAttr <> ".style = bold\n" <>- attrNameToConfig clientEmphAttr <> ".fg = cyan\n" <>- attrNameToConfig clientEmphAttr <> ".style = [bold, underline]\n" <>- attrNameToConfig listSelectedFocusedAttr <> ".fg = brightGreen\n" <>- "```"-- , para $- "In the example above, the theme's default foreground and background colors " <>- "are both customized to *blue* and *black*, respectively. The *default* section " <>- "contains only customizations for the *default* attribute. All other customizations " <>- "go in the *other* section. We can also set the style for attributes; we can either " <>- "set just one style (as with the bold setting above) or multiple styles at once " <>- "(as in the bold/underline example).\n"-- , para $- "Available colors are:\n" <>- " * black\n" <>- " * red\n" <>- " * green\n" <>- " * yellow\n" <>- " * blue\n" <>- " * magenta\n" <>- " * cyan\n" <>- " * white\n" <>- " * brightBlack\n" <>- " * brightRed\n" <>- " * brightGreen\n" <>- " * brightYellow\n" <>- " * brightBlue\n" <>- " * brightMagenta\n" <>- " * brightCyan\n" <>- " * brightWhite"-- , para $- "Available styles are:\n" <>- " * standout\n" <>- " * underline\n" <>- " * italic\n" <>- " * reverseVideo\n" <>- " * blink\n" <>- " * dim\n" <>- " * bold\n"-- , para $- "It is also possible to specify RGB values using HTML syntax: `#RRGGBB`. " <>- "Bear in mind that such colors are clamped to the nearest 256-color palette " <>- "entry, so it is not possible to get the exact color specified.\n\n" <>- "In addition, a special value of *default* is possible for either color " <>- "setting of an attribute. This value indicates that the attribute should " <>- "use the terminal emulator's default foreground or background color of " <>- "choice rather than a specific ANSI color."-- , heading "Username Highlighting"- , para $- "Username colors are chosen by hashing each username and then using the hash " <>- "to choose a color from a list of predefined username colors. If you would like " <>- "to change the color in a given entry of this list, we provide the " <>- "\"username.N\" attributes, where N is the index in the username color list."-- , heading "Theme Attributes"- , para $- "This section lists all possible theme attributes for use in customization " <>- "files along with a description of how each one is used in Matterhorn. Each " <>- "option listed can be set in the *other* section of the customization file. " <>- "Each provides three customization settings:"-- , para $- " * *<option>.fg = <color>*\n" <>- " * *<option>.bg = <color>*\n" <>- " * *<option>.style = <style>* or *<option>.style = [<style>, ...]*\n"-- , let names = sort $- (\(n, msg) -> (n, attrNameToConfig n, msg)) <$>- (M.toList $ themeDescriptions themeDocs)- mkEntry (n, opt, msg) =- padTop (Pad 1) $- vBox [ hBox [ withDefAttr clientEmphAttr $ txt opt- , padLeft Max $ forceAttr n $ txt "(demo)"- ]- , txt msg- ]- in vBox $ mkEntry <$> names- ]--keybindSections :: [(Text, [KeyEventHandler])]-keybindSections =- [ ("Global Keybindings", globalKeyHandlers)- , ("Help Page", helpKeyHandlers)- , ("Main Interface", mainKeyHandlers)- , ("Channel Select Mode", channelSelectKeyHandlers)- , ("URL Select Mode", urlSelectKeyHandlers)- , ("Message Select Mode", messageSelectKeyHandlers)- , ("Text Editing", editingKeyHandlers (csEditState.cedEditor))- , ("Flagged Messages", postListOverlayKeyHandlers)- , ("Theme List Window", themeListOverlayKeyHandlers)- , ("User Listings", userListOverlayKeyHandlers)- , ("Channel Search Window", channelListOverlayKeyHandlers)- , ("Reaction Emoji Search Window", reactionEmojiListOverlayKeyHandlers)- , ("Message Viewer: Common", tabbedWindowKeyHandlers (csViewedMessage.singular _Just._2))- , ("Message Viewer: Message tab", viewMessageKeyHandlers)- , ("Message Viewer: Reactions tab", viewMessageReactionsKeyHandlers)- , ("Attachment List", attachmentListKeyHandlers)- , ("Attachment File Browser", attachmentBrowseKeyHandlers)- ]--helpBox :: Name -> Widget Name -> Widget Name-helpBox n helpText =- withDefAttr helpAttr $- borderWithLabel (emph $ txt "Matterhorn Help") $- viewport HelpViewport Vertical $- cached n helpText--kbColumnWidth :: Int-kbColumnWidth = 14--kbDescColumnWidth :: Int-kbDescColumnWidth = 60--mkKeybindingHelp :: KeyConfig -> (Text, [KeyEventHandler]) -> Widget Name-mkKeybindingHelp kc (sectionName, kbs) =- (heading sectionName) <=>- (padTop (Pad 1) $ hCenter $ vBox $ snd <$> sortWith fst results)- where- results = mkKeybindHelp kc <$> kbs--mkKeybindHelp :: KeyConfig -> KeyEventHandler -> (Text, Widget Name)-mkKeybindHelp kc h =- let unbound = ["(unbound)"]- label = case kehEventTrigger h of- Static k -> ppBinding $ eventToBinding k- ByEvent ev ->- let bindings = case M.lookup ev kc of- Nothing ->- let bs = defaultBindings ev- in if not $ null bs- then ppBinding <$> defaultBindings ev- else unbound- Just Unbound -> unbound- Just (BindingList bs) | not (null bs) -> ppBinding <$> bs- | otherwise -> unbound- in T.intercalate ", " bindings-- rendering = (emph $ txt $ padTo kbColumnWidth $- label) <+> txt " " <+>- (hLimit kbDescColumnWidth $ padRight Max $ renderText $- ehDescription $ kehHandler h)- in (label, rendering)--mkKeybindEventSectionHelp :: KeyConfig- -> ((Either Text Text, Text, [Text]) -> a)- -> ([a] -> a)- -> (Text -> a)- -> (Text, [KeyEventHandler])- -> a-mkKeybindEventSectionHelp kc mkKeybindHelpFunc vertCat mkHeading (sectionName, kbs) =- vertCat $ (mkHeading sectionName) :- (mkKeybindHelpFunc <$> (mkKeybindEventHelp kc <$> kbs))--keybindEventHelpWidget :: (Either Text Text, Text, [Text]) -> Widget Name-keybindEventHelpWidget (evName, desc, evs) =- let evText = T.intercalate ", " evs- label = case evName of- Left s -> txt $ "; " <> s- Right s -> emph $ txt s- in padBottom (Pad 1) $- vBox [ txtWrap ("; " <> desc)- , label <+> txt (" = " <> evText)- ]--keybindEventHelpMarkdown :: (Either Text Text, Text, [Text]) -> Text-keybindEventHelpMarkdown (evName, desc, evs) =- let quote s = "`" <> s <> "`"- name = case evName of- Left s -> s- Right s -> quote s- in "| " <> (T.intercalate ", " $ quote <$> evs) <>- " | " <> name <>- " | " <> desc <>- " |"--keybindEventHelpText :: Int -> Int -> (Either Text Text, Text, [Text]) -> Text-keybindEventHelpText width eventNameWidth (evName, desc, evs) =- let name = case evName of- Left s -> s- Right s -> s- in padTo width (T.intercalate ", " evs) <> " " <>- padTo eventNameWidth name <> " " <>- desc--mkKeybindEventHelp :: KeyConfig -> KeyEventHandler -> (Either Text Text, Text, [Text])-mkKeybindEventHelp kc h =- let trig = kehEventTrigger h- unbound = ["(unbound)"]- (label, evText) = case trig of- Static key -> (Left "(non-customizable key)", [ppBinding $ eventToBinding key])- ByEvent ev -> case M.lookup ev kc of- Nothing ->- let name = keyEventName ev- in if not (null (defaultBindings ev))- then (Right name, ppBinding <$> defaultBindings ev)- else (Left name, unbound)- Just Unbound -> (Right $ keyEventName ev, unbound)- Just (BindingList bs) -> (Right $ keyEventName ev,- if not (null bs)- then ppBinding <$> bs- else unbound- )- in (label, ehDescription $ kehHandler h, evText)--padTo :: Int -> Text -> Text-padTo n s = s <> T.replicate (n - T.length s) " "
− src/Draw/TabbedWindow.hs
@@ -1,77 +0,0 @@-module Draw.TabbedWindow- ( drawTabbedWindow- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center-import Data.List ( intersperse )-import qualified Graphics.Vty as Vty--import Draw.Util ( renderKeybindingHelp )-import Types-import Themes-import Types.KeyEvents---- | Render a tabbed window.-drawTabbedWindow :: (Eq a, Show a)- => TabbedWindow a- -> ChatState- -> Widget Name-drawTabbedWindow w cs =- let cur = getCurrentTabbedWindowEntry w- tabBody = tweRender cur (twValue w) cs- title = forceAttr clientEmphAttr $ twtTitle (twTemplate w) (tweValue cur)- in centerLayer $- vLimit (twWindowHeight w) $- hLimit (twWindowWidth w) $- joinBorders $- borderWithLabel title $- (tabBar w <=> tabBody <=> hBorder <=> hCenter keybindingHelp)---- | Keybinding help to show at the bottom of a tabbed window.-keybindingHelp :: Widget Name-keybindingHelp =- let pairs = [ ("Switch tabs", [SelectNextTabEvent, SelectPreviousTabEvent])- , ("Scroll", [ScrollUpEvent, ScrollDownEvent, ScrollLeftEvent, ScrollRightEvent, PageLeftEvent, PageRightEvent])- ]- in hBox $ intersperse (txt " ") $ (uncurry renderKeybindingHelp) <$> pairs---- | The scrollable tab bar to show at the top of a tabbed window.-tabBar :: (Eq a, Show a)- => TabbedWindow a- -> Widget Name-tabBar w =- let cur = getCurrentTabbedWindowEntry w- entries = twtEntries (twTemplate w)- renderEntry e =- let useAttr = if isCurrent- then withDefAttr tabSelectedAttr- else withDefAttr tabUnselectedAttr- isCurrent = tweValue e == tweValue cur- makeVisible = if isCurrent then visible else id- decorateTab v = Widget Fixed Fixed $ do- result <- render v- let width = Vty.imageWidth (result^.imageL)- if isCurrent- then- render $ padBottom (Pad 1) $ raw $ result^.imageL- else- render $ vBox [raw $ result^.imageL, hLimit width hBorder]- in makeVisible $- decorateTab $- useAttr $- padLeftRight 2 $- txt $- tweTitle e (tweValue e) isCurrent- contents = Widget Fixed Fixed $ do- ctx <- getContext- let width = ctx^.availWidthL- render $ hBox $ (intersperse divider $ renderEntry <$> entries) <>- [divider, padTop (Pad 1) $ hLimit width hBorder]- divider = vLimit 1 vBorder <=> joinableBorder (Edges True False False False)- in vLimit 2 $ viewport TabbedWindowTabBar Horizontal contents
− src/Draw/ThemeListOverlay.hs
@@ -1,53 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--module Draw.ThemeListOverlay- ( drawThemeListOverlay- )-where--import Prelude ()-import Prelude.MH--import Brick-import qualified Brick.Widgets.List as L-import Brick.Widgets.Border ( hBorder )-import Brick.Widgets.Center ( hCenter )--import Draw.Main-import Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )-import Themes-import Types-import Types.KeyEvents ( ppBinding )-import Events.Keybindings---drawThemeListOverlay :: ChatState -> [Widget Name]-drawThemeListOverlay st =- let overlay = drawListOverlay (st^.csThemeListOverlay)- (const $ txt "Themes")- (const $ txt "No matching themes found.")- (const $ txt "Search built-in themes:")- renderInternalTheme- (Just footer)- OverlayUpperRight- 50- footer = hBorder <=>- (hCenter $ hBox [ enter- , txt $ ":choose theme "- , close- , txt ":close"- ])- enter = emph $ txt $ ppBinding (getFirstDefaultBinding ActivateListItemEvent)- close = emph $ txt $ ppBinding (getFirstDefaultBinding CancelEvent)- emph = withDefAttr clientEmphAttr- in joinBorders overlay : drawMain True st--renderInternalTheme :: Bool -> InternalTheme -> Widget Name-renderInternalTheme foc it =- (if foc then forceAttr L.listSelectedFocusedAttr else id) $- (padRight Max $- withDefAttr clientEmphAttr $- txt $ internalThemeName it) <=>- (vLimit 2 $- (padLeft (Pad 2) $ txtWrap $ internalThemeDesc it) <=> fill ' ')
− src/Draw/URLList.hs
@@ -1,57 +0,0 @@-module Draw.URLList- ( renderUrlList- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.List ( renderList )-import qualified Data.Foldable as F-import Lens.Micro.Platform ( to )--import Network.Mattermost.Types ( ServerTime(..) )--import Draw.Messages-import Draw.Util-import Draw.RichText-import Themes-import Types-import Types.RichText ( unURL )---renderUrlList :: ChatState -> Widget Name-renderUrlList st =- header <=> urlDisplay- where- header = withDefAttr channelHeaderAttr $ vLimit 1 $- (txt $ "URLs: " <> (st^.csCurrentChannel.ccInfo.cdName)) <+>- fill ' '-- urlDisplay = if F.length urls == 0- then str "No URLs found in this channel."- else renderList renderItem True urls-- urls = st^.csUrlList-- me = myUsername st-- renderItem sel link =- let time = link^.linkTime- in attr sel $ vLimit 2 $- (vLimit 1 $- hBox [ let u = maybe "<server>" id (link^.linkUser.to (nameForUserRef st))- in colorUsername me u u- , case link^.linkLabel of- Nothing -> emptyWidget- Just label -> txt ": " <+> hBox (F.toList $ renderElementSeq me label)- , fill ' '- , renderDate st $ withServerTime time- , str " "- , renderTime st $ withServerTime time- ] ) <=>- (vLimit 1 (renderText $ unURL $ link^.linkURL))-- attr True = forceAttr urlListSelectedAttr- attr False = id
− src/Draw/UserListOverlay.hs
@@ -1,78 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RecordWildCards #-}--module Draw.UserListOverlay- ( drawUserListOverlay- )-where--import Prelude ()-import Prelude.MH--import Brick-import qualified Brick.Widgets.List as L-import qualified Data.Text as T-import qualified Graphics.Vty as V--import Draw.Main-import Draw.Util ( userSigilFromInfo )-import Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )-import Themes-import Types---drawUserListOverlay :: ChatState -> [Widget Name]-drawUserListOverlay st =- let overlay = drawListOverlay (st^.csUserListOverlay) userSearchScopeHeader- userSearchScopeNoResults userSearchScopePrompt- (renderUser (myUsername st))- Nothing- OverlayCenter- 80- in joinBorders overlay : drawMain False st--userSearchScopePrompt :: UserSearchScope -> Widget Name-userSearchScopePrompt scope =- txt $ case scope of- ChannelMembers _ _ -> "Search channel members:"- ChannelNonMembers _ _ -> "Search users:"- AllUsers Nothing -> "Search users:"- AllUsers (Just _) -> "Search team members:"--userSearchScopeNoResults :: UserSearchScope -> Widget Name-userSearchScopeNoResults scope =- txt $ case scope of- ChannelMembers _ _ -> "No users in channel."- ChannelNonMembers _ _ -> "All users in your team are already in this channel."- AllUsers _ -> "No users found."--userSearchScopeHeader :: UserSearchScope -> Widget Name-userSearchScopeHeader scope =- txt $ case scope of- ChannelMembers {} -> "Channel Members"- ChannelNonMembers {} -> "Invite Users to Channel"- AllUsers Nothing -> "Users On This Server"- AllUsers (Just _) -> "Users In My Team"--renderUser :: Text -> Bool -> UserInfo -> Widget Name-renderUser myUName foc ui =- (if foc then forceAttr L.listSelectedFocusedAttr else id) $- vLimit 2 $- padRight Max $- hBox $ (padRight (Pad 1) $ colorUsername myUName (ui^.uiName) (T.singleton $ userSigilFromInfo ui))- : (hLimit usernameWidth $ padRight Max $ colorUsername myUName (ui^.uiName) (ui^.uiName))- : extras- where- sanitize = T.strip . T.replace "\t" " "- usernameWidth = 20- extras = padRight (Pad 1) <$> catMaybes [mFullname, mNickname, mEmail]- mFullname = if (not (T.null (ui^.uiFirstName)) || not (T.null (ui^.uiLastName)))- then Just $ txt $ (sanitize $ ui^.uiFirstName) <> " " <> (sanitize $ ui^.uiLastName)- else Nothing- mNickname = case ui^.uiNickName of- Just n | n /= (ui^.uiName) -> Just $ txt $ "(" <> n <> ")"- _ -> Nothing- mEmail = if (T.null $ ui^.uiEmail)- then Nothing- else Just $ modifyDefAttr (`V.withURL` ("mailto:" <> ui^.uiEmail)) $- withDefAttr urlAttr (txt ("<" <> ui^.uiEmail <> ">"))
− src/Draw/Util.hs
@@ -1,100 +0,0 @@-module Draw.Util- ( withBrackets- , renderTime- , renderDate- , renderKeybindingHelp- , insertDateMarkers- , getDateFormat- , mkChannelName- , userSigilFromInfo- , multilineHeightLimit- )-where--import Prelude ()-import Prelude.MH--import Brick-import Data.List ( intersperse )-import qualified Data.Set as Set-import qualified Data.Text as T-import Lens.Micro.Platform ( to )-import Network.Mattermost.Types--import Constants ( userSigil, normalChannelSigil )-import Themes-import TimeUtils-import Types-import Types.KeyEvents-import Events.Keybindings ( getFirstDefaultBinding )---defaultTimeFormat :: Text-defaultTimeFormat = "%R"--defaultDateFormat :: Text-defaultDateFormat = "%Y-%m-%d"--multilineHeightLimit :: Int-multilineHeightLimit = 5--getTimeFormat :: ChatState -> Text-getTimeFormat st =- maybe defaultTimeFormat id (st^.csResources.crConfiguration.to configTimeFormat)--getDateFormat :: ChatState -> Text-getDateFormat st =- maybe defaultDateFormat id (st^.csResources.crConfiguration.to configDateFormat)--renderTime :: ChatState -> UTCTime -> Widget Name-renderTime st = renderUTCTime (getTimeFormat st) (st^.timeZone)--renderDate :: ChatState -> UTCTime -> Widget Name-renderDate st = renderUTCTime (getDateFormat st) (st^.timeZone)--renderUTCTime :: Text -> TimeZoneSeries -> UTCTime -> Widget a-renderUTCTime fmt tz t =- if T.null fmt- then emptyWidget- else withDefAttr timeAttr (txt $ localTimeText fmt $ asLocalTime tz t)--renderKeybindingHelp :: Text -> [KeyEvent] -> Widget Name-renderKeybindingHelp label evs =- let ppEv ev = withDefAttr clientEmphAttr $ txt (ppBinding (getFirstDefaultBinding ev))- in hBox $ (intersperse (txt "/") $ ppEv <$> evs) <> [txt (":" <> label)]---- | Generates a local matterhorn-only client message that creates a--- date marker. The server date is converted to a local time (via--- timezone), and midnight of that timezone used to generate date--- markers. Note that the actual time of the server and this client--- are still not synchronized, but no manipulations here actually use--- the client time.-insertDateMarkers :: Messages -> Text -> TimeZoneSeries -> Messages-insertDateMarkers ms datefmt tz = foldr (addMessage . dateMsg) ms dateRange- where dateRange = foldr checkDateChange Set.empty ms- checkDateChange m = let msgDay = startOfDay (Just tz) (withServerTime (m^.mDate))- in if m^.mDeleted then id else Set.insert msgDay- dateMsg d = let t = localTimeText datefmt $ asLocalTime tz d- in newMessageOfType t (C DateTransition) (ServerTime d)---withBrackets :: Widget a -> Widget a-withBrackets w = hBox [str "[", w, str "]"]--userSigilFromInfo :: UserInfo -> Char-userSigilFromInfo u = case u^.uiStatus of- Offline -> ' '- Online -> '+'- Away -> '-'- DoNotDisturb -> '×'- Other _ -> '?'--mkChannelName :: ChannelInfo -> Text-mkChannelName c = T.append sigil (c^.cdName)- where- sigil = case c^.cdType of- Private -> mempty- Ordinary -> normalChannelSigil- Group -> mempty- Direct -> userSigil- Unknown _ -> mempty
− src/Emoji.hs
@@ -1,108 +0,0 @@-{-# LANGUAGE ScopedTypeVariables #-}-module Emoji- ( EmojiCollection- , loadEmoji- , emptyEmojiCollection- , getMatchingEmoji- , matchesEmoji- )-where--import Prelude ()-import Prelude.MH--import qualified Control.Exception as E-import Control.Monad.Except-import qualified Data.Aeson as A-import qualified Data.ByteString.Lazy as BSL-import qualified Data.Foldable as F-import qualified Data.Text as T-import qualified Data.Sequence as Seq--import Network.Mattermost.Types ( Session )-import qualified Network.Mattermost.Endpoints as MM---newtype EmojiData = EmojiData (Seq.Seq T.Text)---- | The collection of all emoji names we loaded from a JSON disk file.--- You might rightly ask: why don't we use a Trie here, for efficient--- lookups? The answer is that we need infix lookups; prefix matches are--- not enough. In practice it seems not to matter that much; despite the--- O(n) search we get good enough performance that we aren't worried--- about this. If at some point this becomes an issue, other data--- structures with good infix lookup performance should be identified--- (full-text search, perhaps?).-newtype EmojiCollection = EmojiCollection [T.Text]--instance A.FromJSON EmojiData where- parseJSON = A.withArray "EmojiData" $ \v -> do- aliasVecs <- forM v $ \val ->- flip (A.withObject "EmojiData Entry") val $ \obj -> do- as <- obj A..: "aliases"- forM as $ A.withText "Alias list element" return-- return $ EmojiData $ mconcat $ F.toList aliasVecs--emptyEmojiCollection :: EmojiCollection-emptyEmojiCollection = EmojiCollection mempty---- | Load an EmojiCollection from a JSON disk file.-loadEmoji :: FilePath -> IO (Either String EmojiCollection)-loadEmoji path = runExceptT $ do- result <- lift $ E.try $ BSL.readFile path- case result of- Left (e::E.SomeException) -> throwError $ show e- Right bs -> do- EmojiData es <- ExceptT $ return $ A.eitherDecode bs- return $ EmojiCollection $ T.toLower <$> F.toList es---- | Look up matching emoji in the collection using the provided search--- string. This does a case-insensitive infix match. The search string--- may be provided with or without leading and trailing colons.-lookupEmoji :: EmojiCollection -> T.Text -> [T.Text]-lookupEmoji (EmojiCollection es) search =- filter (matchesEmoji search) es---- | Match a search string against an emoji.-matchesEmoji :: T.Text- -- ^ The search string (will be converted to lowercase and- -- colons will be removed)- -> T.Text- -- ^ The emoji string (assumed to be lowercase and without- -- leading/trailing colons)- -> Bool-matchesEmoji searchString e =- sanitizeEmojiSearch searchString `T.isInfixOf` e--sanitizeEmojiSearch :: T.Text -> T.Text-sanitizeEmojiSearch = stripColons . T.toLower . T.strip---- | Perform an emoji search against both the local EmojiCollection as--- well as the server's custom emoji. Return the results, sorted. If the--- empty string is specified, all local and all custom emoji will be--- included in the returned list.-getMatchingEmoji :: Session -> EmojiCollection -> T.Text -> IO [T.Text]-getMatchingEmoji session em rawSearchString = do- let localAlts = lookupEmoji em rawSearchString- sanitized = sanitizeEmojiSearch rawSearchString- customResult <- E.try $ case T.null sanitized of- True -> MM.mmGetListOfCustomEmoji Nothing Nothing session- False -> MM.mmSearchCustomEmoji sanitized session-- let custom = case customResult of- Left (_::E.SomeException) -> []- Right result -> result-- return $ sort $ (MM.emojiName <$> custom) <> localAlts--stripColons :: T.Text -> T.Text-stripColons t =- stripHeadColon $ stripTailColon t- where- stripHeadColon v = if ":" `T.isPrefixOf` v- then T.tail v- else v- stripTailColon v = if ":" `T.isSuffixOf` v- then T.init v- else v
− src/Events.hs
@@ -1,383 +0,0 @@-module Events- ( onEvent- , globalKeybindings- , globalKeyHandlers- )-where--import Prelude ()-import Prelude.MH--import Brick-import qualified Data.Sequence as Seq-import qualified Data.Set as Set-import qualified Data.Text as T-import qualified Graphics.Vty as Vty-import Lens.Micro.Platform ( (.=), preuse, _2, singular, _Just )--import qualified Network.Mattermost.Endpoints as MM-import Network.Mattermost.Exceptions ( mattermostErrorMessage )-import Network.Mattermost.Lenses-import Network.Mattermost.Types-import Network.Mattermost.WebSocket--import Connection-import Constants ( userSigil, normalChannelSigil )-import HelpTopics-import State.Channels-import State.Common-import State.Flagging-import State.Help-import State.Messages-import State.Reactions-import State.Users-import Types-import Types.Common--import Events.ChannelSelect-import Events.DeleteChannelConfirm-import Events.Keybindings-import Events.LeaveChannelConfirm-import Events.Main-import Events.MessageSelect-import Events.ThemeListOverlay-import Events.PostListOverlay-import Events.ShowHelp-import Events.UrlSelect-import Events.UserListOverlay-import Events.ChannelListOverlay-import Events.ReactionEmojiListOverlay-import Events.TabbedWindow-import Events.ManageAttachments-import Events.EditNotifyPrefs---onEvent :: ChatState -> BrickEvent Name MHEvent -> EventM Name (Next ChatState)-onEvent st ev = runMHEvent st $ do- onBrickEvent ev- doPendingUserFetches- doPendingUserStatusFetches--onBrickEvent :: BrickEvent Name MHEvent -> MH ()-onBrickEvent (AppEvent e) =- onAppEvent e-onBrickEvent (VtyEvent (Vty.EvKey (Vty.KChar 'l') [Vty.MCtrl])) = do- vty <- mh getVtyHandle- liftIO $ Vty.refresh vty-onBrickEvent (VtyEvent e) =- onVtyEvent e-onBrickEvent _ =- return ()--onAppEvent :: MHEvent -> MH ()-onAppEvent RefreshWebsocketEvent =- connectWebsockets-onAppEvent WebsocketDisconnect = do- csConnectionStatus .= Disconnected- disconnectChannels-onAppEvent WebsocketConnect = do- csConnectionStatus .= Connected- refreshChannelsAndUsers- refreshClientConfig- fetchVisibleIfNeeded-onAppEvent (RateLimitExceeded winSz) =- mhError $ GenericError $ T.pack $- let s = if winSz == 1 then "" else "s"- in "The server's API request rate limit was exceeded; Matterhorn will " <>- "retry the failed request in " <> show winSz <> " second" <> s <>- ". Please contact your Mattermost administrator " <>- "about API rate limiting issues."-onAppEvent RateLimitSettingsMissing =- mhError $ GenericError $- "A request was rate-limited but could not be retried due to rate " <>- "limit settings missing"-onAppEvent RequestDropped =- mhError $ GenericError $- "An API request was retried and dropped due to a rate limit. Matterhorn " <>- "may now be inconsistent with the server. Please contact your " <>- "Mattermost administrator about API rate limiting issues."-onAppEvent BGIdle =- csWorkerIsBusy .= Nothing-onAppEvent (BGBusy n) =- csWorkerIsBusy .= Just n-onAppEvent (WSEvent we) =- handleWSEvent we-onAppEvent (WSActionResponse r) =- handleWSActionResponse r-onAppEvent (RespEvent f) = f-onAppEvent (WebsocketParseError e) = do- let msg = "A websocket message could not be parsed:\n " <>- T.pack e <>- "\nPlease report this error at https://github.com/matterhorn-chat/matterhorn/issues"- mhError $ GenericError msg-onAppEvent (IEvent e) = do- handleIEvent e--handleIEvent :: InternalEvent -> MH ()-handleIEvent (DisplayError e) =- postErrorMessage' $ formatError e-handleIEvent (LoggingStarted path) =- postInfoMessage $ "Logging to " <> T.pack path-handleIEvent (LogDestination dest) =- case dest of- Nothing ->- postInfoMessage "Logging is currently disabled. Enable it with /log-start."- Just path ->- postInfoMessage $ T.pack $ "Logging to " <> path-handleIEvent (LogSnapshotSucceeded path) =- postInfoMessage $ "Log snapshot written to " <> T.pack path-handleIEvent (LoggingStopped path) =- postInfoMessage $ "Stopped logging to " <> T.pack path-handleIEvent (LogStartFailed path err) =- postErrorMessage' $ "Could not start logging to " <> T.pack path <>- ", error: " <> T.pack err-handleIEvent (LogSnapshotFailed path err) =- postErrorMessage' $ "Could not write log snapshot to " <> T.pack path <>- ", error: " <> T.pack err--formatError :: MHError -> T.Text-formatError (GenericError msg) =- msg-formatError (NoSuchChannel chan) =- T.pack $ "No such channel: " <> show chan-formatError (NoSuchUser user) =- T.pack $ "No such user: " <> show user-formatError (AmbiguousName name) =- (T.pack $ "The input " <> show name <> " matches both channels ") <>- "and users. Try using '" <> userSigil <> "' or '" <>- normalChannelSigil <> "' to disambiguate."-formatError (ServerError e) =- mattermostErrorMessage e-formatError (ClipboardError msg) =- msg-formatError (ConfigOptionMissing opt) =- T.pack $ "Config option " <> show opt <> " missing"-formatError (ProgramExecutionFailed progName logPath) =- T.pack $ "An error occurred when running " <> show progName <>- "; see " <> show logPath <> " for details."-formatError (NoSuchScript name) =- "No script named " <> name <> " was found"-formatError (NoSuchHelpTopic topic) =- let knownTopics = (" - " <>) <$> helpTopicName <$> helpTopics- in "Unknown help topic: `" <> topic <> "`. " <>- (T.unlines $ "Available topics are:" : knownTopics)-formatError (AsyncErrEvent e) =- "An unexpected error has occurred! The exception encountered was:\n " <>- T.pack (show e) <>- "\nPlease report this error at https://github.com/matterhorn-chat/matterhorn/issues"--onVtyEvent :: Vty.Event -> MH ()-onVtyEvent e = do- case e of- (Vty.EvResize _ _) ->- -- On resize, invalidate the entire rendering cache since- -- many things depend on the window size.- --- -- Note: we fall through after this because it is sometimes- -- important for modes to have their own additional logic- -- to run when a resize occurs, so we don't want to stop- -- processing here.- mh invalidateCache- _ -> return ()-- void $ handleKeyboardEvent globalKeybindings handleGlobalEvent e--handleGlobalEvent :: Vty.Event -> MH ()-handleGlobalEvent e = do- mode <- gets appMode- case mode of- Main -> onEventMain e- ShowHelp _ _ -> void $ onEventShowHelp e- ChannelSelect -> void $ onEventChannelSelect e- UrlSelect -> void $ onEventUrlSelect e- LeaveChannelConfirm -> onEventLeaveChannelConfirm e- MessageSelect -> onEventMessageSelect e- MessageSelectDeleteConfirm -> onEventMessageSelectDeleteConfirm e- DeleteChannelConfirm -> onEventDeleteChannelConfirm e- ThemeListOverlay -> onEventThemeListOverlay e- PostListOverlay _ -> onEventPostListOverlay e- UserListOverlay -> onEventUserListOverlay e- ChannelListOverlay -> onEventChannelListOverlay e- ReactionEmojiListOverlay -> onEventReactionEmojiListOverlay e- ViewMessage -> void $ handleTabbedWindowEvent (csViewedMessage.singular _Just._2) e- ManageAttachments -> onEventManageAttachments e- ManageAttachmentsBrowseFiles -> onEventManageAttachments e- EditNotifyPrefs -> void $ onEventEditNotifyPrefs e--globalKeybindings :: KeyConfig -> KeyHandlerMap-globalKeybindings = mkKeybindings globalKeyHandlers--globalKeyHandlers :: [KeyEventHandler]-globalKeyHandlers =- [ mkKb ShowHelpEvent- "Show this help screen"- (showHelpScreen mainHelpTopic)- ]--handleWSActionResponse :: WebsocketActionResponse -> MH ()-handleWSActionResponse r =- case warStatus r of- WebsocketActionStatusOK -> return ()--handleWSEvent :: WebsocketEvent -> MH ()-handleWSEvent we = do- myId <- gets myUserId- myTId <- gets myTeamId- case weEvent we of- WMPosted- | Just p <- wepPost (weData we) ->- when (wepTeamId (weData we) == Just myTId ||- wepTeamId (weData we) == Nothing) $ do- let wasMentioned = maybe False (Set.member myId) $ wepMentions (weData we)- addNewPostedMessage $ RecentPost p wasMentioned- cId <- use csCurrentChannelId- when (postChannelId p /= cId) $- showChannelInSidebar (p^.postChannelIdL) False- | otherwise -> return ()-- WMPostEdited- | Just p <- wepPost (weData we) -> do- editMessage p- cId <- use csCurrentChannelId- when (postChannelId p == cId) (updateViewed False)- when (postChannelId p /= cId) $- showChannelInSidebar (p^.postChannelIdL) False- | otherwise -> return ()-- WMPostDeleted- | Just p <- wepPost (weData we) -> do- deleteMessage p- cId <- use csCurrentChannelId- when (postChannelId p == cId) (updateViewed False)- when (postChannelId p /= cId) $- showChannelInSidebar (p^.postChannelIdL) False- | otherwise -> return ()-- WMStatusChange- | Just status <- wepStatus (weData we)- , Just uId <- wepUserId (weData we) ->- setUserStatus uId status- | otherwise -> return ()-- WMUserAdded- | Just cId <- webChannelId (weBroadcast we) ->- when (wepUserId (weData we) == Just myId &&- wepTeamId (weData we) == Just myTId) $- handleChannelInvite cId- | otherwise -> return ()-- WMNewUser- | Just uId <- wepUserId $ weData we ->- handleNewUsers (Seq.singleton uId) (return ())- | otherwise -> return ()-- WMUserRemoved- | Just cId <- wepChannelId (weData we) ->- when (webUserId (weBroadcast we) == Just myId) $- removeChannelFromState cId- | otherwise -> return ()-- WMTyping- | Just uId <- wepUserId $ weData we- , Just cId <- webChannelId (weBroadcast we) -> handleTypingUser uId cId- | otherwise -> return ()-- WMChannelDeleted- | Just cId <- wepChannelId (weData we) ->- when (webTeamId (weBroadcast we) == Just myTId) $- removeChannelFromState cId- | otherwise -> return ()-- WMDirectAdded- | Just cId <- webChannelId (weBroadcast we) -> handleChannelInvite cId- | otherwise -> return ()-- -- An 'ephemeral message' is just Mattermost's version of our- -- 'client message'. This can be a little bit wacky, e.g.- -- if the user types '/shortcuts' in the browser, we'll get- -- an ephemeral message even in MatterHorn with the browser- -- shortcuts, but it's probably a good idea to handle these- -- messages anyway.- WMEphemeralMessage- | Just p <- wepPost $ weData we -> postInfoMessage (sanitizeUserText $ p^.postMessageL)- | otherwise -> return ()-- WMPreferenceChanged- | Just prefs <- wepPreferences (weData we) ->- mapM_ applyPreferenceChange prefs- | otherwise -> return ()-- WMPreferenceDeleted- | Just pref <- wepPreferences (weData we)- , Just fps <- mapM preferenceToFlaggedPost pref ->- forM_ fps $ \f ->- updateMessageFlag (flaggedPostId f) False- | otherwise -> return ()-- WMReactionAdded- | Just r <- wepReaction (weData we)- , Just cId <- webChannelId (weBroadcast we) -> addReactions cId [r]- | otherwise -> return ()-- WMReactionRemoved- | Just r <- wepReaction (weData we)- , Just cId <- webChannelId (weBroadcast we) -> removeReaction r cId- | otherwise -> return ()-- WMChannelViewed- | Just cId <- wepChannelId $ weData we -> refreshChannelById cId- | otherwise -> return ()-- WMChannelUpdated- | Just cId <- webChannelId $ weBroadcast we -> do- mChan <- preuse (csChannel(cId))- when (isJust mChan) $ do- refreshChannelById cId- updateSidebar- | otherwise -> return ()-- WMGroupAdded- | Just cId <- webChannelId (weBroadcast we) -> handleChannelInvite cId- | otherwise -> return ()-- WMChannelMemberUpdated- | Just channelMember <- wepChannelMember $ weData we ->- when (channelMemberUserId channelMember == myId) $- updateChannelNotifyProps- (channelMemberChannelId channelMember)- (channelMemberNotifyProps channelMember)- | otherwise -> return ()-- -- We are pretty sure we should do something about these:- WMAddedToTeam -> return ()-- -- We aren't sure whether there is anything we should do about- -- these yet:- WMUpdateTeam -> return ()- WMTeamDeleted -> return ()- WMUserUpdated -> return ()- WMLeaveTeam -> return ()-- -- We deliberately ignore these events:- WMChannelCreated -> return ()- WMEmojiAdded -> return ()- WMWebRTC -> return ()- WMHello -> return ()- WMAuthenticationChallenge -> return ()- WMUserRoleUpdated -> return ()- WMPluginStatusesChanged -> return ()- WMPluginEnabled -> return ()- WMPluginDisabled -> return ()- WMUnknownEvent {} -> return ()---- | Refresh client-accessible server configuration information. This--- is usually triggered when a reconnect event for the WebSocket to the--- server occurs.-refreshClientConfig :: MH ()-refreshClientConfig = do- session <- getSession- doAsyncWith Preempt $ do- cfg <- MM.mmGetClientConfiguration (Just "old") session- return $ Just $ do- csClientConfig .= Just cfg- updateSidebar
− src/Events/ChannelListOverlay.hs
@@ -1,35 +0,0 @@-module Events.ChannelListOverlay- ( onEventChannelListOverlay- , channelListOverlayKeybindings- , channelListOverlayKeyHandlers- )-where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty--import Events.Keybindings-import State.ChannelListOverlay-import State.ListOverlay-import Types---onEventChannelListOverlay :: Vty.Event -> MH ()-onEventChannelListOverlay =- void . onEventListOverlay csChannelListOverlay channelListOverlayKeybindings---- | The keybindings we want to use while viewing a channel list overlay-channelListOverlayKeybindings :: KeyConfig -> KeyHandlerMap-channelListOverlayKeybindings = mkKeybindings channelListOverlayKeyHandlers--channelListOverlayKeyHandlers :: [KeyEventHandler]-channelListOverlayKeyHandlers =- [ mkKb CancelEvent "Close the channel search list" (exitListOverlay csChannelListOverlay)- , mkKb SearchSelectUpEvent "Select the previous channel" channelListSelectUp- , mkKb SearchSelectDownEvent "Select the next channel" channelListSelectDown- , mkKb PageDownEvent "Page down in the channel list" channelListPageDown- , mkKb PageUpEvent "Page up in the channel list" channelListPageUp- , mkKb ActivateListItemEvent "Join the selected channel" (listOverlayActivateCurrent csChannelListOverlay)- ]
− src/Events/ChannelSelect.hs
@@ -1,45 +0,0 @@-module Events.ChannelSelect where--import Prelude ()-import Prelude.MH--import Brick.Widgets.Edit ( handleEditorEvent )-import qualified Graphics.Vty as Vty--import Events.Keybindings-import State.Channels-import State.ChannelSelect-import State.Editing ( editingKeybindings )-import Types-import qualified Zipper as Z---onEventChannelSelect :: Vty.Event -> MH Bool-onEventChannelSelect =- handleKeyboardEvent channelSelectKeybindings $ \e -> do- handled <- handleKeyboardEvent (editingKeybindings (csChannelSelectState.channelSelectInput)) (const $ return ()) e- when (not handled) $- mhHandleEventLensed (csChannelSelectState.channelSelectInput) handleEditorEvent e-- updateChannelSelectMatches--channelSelectKeybindings :: KeyConfig -> KeyHandlerMap-channelSelectKeybindings = mkKeybindings channelSelectKeyHandlers--channelSelectKeyHandlers :: [KeyEventHandler]-channelSelectKeyHandlers =- [ staticKb "Switch to selected channel"- (Vty.EvKey Vty.KEnter []) $ do- matches <- use (csChannelSelectState.channelSelectMatches)- case Z.focus matches of- Nothing -> return ()- Just match -> do- setMode Main- setFocus $ channelListEntryChannelId $ matchEntry match-- , mkKb CancelEvent "Cancel channel selection" $ setMode Main- , mkKb NextChannelEvent "Select next match" channelSelectNext- , mkKb PrevChannelEvent "Select previous match" channelSelectPrevious- , mkKb NextChannelEventAlternate "Select next match (alternate binding)" channelSelectNext- , mkKb PrevChannelEventAlternate "Select previous match (alternate binding)" channelSelectPrevious- ]
− src/Events/DeleteChannelConfirm.hs
@@ -1,20 +0,0 @@-module Events.DeleteChannelConfirm where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty--import Types-import State.Channels---onEventDeleteChannelConfirm :: Vty.Event -> MH ()-onEventDeleteChannelConfirm (Vty.EvKey k []) = do- case k of- Vty.KChar c | c `elem` ("yY"::String) ->- deleteCurrentChannel- _ -> return ()- setMode Main-onEventDeleteChannelConfirm _ = do- setMode Main
− src/Events/EditNotifyPrefs.hs
@@ -1,48 +0,0 @@-module Events.EditNotifyPrefs- ( onEventEditNotifyPrefs- , editNotifyPrefsKeybindings- , editNotifyPrefsKeyHandlers- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Forms (handleFormEvent, formState)-import Data.Maybe (fromJust)-import qualified Graphics.Vty as V-import qualified Network.Mattermost.Endpoints as MM--import Lens.Micro.Platform (_Just, (.=), singular)--import Types-import Types.KeyEvents-import Events.Keybindings-import State.NotifyPrefs-import State.Async--onEventEditNotifyPrefs :: V.Event -> MH Bool-onEventEditNotifyPrefs =- let fallback e = do- form <- use (csNotifyPrefs.singular _Just)- updatedForm <- mh $ handleFormEvent (VtyEvent e) form- csNotifyPrefs .= Just updatedForm- in handleKeyboardEvent editNotifyPrefsKeybindings fallback--editNotifyPrefsKeybindings :: KeyConfig -> KeyHandlerMap-editNotifyPrefsKeybindings = mkKeybindings editNotifyPrefsKeyHandlers--editNotifyPrefsKeyHandlers :: [KeyEventHandler]-editNotifyPrefsKeyHandlers =- [ mkKb CancelEvent "Close channel notification preferences" exitEditNotifyPrefsMode- , mkKb FormSubmitEvent "Save channel notification preferences" $ do- st <- use id- let form = fromJust $ st^.csNotifyPrefs- cId = st^.csCurrentChannelId-- doAsyncChannelMM Preempt cId- (\s _ -> MM.mmUpdateChannelNotifications cId (myUserId st) (formState form) s)- (\_ _ -> Nothing)- exitEditNotifyPrefsMode- ]
− src/Events/Keybindings.hs
@@ -1,322 +0,0 @@-module Events.Keybindings- ( defaultBindings- , lookupKeybinding- , getFirstDefaultBinding-- , mkKb- , staticKb- , mkKeybindings-- , handleKeyboardEvent-- , EventHandler(..)- , KeyHandler(..)- , KeyEventHandler(..)- , KeyEventTrigger(..)- , KeyHandlerMap(..)-- -- Re-exports:- , KeyEvent (..)- , KeyConfig- , allEvents- , parseBinding- , keyEventName- , keyEventFromName-- , ensureKeybindingConsistency- )-where--import Prelude ()-import Prelude.MH--import qualified Data.Text as T-import qualified Data.Map.Strict as M-import qualified Graphics.Vty as Vty--import Types-import Types.KeyEvents----- * Keybindings---- | An 'EventHandler' represents a event handler.-data EventHandler =- EH { ehDescription :: Text- -- ^ The description of this handler's behavior.- , ehAction :: MH ()- -- ^ The action to take when this handler is invoked.- }---- | A trigger for a key event.-data KeyEventTrigger =- Static Vty.Event- -- ^ The key event is always triggered by a specific key.- | ByEvent KeyEvent- -- ^ The key event is always triggered by an abstract key event (and- -- thus configured to be bound to specific key(s) in the KeyConfig).- deriving (Show, Eq, Ord)---- | A handler for an abstract key event.-data KeyEventHandler =- KEH { kehHandler :: EventHandler- -- ^ The handler to invoke.- , kehEventTrigger :: KeyEventTrigger- -- ^ The trigger for the handler.- }---- | A handler for a specific key.-data KeyHandler =- KH { khHandler :: KeyEventHandler- -- ^ The handler to invoke.- , khKey :: Vty.Event- -- ^ The specific key that should trigger this handler.- }--newtype KeyHandlerMap = KeyHandlerMap (M.Map Vty.Event KeyHandler)---- | Find a keybinding that matches a Vty Event-lookupKeybinding :: Vty.Event -> KeyHandlerMap -> Maybe KeyHandler-lookupKeybinding e (KeyHandlerMap m) = M.lookup e m---- | Handle a keyboard event by looking it up in a map of bindings and--- invoking the matching binding's handler. If no match can be found,--- invoke a fallback action instead. Return True if the key event was--- handled with a matching binding; False if not (the fallback case).-handleKeyboardEvent :: (KeyConfig -> KeyHandlerMap)- -- ^ The function to build a key handler map from a- -- key configuration.- -> (Vty.Event -> MH ())- -- ^ The fallback action to invoke if no matching- -- binding can be found.- -> Vty.Event- -- ^ The event to handle.- -> MH Bool-handleKeyboardEvent mkKeyMap fallthrough e = do- conf <- use (csResources.crConfiguration)- let keyMap = mkKeyMap (configUserKeys conf)- case lookupKeybinding e keyMap of- Just kh -> (ehAction $ kehHandler $ khHandler kh) >> return True- Nothing -> fallthrough e >> return False--mkHandler :: Text -> MH () -> EventHandler-mkHandler msg action =- EH { ehDescription = msg- , ehAction = action- }--mkKb :: KeyEvent -> Text -> MH () -> KeyEventHandler-mkKb ev msg action =- KEH { kehHandler = mkHandler msg action- , kehEventTrigger = ByEvent ev- }--keyHandlerFromConfig :: KeyConfig -> KeyEventHandler -> [KeyHandler]-keyHandlerFromConfig conf eh =- case kehEventTrigger eh of- Static key ->- [ KH eh key ]- ByEvent ev ->- [ KH eh (bindingToEvent b) | b <- allBindings ]- where allBindings | Just (BindingList ks) <- M.lookup ev conf = ks- | Just Unbound <- M.lookup ev conf = []- | otherwise = defaultBindings ev--staticKb :: Text -> Vty.Event -> MH () -> KeyEventHandler-staticKb msg event action =- KEH { kehHandler = mkHandler msg action- , kehEventTrigger = Static event- }--mkKeybindings :: [KeyEventHandler] -> KeyConfig -> KeyHandlerMap-mkKeybindings ks conf = KeyHandlerMap $ M.fromList pairs- where- pairs = mkPair <$> handlers- mkPair h = (khKey h, h)- handlers = concat $ keyHandlerFromConfig conf <$> ks--bindingToEvent :: Binding -> Vty.Event-bindingToEvent binding =- Vty.EvKey (kbKey binding) (kbMods binding)--getFirstDefaultBinding :: KeyEvent -> Binding-getFirstDefaultBinding ev =- case defaultBindings ev of- [] -> error $ "BUG: event " <> show ev <> " has no default bindings!"- (b:_) -> b--defaultBindings :: KeyEvent -> [Binding]-defaultBindings ev =- let meta binding = binding { kbMods = Vty.MMeta : kbMods binding }- ctrl binding = binding { kbMods = Vty.MCtrl : kbMods binding }- shift binding = binding { kbMods = Vty.MShift : kbMods binding }- kb k = Binding { kbMods = [], kbKey = k }- key c = Binding { kbMods = [], kbKey = Vty.KChar c }- fn n = Binding { kbMods = [], kbKey = Vty.KFun n }- in case ev of- VtyRefreshEvent -> [ ctrl (key 'l') ]- ShowHelpEvent -> [ fn 1 ]- EnterSelectModeEvent -> [ ctrl (key 's') ]- ReplyRecentEvent -> [ ctrl (key 'r') ]- ToggleMessagePreviewEvent -> [ meta (key 'p') ]- InvokeEditorEvent -> [ meta (key 'k') ]- EnterFastSelectModeEvent -> [ ctrl (key 'g') ]- QuitEvent -> [ ctrl (key 'q') ]- NextChannelEvent -> [ ctrl (key 'n') ]- PrevChannelEvent -> [ ctrl (key 'p') ]- NextChannelEventAlternate -> [ kb Vty.KDown ]- PrevChannelEventAlternate -> [ kb Vty.KUp ]- NextUnreadChannelEvent -> [ meta (key 'a') ]- ShowAttachmentListEvent -> [ ctrl (key 'x') ]- NextUnreadUserOrChannelEvent -> [ ]- LastChannelEvent -> [ meta (key 's') ]- EnterOpenURLModeEvent -> [ ctrl (key 'o') ]- ClearUnreadEvent -> [ meta (key 'l') ]- ToggleMultiLineEvent -> [ meta (key 'e') ]- EnterFlaggedPostsEvent -> [ meta (key '8') ]- ToggleChannelListVisibleEvent -> [ fn 2 ]- SelectNextTabEvent -> [ key '\t' ]- SelectPreviousTabEvent -> [ kb Vty.KBackTab ]- LoadMoreEvent -> [ ctrl (key 'b') ]- ScrollUpEvent -> [ kb Vty.KUp ]- ScrollDownEvent -> [ kb Vty.KDown ]- ScrollLeftEvent -> [ kb Vty.KLeft ]- ScrollRightEvent -> [ kb Vty.KRight ]- PageUpEvent -> [ kb Vty.KPageUp ]- PageDownEvent -> [ kb Vty.KPageDown ]- PageLeftEvent -> [ shift (kb Vty.KLeft) ]- PageRightEvent -> [ shift (kb Vty.KRight) ]- ScrollTopEvent -> [ kb Vty.KHome ]- ScrollBottomEvent -> [ kb Vty.KEnd ]- SelectOldestMessageEvent -> [ shift (kb Vty.KHome) ]- SelectUpEvent -> [ key 'k', kb Vty.KUp ]- SelectDownEvent -> [ key 'j', kb Vty.KDown ]- ActivateListItemEvent -> [ kb Vty.KEnter ]- SearchSelectUpEvent -> [ ctrl (key 'p'), kb Vty.KUp ]- SearchSelectDownEvent -> [ ctrl (key 'n'), kb Vty.KDown ]- ViewMessageEvent -> [ key 'v' ]- FillGapEvent -> [ kb Vty.KEnter ]- FlagMessageEvent -> [ key 'f' ]- PinMessageEvent -> [ key 'p' ]- YankMessageEvent -> [ key 'y' ]- YankWholeMessageEvent -> [ key 'Y' ]- DeleteMessageEvent -> [ key 'd' ]- EditMessageEvent -> [ key 'e' ]- ReplyMessageEvent -> [ key 'r' ]- ReactToMessageEvent -> [ key 'a' ]- OpenMessageURLEvent -> [ key 'o' ]- AttachmentListAddEvent -> [ key 'a' ]- AttachmentListDeleteEvent -> [ key 'd' ]- AttachmentOpenEvent -> [ key 'o' ]- CancelEvent -> [ kb Vty.KEsc, ctrl (key 'c') ]- EditorBolEvent -> [ ctrl (key 'a') ]- EditorEolEvent -> [ ctrl (key 'e') ]- EditorTransposeCharsEvent -> [ ctrl (key 't') ]- EditorDeleteCharacter -> [ ctrl (key 'd') ]- EditorKillToBolEvent -> [ ctrl (key 'u') ]- EditorKillToEolEvent -> [ ctrl (key 'k') ]- EditorPrevCharEvent -> [ ctrl (key 'b') ]- EditorNextCharEvent -> [ ctrl (key 'f') ]- EditorPrevWordEvent -> [ meta (key 'b') ]- EditorNextWordEvent -> [ meta (key 'f') ]- EditorDeleteNextWordEvent -> [ meta (key 'd') ]- EditorDeletePrevWordEvent -> [ ctrl (key 'w'), meta (kb Vty.KBS) ]- EditorHomeEvent -> [ kb Vty.KHome ]- EditorEndEvent -> [ kb Vty.KEnd ]- EditorYankEvent -> [ ctrl (key 'y') ]- FormSubmitEvent -> [ kb Vty.KEnter ]---- | Given a configuration, we want to check it for internal consistency--- (i.e. that a given keybinding isn't associated with multiple events--- which both need to get generated in the same UI mode) and also for--- basic usability (i.e. we shouldn't be binding events which can appear--- in the main UI to a key like @e@, which would prevent us from being--- able to type messages containing an @e@ in them!-ensureKeybindingConsistency :: KeyConfig -> [(String, KeyConfig -> KeyHandlerMap)] -> Either String ()-ensureKeybindingConsistency kc modeMaps = mapM_ checkGroup allBindings- where- -- This is a list of lists, grouped by keybinding, of all the- -- keybinding/event associations that are going to be used with the- -- provided key configuration.- allBindings = groupWith fst $ concat- [ case M.lookup ev kc of- Nothing -> zip (defaultBindings ev) (repeat (False, ev))- Just (BindingList bs) -> zip bs (repeat (True, ev))- Just Unbound -> []- | ev <- allEvents- ]-- -- The invariant here is that each call to checkGroup is made with a- -- list where the first element of every list is the same binding.- -- The Bool value in these is True if the event was associated with- -- the binding by the user, and False if it's a Matterhorn default.- checkGroup :: [(Binding, (Bool, KeyEvent))] -> Either String ()- checkGroup [] = error "[ensureKeybindingConsistency: unreachable]"- checkGroup evs@((b, _):_) = do-- -- We find out which modes an event can be used in and then invert- -- the map, so this is a map from mode to the events contains- -- which are bound by the binding included above.- let modesFor :: M.Map String [(Bool, KeyEvent)]- modesFor = M.unionsWith (++)- [ M.fromList [ (m, [(i, ev)]) | m <- modeMap ev ]- | (_, (i, ev)) <- evs- ]-- -- If there is ever a situation where the same key is bound to two- -- events which can appear in the same mode, then we want to throw- -- an error, and also be informative about why. It is still okay- -- to bind the same key to two events, so long as those events- -- never appear in the same UI mode.- forM_ (M.assocs modesFor) $ \ (_, vs) ->- when (length vs > 1) $- Left $ concat $- "Multiple overlapping events bound to `" :- T.unpack (ppBinding b) :- "`:\n" :- concat [ [ " - `"- , T.unpack (keyEventName ev)- , "` "- , if isFromUser- then "(via user override)"- else "(matterhorn default)"- , "\n"- ]- | (isFromUser, ev) <- vs- ]-- -- Check for overlap a set of built-in keybindings when we're in a- -- mode where the user is typing. (These are perfectly fine when- -- we're in other modes.)- when ("main" `M.member` modesFor && isBareBinding b) $ do- Left $ concat $- [ "The keybinding `"- , T.unpack (ppBinding b)- , "` is bound to the "- , case map (ppEvent . snd . snd) evs of- [] -> error "unreachable"- [e] -> "event " ++ e- es -> "events " ++ intercalate " and " es- , "\n"- , "This is probably not what you want, as it will interfere "- , "with the ability to write messages!\n"- ]-- -- Events get some nice formatting!- ppEvent ev = "`" ++ T.unpack (keyEventName ev) ++ "`"-- -- This check should get more nuanced, but as a first approximation,- -- we shouldn't bind to any bare character key in the main mode.- isBareBinding (Binding [] (Vty.KChar {})) = True- isBareBinding _ = False-- -- We generate the which-events-are-valid-in-which-modes map from- -- our actual keybinding set, so this should never get out of date.- modeMap :: KeyEvent -> [String]- modeMap ev =- let matches kh = ByEvent ev == (kehEventTrigger $ khHandler kh)- in [ mode- | (mode, mkBindings) <- modeMaps- , let KeyHandlerMap m = mkBindings kc- in not $ null $ M.filter matches m- ]
− src/Events/LeaveChannelConfirm.hs
@@ -1,19 +0,0 @@-module Events.LeaveChannelConfirm where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty--import State.Channels-import Types---onEventLeaveChannelConfirm :: Vty.Event -> MH ()-onEventLeaveChannelConfirm (Vty.EvKey k []) = do- case k of- Vty.KChar c | c `elem` ("yY"::String) ->- leaveCurrentChannel- _ -> return ()- setMode Main-onEventLeaveChannelConfirm _ = return ()
− src/Events/Main.hs
@@ -1,148 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-module Events.Main where--import Prelude ()-import Prelude.MH--import Brick.Widgets.Edit-import qualified Graphics.Vty as Vty--import Command-import Events.Keybindings-import State.Attachments-import State.ChannelSelect-import State.Channels-import State.Editing-import State.MessageSelect-import State.PostListOverlay ( enterFlaggedPostListMode )-import State.UrlSelect-import Types---onEventMain :: Vty.Event -> MH ()-onEventMain =- void . handleKeyboardEvent mainKeybindings (\ ev -> do- resetReturnChannel- case ev of- (Vty.EvPaste bytes) -> handlePaste bytes- _ -> handleEditingInput ev- )--mainKeybindings :: KeyConfig -> KeyHandlerMap-mainKeybindings = mkKeybindings mainKeyHandlers--mainKeyHandlers :: [KeyEventHandler]-mainKeyHandlers =- [ mkKb EnterSelectModeEvent- "Select a message to edit/reply/delete"- beginMessageSelect-- , mkKb ReplyRecentEvent- "Reply to the most recent message"- replyToLatestMessage-- , mkKb ToggleMessagePreviewEvent "Toggle message preview"- toggleMessagePreview-- , mkKb ToggleChannelListVisibleEvent "Toggle channel list visibility"- toggleChannelListVisibility-- , mkKb- InvokeEditorEvent- "Invoke `$EDITOR` to edit the current message"- invokeExternalEditor-- , mkKb- EnterFastSelectModeEvent- "Enter fast channel selection mode"- beginChannelSelect-- , mkKb- QuitEvent- "Quit"- requestQuit-- , staticKb "Tab-complete forward"- (Vty.EvKey (Vty.KChar '\t') []) $- tabComplete Forwards-- , staticKb "Tab-complete backward"- (Vty.EvKey (Vty.KBackTab) []) $- tabComplete Backwards-- , mkKb- ScrollUpEvent- "Scroll up in the channel input history" $ do- -- Up in multiline mode does the usual thing; otherwise we- -- navigate the history.- isMultiline <- use (csEditState.cedEphemeral.eesMultiline)- case isMultiline of- True -> mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent- (Vty.EvKey Vty.KUp [])- False -> channelHistoryBackward-- , mkKb- ScrollDownEvent- "Scroll down in the channel input history" $ do- -- Down in multiline mode does the usual thing; otherwise- -- we navigate the history.- isMultiline <- use (csEditState.cedEphemeral.eesMultiline)- case isMultiline of- True -> mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent- (Vty.EvKey Vty.KDown [])- False -> channelHistoryForward-- , mkKb PageUpEvent "Page up in the channel message list (enters message select mode)" $ do- beginMessageSelect-- , mkKb SelectOldestMessageEvent "Scroll to top of channel message list" $ do- beginMessageSelect- messageSelectFirst-- , mkKb NextChannelEvent "Change to the next channel in the channel list"- nextChannel-- , mkKb PrevChannelEvent "Change to the previous channel in the channel list"- prevChannel-- , mkKb NextUnreadChannelEvent "Change to the next channel with unread messages or return to the channel marked '~'"- nextUnreadChannel-- , mkKb ShowAttachmentListEvent "Show the attachment list"- showAttachmentList-- , mkKb NextUnreadUserOrChannelEvent- "Change to the next channel with unread messages preferring direct messages"- nextUnreadUserOrChannel-- , mkKb LastChannelEvent "Change to the most recently-focused channel"- recentChannel-- , staticKb "Send the current message"- (Vty.EvKey Vty.KEnter []) $ do- isMultiline <- use (csEditState.cedEphemeral.eesMultiline)- case isMultiline of- -- Normally, this event causes the current message to- -- be sent. But in multiline mode we want to insert a- -- newline instead.- True -> handleEditingInput (Vty.EvKey Vty.KEnter [])- False -> do- cId <- use csCurrentChannelId- content <- getEditorContent- handleInputSubmission cId content-- , mkKb EnterOpenURLModeEvent "Select and open a URL posted to the current channel"- startUrlSelect-- , mkKb ClearUnreadEvent "Clear the current channel's unread / edited indicators" $- clearChannelUnreadStatus =<< use csCurrentChannelId-- , mkKb ToggleMultiLineEvent "Toggle multi-line message compose mode"- toggleMultilineEditing-- , mkKb CancelEvent "Cancel autocomplete, message reply, or edit, in that order"- cancelAutocompleteOrReplyOrEdit-- , mkKb EnterFlaggedPostsEvent "View currently flagged posts"- enterFlaggedPostListMode- ]
− src/Events/ManageAttachments.hs
@@ -1,182 +0,0 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE ScopedTypeVariables #-}-module Events.ManageAttachments- ( onEventManageAttachments- , attachmentListKeybindings- , attachmentBrowseKeyHandlers- , attachmentBrowseKeybindings- , attachmentListKeyHandlers- )-where--import Prelude ()-import Prelude.MH--import qualified Control.Exception as E-import qualified Brick.Widgets.FileBrowser as FB-import qualified Brick.Widgets.List as L-import qualified Data.ByteString as BS-import qualified Data.Text as T-import qualified Data.Vector as Vector-import qualified Graphics.Vty as V-import Lens.Micro.Platform ( (?=), (%=), to )--import Types-import Types.KeyEvents-import Events.Keybindings-import State.Attachments-import State.Common---onEventManageAttachments :: V.Event -> MH ()-onEventManageAttachments e = do- mode <- gets appMode- case mode of- ManageAttachments -> void $ onEventAttachmentList e- ManageAttachmentsBrowseFiles -> onEventBrowseFile e- _ -> error "BUG: onEventManageAttachments called in invalid mode"--onEventAttachmentList :: V.Event -> MH Bool-onEventAttachmentList =- handleKeyboardEvent attachmentListKeybindings $- mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent--attachmentListKeybindings :: KeyConfig -> KeyHandlerMap-attachmentListKeybindings = mkKeybindings attachmentListKeyHandlers--attachmentListKeyHandlers :: [KeyEventHandler]-attachmentListKeyHandlers =- [ mkKb CancelEvent "Close attachment list"- (setMode Main)- , mkKb SelectUpEvent "Move cursor up" $- mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent (V.EvKey V.KUp [])- , mkKb SelectDownEvent "Move cursor down" $- mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent (V.EvKey V.KDown [])- , mkKb AttachmentListAddEvent "Add a new attachment to the attachment list"- showAttachmentFileBrowser- , mkKb AttachmentOpenEvent "Open the selected attachment using the URL open command"- openSelectedAttachment- , mkKb AttachmentListDeleteEvent "Delete the selected attachment from the attachment list"- deleteSelectedAttachment- ]--attachmentBrowseKeybindings :: KeyConfig -> KeyHandlerMap-attachmentBrowseKeybindings = mkKeybindings attachmentBrowseKeyHandlers--attachmentBrowseKeyHandlers :: [KeyEventHandler]-attachmentBrowseKeyHandlers =- [ mkKb CancelEvent "Cancel attachment file browse"- cancelAttachmentBrowse- , mkKb AttachmentOpenEvent "Open the selected file using the URL open command"- openSelectedBrowserEntry- ]--withFileBrowser :: ((FB.FileBrowser Name) -> MH ()) -> MH ()-withFileBrowser f = do- use (csEditState.cedFileBrowser) >>= \case- Nothing -> do- -- The widget has not been created yet. This should- -- normally not occur, because the ManageAttachments- -- events should not fire when there is no FileBrowser- -- Widget active to cause Brick to generate these events.- -- This could therefore be implemented as an `error "BUG:- -- ..."` handler, but the more benign approach is to- -- simply create an available FileBrowser at this stage.- new_b <- liftIO $ FB.newFileBrowser FB.selectNonDirectories AttachmentFileBrowser Nothing- csEditState.cedFileBrowser ?= new_b- f new_b- Just b -> f b--openSelectedAttachment :: MH ()-openSelectedAttachment = do- cur <- use (csEditState.cedAttachmentList.to L.listSelectedElement)- case cur of- Nothing -> return ()- Just (_, entry) -> void $ openURL (OpenLocalFile $ FB.fileInfoFilePath $- attachmentDataFileInfo entry)--openSelectedBrowserEntry :: MH ()-openSelectedBrowserEntry = withFileBrowser $ \b ->- case FB.fileBrowserCursor b of- Nothing -> return ()- Just entry -> void $ openURL (OpenLocalFile $ FB.fileInfoFilePath entry)--onEventBrowseFile :: V.Event -> MH ()-onEventBrowseFile e = do- withFileBrowser $ \b -> do- case FB.fileBrowserIsSearching b of- False ->- void $ handleKeyboardEvent attachmentBrowseKeybindings handleFileBrowserEvent e- True ->- handleFileBrowserEvent e-- -- n.b. the FileBrowser may have been updated above, so re-acquire it- withFileBrowser $ \b -> do- case FB.fileBrowserException b of- Nothing -> return ()- Just ex -> do- mhLog LogError $ T.pack $ "FileBrowser exception: " <> show ex--cancelAttachmentBrowse :: MH ()-cancelAttachmentBrowse = do- es <- use (csEditState.cedAttachmentList.L.listElementsL)- case length es of- 0 -> setMode Main- _ -> setMode ManageAttachments--handleFileBrowserEvent :: V.Event -> MH ()-handleFileBrowserEvent e = do- let fbHandle ev = sequence . (fmap (FB.handleFileBrowserEvent ev))- mhHandleEventLensed (csEditState.cedFileBrowser) fbHandle e-- withFileBrowser $ \b -> do- -- TODO: Check file browser exception state- let entries = FB.fileBrowserSelection b- forM_ entries $ \entry -> do- -- Is the entry already present? If so, ignore the selection.- es <- use (csEditState.cedAttachmentList.L.listElementsL)- let matches = (== (FB.fileInfoFilePath entry)) .- FB.fileInfoFilePath .- attachmentDataFileInfo- case Vector.find matches es of- Just _ -> return ()- Nothing -> do- let path = FB.fileInfoFilePath entry- readResult <- liftIO $ E.try $ BS.readFile path- case readResult of- Left (_::E.SomeException) ->- -- TODO: report the error- return ()- Right bytes -> do- let a = AttachmentData { attachmentDataFileInfo = entry- , attachmentDataBytes = bytes- }- oldIdx <- use (csEditState.cedAttachmentList.L.listSelectedL)- let newIdx = if Vector.null es- then Just 0- else oldIdx- csEditState.cedAttachmentList %= L.listReplace (Vector.snoc es a) newIdx-- when (not $ null entries) $ setMode Main--deleteSelectedAttachment :: MH ()-deleteSelectedAttachment = do- es <- use (csEditState.cedAttachmentList.L.listElementsL)- mSel <- use (csEditState.cedAttachmentList.to L.listSelectedElement)- case mSel of- Nothing ->- return ()- Just (pos, _) -> do- oldIdx <- use (csEditState.cedAttachmentList.L.listSelectedL)- let idx = if Vector.length es == 1- then Nothing- else case oldIdx of- Nothing -> Just 0- Just old -> if pos >= old- then Just $ pos - 1- else Just pos- csEditState.cedAttachmentList %= L.listReplace (deleteAt pos es) idx--deleteAt :: Int -> Vector.Vector a -> Vector.Vector a-deleteAt p as | p < 0 || p >= length as = as- | otherwise = Vector.take p as <> Vector.drop (p + 1) as
− src/Events/MessageSelect.hs
@@ -1,85 +0,0 @@-module Events.MessageSelect where--import Prelude ()-import Prelude.MH--import qualified Data.Text as T-import qualified Graphics.Vty as Vty--import Events.Keybindings-import State.MessageSelect-import State.ReactionEmojiListOverlay-import Types---messagesPerPageOperation :: Int-messagesPerPageOperation = 10--onEventMessageSelect :: Vty.Event -> MH ()-onEventMessageSelect =- void . handleKeyboardEvent messageSelectKeybindings (const $ return ())--onEventMessageSelectDeleteConfirm :: Vty.Event -> MH ()-onEventMessageSelectDeleteConfirm (Vty.EvKey (Vty.KChar 'y') []) = do- deleteSelectedMessage- setMode Main-onEventMessageSelectDeleteConfirm _ =- setMode Main--messageSelectKeybindings :: KeyConfig -> KeyHandlerMap-messageSelectKeybindings = mkKeybindings messageSelectKeyHandlers--messageSelectKeyHandlers :: [KeyEventHandler]-messageSelectKeyHandlers =- [ mkKb CancelEvent "Cancel message selection" $- setMode Main-- , mkKb SelectUpEvent "Select the previous message" messageSelectUp- , mkKb SelectDownEvent "Select the next message" messageSelectDown- , mkKb ScrollTopEvent "Scroll to top and select the oldest message"- messageSelectFirst- , mkKb ScrollBottomEvent "Scroll to bottom and select the latest message"- messageSelectLast- , mkKb- PageUpEvent- (T.pack $ "Move the cursor up by " <> show messagesPerPageOperation <> " messages")- (messageSelectUpBy messagesPerPageOperation)- , mkKb- PageDownEvent- (T.pack $ "Move the cursor down by " <> show messagesPerPageOperation <> " messages")- (messageSelectDownBy messagesPerPageOperation)-- , mkKb OpenMessageURLEvent "Open all URLs in the selected message"- openSelectedMessageURLs-- , mkKb ReplyMessageEvent "Begin composing a reply to the selected message"- beginReplyCompose-- , mkKb EditMessageEvent "Begin editing the selected message"- beginEditMessage-- , mkKb DeleteMessageEvent "Delete the selected message (with confirmation)"- beginConfirmDeleteSelectedMessage-- , mkKb YankMessageEvent "Copy a verbatim section or message to the clipboard"- yankSelectedMessageVerbatim-- , mkKb YankWholeMessageEvent "Copy an entire message to the clipboard"- yankSelectedMessage-- , mkKb PinMessageEvent "Toggle whether the selected message is pinned"- pinSelectedMessage-- , mkKb FlagMessageEvent "Flag the selected message"- flagSelectedMessage-- , mkKb ViewMessageEvent "View the selected message"- viewSelectedMessage-- , mkKb FillGapEvent "Fetch messages for the selected gap"- fillSelectedGap-- , mkKb ReactToMessageEvent "Post a reaction to the selected message"- enterReactionEmojiListOverlayMode-- ]
− src/Events/PostListOverlay.hs
@@ -1,28 +0,0 @@-module Events.PostListOverlay where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty--import Types-import Events.Keybindings-import State.PostListOverlay---onEventPostListOverlay :: Vty.Event -> MH ()-onEventPostListOverlay =- void . handleKeyboardEvent postListOverlayKeybindings (const $ return ())---- | The keybindings we want to use while viewing a post list overlay-postListOverlayKeybindings :: KeyConfig -> KeyHandlerMap-postListOverlayKeybindings = mkKeybindings postListOverlayKeyHandlers--postListOverlayKeyHandlers :: [KeyEventHandler]-postListOverlayKeyHandlers =- [ mkKb CancelEvent "Exit post browsing" exitPostListMode- , mkKb SelectUpEvent "Select the previous message" postListSelectUp- , mkKb SelectDownEvent "Select the next message" postListSelectDown- , mkKb FlagMessageEvent "Toggle the selected message flag" postListUnflagSelected- , mkKb ActivateListItemEvent "Jump to and select current message" postListJumpToCurrent- ]
− src/Events/ReactionEmojiListOverlay.hs
@@ -1,35 +0,0 @@-module Events.ReactionEmojiListOverlay- ( onEventReactionEmojiListOverlay- , reactionEmojiListOverlayKeybindings- , reactionEmojiListOverlayKeyHandlers- )-where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty--import Events.Keybindings-import State.ReactionEmojiListOverlay-import State.ListOverlay-import Types---onEventReactionEmojiListOverlay :: Vty.Event -> MH ()-onEventReactionEmojiListOverlay =- void . onEventListOverlay csReactionEmojiListOverlay reactionEmojiListOverlayKeybindings---- | The keybindings we want to use while viewing an emoji list overlay-reactionEmojiListOverlayKeybindings :: KeyConfig -> KeyHandlerMap-reactionEmojiListOverlayKeybindings = mkKeybindings reactionEmojiListOverlayKeyHandlers--reactionEmojiListOverlayKeyHandlers :: [KeyEventHandler]-reactionEmojiListOverlayKeyHandlers =- [ mkKb CancelEvent "Close the emoji search window" (exitListOverlay csReactionEmojiListOverlay)- , mkKb SearchSelectUpEvent "Select the previous emoji" reactionEmojiListSelectUp- , mkKb SearchSelectDownEvent "Select the next emoji" reactionEmojiListSelectDown- , mkKb PageDownEvent "Page down in the emoji list" reactionEmojiListPageDown- , mkKb PageUpEvent "Page up in the emoji list" reactionEmojiListPageUp- , mkKb ActivateListItemEvent "Post the selected emoji reaction" (listOverlayActivateCurrent csReactionEmojiListOverlay)- ]
− src/Events/ShowHelp.hs
@@ -1,44 +0,0 @@-module Events.ShowHelp where--import Prelude ()-import Prelude.MH--import Brick-import qualified Graphics.Vty as Vty--import Constants-import Events.Keybindings-import Types---onEventShowHelp :: Vty.Event -> MH Bool-onEventShowHelp =- handleKeyboardEvent helpKeybindings $ \ e -> case e of- Vty.EvKey _ _ -> popMode- _ -> return ()--helpKeybindings :: KeyConfig -> KeyHandlerMap-helpKeybindings = mkKeybindings helpKeyHandlers--helpKeyHandlers :: [KeyEventHandler]-helpKeyHandlers =- [ mkKb ScrollUpEvent "Scroll up" $- mh $ vScrollBy (viewportScroll HelpViewport) (-1)- , mkKb ScrollDownEvent "Scroll down" $- mh $ vScrollBy (viewportScroll HelpViewport) 1- , mkKb PageUpEvent "Page up" $- mh $ vScrollBy (viewportScroll HelpViewport) (-1 * pageAmount)- , mkKb PageDownEvent "Page down" $- mh $ vScrollBy (viewportScroll HelpViewport) (1 * pageAmount)- , mkKb CancelEvent "Return to the previous interface" $- popMode- , mkKb ScrollBottomEvent "Scroll to the end of the help" $- mh $ vScrollToEnd (viewportScroll HelpViewport)- , mkKb ScrollTopEvent "Scroll to the beginning of the help" $- mh $ vScrollToBeginning (viewportScroll HelpViewport)- ]--popMode :: MH ()-popMode = do- ShowHelp _ prevMode <- gets appMode- setMode prevMode
− src/Events/TabbedWindow.hs
@@ -1,56 +0,0 @@-{-# LANGUAGE RankNTypes #-}-module Events.TabbedWindow- ( handleTabbedWindowEvent- , tabbedWindowKeybindings- , tabbedWindowKeyHandlers- )-where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty-import Lens.Micro.Platform ( Lens', (.=) )--import Types-import Types.KeyEvents-import Events.Keybindings--handleTabbedWindowEvent :: (Show a, Eq a)- => Lens' ChatState (TabbedWindow a)- -> Vty.Event- -> MH Bool-handleTabbedWindowEvent target e = do- w <- use target- handleKeyboardEvent (tabbedWindowKeybindings target) (forwardEvent w) e--forwardEvent :: (Show a, Eq a)- => TabbedWindow a- -> Vty.Event- -> MH ()-forwardEvent w e = do- let cur = getCurrentTabbedWindowEntry w- tweHandleEvent cur (twValue w) e--tabbedWindowKeybindings :: (Show a, Eq a)- => Lens' ChatState (TabbedWindow a)- -> KeyConfig- -> KeyHandlerMap-tabbedWindowKeybindings target = mkKeybindings $ tabbedWindowKeyHandlers target--tabbedWindowKeyHandlers :: (Show a, Eq a)- => Lens' ChatState (TabbedWindow a)- -> [KeyEventHandler]-tabbedWindowKeyHandlers target =- [ mkKb CancelEvent "Close window" $ do- w <- use target- setMode (twReturnMode w)-- , mkKb SelectNextTabEvent "Select next tab" $ do- w' <- tabbedWindowNextTab =<< use target- target .= w'-- , mkKb SelectPreviousTabEvent "Select previous tab" $ do- w' <- tabbedWindowPreviousTab =<< use target- target .= w'- ]
− src/Events/ThemeListOverlay.hs
@@ -1,30 +0,0 @@-module Events.ThemeListOverlay where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty--import Events.Keybindings-import State.ThemeListOverlay-import State.ListOverlay-import Types---onEventThemeListOverlay :: Vty.Event -> MH ()-onEventThemeListOverlay =- void . onEventListOverlay csThemeListOverlay themeListOverlayKeybindings---- | The keybindings we want to use while viewing a user list overlay-themeListOverlayKeybindings :: KeyConfig -> KeyHandlerMap-themeListOverlayKeybindings = mkKeybindings themeListOverlayKeyHandlers--themeListOverlayKeyHandlers :: [KeyEventHandler]-themeListOverlayKeyHandlers =- [ mkKb CancelEvent "Close the theme list" (exitListOverlay csThemeListOverlay)- , mkKb SearchSelectUpEvent "Select the previous theme" themeListSelectUp- , mkKb SearchSelectDownEvent "Select the next theme" themeListSelectDown- , mkKb PageDownEvent "Page down in the theme list" themeListPageDown- , mkKb PageUpEvent "Page up in the theme list" themeListPageUp- , mkKb ActivateListItemEvent "Switch to the selected color theme" (listOverlayActivateCurrent csThemeListOverlay)- ]
− src/Events/UrlSelect.hs
@@ -1,40 +0,0 @@-module Events.UrlSelect where--import Prelude ()-import Prelude.MH--import Brick.Widgets.List-import qualified Graphics.Vty as Vty--import Events.Keybindings-import State.UrlSelect-import Types---onEventUrlSelect :: Vty.Event -> MH Bool-onEventUrlSelect =- handleKeyboardEvent urlSelectKeybindings $ \ ev ->- mhHandleEventLensed csUrlList handleListEvent ev--urlSelectKeybindings :: KeyConfig -> KeyHandlerMap-urlSelectKeybindings = mkKeybindings urlSelectKeyHandlers--urlSelectKeyHandlers :: [KeyEventHandler]-urlSelectKeyHandlers =- [ staticKb "Open the selected URL, if any"- (Vty.EvKey Vty.KEnter []) $ do- openSelectedURL- setMode Main-- , mkKb CancelEvent "Cancel URL selection" stopUrlSelect-- , mkKb SelectUpEvent "Move cursor up" $- mhHandleEventLensed csUrlList handleListEvent (Vty.EvKey Vty.KUp [])-- , mkKb SelectDownEvent "Move cursor down" $- mhHandleEventLensed csUrlList handleListEvent (Vty.EvKey Vty.KDown [])-- , staticKb "Cancel URL selection"- (Vty.EvKey (Vty.KChar 'q') []) $ stopUrlSelect-- ]
− src/Events/UserListOverlay.hs
@@ -1,30 +0,0 @@-module Events.UserListOverlay where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty--import Events.Keybindings-import State.UserListOverlay-import State.ListOverlay-import Types---onEventUserListOverlay :: Vty.Event -> MH ()-onEventUserListOverlay =- void . onEventListOverlay csUserListOverlay userListOverlayKeybindings---- | The keybindings we want to use while viewing a user list overlay-userListOverlayKeybindings :: KeyConfig -> KeyHandlerMap-userListOverlayKeybindings = mkKeybindings userListOverlayKeyHandlers--userListOverlayKeyHandlers :: [KeyEventHandler]-userListOverlayKeyHandlers =- [ mkKb CancelEvent "Close the user search list" (exitListOverlay csUserListOverlay)- , mkKb SearchSelectUpEvent "Select the previous user" userListSelectUp- , mkKb SearchSelectDownEvent "Select the next user" userListSelectDown- , mkKb PageDownEvent "Page down in the user list" userListPageDown- , mkKb PageUpEvent "Page up in the user list" userListPageUp- , mkKb ActivateListItemEvent "Interact with the selected user" (listOverlayActivateCurrent csUserListOverlay)- ]
− src/FilePaths.hs
@@ -1,167 +0,0 @@-{-# LANGUAGE TupleSections #-}-module FilePaths- ( historyFilePath- , historyFileName-- , lastRunStateFilePath- , lastRunStateFileName-- , configFileName-- , xdgName- , locateConfig- , xdgSyntaxDir- , syntaxDirName- , userEmojiJsonPath- , bundledEmojiJsonPath- , emojiJsonFilename-- , Script(..)- , locateScriptPath- , getAllScripts- )-where--import Prelude ()-import Prelude.MH--import Data.Text ( unpack )-import System.Directory ( doesFileExist- , doesDirectoryExist- , getDirectoryContents- , getPermissions- , executable- )-import System.Environment ( getExecutablePath )-import System.Environment.XDG.BaseDir ( getUserConfigFile- , getAllConfigFiles- , getUserConfigDir- )-import System.FilePath ( (</>), takeBaseName, takeDirectory, splitPath, joinPath )---xdgName :: String-xdgName = "matterhorn"--historyFileName :: FilePath-historyFileName = "history.txt"--lastRunStateFileName :: Text -> FilePath-lastRunStateFileName teamId = "last_run_state_" ++ unpack teamId ++ ".json"--configFileName :: FilePath-configFileName = "config.ini"--historyFilePath :: IO FilePath-historyFilePath = getUserConfigFile xdgName historyFileName--lastRunStateFilePath :: Text -> IO FilePath-lastRunStateFilePath teamId =- getUserConfigFile xdgName (lastRunStateFileName teamId)---- | Get the XDG path to the user-specific syntax definition directory.--- The path does not necessarily exist.-xdgSyntaxDir :: IO FilePath-xdgSyntaxDir = (</> syntaxDirName) <$> getUserConfigDir xdgName---- | Get the XDG path to the user-specific emoji JSON file. The path--- does not necessarily exist.-userEmojiJsonPath :: IO FilePath-userEmojiJsonPath = (</> emojiJsonFilename) <$> getUserConfigDir xdgName---- | Get the emoji JSON path relative to the development binary location--- or the release binary location.-bundledEmojiJsonPath :: IO FilePath-bundledEmojiJsonPath = do- selfPath <- getExecutablePath- let distDir = "dist-newstyle/"- pathBits = splitPath selfPath-- return $ if distDir `elem` pathBits- then- -- We're in development, so use the development- -- executable path to locate the emoji path in the- -- development tree.- (joinPath $ takeWhile (/= distDir) pathBits) </> emojiDirName </> emojiJsonFilename- else- -- In this case we assume the binary is being run from- -- a release, in which case the syntax directory is a- -- sibling of the executable path.- takeDirectory selfPath </> emojiDirName </> emojiJsonFilename--emojiJsonFilename :: FilePath-emojiJsonFilename = "emoji.json"--emojiDirName :: FilePath-emojiDirName = "emoji"--syntaxDirName :: FilePath-syntaxDirName = "syntax"---- | Find a specified configuration file by looking in all of the--- supported locations.-locateConfig :: FilePath -> IO (Maybe FilePath)-locateConfig filename = do- xdgLocations <- getAllConfigFiles xdgName filename- let confLocations = ["./" <> filename] ++- xdgLocations ++- ["/etc/matterhorn/" <> filename]- results <- forM confLocations $ \fp -> (fp,) <$> doesFileExist fp- return $ listToMaybe $ fst <$> filter snd results--scriptDirName :: FilePath-scriptDirName = "scripts"--data Script- = ScriptPath FilePath- | NonexecScriptPath FilePath- | ScriptNotFound- deriving (Eq, Read, Show)--toScript :: FilePath -> IO (Script)-toScript fp = do- perm <- getPermissions fp- return $ if executable perm- then ScriptPath fp- else NonexecScriptPath fp--isExecutable :: FilePath -> IO Bool-isExecutable fp = do- perm <- getPermissions fp- return (executable perm)--locateScriptPath :: FilePath -> IO Script-locateScriptPath name- | head name == '.' = return ScriptNotFound- | otherwise = do- xdgLocations <- getAllConfigFiles xdgName scriptDirName- let cmdLocations = [ xdgLoc ++ "/" ++ name- | xdgLoc <- xdgLocations- ] ++ [ "/etc/matterhorn/scripts/" <> name ]- existingFiles <- filterM doesFileExist cmdLocations- executables <- mapM toScript existingFiles- return $ case executables of- (path:_) -> path- _ -> ScriptNotFound---- | This returns a list of valid scripts, and a list of non-executable--- scripts.-getAllScripts :: IO ([FilePath], [FilePath])-getAllScripts = do- xdgLocations <- getAllConfigFiles xdgName scriptDirName- let cmdLocations = xdgLocations ++ ["/etc/matterhorn/scripts"]- let getCommands dir = do- exists <- doesDirectoryExist dir- if exists- then map ((dir ++ "/") ++) `fmap` getDirectoryContents dir- else return []- let isNotHidden f = case f of- ('.':_) -> False- [] -> False- _ -> True- allScripts <- concat `fmap` mapM getCommands cmdLocations- execs <- filterM isExecutable allScripts- nonexecs <- filterM (fmap not . isExecutable) allScripts- return ( filter isNotHidden $ map takeBaseName execs- , filter isNotHidden $ map takeBaseName nonexecs- )
− src/HelpTopics.hs
@@ -1,49 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module HelpTopics- ( helpTopics- , lookupHelpTopic- , themeHelpTopic- , mainHelpTopic- )-where--import Prelude ()-import Prelude.MH--import Types---helpTopics :: [HelpTopic]-helpTopics =- [ mainHelpTopic- , scriptHelpTopic- , themeHelpTopic- , keybindingHelpTopic- , syntaxHighlightingHelpTopic- ]--mainHelpTopic :: HelpTopic-mainHelpTopic =- HelpTopic "main" "This help page" MainHelp HelpText--scriptHelpTopic :: HelpTopic-scriptHelpTopic =- HelpTopic "scripts" "Help on available scripts" ScriptHelp ScriptHelpText--themeHelpTopic :: HelpTopic-themeHelpTopic =- HelpTopic "themes" "Help on color themes" ThemeHelp ThemeHelpText--keybindingHelpTopic :: HelpTopic-keybindingHelpTopic =- HelpTopic "keybindings" "Help on overriding keybindings"- KeybindingHelp KeybindingHelpText--syntaxHighlightingHelpTopic :: HelpTopic-syntaxHighlightingHelpTopic =- HelpTopic "syntax" "Help on syntax highlighing"- SyntaxHighlightHelp SyntaxHighlightHelpText--lookupHelpTopic :: Text -> Maybe HelpTopic-lookupHelpTopic topic =- listToMaybe $ filter ((== topic) . helpTopicName) helpTopics
− src/IOUtil.hs
@@ -1,20 +0,0 @@-module IOUtil- ( convertIOException- )-where--import Prelude ()-import Prelude.MH--import Control.Exception-import Control.Monad.Trans.Except-import System.IO.Error ( ioeGetErrorString )---convertIOException :: IO a -> ExceptT String IO a-convertIOException act = do- result <- liftIO $ (Right <$> act) `catch`- (\(e::IOError) -> return $ Left $ ioeGetErrorString e)- case result of- Left e -> throwE e- Right v -> return v
− src/InputHistory.hs
@@ -1,76 +0,0 @@-{-# LANGUAGE TemplateHaskell #-}-module InputHistory- ( InputHistory- , newHistory- , readHistory- , writeHistory- , addHistoryEntry- , getHistoryEntry- , removeChannelHistory- )-where--import Prelude ()-import Prelude.MH--import Control.Monad.Trans.Except-import qualified Data.HashMap.Strict as HM-import qualified Data.Vector as V-import Lens.Micro.Platform ( (.~), (^?), (%~), at, ix, makeLenses )-import System.Directory ( createDirectoryIfMissing )-import System.FilePath ( dropFileName )-import qualified System.IO.Strict as S-import qualified System.Posix.Files as P-import qualified System.Posix.Types as P--import Network.Mattermost.Types ( ChannelId )--import FilePaths-import IOUtil---data InputHistory =- InputHistory { _historyEntries :: HashMap ChannelId (V.Vector Text)- }- deriving (Show)--makeLenses ''InputHistory--newHistory :: InputHistory-newHistory = InputHistory mempty--removeChannelHistory :: ChannelId -> InputHistory -> InputHistory-removeChannelHistory cId ih = ih & historyEntries.at cId .~ Nothing--historyFileMode :: P.FileMode-historyFileMode = P.unionFileModes P.ownerReadMode P.ownerWriteMode--writeHistory :: InputHistory -> IO ()-writeHistory ih = do- historyFile <- historyFilePath- createDirectoryIfMissing True $ dropFileName historyFile- let entries = (\(cId, z) -> (cId, V.toList z)) <$>- (HM.toList $ ih^.historyEntries)- writeFile historyFile $ show entries- P.setFileMode historyFile historyFileMode--readHistory :: IO (Either String InputHistory)-readHistory = runExceptT $ do- contents <- convertIOException (S.readFile =<< historyFilePath)- case reads contents of- [(val, "")] -> do- let entries = (\(cId, es) -> (cId, V.fromList es)) <$> val- return $ InputHistory $ HM.fromList entries- _ -> throwE "Failed to parse history file"--addHistoryEntry :: Text -> ChannelId -> InputHistory -> InputHistory-addHistoryEntry e cId ih = ih & historyEntries.at cId %~ insertEntry- where- insertEntry Nothing = Just $ V.singleton e- insertEntry (Just v) =- Just $ V.cons e (V.filter (/= e) v)--getHistoryEntry :: ChannelId -> Int -> InputHistory -> Maybe Text-getHistoryEntry cId i ih = do- es <- ih^.historyEntries.at cId- es ^? ix i
− src/KeyMap.hs
@@ -1,28 +0,0 @@-module KeyMap- ( keybindingModeMap- )-where--import Prelude ()-import Prelude.MH--import Events.Keybindings-import Events.ChannelSelect-import Events.Main-import Events.MessageSelect-import Events.PostListOverlay-import Events.ShowHelp-import Events.UrlSelect-import Events.ManageAttachments--keybindingModeMap :: [(String, KeyConfig -> KeyHandlerMap)]-keybindingModeMap =- [ ("main", mainKeybindings)- , ("help screen", helpKeybindings)- , ("channel select", channelSelectKeybindings)- , ("url select", urlSelectKeybindings)- , ("message select", messageSelectKeybindings)- , ("post list overlay", postListOverlayKeybindings)- , ("attachment list", attachmentListKeybindings)- , ("attachment file browse", attachmentBrowseKeybindings)- ]
− src/LastRunState.hs
@@ -1,99 +0,0 @@-{-# LANGUAGE TemplateHaskell #-}-module LastRunState- ( LastRunState- , lrsHost- , lrsPort- , lrsUserId- , lrsSelectedChannelId- , writeLastRunState- , readLastRunState- , isValidLastRunState- )-where--import Prelude ()-import Prelude.MH--import Control.Monad.Trans.Except-import qualified Data.Aeson as A-import qualified Data.ByteString as BS-import qualified Data.ByteString.Lazy as LBS-import Lens.Micro.Platform ( makeLenses )-import System.Directory ( createDirectoryIfMissing )-import System.FilePath ( dropFileName )-import qualified System.Posix.Files as P-import qualified System.Posix.Types as P--import Network.Mattermost.Lenses-import Network.Mattermost.Types--import FilePaths-import IOUtil-import Types----- | Run state of the program. This is saved in a file on program exit and--- | looked up from the file on program startup.-data LastRunState = LastRunState- { _lrsHost :: Hostname -- ^ Host of the server- , _lrsPort :: Port -- ^ Post of the server- , _lrsUserId :: UserId -- ^ ID of the logged-in user- , _lrsSelectedChannelId :: ChannelId -- ^ ID of the last selected channel- }--instance A.ToJSON LastRunState where- toJSON lrs = A.object [ "host" A..= _lrsHost lrs- , "port" A..= _lrsPort lrs- , "user_id" A..= _lrsUserId lrs- , "sel_channel_id" A..= _lrsSelectedChannelId lrs- ]--instance A.FromJSON LastRunState where- parseJSON = A.withObject "LastRunState" $ \v ->- LastRunState- <$> v A..: "host"- <*> v A..: "port"- <*> v A..: "user_id"- <*> v A..: "sel_channel_id"--makeLenses ''LastRunState--toLastRunState :: ChatState -> LastRunState-toLastRunState cs = LastRunState- { _lrsHost = cs^.csResources.crConn.cdHostnameL- , _lrsPort = cs^.csResources.crConn.cdPortL- , _lrsUserId = myUserId cs- , _lrsSelectedChannelId = cs^.csCurrentChannelId- }--lastRunStateFileMode :: P.FileMode-lastRunStateFileMode = P.unionFileModes P.ownerReadMode P.ownerWriteMode---- | Writes the run state to a file. The file is specific to the current team.--- | Writes only if the current channel is an ordrinary or a private channel.-writeLastRunState :: ChatState -> IO ()-writeLastRunState cs =- when (cs^.csCurrentChannel.ccInfo.cdType `elem` [Ordinary, Private]) $ do- let runState = toLastRunState cs- tId = myTeamId cs-- lastRunStateFile <- lastRunStateFilePath $ unId $ toId tId- createDirectoryIfMissing True $ dropFileName lastRunStateFile- BS.writeFile lastRunStateFile $ LBS.toStrict $ A.encode runState- P.setFileMode lastRunStateFile lastRunStateFileMode---- | Reads the last run state from a file given the current team ID.-readLastRunState :: TeamId -> IO (Either String LastRunState)-readLastRunState tId = runExceptT $ do- contents <- convertIOException $- lastRunStateFilePath (unId $ toId tId) >>= BS.readFile- case A.eitherDecodeStrict' contents of- Right val -> return val- Left err -> throwE $ "Failed to parse lastRunState file: " ++ err---- | Checks if the given last run state is valid for the current server and user.-isValidLastRunState :: ChatResources -> User -> LastRunState -> Bool-isValidLastRunState cr me rs =- rs^.lrsHost == cr^.crConn.cdHostnameL- && rs^.lrsPort == cr^.crConn.cdPortL- && rs^.lrsUserId == me^.userIdL
− src/Login.hs
@@ -1,594 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE TemplateHaskell #-}-{-# LANGUAGE RankNTypes #-}--- | This module provides the login interface for Matterhorn.------ * Overview------ The interface provides a set of form fields for the user to use to--- enter their server information and credentials. The user enters--- this information and presses Enter, and then this module--- attempts to connect to the server. The module's main function,--- interactiveGetLoginSession, returns the result of that connection--- attempt, if any.------ * Details------ The interactiveGetLoginSession function takes the Matterhorn--- configuration's initial connection information as input. If the--- configuration provided a complete set of values needed to make a--- login attempt, this module goes ahead and immediately makes a login--- attempt before even showing the user the login form. This case is--- the case where the configuration provided all four values needed:--- server host name, port, username, and password. When the interface--- immediately makes a login attempt under these conditions, this is--- referred to as an "initial" attempt in various docstrings below.--- Otherwise, the user is prompted to fill out the form to enter any--- missing values. On pressing Enter, a login attempt is made.------ A status message about whether a connection is underway is shown in--- both cases: in the case where the user has edited the credentials and--- pressed Enter, and in the case where the original credentials--- provided to interactiveGetLoginSession caused an initial connection--- attempt.------ The "initial" login case is special because in addition to not--- showing the form, we want to ensure that the "connecting to..."--- status message that is shown is shown long enough for the user to--- see what is happening (rather than just flashing by in the case--- of a fast server connection). For this usability reason, we have--- a "startup timer" thread: the thread waits a specified number--- of milliseconds (see 'startupTimerMilliseconds' below) and then--- notifies the interface that it has timed out. If there is an initial--- connection attempt underway that succeeds *before* the timer--- fires, we wait until the timer fires before quitting the Login--- application and returning control to Matterhorn. This ensures that--- the "connecting to..." message stays on the screen long enough to not--- be jarring, and to show the user what is happening. If the connection--- fails before the timer fires, we just resume normal operation and--- show the login form so the user can intervene.-module Login- ( LoginAttempt(..)- , interactiveGetLoginSession- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.BChan-import Brick.Focus-import Brick.Forms-import Brick.Widgets.Border-import Brick.Widgets.Center-import Brick.Widgets.Edit-import Control.Concurrent ( forkIO, threadDelay )-import Control.Exception ( SomeException, catch, try )-import Data.Char (isHexDigit)-import Data.List (tails, inits)-import System.IO.Error ( catchIOError )-import qualified Data.Text as T-import Graphics.Vty hiding (mkVty)-import Lens.Micro.Platform ( (.~), Lens', makeLenses )-import qualified System.IO.Error as Err-import Network.URI ( URI(..), URIAuth(..), parseURI )--import Network.Mattermost ( ConnectionData )-import Network.Mattermost.Types.Internal ( Token(..) )-import Network.Mattermost.Types ( Session(..), User, Login(..), ConnectionPoolConfig(..)- , initConnectionData, ConnectionType(..), UserParam(..) )-import Network.Mattermost.Exceptions ( LoginFailureException(..) )-import Network.Mattermost.Endpoints ( mmGetUser, mmGetLimitedClientConfiguration, mmLogin )--import Draw.RichText-import Themes ( clientEmphAttr )-import Types ( ConnectionInfo(..)- , ciPassword, ciUsername, ciHostname, ciUrlPath- , ciPort, ciType, AuthenticationException(..)- , LogManager, LogCategory(..), ioLogWithManager- , ciAccessToken- )----- | Resource names for the login interface.-data Name =- Server- | Username- | Password- | AccessToken- deriving (Ord, Eq, Show)---- | The result of an authentication attempt.-data LoginAttempt =- AttemptFailed AuthenticationException- -- ^ The attempt failed with the corresponding error.- | AttemptSucceeded ConnectionInfo ConnectionData Session User (Maybe Text) --team- -- ^ The attempt succeeded.---- | The state of the login interface: whether a login attempt is--- currently in progress.-data LoginState =- Idle- -- ^ No login attempt is in progress.- | Connecting Bool Text- -- ^ A login attempt to the specified host is in progress. The- -- boolean flag indicates whether this login was user initiated- -- (False) or triggered immediately when starting the interface- -- (True). This "initial" flag is used to determine whether the- -- login form is shown while the connection attempt is underway.- deriving (Eq)---- | Requests that we can make to the login worker thead.-data LoginRequest =- DoLogin Bool ConnectionInfo- -- ^ Request a login using the specified connection information.- -- The boolean flag is the "initial" flag value corresponding to the- -- "Connecting" constructor flag of the "LoginState" type.---- | The messages that the login worker thread can send to the user--- interface event handler.-data LoginEvent =- StartConnect Bool Text- -- ^ A connection to the specified host has begun. The boolean- -- value is whether this was an "initial" connection attempt (see- -- LoginState).- | LoginResult LoginAttempt- -- ^ A login attempt finished with the specified result.- | StartupTimeout- -- ^ The startup timer thread fired.---- | The login application state.-data State =- State { _loginForm :: Form ConnectionInfo LoginEvent Name- , _lastAttempt :: Maybe LoginAttempt- , _currentState :: LoginState- , _reqChan :: BChan LoginRequest- , _timeoutFired :: Bool- }--makeLenses ''State---- | The HTTP connection pool settings for the login worker thread.-poolCfg :: ConnectionPoolConfig-poolCfg = ConnectionPoolConfig { cpIdleConnTimeout = 60- , cpStripesCount = 1- , cpMaxConnCount = 5- }---- | Run an IO action and convert various kinds of thrown exceptions--- into a returned AuthenticationException.-convertLoginExceptions :: IO a -> IO (Either AuthenticationException a)-convertLoginExceptions act =- (Right <$> act)- `catch` (\e -> return $ Left $ ResolveError e)- `catch` (\e -> return $ Left $ ConnectError e)- `catchIOError` (\e -> return $ Left $ AuthIOError e)- `catch` (\e -> return $ Left $ OtherAuthError e)---- | The login worker thread.-loginWorker :: (ConnectionData -> ConnectionData)- -- ^ The function used to set the logger on the- -- ConnectionData that results from a successful login- -- attempt.- -> LogManager- -- ^ The log manager used to do logging.- -> BChan LoginRequest- -- ^ The channel on which we'll await requests.- -> BChan LoginEvent- -- ^ The channel to which we'll send login attempt results.- -> IO ()-loginWorker setLogger logMgr requestChan respChan = forever $ do- req <- readBChan requestChan- case req of- DoLogin initial connInfo -> do- writeBChan respChan $ StartConnect initial $ connInfo^.ciHostname- let doLog = ioLogWithManager logMgr Nothing LogGeneral-- doLog $ "Attempting authentication to " <> connInfo^.ciHostname-- cdResult <- findConnectionData connInfo- case cdResult of- Left e ->- do writeBChan respChan $ LoginResult $ AttemptFailed $ OtherAuthError e- Right (cd_, mbTeam) -> do- let cd = setLogger cd_- token = connInfo^.ciAccessToken- case T.null token of- False -> do- let sess = Session cd $ Token $ T.unpack token-- userResult <- try $ mmGetUser UserMe sess- writeBChan respChan $ case userResult of- Left (e::SomeException) ->- LoginResult $ AttemptFailed $ OtherAuthError e- Right user ->- LoginResult $ AttemptSucceeded connInfo cd sess user mbTeam- True -> do- let login = Login { username = connInfo^.ciUsername- , password = connInfo^.ciPassword- }-- result <- convertLoginExceptions $ mmLogin cd login- case result of- Left e -> do- doLog $ "Error authenticating to " <> connInfo^.ciHostname <> ": " <> (T.pack $ show e)- writeBChan respChan $ LoginResult $ AttemptFailed e- Right (Left e) -> do- doLog $ "Error authenticating to " <> connInfo^.ciHostname <> ": " <> (T.pack $ show e)- writeBChan respChan $ LoginResult $ AttemptFailed $ LoginError e- Right (Right (sess, user)) -> do- doLog $ "Authenticated successfully to " <> connInfo^.ciHostname <> " as " <> connInfo^.ciUsername- writeBChan respChan $ LoginResult $ AttemptSucceeded connInfo cd sess user mbTeam------ | Searches prefixes of the given URL to determine Mattermost API URL--- path and team name-findConnectionData :: ConnectionInfo -> IO (Either SomeException (ConnectionData, Maybe Text))-findConnectionData connInfo = startSearch- where- components = filter (not . T.null) (T.split ('/'==) (connInfo^.ciUrlPath))-- -- the candidates list is never empty because inits never returns an- -- empty list- primary:alternatives =- reverse- [ (T.intercalate "/" l, listToMaybe r)- | (l,r) <- zip (inits components) (tails components)- ]-- tryCandidate :: (Text, Maybe Text)- -> IO (Either SomeException (ConnectionData, Maybe Text))- tryCandidate (path, team) =- do cd <- initConnectionData (connInfo^.ciHostname)- (fromIntegral (connInfo^.ciPort))- path (connInfo^.ciType) poolCfg- res <- try (mmGetLimitedClientConfiguration cd)- pure $! case res of- Left e -> Left e- Right{} -> Right (cd, team)-- -- This code prefers to report the error from the URL corresponding- -- to what the user actually provided. Errors from derived URLs are- -- lost in favor of this first error.- startSearch =- do res1 <- tryCandidate primary- case res1 of- Left e -> search e alternatives- Right cd -> pure (Right cd)-- search e [] = pure (Left e)- search e (x:xs) =- do res <- tryCandidate x- case res of- Left {} -> search e xs- Right cd -> pure (Right cd)----- | The amount of time that the startup timer thread will wait before--- firing.-startupTimerMilliseconds :: Int-startupTimerMilliseconds = 750---- | The startup timer thread.-startupTimer :: BChan LoginEvent -> IO ()-startupTimer respChan = do- threadDelay $ startupTimerMilliseconds * 1000- writeBChan respChan StartupTimeout---- | The main function of this module: interactively present a login--- interface, get the user's input, and attempt to log into the user's--- specified mattermost server.------ This always returns the final terminal state handle. If the user--- makes no login attempt, this returns Nothing. Otherwise it returns--- Just the result of the latest attempt.-interactiveGetLoginSession :: Vty- -- ^ The initial terminal state handle to use.- -> IO Vty- -- ^ An action to build a new state handle- -- if one is needed. (In practice this- -- never fires since the login app doesn't- -- use suspendAndResume, but we need it to- -- satisfy the Brick API.)- -> (ConnectionData -> ConnectionData)- -- ^ The function to set the logger on- -- connection handles.- -> LogManager- -- ^ The log manager used to do logging.- -> ConnectionInfo- -- ^ Initial connection info to use to- -- populate the login form. If the connection- -- info provided here is fully populated, an- -- initial connection attempt is made without- -- first getting the user to hit Enter.- -> IO (Maybe LoginAttempt, Vty)-interactiveGetLoginSession vty mkVty setLogger logMgr initialConfig = do- requestChan <- newBChan 10- respChan <- newBChan 10-- void $ forkIO $ loginWorker setLogger logMgr requestChan respChan- void $ forkIO $ startupTimer respChan-- let initialState = mkState initialConfig requestChan-- startState <- case (populatedConnectionInfo initialConfig) of- True -> do- writeBChan requestChan $ DoLogin True initialConfig- return $ initialState & currentState .~ Connecting True (initialConfig^.ciHostname)- False -> do- return initialState-- (finalSt, finalVty) <- customMainWithVty vty mkVty (Just respChan) app startState-- return (finalSt^.lastAttempt, finalVty)---- | Is the specified ConnectionInfo sufficiently populated for us to--- bother attempting to use it to connect?-populatedConnectionInfo :: ConnectionInfo -> Bool-populatedConnectionInfo ci =- and [ not $ T.null $ ci^.ciHostname- , or [ and [ not $ T.null $ ci^.ciUsername- , not $ T.null $ ci^.ciPassword- ]- , not $ T.null $ ci^.ciAccessToken- ]- , ci^.ciPort > 0- ]---- | Make an initial login application state.-mkState :: ConnectionInfo -> BChan LoginRequest -> State-mkState cInfo chan = state- where- state = State { _loginForm = form { formFocus = focusSetCurrent initialFocus (formFocus form)- }- , _currentState = Idle- , _lastAttempt = Nothing- , _reqChan = chan- , _timeoutFired = False- }- form = mkForm cInfo- initialFocus = if | T.null (cInfo^.ciHostname) -> Server- | T.null (cInfo^.ciUsername) -> Username- | T.null (cInfo^.ciPassword) -> Password- | otherwise -> Server--app :: App State LoginEvent Name-app = App- { appDraw = credsDraw- , appChooseCursor = showFirstCursor- , appHandleEvent = onEvent- , appStartEvent = return- , appAttrMap = const colorTheme- }--onEvent :: State -> BrickEvent Name LoginEvent -> EventM Name (Next State)-onEvent st (VtyEvent (EvKey KEsc [])) = do- halt $ st & lastAttempt .~ Nothing-onEvent st (AppEvent (StartConnect initial host)) = do- continue $ st & currentState .~ Connecting initial host- & lastAttempt .~ Nothing-onEvent st (AppEvent StartupTimeout) = do- -- If the startup timer fired and we have already succeeded, halt.- case st^.lastAttempt of- Just (AttemptSucceeded {}) -> halt st- _ -> continue $ st & timeoutFired .~ True-onEvent st (AppEvent (LoginResult attempt)) = do- let st' = st & lastAttempt .~ Just attempt- & currentState .~ Idle-- case attempt of- AttemptSucceeded {} -> do- -- If the startup timer already fired, halt. Otherwise wait- -- until that timer fires.- case st^.timeoutFired of- True -> halt st'- False -> continue st'- AttemptFailed {} -> continue st'--onEvent st (VtyEvent (EvKey KEnter [])) = do- -- Ignore the keypress if we are already attempting a connection, or- -- if have already successfully connected but are waiting on the- -- startup timer.- case st^.currentState of- Connecting {} -> return ()- Idle ->- case st^.lastAttempt of- Just (AttemptSucceeded {}) -> return ()- _ -> do- let ci = formState $ st^.loginForm- when (populatedConnectionInfo ci) $ do- liftIO $ writeBChan (st^.reqChan) $ DoLogin False ci-- continue st-onEvent st e = do- f' <- handleFormEvent e (st^.loginForm)- continue $ st & loginForm .~ f'--mkForm :: ConnectionInfo -> Form ConnectionInfo e Name-mkForm =- let label s w = padBottom (Pad 1) $- (vLimit 1 $ hLimit 22 $ str s <+> fill ' ') <+> w- above s w = hCenter (str s) <=> w- in newForm [ label "Server URL:" @@= editServer- , (above "Provide a username and password:" .- label "Username:") @@= editTextField ciUsername Username (Just 1)- , label "Password:" @@= editPasswordField ciPassword Password- , (above "Or provide a Session or Personal Access Token:" .- label "Access Token:") @@= editPasswordField ciAccessToken AccessToken- ]--serverLens :: Lens' ConnectionInfo (Text, Int, Text, ConnectionType)-serverLens f ci = fmap (\(x,y,z,w) -> ci { _ciHostname = x, _ciPort = y, _ciUrlPath = z, _ciType = w})- (f (ci^.ciHostname, ci^.ciPort, ci^.ciUrlPath, ci^.ciType))---- | Validate a server URI @hostname[:port][/path]@. Result is either an error message--- indicating why validation failed or a tuple of (hostname, port, path)-validServer :: [Text] -> Either String (Text, Int, Text, ConnectionType)-validServer ts =-- do t <- case ts of- [] -> Left "No input"- [t] -> Right t- _ -> Left "Too many lines"-- let inputWithScheme- | "://" `T.isInfixOf` t = t- | otherwise = "https://" <> t-- uri <- case parseURI (T.unpack inputWithScheme) of- Nothing -> Left "Unable to parse URI"- Just uri -> Right uri-- auth <- case uriAuthority uri of- Nothing -> Left "Missing authority part"- Just auth -> Right auth-- ty <- case uriScheme uri of- "http:" -> Right ConnectHTTP- "https:" -> Right (ConnectHTTPS True)- "https-insecure:" -> Right (ConnectHTTPS False)- _ -> Left "Unknown scheme"-- port <- case (ty, uriPort auth) of- (ConnectHTTP , "") -> Right 80- (ConnectHTTPS{}, "") -> Right 443- (_, ':':portStr)- | Just port <- readMaybe portStr -> Right port- _ -> Left "Invalid port"-- let host- | not (null (uriRegName auth))- , '[' == head (uriRegName auth)- , ']' == last (uriRegName auth)- = init (tail (uriRegName auth))- | otherwise = uriRegName auth-- if null (uriRegName auth) then Left "Missing server name" else Right ()- if null (uriQuery uri) then Right () else Left "Unexpected URI query"- if null (uriFragment uri) then Right () else Left "Unexpected URI fragment"- if null (uriUserInfo auth) then Right () else Left "Unexpected credentials"-- Right (T.pack host, port, T.pack (uriPath uri), ty)---renderServer :: (Text, Int, Text, ConnectionType) -> Text-renderServer (h,p,u,t) = scheme <> hStr <> portStr <> uStr- where- hStr- | T.all (\x -> isHexDigit x || ':'==x) h- , T.any (':'==) h = "[" <> h <> "]"- | otherwise = h-- scheme =- case t of- ConnectHTTP -> "http://"- ConnectHTTPS True -> ""- ConnectHTTPS False -> "https-insecure://"-- uStr- | T.null u = u- | otherwise = T.cons '/' (T.dropWhile ('/'==) u)-- portStr =- case t of- ConnectHTTP | p == 80 -> T.empty- ConnectHTTPS{} | p == 443 -> T.empty- _ -> T.pack (':':show p)--editServer :: ConnectionInfo -> FormFieldState ConnectionInfo e Name-editServer =- let val ts = case validServer ts of- Left{} -> Nothing- Right x-> Just x- limit = Just 1- renderTxt [""] = str "(Paste your Mattermost URL here)"- renderTxt ts = txt (T.unlines ts)- in editField serverLens Server limit renderServer val renderTxt id--errorAttr :: AttrName-errorAttr = "errorMessage"--colorTheme :: AttrMap-colorTheme = attrMap defAttr- [ (editAttr, black `on` white)- , (editFocusedAttr, black `on` yellow)- , (errorAttr, fg red)- , (focusedFormInputAttr, black `on` yellow)- , (invalidFormInputAttr, white `on` red)- , (clientEmphAttr, fg white `withStyle` bold)- ]--credsDraw :: State -> [Widget Name]-credsDraw st =- [ center $ vBox [ if shouldShowForm st then credentialsForm st else emptyWidget- , currentStateDisplay st- , lastAttemptDisplay (st^.lastAttempt)- ]- ]---- | Whether the login form should be displayed.-shouldShowForm :: State -> Bool-shouldShowForm st =- case st^.currentState of- -- If we're connecting, only show the form if the connection- -- attempt is not an initial one.- Connecting initial _ -> not initial-- -- If we're idle, we want to show the form - unless we have- -- already connected and are waiting for the startup timer to- -- fire.- Idle -> case st^.lastAttempt of- Just (AttemptSucceeded {}) -> st^.timeoutFired- _ -> True---- | The "current state" of the login process. Show a connecting status--- message if a connection is underway, or if one is already established--- and the startup timer has not fired.-currentStateDisplay :: State -> Widget Name-currentStateDisplay st =- let msg host = padTop (Pad 1) $ hCenter $- txt "Connecting to " <+> withDefAttr clientEmphAttr (txt host) <+> txt "..."- in case st^.currentState of- Idle -> case st^.lastAttempt of- Just (AttemptSucceeded ci _ _ _ _) -> msg (ci^.ciHostname)- _ -> emptyWidget- (Connecting _ host) -> msg host--lastAttemptDisplay :: Maybe LoginAttempt -> Widget Name-lastAttemptDisplay Nothing = emptyWidget-lastAttemptDisplay (Just (AttemptSucceeded {})) = emptyWidget-lastAttemptDisplay (Just (AttemptFailed e)) =- hCenter $ hLimit uiWidth $- padTop (Pad 1) $ renderError $ renderText $- "Error: " <> renderAuthError e--renderAuthError :: AuthenticationException -> Text-renderAuthError (ConnectError _) =- "Could not connect to server"-renderAuthError (ResolveError _) =- "Could not resolve server hostname"-renderAuthError (AuthIOError err)- | Err.isDoesNotExistErrorType (Err.ioeGetErrorType err) =- "Unable to connect to the network"- | otherwise = "GetAddrInfo: " <> T.pack (Err.ioeGetErrorString err)-renderAuthError (OtherAuthError e) =- T.pack $ show e-renderAuthError (LoginError (LoginFailureException msg)) =- T.pack msg--renderError :: Widget a -> Widget a-renderError = withDefAttr errorAttr--uiWidth :: Int-uiWidth = 60--credentialsForm :: State -> Widget Name-credentialsForm st =- hCenter $ hLimit uiWidth $ vLimit 15 $- border $- vBox [ renderText "Please enter your Mattermost credentials to log in."- , padTop (Pad 1) $ renderForm (st^.loginForm)- , hCenter $ renderText "Press Enter to log in or Esc to exit."- ]
− src/Main.hs
@@ -1,60 +0,0 @@-module Main where--import Prelude ()-import Prelude.MH-import qualified Data.Text.IO as T--import System.Exit ( exitFailure, exitSuccess )--import Config-import Options-import App-import Events.Keybindings ( ensureKeybindingConsistency )-import KeyMap ( keybindingModeMap )-import Draw.ShowHelp ( keybindingMarkdownTable, keybindingTextTable- , commandMarkdownTable, commandTextTable )---main :: IO ()-main = do- opts <- grabOptions-- configResult <- if optIgnoreConfig opts- then return $ Right defaultConfig- else fmap snd <$> findConfig (optConfLocation opts)-- config <- case configResult of- Left err -> do- putStrLn $ "Error loading config: " <> err- exitFailure- Right c -> return c-- let keyConfig = configUserKeys config- format = optPrintFormat opts-- printedCommands <- case optPrintCommands opts of- False -> return False- True -> do- case format of- Markdown -> T.putStrLn commandMarkdownTable- Plain -> T.putStrLn commandTextTable- return True-- printedKeybindings <- case optPrintKeybindings opts of- False -> return False- True -> do- case format of- Markdown -> T.putStrLn $ keybindingMarkdownTable keyConfig- Plain -> T.putStrLn $ keybindingTextTable keyConfig- return True-- when (printedKeybindings || printedCommands) exitSuccess-- case ensureKeybindingConsistency keyConfig keybindingModeMap of- Right () -> return ()- Left err -> do- putStrLn $ "Configuration error: " <> err- exitFailure-- finalSt <- runMatterhorn opts config- closeMatterhorn finalSt
+ src/Matterhorn/App.hs view
@@ -0,0 +1,115 @@+module Matterhorn.App+ ( runMatterhorn+ , closeMatterhorn+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Control.Monad.Trans.Except ( runExceptT )+import qualified Graphics.Vty as Vty+import Text.Aspell ( stopAspell )+import GHC.Conc (getNumProcessors, setNumCapabilities)+import System.Posix.IO ( stdInput )++import Network.Mattermost++import Matterhorn.Config+import Matterhorn.Draw+import qualified Matterhorn.Events as Events+import Matterhorn.IOUtil+import Matterhorn.InputHistory+import Matterhorn.LastRunState+import Matterhorn.Options hiding ( ShowHelp )+import Matterhorn.State.Setup+import Matterhorn.State.Setup.Threads.Logging ( shutdownLogManager )+import Matterhorn.Types+++app :: App ChatState MHEvent Name+app = App+ { appDraw = draw+ , appChooseCursor = \s cs -> case appMode s of+ Main -> showFirstCursor s cs+ ChannelSelect -> showFirstCursor s cs+ UserListOverlay -> showFirstCursor s cs+ ReactionEmojiListOverlay -> showFirstCursor s cs+ ChannelListOverlay -> showFirstCursor s cs+ ManageAttachmentsBrowseFiles -> showFirstCursor s cs+ ThemeListOverlay -> showFirstCursor s cs+ ChannelTopicWindow -> showCursorNamed ChannelTopicEditor cs+ LeaveChannelConfirm -> Nothing+ DeleteChannelConfirm -> Nothing+ MessageSelect -> Nothing+ MessageSelectDeleteConfirm -> Nothing+ PostListOverlay _ -> Nothing+ ManageAttachments -> Nothing+ ViewMessage -> Nothing+ ShowHelp _ _ -> Nothing+ UrlSelect -> Nothing+ EditNotifyPrefs -> Nothing+ , appHandleEvent = Events.onEvent+ , appStartEvent = return+ , appAttrMap = (^.csResources.crTheme)+ }++applicationMaxCPUs :: Int+applicationMaxCPUs = 2++setupCpuUsage :: Config -> IO ()+setupCpuUsage config = do+ actualNumCpus <- getNumProcessors++ let requestedCPUs = case configCpuUsagePolicy config of+ SingleCPU -> 1+ MultipleCPUs -> min applicationMaxCPUs actualNumCpus++ setNumCapabilities requestedCPUs++runMatterhorn :: Options -> Config -> IO ChatState+runMatterhorn opts config = do+ setupCpuUsage config++ let mkVty = do+ mEraseChar <- Vty.getTtyEraseChar stdInput+ let addEraseChar cfg = case mEraseChar of+ Nothing -> cfg+ Just ch -> cfg { Vty.inputMap = (Nothing, [ch], Vty.EvKey Vty.KBS []) : Vty.inputMap cfg }++ vty <- Vty.mkVty $ addEraseChar Vty.defaultConfig+ let output = Vty.outputIface vty+ Vty.setMode output Vty.BracketedPaste True+ Vty.setMode output Vty.Hyperlink $ configHyperlinkingMode config+ return vty++ (st, vty) <- setupState mkVty (optLogLocation opts) config+ finalSt <- customMain vty mkVty (Just $ st^.csResources.crEventQueue) app st++ case finalSt^.csEditState.cedSpellChecker of+ Nothing -> return ()+ Just (s, _) -> stopAspell s++ return finalSt++-- | Cleanup resources and save data for restoring on program restart.+closeMatterhorn :: ChatState -> IO ()+closeMatterhorn finalSt = do+ logIfError (mmCloseSession $ getResourceSession $ finalSt^.csResources)+ "Error in closing session"++ logIfError (writeHistory (finalSt^.csEditState.cedInputHistory))+ "Error in writing history"++ logIfError (writeLastRunState finalSt)+ "Error in writing last run state"++ shutdownLogManager $ finalSt^.csResources.crLogManager++ where+ logIfError action msg = do+ done <- runExceptT $ convertIOException $ action+ case done of+ Left err -> putStrLn $ msg <> ": " <> err+ Right _ -> return ()
+ src/Matterhorn/Clipboard.hs view
@@ -0,0 +1,31 @@+module Matterhorn.Clipboard+ ( copyToClipboard+ )+where++import Prelude ()+import Matterhorn.Prelude++import Control.Exception ( try )+import qualified Data.Text as T+import System.Hclip ( setClipboard, ClipboardException(..) )++import Matterhorn.Types+++copyToClipboard :: Text -> MH ()+copyToClipboard txt = do+ result <- liftIO (try (setClipboard (T.unpack txt)))+ case result of+ Left e -> do+ let errMsg = case e of+ UnsupportedOS _ ->+ "Matterhorn does not support yanking on this operating system."+ NoTextualData ->+ "Textual data is required to set the clipboard."+ MissingCommands cmds ->+ "Could not set clipboard due to missing one of the " <>+ "required program(s): " <> (T.pack $ show cmds)+ mhError $ ClipboardError errMsg+ Right () ->+ return ()
+ src/Matterhorn/Command.hs view
@@ -0,0 +1,338 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE OverloadedStrings #-}+module Matterhorn.Command+ ( commandList+ , dispatchCommand+ , printArgSpec+ , toggleMessagePreview+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Main ( invalidateCache )+import qualified Control.Exception as Exn+import qualified Data.Char as Char+import qualified Data.Text as T+import Lens.Micro.Platform ( (%=) )++import qualified Network.Mattermost.Endpoints as MM+import qualified Network.Mattermost.Exceptions as MM+import qualified Network.Mattermost.Types as MM++import Matterhorn.Connection ( connectWebsockets )+import Matterhorn.Constants ( userSigil, normalChannelSigil )+import Matterhorn.HelpTopics+import Matterhorn.Scripts+import Matterhorn.State.Help+import Matterhorn.State.Editing+import Matterhorn.State.Channels+import Matterhorn.State.ChannelTopicWindow+import Matterhorn.State.ChannelSelect+import Matterhorn.State.Logging+import Matterhorn.State.PostListOverlay+import Matterhorn.State.UserListOverlay+import Matterhorn.State.ChannelListOverlay+import Matterhorn.State.ThemeListOverlay+import Matterhorn.State.Messages+import Matterhorn.State.NotifyPrefs+import Matterhorn.State.Common ( postInfoMessage )+import Matterhorn.State.Users+import Matterhorn.Themes ( attrForUsername )+import Matterhorn.Types+++-- | This function skips any initial whitespace and returns the first+-- 'token' (i.e. any sequence of non-whitespace characters) as well as+-- the trailing rest of the string, after any whitespace. This is used+-- for tokenizing the first bits of command input while leaving the+-- subsequent chunks unchanged, preserving newlines and other+-- important formatting.+unwordHead :: Text -> Maybe (Text, Text)+unwordHead t =+ let t' = T.dropWhile Char.isSpace t+ (w, rs) = T.break Char.isSpace t'+ in if T.null w+ then Nothing+ else Just (w, T.dropWhile Char.isSpace rs)++printArgSpec :: CmdArgs a -> Text+printArgSpec NoArg = ""+printArgSpec (LineArg ts) = "<" <> ts <> ">"+printArgSpec (TokenArg t NoArg) = "<" <> t <> ">"+printArgSpec (UserArg rs) = "<" <> userSigil <> "user>" <> addSpace (printArgSpec rs)+printArgSpec (ChannelArg rs) = "<" <> normalChannelSigil <> "channel>" <> addSpace (printArgSpec rs)+printArgSpec (TokenArg t rs) = "<" <> t <> ">" <> addSpace (printArgSpec rs)++addSpace :: Text -> Text+addSpace "" = ""+addSpace t = " " <> t++matchArgs :: CmdArgs a -> Text -> Either Text a+matchArgs NoArg t = case unwordHead t of+ Nothing -> return ()+ Just (a, as)+ | not (T.all Char.isSpace as) -> Left ("Unexpected arguments '" <> t <> "'")+ | otherwise -> Left ("Unexpected argument '" <> a <> "'")+matchArgs (LineArg _) t = return t+matchArgs spec@(UserArg rs) t = case unwordHead t of+ Nothing -> case rs of+ NoArg -> Left ("Missing argument: " <> printArgSpec spec)+ _ -> Left ("Missing arguments: " <> printArgSpec spec)+ Just (a, as) -> (,) <$> pure a <*> matchArgs rs as+matchArgs spec@(ChannelArg rs) t = case unwordHead t of+ Nothing -> case rs of+ NoArg -> Left ("Missing argument: " <> printArgSpec spec)+ _ -> Left ("Missing arguments: " <> printArgSpec spec)+ Just (a, as) -> (,) <$> pure a <*> matchArgs rs as+matchArgs spec@(TokenArg _ rs) t = case unwordHead t of+ Nothing -> case rs of+ NoArg -> Left ("Missing argument: " <> printArgSpec spec)+ _ -> Left ("Missing arguments: " <> printArgSpec spec)+ Just (a, as) -> (,) <$> pure a <*> matchArgs rs as++commandList :: [Cmd]+commandList =+ [ Cmd "quit" "Exit Matterhorn" NoArg $ \ () -> requestQuit++ , Cmd "right" "Focus on the next channel" NoArg $ \ () ->+ nextChannel++ , Cmd "left" "Focus on the previous channel" NoArg $ \ () ->+ prevChannel++ , Cmd "create-channel" "Create a new public channel"+ (LineArg "channel name") $ \ name ->+ createOrdinaryChannel True name++ , Cmd "create-private-channel" "Create a new private channel"+ (LineArg "channel name") $ \ name ->+ createOrdinaryChannel False name++ , Cmd "delete-channel" "Delete the current channel"+ NoArg $ \ () ->+ beginCurrentChannelDeleteConfirm++ , Cmd "hide" "Hide the current DM or group channel from the channel list"+ NoArg $ \ () -> do+ hideDMChannel =<< use csCurrentChannelId++ , Cmd "reconnect" "Force a reconnection attempt to the server"+ NoArg $ \ () ->+ connectWebsockets++ , Cmd "members" "Show the current channel's members"+ NoArg $ \ () ->+ enterChannelMembersUserList++ , Cmd "leave" "Leave a normal channel or hide a DM channel" NoArg $ \ () ->+ startLeaveCurrentChannel++ , Cmd "join" "Find a channel to join" NoArg $ \ () ->+ enterChannelListOverlayMode++ , Cmd "join" "Join the specified channel" (ChannelArg NoArg) $ \(n, ()) ->+ joinChannelByName n++ , Cmd "theme" "List the available themes" NoArg $ \ () ->+ enterThemeListMode++ , Cmd "theme" "Set the color theme"+ (TokenArg "theme" NoArg) $ \ (themeName, ()) ->+ setTheme themeName++ , Cmd "topic" "Set the current channel's topic (header) interactively"+ NoArg $ \ () ->+ openChannelTopicWindow++ , Cmd "topic" "Set the current channel's topic (header)"+ (LineArg "topic") $ \ p ->+ if not (T.null p) then setChannelTopic p else return ()++ , Cmd "add-user" "Search for a user to add to the current channel"+ NoArg $ \ () ->+ enterChannelInviteUserList++ , Cmd "msg" "Search for a user to enter a private chat"+ NoArg $ \ () ->+ enterDMSearchUserList++ , Cmd "msg" "Chat with the specified user"+ (UserArg NoArg) $ \ (name, ()) ->+ changeChannelByName name++ , Cmd "username-attribute" "Display the attribute used to color the specified username"+ (UserArg NoArg) $ \ (name, ()) ->+ displayUsernameAttribute name++ , Cmd "msg" "Go to a user's channel and send the specified message or command"+ (UserArg $ LineArg "message or command") $ \ (name, msg) -> do+ withFetchedUserMaybe (UserFetchByUsername name) $ \foundUser -> do+ case foundUser of+ Just user -> createOrFocusDMChannel user $ Just $ \cId ->+ handleInputSubmission cId msg+ Nothing -> mhError $ NoSuchUser name++ , Cmd "log-start" "Begin logging debug information to the specified path"+ (TokenArg "path" NoArg) $ \ (path, ()) ->+ startLogging $ T.unpack path++ , Cmd "log-snapshot" "Dump the current debug log buffer to the specified path"+ (TokenArg "path" NoArg) $ \ (path, ()) ->+ logSnapshot $ T.unpack path++ , Cmd "log-stop" "Stop logging"+ NoArg $ \ () ->+ stopLogging++ , Cmd "log-mark" "Add a custom marker message to the Matterhorn debug log"+ (LineArg "message") $ \ markMsg ->+ mhLog LogUserMark markMsg++ , Cmd "log-status" "Show current debug logging status"+ NoArg $ \ () ->+ getLogDestination++ , Cmd "add-user" "Add a user to the current channel"+ (UserArg NoArg) $ \ (uname, ()) ->+ addUserByNameToCurrentChannel uname++ , Cmd "remove-user" "Remove a user from the current channel"+ (UserArg NoArg) $ \ (uname, ()) ->+ removeUserFromCurrentChannel uname++ , Cmd "user" "Show users to initiate a private DM chat channel"+ -- n.b. this is identical to "msg", but is provided as an+ -- alternative mental model for useability.+ NoArg $ \ () ->+ enterDMSearchUserList++ , Cmd "message-preview" "Toggle preview of the current message" NoArg $ \_ ->+ toggleMessagePreview++ , Cmd "toggle-channel-list" "Toggle channel list visibility" NoArg $ \_ ->+ toggleChannelListVisibility++ , Cmd "toggle-message-timestamps" "Toggle message timestamps" NoArg $ \_ ->+ toggleMessageTimestamps++ , Cmd "toggle-expanded-topics" "Toggle expanded channel topics" NoArg $ \_ ->+ toggleExpandedChannelTopics++ , Cmd "focus" "Focus on a channel or user"+ (ChannelArg NoArg) $ \ (name, ()) ->+ changeChannelByName name++ , Cmd "focus" "Select from available channels" NoArg $ \ () ->+ beginChannelSelect++ , Cmd "help" "Show the main help screen" NoArg $ \ _ ->+ showHelpScreen mainHelpTopic++ , Cmd "shortcuts" "Show keyboard shortcuts" NoArg $ \ _ ->+ showHelpScreen mainHelpTopic++ , Cmd "help" "Show help about a particular topic"+ (TokenArg "topic" NoArg) $ \ (topicName, ()) ->+ case lookupHelpTopic topicName of+ Nothing -> mhError $ NoSuchHelpTopic topicName+ Just topic -> showHelpScreen topic++ , Cmd "sh" "List the available shell scripts" NoArg $ \ () ->+ listScripts++ , Cmd "group-msg" "Create a group chat"+ (LineArg (userSigil <> "user [" <> userSigil <> "user ...]")) createGroupChannel++ , Cmd "sh" "Run a prewritten shell script"+ (TokenArg "script" (LineArg "message")) $ \ (script, text) -> do+ cId <- use csCurrentChannelId+ findAndRunScript cId script text++ , Cmd "flags" "Open a window of your flagged posts" NoArg $ \ () ->+ enterFlaggedPostListMode++ , Cmd "pinned-posts" "Open a window of this channel's pinned posts" NoArg $ \ () ->+ enterPinnedPostListMode++ , Cmd "search" "Search for posts with given terms" (LineArg "terms") $+ enterSearchResultPostListMode++ , Cmd "notify-prefs" "Edit the current channel's notification preferences" NoArg $ \_ ->+ enterEditNotifyPrefsMode++ ]++displayUsernameAttribute :: Text -> MH ()+displayUsernameAttribute name = do+ let an = attrForUsername trimmed+ trimmed = trimUserSigil name+ postInfoMessage $ "The attribute used for " <> userSigil <> trimmed <>+ " is " <> (attrNameToConfig an)++execMMCommand :: Text -> Text -> MH ()+execMMCommand name rest = do+ cId <- use csCurrentChannelId+ session <- getSession+ em <- use (csEditState.cedEditMode)+ tId <- gets myTeamId+ let mc = MM.MinCommand+ { MM.minComChannelId = cId+ , MM.minComCommand = "/" <> name <> " " <> rest+ , MM.minComParentId = case em of+ Replying _ p -> Just $ MM.getId p+ Editing p _ -> MM.postRootId p+ _ -> Nothing+ , MM.minComRootId = case em of+ Replying _ p -> MM.postRootId p <|> (Just $ MM.postId p)+ Editing p _ -> MM.postRootId p+ _ -> Nothing+ , MM.minComTeamId = tId+ }+ runCmd = liftIO $ do+ void $ MM.mmExecuteCommand mc session+ handleHTTP (MM.HTTPResponseException err) =+ return (Just (T.pack err))+ -- XXX: this might be a bit brittle in the future, because it+ -- assumes the shape of an error message. We might want to+ -- think about a better way of discovering this error and+ -- reporting it accordingly?+ handleCmdErr (MM.MattermostServerError err) =+ let (_, msg) = T.breakOn ": " err in+ return (Just (T.drop 2 msg))+ handleMMErr (MM.MattermostError+ { MM.mattermostErrorMessage = msg }) =+ return (Just msg)+ errMsg <- liftIO $ (runCmd >> return Nothing) `Exn.catch` handleHTTP+ `Exn.catch` handleCmdErr+ `Exn.catch` handleMMErr+ case errMsg of+ Nothing -> return ()+ Just err ->+ mhError $ GenericError ("Error running command: " <> err)++dispatchCommand :: Text -> MH ()+dispatchCommand cmd =+ case unwordHead cmd of+ Just (x, xs)+ | matchingCmds <- [ c+ | c@(Cmd name _ _ _) <- commandList+ , name == x+ ] -> go [] matchingCmds+ where go [] [] = do+ execMMCommand x xs+ go errs [] = do+ let msg = ("error running command /" <> x <> ":\n" <>+ mconcat [ " " <> e | e <- errs ])+ mhError $ GenericError msg+ go errs (Cmd _ _ spec exe : cs) =+ case matchArgs spec xs of+ Left e -> go (e:errs) cs+ Right args -> exe args+ _ -> return ()++toggleMessagePreview :: MH ()+toggleMessagePreview = do+ mh invalidateCache+ csShowMessagePreview %= not
+ src/Matterhorn/Command.hs-boot view
@@ -0,0 +1,9 @@+module Matterhorn.Command where++import Data.Text ( Text )++import Matterhorn.Types ( MH, Cmd, CmdArgs )++commandList :: [Cmd]+printArgSpec :: CmdArgs a -> Text+dispatchCommand :: Text -> MH ()
+ src/Matterhorn/Config.hs view
@@ -0,0 +1,365 @@+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE MultiWayIf #-}+module Matterhorn.Config+ ( Config(..)+ , PasswordSource(..)+ , findConfig+ , defaultConfig+ , configConnectionType+ )+where++import Prelude ()+import Matterhorn.Prelude++import Control.Monad.Trans.Except+import Control.Monad.Trans.Class ( lift )+import Data.Char ( isDigit, isAlpha )+import Data.List ( isPrefixOf )+import Data.List.Split ( splitOn )+import qualified Data.Map.Strict as M+import qualified Data.Text as T+import qualified Data.Text.IO as T+import System.Directory ( makeAbsolute, getHomeDirectory )+import System.Environment ( getExecutablePath )+import System.FilePath ( (</>), takeDirectory, splitPath, joinPath )+import System.Process ( readProcess )+import Network.Mattermost.Types (ConnectionType(..))+import Network.URI ( isIPv4address, isIPv6address )++import Matterhorn.Config.Schema+import Matterhorn.FilePaths+import Matterhorn.IOUtil+import Matterhorn.Types+import Matterhorn.Types.KeyEvents+++defaultPort :: Int+defaultPort = 443++bundledSyntaxPlaceholderName :: String+bundledSyntaxPlaceholderName = "BUNDLED_SYNTAX"++userSyntaxPlaceholderName :: String+userSyntaxPlaceholderName = "USER_SYNTAX"++defaultSkylightingPaths :: IO [FilePath]+defaultSkylightingPaths = do+ xdg <- xdgSyntaxDir+ adjacent <- getBundledSyntaxPath+ return [xdg, adjacent]++getBundledSyntaxPath :: IO FilePath+getBundledSyntaxPath = do+ selfPath <- getExecutablePath+ let distDir = "dist-newstyle/"+ pathBits = splitPath selfPath++ return $ if distDir `elem` pathBits+ then+ -- We're in development, so use the development+ -- executable path to locate the XML path in the+ -- development tree.+ (joinPath $ takeWhile (/= distDir) pathBits) </> syntaxDirName+ else+ -- In this case we assume the binary is being run from+ -- a release, in which case the syntax directory is a+ -- sibling of the executable path.+ takeDirectory selfPath </> syntaxDirName++fromIni :: IniParser Config+fromIni = do+ conf <- section "mattermost" $ do+ configUser <- fieldMbOf "user" stringField+ configHost <- fieldMbOf "host" hostField+ configTeam <- fieldMbOf "team" stringField+ configPort <- fieldDefOf "port" number (configPort defaultConfig)+ configUrlPath <- fieldMbOf "urlPath" stringField+ configChannelListWidth <- fieldDefOf "channelListWidth" number+ (configChannelListWidth defaultConfig)+ configCpuUsagePolicy <- fieldDefOf "cpuUsagePolicy" cpuUsagePolicy+ (configCpuUsagePolicy defaultConfig)+ configLogMaxBufferSize <- fieldDefOf "logMaxBufferSize" number+ (configLogMaxBufferSize defaultConfig)+ configTimeFormat <- fieldMbOf "timeFormat" stringField+ configDateFormat <- fieldMbOf "dateFormat" stringField+ configTheme <- fieldMbOf "theme" stringField+ configThemeCustomizationFile <- fieldMbOf "themeCustomizationFile" stringField+ configAspellDictionary <- fieldMbOf "aspellDictionary" stringField+ configURLOpenCommand <- fieldMbOf "urlOpenCommand" stringField+ configURLOpenCommandInteractive <- fieldFlagDef "urlOpenCommandIsInteractive" False+ configSmartBacktick <- fieldFlagDef "smartbacktick"+ (configSmartBacktick defaultConfig)+ configSmartEditing <- fieldFlagDef "smartediting"+ (configSmartEditing defaultConfig)+ configShowOlderEdits <- fieldFlagDef "showOlderEdits"+ (configShowOlderEdits defaultConfig)+ configShowBackground <- fieldDefOf "showBackgroundActivity" backgroundField+ (configShowBackground defaultConfig)+ configShowMessagePreview <- fieldFlagDef "showMessagePreview"+ (configShowMessagePreview defaultConfig)+ configShowChannelList <- fieldFlagDef "showChannelList"+ (configShowChannelList defaultConfig)+ configShowExpandedChannelTopics <- fieldFlagDef "showExpandedChannelTopics"+ (configShowExpandedChannelTopics defaultConfig)+ configShowTypingIndicator <- fieldFlagDef "showTypingIndicator"+ (configShowTypingIndicator defaultConfig)+ configEnableAspell <- fieldFlagDef "enableAspell"+ (configEnableAspell defaultConfig)+ configSyntaxDirs <- fieldDefOf "syntaxDirectories" syntaxDirsField []+ configActivityNotifyCommand <- fieldMb "activityNotifyCommand"+ configShowMessageTimestamps <- fieldFlagDef "showMessageTimestamps"+ (configShowMessageTimestamps defaultConfig)+ configActivityBell <- fieldFlagDef "activityBell"+ (configActivityBell defaultConfig)+ configHyperlinkingMode <- fieldFlagDef "hyperlinkURLs"+ (configHyperlinkingMode defaultConfig)+ configPass <- (Just . PasswordCommand <$> field "passcmd") <!>+ (Just . PasswordString <$> field "pass") <!>+ pure Nothing+ configChannelListOrientation <- fieldDefOf "channelListOrientation"+ channelListOrientationField+ (configChannelListOrientation defaultConfig)+ configToken <- (Just . TokenCommand <$> field "tokencmd") <!>+ pure Nothing+ configUnsafeUseHTTP <-+ fieldFlagDef "unsafeUseUnauthenticatedConnection" False+ configValidateServerCertificate <-+ fieldFlagDef "validateServerCertificate" True+ configDirectChannelExpirationDays <- fieldDefOf "directChannelExpirationDays" number+ (configDirectChannelExpirationDays defaultConfig)+ configDefaultAttachmentPath <- fieldMbOf "defaultAttachmentPath" filePathField++ let configAbsPath = Nothing+ configUserKeys = mempty+ return Config { .. }+ keys <- sectionMb "keybindings" $ do+ fmap (M.fromList . catMaybes) $ forM allEvents $ \ ev -> do+ kb <- fieldMbOf (keyEventName ev) parseBindingList+ case kb of+ Nothing -> return Nothing+ Just binding -> return (Just (ev, binding))+ return conf { configUserKeys = fromMaybe mempty keys }++channelListOrientationField :: Text -> Either String ChannelListOrientation+channelListOrientationField t =+ case T.toLower t of+ "left" -> return ChannelListLeft+ "right" -> return ChannelListRight+ _ -> Left $ "Invalid value for channelListOrientation: " <> show t++syntaxDirsField :: Text -> Either String [FilePath]+syntaxDirsField = listWithSeparator ":" string++validHostnameFragmentChar :: Char -> Bool+validHostnameFragmentChar c = isAlpha c || isDigit c || c == '-'++isHostnameFragment :: String -> Bool+isHostnameFragment "" = False+isHostnameFragment s = all validHostnameFragmentChar s++isHostname :: String -> Bool+isHostname "" = False+isHostname s =+ let parts@(h:_) = splitOn "." s+ in all isHostnameFragment parts && not ("-" `isPrefixOf` h)++hostField :: Text -> Either String Text+hostField t =+ let s = T.unpack t+ valid = or [ isIPv4address s+ , isIPv6address s+ , isHostname s+ ]+ in if valid+ then Right t+ else Left "Invalid 'host' value, must be a hostname or IPv4/IPv6 address"++expandTilde :: FilePath -> FilePath -> FilePath+expandTilde homeDir p =+ let parts = splitPath p+ f part | part == "~/" = homeDir <> "/"+ | otherwise = part+ in joinPath $ f <$> parts++backgroundField :: Text -> Either String BackgroundInfo+backgroundField t =+ case t of+ "Disabled" -> Right Disabled+ "Active" -> Right Active+ "ActiveCount" -> Right ActiveCount+ _ -> Left ("Invalid value " <> show t+ <> "; must be one of: Disabled, Active, ActiveCount")++cpuUsagePolicy :: Text -> Either String CPUUsagePolicy+cpuUsagePolicy t =+ case T.toLower t of+ "single" -> return SingleCPU+ "multiple" -> return MultipleCPUs+ _ -> Left $ "Invalid CPU usage policy value: " <> show t++stringField :: Text -> Either e Text+stringField t =+ case isQuoted t of+ True -> Right $ parseQuotedString t+ False -> Right t++filePathField :: Text -> Either e FilePath+filePathField t = let path = T.unpack t in Right path++parseQuotedString :: Text -> Text+parseQuotedString t =+ let body = T.drop 1 $ T.init t+ unescapeQuotes s | T.null s = s+ | "\\\"" `T.isPrefixOf` s = "\"" <> unescapeQuotes (T.drop 2 s)+ | otherwise = (T.singleton $ T.head s) <> unescapeQuotes (T.drop 1 s)+ in unescapeQuotes body++isQuoted :: Text -> Bool+isQuoted t =+ let quote = "\""+ in (quote `T.isPrefixOf` t) &&+ (quote `T.isSuffixOf` t)++defaultConfig :: Config+defaultConfig =+ Config { configAbsPath = Nothing+ , configUser = Nothing+ , configHost = Nothing+ , configTeam = Nothing+ , configPort = defaultPort+ , configUrlPath = Nothing+ , configPass = Nothing+ , configToken = Nothing+ , configTimeFormat = Nothing+ , configDateFormat = Nothing+ , configTheme = Nothing+ , configThemeCustomizationFile = Nothing+ , configSmartBacktick = True+ , configSmartEditing = True+ , configURLOpenCommand = Nothing+ , configURLOpenCommandInteractive = False+ , configActivityNotifyCommand = Nothing+ , configActivityBell = False+ , configShowMessageTimestamps = True+ , configShowBackground = Disabled+ , configShowMessagePreview = False+ , configShowChannelList = True+ , configShowExpandedChannelTopics = True+ , configEnableAspell = False+ , configAspellDictionary = Nothing+ , configUnsafeUseHTTP = False+ , configValidateServerCertificate = True+ , configChannelListWidth = 20+ , configLogMaxBufferSize = 200+ , configShowOlderEdits = True+ , configUserKeys = mempty+ , configShowTypingIndicator = False+ , configHyperlinkingMode = True+ , configSyntaxDirs = []+ , configDirectChannelExpirationDays = 7+ , configCpuUsagePolicy = MultipleCPUs+ , configDefaultAttachmentPath = Nothing+ , configChannelListOrientation = ChannelListLeft+ }++findConfig :: Maybe FilePath -> IO (Either String ([String], Config))+findConfig Nothing = runExceptT $ do+ cfg <- lift $ locateConfig configFileName+ (warns, config) <-+ case cfg of+ Nothing -> return ([], defaultConfig)+ Just path -> getConfig path+ config' <- fixupPaths config+ return (warns, config')+findConfig (Just path) =+ runExceptT $ do (warns, config) <- getConfig path+ config' <- fixupPaths config+ return (warns, config')++-- | Fix path references in the configuration:+--+-- * Rewrite the syntax directory path list with 'fixupSyntaxDirs'+-- * Expand "~" encountered in any setting that contains a path value+fixupPaths :: Config -> ExceptT String IO Config+fixupPaths initial = do+ new <- fixupSyntaxDirs initial+ homeDir <- liftIO getHomeDirectory+ let fixP = expandTilde homeDir+ fixPText = T.pack . expandTilde homeDir . T.unpack+ return $ new { configThemeCustomizationFile = fixPText <$> configThemeCustomizationFile new+ , configSyntaxDirs = fixP <$> configSyntaxDirs new+ , configURLOpenCommand = fixPText <$> configURLOpenCommand new+ , configActivityNotifyCommand = fixPText <$> configActivityNotifyCommand new+ , configDefaultAttachmentPath = fixP <$> configDefaultAttachmentPath new+ }++-- | If the configuration has no syntax directories specified (the+-- default if the user did not provide the setting), fill in the+-- list with the defaults. Otherwise replace any bundled directory+-- placeholders in the config's syntax path list.+fixupSyntaxDirs :: Config -> ExceptT String IO Config+fixupSyntaxDirs c =+ if configSyntaxDirs c == []+ then do+ dirs <- liftIO defaultSkylightingPaths+ return $ c { configSyntaxDirs = dirs }+ else do+ newDirs <- forM (configSyntaxDirs c) $ \dir ->+ if | dir == bundledSyntaxPlaceholderName -> liftIO getBundledSyntaxPath+ | dir == userSyntaxPlaceholderName -> liftIO xdgSyntaxDir+ | otherwise -> return dir++ return $ c { configSyntaxDirs = newDirs }++getConfig :: FilePath -> ExceptT String IO ([String], Config)+getConfig fp = do+ absPath <- convertIOException $ makeAbsolute fp+ t <- (convertIOException $ T.readFile absPath) `catchE`+ (\e -> throwE $ "Could not read " <> show absPath <> ": " <> e)++ -- HACK ALERT FIXME:+ --+ -- The config parser library we use, config-ini (as of 0.2.4.0)+ -- cannot handle configuration files without trailing newlines.+ -- Since that's not a really good reason for this function to raise+ -- an exception (and is fixable on the fly), we have the following+ -- check. This check is admittedly not a great thing to have to do,+ -- and we should definitely get rid of it when config-ini fixes this+ -- issue.+ let t' = if "\n" `T.isSuffixOf` t then t else t <> "\n"++ case parseIniFile t' fromIni of+ Left err -> do+ throwE $ "Unable to parse " ++ absPath ++ ":" ++ fatalString err+ Right (warns, conf) -> do+ actualPass <- case configPass conf of+ Just (PasswordCommand cmdString) -> do+ let (cmd:rest) = T.unpack <$> T.words cmdString+ output <- convertIOException (readProcess cmd rest "") `catchE`+ (\e -> throwE $ "Could not execute password command: " <> e)+ return $ Just $ T.pack (takeWhile (/= '\n') output)+ Just (PasswordString pass) -> return $ Just pass+ Nothing -> return Nothing++ actualToken <- case configToken conf of+ Just (TokenCommand cmdString) -> do+ let (cmd:rest) = T.unpack <$> T.words cmdString+ output <- convertIOException (readProcess cmd rest "") `catchE`+ (\e -> throwE $ "Could not execute token command: " <> e)+ return $ Just $ T.pack (takeWhile (/= '\n') output)+ Just (TokenString _) -> error $ "BUG: getConfig: token in the Config was already a TokenString"+ Nothing -> return Nothing++ let conf' = conf+ { configPass = PasswordString <$> actualPass+ , configToken = TokenString <$> actualToken+ , configAbsPath = Just absPath+ }+ return (map warningString warns, conf')++configConnectionType :: Config -> ConnectionType+configConnectionType config+ | configUnsafeUseHTTP config = ConnectHTTP+ | otherwise = ConnectHTTPS (configValidateServerCertificate config)
+ src/Matterhorn/Config/Schema.hs view
@@ -0,0 +1,203 @@+{- |++This module provides an INI schema validator that is able to track unused+sections and fields in order to report warning messages to the user.+++ -}+module Matterhorn.Config.Schema+ ( IniParser+ , parseIniFile+ , (<!>)++ , Fatal(..)+ , fatalString++ , Warning(..)+ , warningString++ , section+ , sectionMb+ , fieldMbOf+ , fieldMb+ , field+ , fieldDefOf+ , fieldFlagDef++ -- * Re-exports+ , number+ , string+ , listWithSeparator+ ) where++import Prelude ()+import Matterhorn.Prelude++import Data.Map (Map)+import qualified Data.Map as Map+import Data.List.NonEmpty (NonEmpty)+import qualified Data.List.NonEmpty as NonEmpty+import qualified Data.Text as T+import Control.Monad+import Data.Ini.Config (flag, number, listWithSeparator, string)+import Data.Ini.Config.Raw+++------------------------------------------------------------------------++newtype Parser e t a = Parser { unParser :: e -> Map NormalizedText (NonEmpty t) -> Either Fatal (Map NormalizedText (NonEmpty t), [Warning], a) }++instance Functor (Parser e t) where+ fmap = liftM++instance Applicative (Parser e t) where+ pure x = Parser $ \_ s -> Right (s, [], x)+ (<*>) = ap++instance Monad (Parser e t) where+ m >>= f = Parser $ \e s0 ->+ do (s1, ws1, x1) <- unParser m e s0+ (s2, ws2, x2) <- unParser (f x1) e s1+ Right (s2, ws1++ws2, x2)++++(<!>) :: Parser e t a -> Parser e t a -> Parser e t a+p <!> q = Parser $ \e s ->+ case unParser p e s of+ Right r -> Right r+ Left {} -> unParser q e s++getenv :: Parser e t e+getenv = Parser $ \e s -> Right (s, [], e)++request :: Text -> Parser e t (Maybe t)+request name = Parser $ \_ s ->+ let name' = normalize name in+ Right $!+ case Map.lookup name' s of+ Nothing -> (s , [], Nothing)+ Just (x NonEmpty.:| xs) -> (s', [], Just x)+ where+ s' = case NonEmpty.nonEmpty xs of+ Nothing -> Map.delete name' s+ Just ne -> Map.insert name' ne s++fatal :: Fatal -> Parser e t a+fatal e = Parser $ \_ _ -> Left e++warnings :: [Warning] -> IniParser ()+warnings ws = Parser $ \_ s -> Right (s, ws, ())++------------------------------------------------------------------------++type IniParser = Parser RawIni IniSection++type SectionParser = Parser IniSection IniValue++data Fatal+ = NoSection Text+ | MissingField IniSection Text+ | BadField IniSection IniValue String+ | ParseError String+ deriving Show++data Warning+ = UnusedSection IniSection+ | UnusedField IniSection IniValue+ deriving Show++------------------------------------------------------------------------++fatalString :: Fatal -> String+fatalString (NoSection name) = "No top-level section named " ++ show name+fatalString (MissingField sec name) = "Missing field " ++ show name ++ " in section " ++ show (isName sec)+fatalString (BadField sec val err) =+ "Line " ++ show (vLineNo val) +++ " in section " ++ show (isName sec) +++ ": " ++ err+fatalString (ParseError err) = err++warningString :: Warning -> String+warningString (UnusedSection sec) = "Unused section " ++ show (isName sec)+warningString (UnusedField sec val) =+ "Line " ++ show (vLineNo val) +++ " in section " ++ show (isName sec) +++ ": unused field"++------------------------------------------------------------------------++parseIniFile :: Text -> IniParser a -> Either Fatal ([Warning], a)+parseIniFile text parser =+ case parseRawIni text of+ Left e -> Left (ParseError e)+ Right ini ->+ let entries = Map.fromListWith (<>)+ [ (k, pure v) | (k,v) <- toList (fromRawIni ini) ]+ in+ case unParser parser ini entries of+ Left e -> Left e+ Right (entries', ws, x) -> Right (ws ++ unused, x)+ where+ unused = [ UnusedSection e | e <- concatMap toList entries' ]++------------------------------------------------------------------------++section :: Text -> SectionParser a -> IniParser a+section name parser =+ do mb <- sectionMb name parser+ case mb of+ Nothing -> fatal (NoSection name)+ Just x -> pure x++sectionMb :: Text -> SectionParser a -> IniParser (Maybe a)+sectionMb name parser =+ do mb <- request name+ case mb of+ Nothing -> pure Nothing+ Just sec ->+ let entries = Map.fromListWith (<>)+ [ (k, pure v) | (k,v) <- toList (isVals sec) ]+ in+ case unParser parser sec entries of+ Left e -> fatal e+ Right (entries', ws, result) ->+ do warnings (ws ++ [ UnusedField sec v | v <- concatMap toList entries' ])+ pure (Just result)++------------------------------------------------------------------------++field :: Text -> SectionParser Text+field name =+ do mb <- fieldMb name+ case mb of+ Just x -> pure x+ Nothing ->+ do s <- getenv+ fatal (MissingField s name)++fieldMbOf :: Text -> (Text -> Either String a) -> SectionParser (Maybe a)+fieldMbOf name validate =+ do mb <- request name+ case mb of+ Nothing -> pure Nothing+ Just val ->+ case validate (getVal val) of+ Left e ->+ do sec <- getenv+ fatal (BadField sec val e)+ Right x -> pure (Just x)++fieldMb :: Text -> SectionParser (Maybe Text)+fieldMb name = fmap getVal <$> request name++fieldDefOf :: Text -> (Text -> Either String a) -> a -> SectionParser a+fieldDefOf name validate def = fromMaybe def <$> fieldMbOf name validate++fieldFlagDef :: Text -> Bool -> SectionParser Bool+fieldFlagDef name def = fieldDefOf name flag def++------------------------------------------------------------------------++getVal :: IniValue -> Text+getVal = T.strip . vValue
+ src/Matterhorn/Connection.hs view
@@ -0,0 +1,104 @@+module Matterhorn.Connection where++import Prelude ()+import Matterhorn.Prelude++import Control.Concurrent ( forkIO, threadDelay, killThread )+import qualified Control.Concurrent.STM as STM+import Control.Exception ( SomeException, catch, AsyncException(..), throwIO )+import qualified Data.HashMap.Strict as HM+import Data.Int (Int64)+import Data.Semigroup ( Max(..) )+import qualified Data.Text as T+import Data.Time ( UTCTime(..), secondsToDiffTime, getCurrentTime+ , diffUTCTime )+import Data.Time.Calendar ( Day(..) )+import Lens.Micro.Platform ( (.=) )++import Network.Mattermost.Types ( ChannelId )+import qualified Network.Mattermost.WebSocket as WS++import Matterhorn.Constants+import Matterhorn.Types+++connectWebsockets :: MH ()+connectWebsockets = do+ logger <- mhGetIOLogger++ -- If we have an old websocket thread, kill it.+ mOldTid <- use (csResources.crWebsocketThreadId)+ case mOldTid of+ Nothing -> return ()+ Just oldTid -> liftIO $ do+ logger LogWebsocket "Terminating previous websocket thread"+ killThread oldTid++ st <- use id+ session <- getSession++ tid <- liftIO $ do+ let shunt (Left msg) = writeBChan (st^.csResources.crEventQueue) (WebsocketParseError msg)+ shunt (Right (Right e)) = writeBChan (st^.csResources.crEventQueue) (WSEvent e)+ shunt (Right (Left e)) = writeBChan (st^.csResources.crEventQueue) (WSActionResponse e)+ runWS = WS.mmWithWebSocket session shunt $ \ws -> do+ writeBChan (st^.csResources.crEventQueue) WebsocketConnect+ processWebsocketActions st ws 1 HM.empty+ logger LogWebsocket "Starting new websocket thread"+ forkIO $ runWS `catch` ignoreThreadKilled+ `catch` handleTimeout logger 1 st+ `catch` handleError logger 5 st++ csResources.crWebsocketThreadId .= Just tid++ignoreThreadKilled :: AsyncException -> IO ()+ignoreThreadKilled ThreadKilled = return ()+ignoreThreadKilled e = throwIO e++-- | Take websocket actions from the websocket action channel in the+-- ChatState and send them to the server over the websocket.+--+-- Takes and propagates the action sequence number which is incremented+-- for each successful send.+--+-- Keeps and propagates a map of channel id to last user_typing+-- notification send time so that the new user_typing actions are+-- throttled to be send only once in two seconds.+processWebsocketActions :: ChatState -> WS.MMWebSocket -> Int64 -> HashMap ChannelId (Max UTCTime) -> IO ()+processWebsocketActions st ws s userTypingLastNotifTimeMap = do+ action <- STM.atomically $ STM.readTChan (st^.csResources.crWebsocketActionChan)+ if (shouldSendAction action)+ then do+ WS.mmSendWSAction (st^.csResources.crConn) ws $ convert action+ now <- getCurrentTime+ processWebsocketActions st ws (s + 1) $ userTypingLastNotifTimeMap' action now+ else do+ processWebsocketActions st ws s userTypingLastNotifTimeMap+ where+ convert (UserTyping _ cId pId) = WS.UserTyping s cId pId++ shouldSendAction (UserTyping ts cId _) =+ diffUTCTime ts (userTypingLastNotifTime cId) >= (userTypingExpiryInterval / 2 - 0.5)++ userTypingLastNotifTime cId = getMax $ HM.lookupDefault (Max zeroTime) cId userTypingLastNotifTimeMap++ zeroTime = UTCTime (ModifiedJulianDay 0) (secondsToDiffTime 0)++ userTypingLastNotifTimeMap' (UserTyping _ cId _) now =+ HM.insertWith (<>) cId (Max now) userTypingLastNotifTimeMap++handleTimeout :: (LogCategory -> Text -> IO ()) -> Int -> ChatState -> WS.MMWebSocketTimeoutException -> IO ()+handleTimeout logger seconds st e = do+ logger LogWebsocket $ T.pack $ "Websocket timeout exception: " <> show e+ reconnectAfter seconds st++handleError :: (LogCategory -> Text -> IO ()) -> Int -> ChatState -> SomeException -> IO ()+handleError logger seconds st e = do+ logger LogWebsocket $ T.pack $ "Websocket error: " <> show e+ reconnectAfter seconds st++reconnectAfter :: Int -> ChatState -> IO ()+reconnectAfter seconds st = do+ writeBChan (st^.csResources.crEventQueue) WebsocketDisconnect+ threadDelay (seconds * 1000 * 1000)+ writeBChan (st^.csResources.crEventQueue) RefreshWebsocketEvent
+ src/Matterhorn/Constants.hs view
@@ -0,0 +1,35 @@+module Matterhorn.Constants+ ( pageAmount+ , userTypingExpiryInterval+ , numScrollbackPosts+ , previewMaxHeight+ , normalChannelSigil+ , userSigil+ )+where++import Prelude ()+import Matterhorn.Prelude+++-- | The number of rows to consider a "page" when scrolling+pageAmount :: Int+pageAmount = 15++-- | The expiry interval in seconds for user typing notifications.+userTypingExpiryInterval :: NominalDiffTime+userTypingExpiryInterval = 5++numScrollbackPosts :: Int+numScrollbackPosts = 100++-- | The maximum height of the message preview, in lines.+previewMaxHeight :: Int+previewMaxHeight = 5++-- Sigils+normalChannelSigil :: Text+normalChannelSigil = "~"++userSigil :: Text+userSigil = "@"
+ src/Matterhorn/Draw.hs view
@@ -0,0 +1,52 @@+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE PackageImports #-}+module Matterhorn.Draw (draw) where++import Prelude ()+import Matterhorn.Prelude++import Brick++import Lens.Micro.Platform ( _2, singular, _Just )++import Matterhorn.Draw.ChannelTopicWindow+import Matterhorn.Draw.DeleteChannelConfirm+import Matterhorn.Draw.LeaveChannelConfirm+import Matterhorn.Draw.Main+import Matterhorn.Draw.ThemeListOverlay+import Matterhorn.Draw.PostListOverlay+import Matterhorn.Draw.ShowHelp+import Matterhorn.Draw.UserListOverlay+import Matterhorn.Draw.ChannelListOverlay+import Matterhorn.Draw.ReactionEmojiListOverlay+import Matterhorn.Draw.TabbedWindow+import Matterhorn.Draw.ManageAttachments+import Matterhorn.Draw.NotifyPrefs+import Matterhorn.Types+++draw :: ChatState -> [Widget Name]+draw st =+ case appMode st of+ Main -> mainLayers+ UrlSelect -> mainLayers+ ChannelSelect -> mainLayers+ MessageSelect -> mainLayers+ MessageSelectDeleteConfirm -> mainLayers+ ShowHelp topic _ -> drawShowHelp topic st+ ThemeListOverlay -> drawThemeListOverlay st : mainLayers+ LeaveChannelConfirm -> drawLeaveChannelConfirm st : mainLayersMonochrome+ DeleteChannelConfirm -> drawDeleteChannelConfirm st : mainLayersMonochrome+ PostListOverlay contents -> drawPostListOverlay contents st : mainLayersMonochrome+ UserListOverlay -> drawUserListOverlay st : mainLayersMonochrome+ ChannelListOverlay -> drawChannelListOverlay st : mainLayersMonochrome+ ReactionEmojiListOverlay -> drawReactionEmojiListOverlay st : mainLayersMonochrome+ ViewMessage -> drawTabbedWindow messageViewWindow st : mainLayersMonochrome+ ManageAttachments -> drawManageAttachments st : mainLayersMonochrome+ ManageAttachmentsBrowseFiles -> drawManageAttachments st : mainLayersMonochrome+ EditNotifyPrefs -> drawNotifyPrefs st : mainLayersMonochrome+ ChannelTopicWindow -> drawChannelTopicWindow st : mainLayersMonochrome+ where+ mainLayers = drawMain True st+ mainLayersMonochrome = drawMain False st+ messageViewWindow = st^.csViewedMessage.singular _Just._2
+ src/Matterhorn/Draw/Autocomplete.hs view
@@ -0,0 +1,205 @@+module Matterhorn.Draw.Autocomplete+ ( autocompleteLayer+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.List ( renderList, listElementsL, listSelectedFocusedAttr+ , listSelectedElement+ )+import qualified Data.Text as T++import Network.Mattermost.Types ( User(..), Channel(..) )++import Matterhorn.Constants ( normalChannelSigil )+import Matterhorn.Draw.Util+import Matterhorn.Themes+import Matterhorn.Types+import Matterhorn.Types.Common ( sanitizeUserText )+++autocompleteLayer :: ChatState -> Widget Name+autocompleteLayer st =+ case st^.csEditState.cedAutocomplete of+ Nothing ->+ emptyWidget+ Just ac ->+ renderAutocompleteBox st ac++userNotInChannelMarker :: T.Text+userNotInChannelMarker = "*"++elementTypeLabel :: AutocompletionType -> Text+elementTypeLabel ACUsers = "Users"+elementTypeLabel ACChannels = "Channels"+elementTypeLabel ACCodeBlockLanguage = "Languages"+elementTypeLabel ACEmoji = "Emoji"+elementTypeLabel ACCommands = "Commands"++renderAutocompleteBox :: ChatState -> AutocompleteState -> Widget Name+renderAutocompleteBox st ac =+ let matchList = _acCompletionList ac+ maxListHeight = 5+ visibleHeight = min maxListHeight numResults+ numResults = length elements+ elements = matchList^.listElementsL+ label = withDefAttr clientMessageAttr $+ txt $ elementTypeLabel (ac^.acType) <> ": " <> (T.pack $ show numResults) <>+ " match" <> (if numResults == 1 then "" else "es") <>+ " (Tab/Shift-Tab to select)"++ selElem = snd <$> listSelectedElement matchList+ curChan = st^.csCurrentChannel+ footer = case renderAutocompleteFooterFor curChan =<< selElem of+ Just w -> hBorderWithLabel w+ _ -> hBorder+ curUser = myUsername st++ in if numResults == 0+ then emptyWidget+ else Widget Greedy Greedy $ do+ ctx <- getContext+ let rowOffset = ctx^.availHeightL - 3 - editorOffset - visibleHeight+ editorOffset = if st^.csEditState.cedEphemeral.eesMultiline+ then multilineHeightLimit+ else 0+ render $ translateBy (Location (0, rowOffset)) $+ vBox [ hBorderWithLabel label+ , vLimit visibleHeight $+ renderList (renderAutocompleteAlternative curUser) True matchList+ , footer+ ]++renderAutocompleteFooterFor :: ClientChannel -> AutocompleteAlternative -> Maybe (Widget Name)+renderAutocompleteFooterFor _ (SpecialMention MentionChannel) = Nothing+renderAutocompleteFooterFor _ (SpecialMention MentionAll) = Nothing+renderAutocompleteFooterFor ch (UserCompletion _ False) =+ Just $ hBox [ txt $ "("+ , withDefAttr clientEmphAttr (txt userNotInChannelMarker)+ , txt ": not a member of "+ , withDefAttr channelNameAttr (txt $ normalChannelSigil <> ch^.ccInfo.cdName)+ , txt ")"+ ]+renderAutocompleteFooterFor _ (ChannelCompletion False ch) =+ Just $ hBox [ txt $ "("+ , withDefAttr clientEmphAttr (txt userNotInChannelMarker)+ , txt ": you are not a member of "+ , withDefAttr channelNameAttr (txt $ normalChannelSigil <> sanitizeUserText (channelName ch))+ , txt ")"+ ]+renderAutocompleteFooterFor _ (CommandCompletion src _ _ _) =+ case src of+ Server ->+ Just $ hBox [ txt $ "("+ , withDefAttr clientEmphAttr (txt serverCommandMarker)+ , txt ": command provided by the server)"+ ]+ Client -> Nothing+renderAutocompleteFooterFor _ _ =+ Nothing++serverCommandMarker :: Text+serverCommandMarker = "*"++renderAutocompleteAlternative :: Text -> Bool -> AutocompleteAlternative -> Widget Name+renderAutocompleteAlternative _ sel (EmojiCompletion e) =+ padRight Max $ renderEmojiCompletion sel e+renderAutocompleteAlternative _ sel (SpecialMention m) =+ padRight Max $ renderSpecialMention m sel+renderAutocompleteAlternative curUser sel (UserCompletion u inChan) =+ padRight Max $ renderUserCompletion curUser u inChan sel+renderAutocompleteAlternative _ sel (ChannelCompletion inChan c) =+ padRight Max $ renderChannelCompletion c inChan sel+renderAutocompleteAlternative _ _ (SyntaxCompletion t) =+ padRight Max $ txt t+renderAutocompleteAlternative _ _ (CommandCompletion src n args desc) =+ padRight Max $ renderCommandCompletion src n args desc++renderSpecialMention :: SpecialMention -> Bool -> Widget Name+renderSpecialMention m sel =+ let usernameWidth = 18+ padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')+ maybeForce = if sel+ then forceAttr listSelectedFocusedAttr+ else id+ t = autocompleteAlternativeReplacement $ SpecialMention m+ desc = case m of+ MentionChannel -> "Notifies all users in this channel"+ MentionAll -> "Mentions all users in this channel"+ in maybeForce $+ hBox [ txt " "+ , padTo usernameWidth $ withDefAttr clientEmphAttr $ txt t+ , txt desc+ ]++renderEmojiCompletion :: Bool -> T.Text -> Widget Name+renderEmojiCompletion sel e =+ let maybeForce = if sel+ then forceAttr listSelectedFocusedAttr+ else id+ in maybeForce $+ padLeft (Pad 2) $+ withDefAttr emojiAttr $+ txt $+ autocompleteAlternativeReplacement $ EmojiCompletion e++renderUserCompletion :: Text -> User -> Bool -> Bool -> Widget Name+renderUserCompletion curUser u inChan selected =+ let usernameWidth = 18+ fullNameWidth = 25+ padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')+ username = userUsername u+ fullName = (sanitizeUserText $ userFirstName u) <> " " <>+ (sanitizeUserText $ userLastName u)+ nickname = sanitizeUserText $ userNickname u+ maybeForce = if selected+ then forceAttr listSelectedFocusedAttr+ else id+ memberDisplay = if inChan+ then txt " "+ else withDefAttr clientEmphAttr $+ txt $ userNotInChannelMarker <> " "+ in maybeForce $+ hBox [ memberDisplay+ , padTo usernameWidth $ colorUsername curUser username ("@" <> username)+ , padTo fullNameWidth $ txt fullName+ , txt nickname+ ]++renderChannelCompletion :: Channel -> Bool -> Bool -> Widget Name+renderChannelCompletion c inChan selected =+ let urlNameWidth = 30+ displayNameWidth = 30+ padTo n a = hLimit n $ vLimit 1 (a <+> fill ' ')+ maybeForce = if selected+ then forceAttr listSelectedFocusedAttr+ else id+ memberDisplay = if inChan+ then txt " "+ else withDefAttr clientEmphAttr $+ txt $ userNotInChannelMarker <> " "+ in maybeForce $+ hBox [ memberDisplay+ , padTo urlNameWidth $+ withDefAttr channelNameAttr $+ txt $ normalChannelSigil <> (sanitizeUserText $ channelName c)+ , padTo displayNameWidth $+ withDefAttr channelNameAttr $+ txt $ sanitizeUserText $ channelDisplayName c+ , txt $ sanitizeUserText $ channelPurpose c+ ]++renderCommandCompletion :: CompletionSource -> Text -> Text -> Text -> Widget Name+renderCommandCompletion src name args desc =+ (txt $ " " <> srcTxt <> " ") <+>+ withDefAttr clientMessageAttr+ (txt $ "/" <> name <> if T.null args then "" else " " <> args) <+>+ (txt $ " - " <> desc)+ where+ srcTxt = case src of+ Server -> serverCommandMarker+ Client -> " "
+ src/Matterhorn/Draw/Buttons.hs view
@@ -0,0 +1,29 @@+module Matterhorn.Draw.Buttons+ ( drawButton+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Focus+import Brick.Widgets.Center++import qualified Data.Text as T++import Matterhorn.Themes+++buttonWidth :: Int+buttonWidth = 10++drawButton :: (Eq n) => FocusRing n -> n -> T.Text -> Widget n+drawButton f n label =+ let attr = if focusGetCurrent f == Just n+ then buttonFocusedAttr+ else buttonAttr+ in withDefAttr attr $+ hLimit buttonWidth $+ hCenter $+ txt label
+ src/Matterhorn/Draw/ChannelList.hs view
@@ -0,0 +1,239 @@+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE RankNTypes #-}++-- | This module provides the Drawing functionality for the+-- ChannelList sidebar. The sidebar is divided vertically into groups+-- and each group is rendered separately.+--+-- There are actually two UI modes handled by this code:+--+-- * Normal display of the channels, with various markers to+-- indicate the current channel, channels with unread messages,+-- user state (for Direct Message channels), etc.+--+-- * ChannelSelect display where the user is typing match characters+-- into a prompt at the ChannelList sidebar is showing only those+-- channels matching the entered text (and highlighting the+-- matching portion).++module Matterhorn.Draw.ChannelList (renderChannelList, renderChannelListHeader) where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center (hCenter)+import qualified Data.Text as T+import Lens.Micro.Platform (non)++import qualified Network.Mattermost.Types as MM++import Matterhorn.Constants ( userSigil )+import Matterhorn.Draw.Util+import Matterhorn.State.Channels+import Matterhorn.Themes+import Matterhorn.Types+import Matterhorn.Types.Common ( sanitizeUserText )+import qualified Matterhorn.Zipper as Z++-- | Internal record describing each channel entry and its associated+-- attributes. This is the object passed to the rendering function so+-- that it can determine how to render each channel.+data ChannelListEntryData =+ ChannelListEntryData { entrySigil :: Text+ , entryLabel :: Text+ , entryHasUnread :: Bool+ , entryMentions :: Int+ , entryIsRecent :: Bool+ , entryIsReturn :: Bool+ , entryIsCurrent :: Bool+ , entryIsMuted :: Bool+ , entryUserStatus :: Maybe UserStatus+ }++renderChannelListHeader :: ChatState -> Widget Name+renderChannelListHeader st =+ vBox [ teamHeader+ , selfHeader+ , unreadCountHeader+ ]+ where+ myUsername_ = myUsername st+ teamHeader = hCenter $+ withDefAttr clientEmphAttr $+ txt $ "Team: " <> teamNameStr+ selfHeader = hCenter $+ colorUsername myUsername_ myUsername_+ (T.singleton statusSigil <> " " <> userSigil <> myUsername_)+ teamNameStr = sanitizeUserText $ MM.teamDisplayName $ st^.csMyTeam+ statusSigil = maybe ' ' userSigilFromInfo me+ me = userById (myUserId st) st+ unreadCountHeader = hCenter $ txt $ "Unread: " <> (T.pack $ show unreadCount)+ unreadCount = sum $ (channelListGroupUnread . fst) <$> Z.toList (st^.csFocus)++renderChannelList :: ChatState -> Widget Name+renderChannelList st =+ viewport ChannelList Vertical body+ where+ myUsername_ = myUsername st+ renderEntry s e = renderChannelListEntry myUsername_ $ mkChannelEntryData s e+ body = case appMode st of+ ChannelSelect ->+ let zipper = st^.csChannelSelectState.channelSelectMatches+ matches = if Z.isEmpty zipper+ then [hCenter $ txt "No matches"]+ else (renderChannelListGroup st+ (renderChannelSelectListEntry (Z.focus zipper)) <$>+ Z.toList zipper)+ in vBox $+ renderChannelListHeader st :+ matches+ _ ->+ cached ChannelSidebar $+ vBox $+ renderChannelListHeader st :+ (renderChannelListGroup st renderEntry <$> Z.toList (st^.csFocus))++renderChannelListGroupHeading :: ChannelListGroup -> Widget Name+renderChannelListGroupHeading g =+ let (unread, label) = case g of+ ChannelGroupPublicChannels u -> (u, "Public Channels")+ ChannelGroupPrivateChannels u -> (u, "Private Channels")+ ChannelGroupDirectMessages u -> (u, "Direct Messages")+ addUnread = if unread > 0+ then (<+> (withDefAttr unreadGroupMarkerAttr $ txt "*"))+ else id+ labelWidget = addUnread $ withDefAttr channelListHeaderAttr $ txt label+ in hBorderWithLabel labelWidget++renderChannelListGroup :: ChatState+ -> (ChatState -> e -> Widget Name)+ -> (ChannelListGroup, [e])+ -> Widget Name+renderChannelListGroup st renderEntry (group, es) =+ let heading = renderChannelListGroupHeading group+ entryWidgets = renderEntry st <$> es+ in if null entryWidgets+ then emptyWidget+ else vBox (heading : entryWidgets)++mkChannelEntryData :: ChatState+ -> ChannelListEntry+ -> ChannelListEntryData+mkChannelEntryData st e =+ ChannelListEntryData { entrySigil = sigilWithSpace+ , entryLabel = name+ , entryHasUnread = unread+ , entryMentions = mentions+ , entryIsRecent = recent+ , entryIsReturn = ret+ , entryIsCurrent = current+ , entryIsMuted = muted+ , entryUserStatus = status+ }+ where+ cId = channelListEntryChannelId e+ Just chan = findChannelById cId (st^.csChannels)+ unread = hasUnread' chan+ recent = isRecentChannel st cId+ ret = isReturnChannel st cId+ current = isCurrentChannel st cId+ muted = isMuted chan+ (name, normalSigil, addSpace, status) = case e of+ CLChannel _ ->+ (chan^.ccInfo.cdDisplayName, Nothing, False, Nothing)+ CLGroupDM _ ->+ (chan^.ccInfo.cdDisplayName, Just " ", True, Nothing)+ CLUserDM _ uId ->+ let Just u = userById uId st+ uname = if useNickname st+ then u^.uiNickName.non (u^.uiName)+ else u^.uiName+ in (uname, Just $ T.singleton $ userSigilFromInfo u,+ True, Just $ u^.uiStatus)+ sigilWithSpace = sigil <> if addSpace then " " else ""+ prevEditSigil = "»"+ sigil = if current+ then fromMaybe "" normalSigil+ else case chan^.ccEditState.eesInputHistoryPosition of+ Just _ -> prevEditSigil+ Nothing ->+ case chan^.ccEditState.eesLastInput of+ ("", _) -> fromMaybe "" normalSigil+ _ -> prevEditSigil+ mentions = chan^.ccInfo.cdMentionCount++-- | Render an individual Channel List entry (in Normal mode) with+-- appropriate visual decorations.+renderChannelListEntry :: Text -> ChannelListEntryData -> Widget Name+renderChannelListEntry myUName entry = body+ where+ body = decorate $ decorateEntry entry $ decorateMentions entry $ padRight Max $+ entryWidget $ entrySigil entry <> entryLabel entry+ decorate = if | entryIsCurrent entry ->+ visible . forceAttr currentChannelNameAttr+ | entryMentions entry > 0 && not (entryIsMuted entry) ->+ forceAttr mentionsChannelAttr+ | entryHasUnread entry ->+ forceAttr unreadChannelAttr+ | otherwise -> id+ entryWidget = case entryUserStatus entry of+ Just Offline -> withDefAttr clientMessageAttr . txt+ Just _ -> colorUsername myUName (entryLabel entry)+ Nothing -> txt++-- | Render an individual entry when in Channel Select mode,+-- highlighting the matching portion, or completely suppressing the+-- entry if it doesn't match.+renderChannelSelectListEntry :: Maybe ChannelSelectMatch+ -> ChatState+ -> ChannelSelectMatch+ -> Widget Name+renderChannelSelectListEntry curMatch st match =+ let ChannelSelectMatch preMatch inMatch postMatch _ entry = match+ maybeSelect = if (Just entry) == (matchEntry <$> curMatch)+ then visible . withDefAttr currentChannelNameAttr+ else id+ entryData = mkChannelEntryData st entry+ decorate = if | entryMentions entryData > 0 && not (entryIsMuted entryData) ->+ withDefAttr mentionsChannelAttr+ | entryHasUnread entryData ->+ withDefAttr unreadChannelAttr+ | otherwise -> id+ in decorate $ maybeSelect $+ decorateEntry entryData $ decorateMentions entryData $+ padRight Max $+ hBox [ txt $ entrySigil entryData <> preMatch+ , forceAttr channelSelectMatchAttr $ txt inMatch+ , txt postMatch+ ]++-- If this channel is the return channel, add a decoration to denote+-- that.+--+-- Otherwise, if this channel is the most recently viewed channel (prior+-- to the currently viewed channel), add a decoration to denote that.+decorateEntry :: ChannelListEntryData -> Widget n -> Widget n+decorateEntry entry =+ if entryIsReturn entry+ then (<+> (withDefAttr recentMarkerAttr $ str returnChannelSigil))+ else if entryIsRecent entry+ then (<+> (withDefAttr recentMarkerAttr $ str recentChannelSigil))+ else id++decorateMentions :: ChannelListEntryData -> Widget n -> Widget n+decorateMentions entry+ | entryMentions entry > 9 =+ (<+> str "(9+)")+ | entryMentions entry > 0 =+ (<+> str ("(" <> show (entryMentions entry) <> ")"))+ | entryIsMuted entry =+ (<+> str "(m)")+ | otherwise = id++recentChannelSigil :: String+recentChannelSigil = "<"++returnChannelSigil :: String+returnChannelSigil = "~"
+ src/Matterhorn/Draw/ChannelListOverlay.hs view
@@ -0,0 +1,53 @@+module Matterhorn.Draw.ChannelListOverlay+ ( drawChannelListOverlay+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import qualified Data.Text as T+import Text.Wrap ( defaultWrapSettings, preserveIndentation )++import Network.Mattermost.Types+import Network.Mattermost.Lenses++import Matterhorn.Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )+import Matterhorn.Types+import Matterhorn.Types.Common ( sanitizeUserText )+import Matterhorn.Themes+++drawChannelListOverlay :: ChatState -> Widget Name+drawChannelListOverlay st =+ let overlay = drawListOverlay (st^.csChannelListOverlay) channelSearchScopeHeader+ channelSearchScopeNoResults channelSearchScopePrompt+ renderChannel+ Nothing+ OverlayCenter+ 80+ in joinBorders overlay++channelSearchScopePrompt :: ChannelSearchScope -> Widget Name+channelSearchScopePrompt scope =+ txt $ case scope of+ AllChannels -> "Search channels:"++channelSearchScopeNoResults :: ChannelSearchScope -> Widget Name+channelSearchScopeNoResults scope =+ txt $ case scope of+ AllChannels -> "No matching channels found."++channelSearchScopeHeader :: ChannelSearchScope -> Widget Name+channelSearchScopeHeader scope =+ withDefAttr clientEmphAttr $ txt $ case scope of+ AllChannels -> "Join a Channel"++renderChannel :: Bool -> Channel -> Widget Name+renderChannel _ chan =+ let baseStr = (sanitizeUserText $ chan^.channelNameL) <> " (" <>+ (sanitizeUserText $ chan^.channelDisplayNameL) <> ")"+ s = " " <> (T.strip $ sanitizeUserText $ chan^.channelPurposeL)+ in (vLimit 1 $ padRight Max $ withDefAttr clientEmphAttr $ txt baseStr) <=>+ (vLimit 1 $ txtWrapWith (defaultWrapSettings { preserveIndentation = True }) s)
+ src/Matterhorn/Draw/ChannelTopicWindow.hs view
@@ -0,0 +1,56 @@+module Matterhorn.Draw.ChannelTopicWindow+ ( drawChannelTopicWindow+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Focus+import Brick.Widgets.Border+import Brick.Widgets.Center+import Brick.Widgets.Edit++import Control.Arrow ( (>>>) )+import qualified Data.Text as T+import Data.Text.Zipper ( insertChar, gotoEOL )++import Matterhorn.Types+import Matterhorn.Draw.Buttons+import Matterhorn.Draw.RichText+import Matterhorn.Themes+++drawChannelTopicWindow :: ChatState -> Widget Name+drawChannelTopicWindow st =+ centerLayer $+ hLimit maxWindowWidth $+ joinBorders $+ borderWithLabel (withDefAttr clientEmphAttr $ txt "Edit Channel Topic") $+ vBox [ vLimit editorHeight $+ withFocusRing foc (renderEditor drawTopicEditorTxt) ed+ , hBorderWithLabel (withDefAttr clientEmphAttr $ txt "Preview")+ , vLimit previewHeight $+ viewport ChannelTopicEditorPreview Vertical $+ renderText' (Just baseUrl) "" hSet topicTxtWithCursor+ , hBorder+ , hBox [ padRight Max $+ padLeft (Pad 1) $+ drawButton foc ChannelTopicSaveButton "Save"+ , padRight (Pad 1) $+ drawButton foc ChannelTopicCancelButton "Cancel"+ ]+ ]+ where+ baseUrl = serverBaseUrl st+ editorHeight = 5+ previewHeight = 5+ maxWindowWidth = 70+ foc = st^.csChannelTopicDialog.channelTopicDialogFocus+ ed = st^.csChannelTopicDialog.channelTopicDialogEditor+ hSet = getHighlightSet st+ topicTxtWithCursor = T.unlines $+ getEditContents $+ applyEdit (gotoEOL >>> insertChar cursorSentinel) ed+ drawTopicEditorTxt = txt . T.unlines
+ src/Matterhorn/Draw/DeleteChannelConfirm.hs view
@@ -0,0 +1,28 @@+{-# LANGUAGE OverloadedStrings #-}+module Matterhorn.Draw.DeleteChannelConfirm+ ( drawDeleteChannelConfirm+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center++import Matterhorn.Themes+import Matterhorn.Types+++drawDeleteChannelConfirm :: ChatState -> Widget Name+drawDeleteChannelConfirm st =+ let cName = st^.csCurrentChannel.ccInfo.cdName+ in centerLayer $ hLimit 50 $ vLimit 15 $+ withDefAttr dialogAttr $+ borderWithLabel (txt "Confirm Delete Channel") $+ vBox [ padBottom (Pad 1) $ hCenter $ txt "Are you sure you want to delete this channel?"+ , padBottom (Pad 1) $ hCenter $ withDefAttr dialogEmphAttr $ txt cName+ , hCenter $ txt "Press " <+> (withDefAttr dialogEmphAttr $ txt "Y") <+> txt " to delete the channel"+ , hCenter $ txt "or any other key to cancel."+ ]
+ src/Matterhorn/Draw/LeaveChannelConfirm.hs view
@@ -0,0 +1,28 @@+{-# LANGUAGE OverloadedStrings #-}+module Matterhorn.Draw.LeaveChannelConfirm+ ( drawLeaveChannelConfirm+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center++import Matterhorn.Themes+import Matterhorn.Types+++drawLeaveChannelConfirm :: ChatState -> Widget Name+drawLeaveChannelConfirm st =+ let cName = st^.csCurrentChannel.ccInfo.cdName+ in centerLayer $ hLimit 50 $ vLimit 15 $+ withDefAttr dialogAttr $+ borderWithLabel (txt "Confirm Leave Channel") $+ vBox [ padBottom (Pad 1) $ hCenter $ txt "Are you sure you want to leave this channel?"+ , padBottom (Pad 1) $ hCenter $ withDefAttr dialogEmphAttr $ txt cName+ , hCenter $ txt "Press " <+> (withDefAttr dialogEmphAttr $ txt "Y") <+> txt " to leave the channel"+ , hCenter $ txt "or any other key to cancel."+ ]
+ src/Matterhorn/Draw/ListOverlay.hs view
@@ -0,0 +1,134 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++module Matterhorn.Draw.ListOverlay+ ( drawListOverlay+ , OverlayPosition(..)+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center+import Brick.Widgets.Edit+import qualified Brick.Widgets.List as L+import Control.Monad.Trans.Reader ( withReaderT )+import qualified Data.Foldable as F+import qualified Data.Text as T+import Graphics.Vty ( imageWidth, translateX)+import Lens.Micro.Platform ( (%~), (.~), to )++import Matterhorn.Themes+import Matterhorn.Types+++hLimitWithPadding :: Int -> Widget n -> Widget n+hLimitWithPadding pad contents = Widget+ { hSize = Fixed+ , vSize = (vSize contents)+ , render =+ withReaderT (& availWidthL %~ (\ n -> n - (2 * pad))) $ render $ cropToContext contents+ }++data OverlayPosition =+ OverlayCenter+ | OverlayUpperRight+ deriving (Eq, Show)++-- | Draw a ListOverlayState. This draws a bordered box with the+-- overlay's search input and results list inside the box. The provided+-- functions determine how to render the overlay in various states.+drawListOverlay :: ListOverlayState a b+ -- ^ The overlay state+ -> (b -> Widget Name)+ -- ^ The function to build the window title from the+ -- current search scope+ -> (b -> Widget Name)+ -- ^ The function to generate a message for the search+ -- scope indicating that no results were found+ -> (b -> Widget Name)+ -- ^ The function to generate the editor prompt for the+ -- search scope+ -> (Bool -> a -> Widget Name)+ -- ^ The function to render an item from the overlay's+ -- list+ -> Maybe (Widget Name)+ -- ^ The footer widget to render underneath the search+ -- results+ -> OverlayPosition+ -- ^ How to position the overlay layer+ -> Int+ -- ^ The maximum window width in columns+ -> Widget Name+drawListOverlay st scopeHeader scopeNoResults scopePrompt renderItem footer layerPos maxWinWidth =+ positionLayer $ hLimitWithPadding 10 $ vLimit 25 $+ hLimit maxWinWidth $+ borderWithLabel title body+ where+ title = withDefAttr clientEmphAttr $+ hBox [ scopeHeader scope+ , case st^.listOverlayRecordCount of+ Nothing -> emptyWidget+ Just c -> txt " (" <+> str (show c) <+> txt ")"+ ]+ positionLayer = case layerPos of+ OverlayCenter -> centerLayer+ OverlayUpperRight -> upperRightLayer+ body = vBox [ (padRight (Pad 1) promptMsg) <+>+ renderEditor (txt . T.unlines) True (st^.listOverlaySearchInput)+ , cursorPositionBorder+ , showResults+ , fromMaybe emptyWidget footer+ ]+ plural 1 = ""+ plural _ = "s"+ cursorPositionBorder =+ if st^.listOverlaySearching+ then hBorderWithLabel $ txt "[Searching...]"+ else case st^.listOverlaySearchResults.L.listSelectedL of+ Nothing -> hBorder+ Just _ ->+ let showingFirst = "Showing first " <> show numSearchResults <>+ " result" <> plural numSearchResults+ showingAll = "Showing all " <> show numSearchResults <>+ " result" <> plural numSearchResults+ showing = "Showing " <> show numSearchResults <>+ " result" <> plural numSearchResults+ msg = case getEditContents (st^.listOverlaySearchInput) of+ [""] ->+ case st^.listOverlayRecordCount of+ Nothing -> showing+ Just total -> if numSearchResults < total+ then showingFirst+ else showingAll+ _ -> showing+ in hBorderWithLabel $ str $ "[" <> msg <> "]"++ scope = st^.listOverlaySearchScope+ promptMsg = scopePrompt scope++ showMessage = center . withDefAttr clientEmphAttr++ showResults+ | numSearchResults == 0 = showMessage $ scopeNoResults scope+ | otherwise = renderedUserList++ renderedUserList = L.renderList renderItem True (st^.listOverlaySearchResults)+ numSearchResults = F.length $ st^.listOverlaySearchResults.L.listElementsL++upperRightLayer :: Widget a -> Widget a+upperRightLayer w =+ Widget (hSize w) (vSize w) $ do+ result <- render w+ c <- getContext+ let rWidth = result^.imageL.to imageWidth+ leftPaddingAmount = max 0 $ c^.availWidthL - rWidth+ paddedImage = translateX leftPaddingAmount $ result^.imageL+ off = Location (leftPaddingAmount, 0)+ if leftPaddingAmount == 0 then+ return result else+ return $ addResultOffset off+ $ result & imageL .~ paddedImage
+ src/Matterhorn/Draw/Main.hs view
@@ -0,0 +1,721 @@+{-# LANGUAGE MultiWayIf #-}+module Matterhorn.Draw.Main (drawMain) where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Border.Style+import Brick.Widgets.Center ( hCenter )+import Brick.Widgets.List ( listElements )+import Brick.Widgets.Edit ( editContentsL, renderEditor, getEditContents )+import Control.Arrow ( (>>>) )+import Data.Char ( isSpace, isPunctuation )+import Data.List ( intersperse )+import qualified Data.Map as M+import qualified Data.Sequence as Seq+import qualified Data.Set as S+import qualified Data.Text as T+import Data.Text.Zipper ( cursorPosition, insertChar, getText, gotoEOL )+import Data.Time.Calendar ( fromGregorian )+import Data.Time.Clock ( UTCTime(..) )+import qualified Graphics.Vty as Vty+import Lens.Micro.Platform ( (.~), (^?!), to, view, folding )++import Network.Mattermost.Types ( ChannelId, Type(Direct, Private, Group)+ , ServerTime(..), UserId+ )+++import Matterhorn.Constants+import Matterhorn.Draw.ChannelList ( renderChannelList, renderChannelListHeader )+import Matterhorn.Draw.Messages+import Matterhorn.Draw.Autocomplete+import Matterhorn.Draw.URLList+import Matterhorn.Draw.Util+import Matterhorn.Draw.RichText+import Matterhorn.Events.Keybindings+import Matterhorn.Events.MessageSelect+import Matterhorn.State.MessageSelect+import Matterhorn.Themes+import Matterhorn.TimeUtils ( justAfter, justBefore )+import Matterhorn.Types+import Matterhorn.Types.RichText ( parseMarkdown, TeamBaseURL )+import Matterhorn.Types.KeyEvents+++previewFromInput :: TeamBaseURL -> Maybe MessageType -> UserId -> Text -> Maybe Message+previewFromInput _ _ _ s | s == T.singleton cursorSentinel = Nothing+previewFromInput baseUrl overrideTy uId s =+ -- If it starts with a slash but not /me, this has no preview+ -- representation+ let isCommand = "/" `T.isPrefixOf` s+ isEmoteCmd = "/me " `T.isPrefixOf` s+ content = if isEmoteCmd+ then T.stripStart $ T.drop 3 s+ else s+ msgTy = fromMaybe (if isEmoteCmd then CP Emote else CP NormalPost) overrideTy+ in if isCommand && not isEmoteCmd+ then Nothing+ else Just $ Message { _mText = parseMarkdown (Just baseUrl) content+ , _mMarkdownSource = content+ , _mUser = UserI False uId+ , _mDate = ServerTime $ UTCTime (fromGregorian 1970 1 1) 0+ -- The date is not used for preview+ -- rendering, but we need to provide one.+ -- Ideally we'd just use today's date, but+ -- the rendering function is pure so we+ -- can't.+ , _mType = msgTy+ , _mPending = False+ , _mDeleted = False+ , _mAttachments = mempty+ , _mInReplyToMsg = NotAReply+ , _mMessageId = Nothing+ , _mReactions = mempty+ , _mOriginalPost = Nothing+ , _mFlagged = False+ , _mPinned = False+ , _mChannelId = Nothing+ }++-- | Tokens in spell check highlighting.+data Token =+ Ignore Text+ -- ^ This bit of text is to be ignored for the purposes of+ -- spell-checking.+ | Check Text+ -- ^ This bit of text should be checked against the spell checker's+ -- misspelling list.+ deriving (Show)++drawEditorContents :: ChatState -> HighlightSet -> [Text] -> Widget Name+drawEditorContents st hs =+ let noHighlight = txt . T.unlines+ in case st^.csEditState.cedSpellChecker of+ Nothing -> noHighlight+ Just _ ->+ case S.null (st^.csEditState.cedMisspellings) of+ True -> noHighlight+ False -> doHighlightMisspellings+ hs+ (st^.csEditState.cedMisspellings)++-- | This function takes a set of misspellings from the spell+-- checker, the editor lines, and builds a rendering of the text with+-- misspellings highlighted.+--+-- This function processes each line of text from the editor as follows:+--+-- * Tokenize the line based on our rules for what constitutes+-- whitespace. We do this because we need to check "words" in the+-- user's input against the list of misspellings returned by the spell+-- checker. But to do this we need to ignore the same things that+-- Aspell ignores, and it ignores whitespace and lots of puncutation.+-- We also do this because once we have identified the misspellings+-- present in the input, we need to reconstruct the user's input and+-- that means preserving whitespace so that the input looks as it was+-- originally typed.+--+-- * Once we have a list of tokens -- the whitespace tokens to be+-- preserved but ignored and the tokens to be checked -- we check+-- each non-whitespace token for presence in the list of misspellings+-- reported by the checker.+--+-- * Having indicated which tokens correspond to misspelled words, we+-- then need to coallesce adjacent tokens that are of the same+-- "misspelling status", i.e., two neighboring tokens (of whitespace+-- or check type) need to be coallesced if they both correspond to+-- text that is a misspelling or if they both are NOT a misspelling.+-- We do this so that the final Brick widget is optimal in that it+-- uses a minimal number of box cells to display substrings that have+-- the same attribute.+--+-- * Finally we build a widget out of these coallesced tokens and apply+-- the misspellingAttr attribute to the misspelled tokens.+--+-- Note that since we have to come to our own conclusion about which+-- words are worth checking in the checker's output, sometimes our+-- algorithm will differ from aspell in what is considered "part of a+-- word" and what isn't. In particular, Aspell is smart about sometimes+-- noticing that "'" is an apostrophe and at other times that it is+-- a single quote as part of a quoted string. As a result there will+-- be cases where Markdown formatting characters interact poorly+-- with Aspell's checking to result in misspellings that are *not*+-- highlighted.+--+-- One way to deal with this would be to *not* parse the user's input+-- as done here, complete with all its Markdown metacharacters, but to+-- instead 1) parse the input as Markdown, 2) traverse the Markdown AST+-- and extract the words from the relevant subtrees, and 3) spell-check+-- those words. The reason we don't do it that way in the first place is+-- because 1) the user's input might not be valid markdown and 2) even+-- if we did that, we'd still have to do this tokenization operation to+-- annotate misspellings and reconstruct the user's raw input.+doHighlightMisspellings :: HighlightSet -> S.Set Text -> [Text] -> Widget Name+doHighlightMisspellings hSet misspellings contents =+ -- Traverse the input, gathering non-whitespace into tokens and+ -- checking if they appear in the misspelling collection+ let whitelist = S.union (hUserSet hSet) (hChannelSet hSet)++ handleLine t | t == "" = txt " "+ handleLine t =+ -- For annotated tokens, coallesce tokens of the same type+ -- and add attributes for misspellings.+ let mkW (Left tok) =+ let s = getTokenText tok+ in if T.null s+ then emptyWidget+ else withDefAttr misspellingAttr $ txt $ getTokenText tok+ mkW (Right tok) =+ let s = getTokenText tok+ in if T.null s+ then emptyWidget+ else txt s++ go :: Either Token Token -> [Either Token Token] -> [Either Token Token]+ go lst [] = [lst]+ go lst (tok:toks) =+ case (lst, tok) of+ (Left a, Left b) -> go (Left $ combineTokens a b) toks+ (Right a, Right b) -> go (Right $ combineTokens a b) toks+ _ -> lst : go tok toks++ in hBox $ mkW <$> (go (Right $ Ignore "") $ annotatedTokens t)++ combineTokens (Ignore a) (Ignore b) = Ignore $ a <> b+ combineTokens (Check a) (Check b) = Check $ a <> b+ combineTokens (Ignore a) (Check b) = Check $ a <> b+ combineTokens (Check a) (Ignore b) = Check $ a <> b++ getTokenText (Ignore a) = a+ getTokenText (Check a) = a++ annotatedTokens t =+ -- For every token, check on whether it is a misspelling.+ -- The result is Either Token Token where the Left is a+ -- misspelling and the Right is not.+ checkMisspelling <$> tokenize t (Ignore "")++ checkMisspelling t@(Ignore _) = Right t+ checkMisspelling t@(Check s) =+ if s `S.member` whitelist+ then Right t+ else if s `S.member` misspellings+ then Left t+ else Right t++ ignoreChar c = isSpace c || isPunctuation c || c == '`' || c == '/' ||+ T.singleton c == userSigil || T.singleton c == normalChannelSigil++ tokenize t curTok+ | T.null t = [curTok]+ | ignoreChar $ T.head t =+ case curTok of+ Ignore s -> tokenize (T.tail t) (Ignore $ s <> (T.singleton $ T.head t))+ Check s -> Check s : tokenize (T.tail t) (Ignore $ T.singleton $ T.head t)+ | otherwise =+ case curTok of+ Ignore s -> Ignore s : tokenize (T.tail t) (Check $ T.singleton $ T.head t)+ Check s -> tokenize (T.tail t) (Check $ s <> (T.singleton $ T.head t))++ in vBox $ handleLine <$> contents++renderUserCommandBox :: ChatState -> HighlightSet -> Widget Name+renderUserCommandBox st hs =+ let prompt = txt $ case st^.csEditState.cedEditMode of+ Replying _ _ -> "reply> "+ Editing _ _ -> "edit> "+ NewPost -> "> "+ inputBox = renderEditor (drawEditorContents st hs) True (st^.csEditState.cedEditor)+ curContents = getEditContents $ st^.csEditState.cedEditor+ multilineContent = length curContents > 1+ multilineHints =+ hBox [ borderElem bsHorizontal+ , str $ "[" <> (show $ (+1) $ fst $ cursorPosition $+ st^.csEditState.cedEditor.editContentsL) <>+ "/" <> (show $ length curContents) <> "]"+ , hBorderWithLabel $ withDefAttr clientEmphAttr $+ txt $ "In multi-line mode. Press " <> multiLineToggleKey <>+ " to finish."+ ]++ replyDisplay = case st^.csEditState.cedEditMode of+ Replying msg _ ->+ let msgWithoutParent = msg & mInReplyToMsg .~ NotAReply+ in hBox [ replyArrow+ , addEllipsis $ renderMessage MessageData+ { mdMessage = msgWithoutParent+ , mdUserName = msgWithoutParent^.mUser.to (nameForUserRef st)+ , mdParentMessage = Nothing+ , mdParentUserName = Nothing+ , mdHighlightSet = hs+ , mdEditThreshold = Nothing+ , mdShowOlderEdits = False+ , mdRenderReplyParent = True+ , mdIndentBlocks = False+ , mdThreadState = NoThread+ , mdShowReactions = True+ , mdMessageWidthLimit = Nothing+ , mdMyUsername = myUsername st+ , mdWrapNonhighlightedCodeBlocks = True+ }+ ]+ _ -> emptyWidget++ multiLineToggleKey = ppBinding $ getFirstDefaultBinding ToggleMultiLineEvent++ commandBox = case st^.csEditState.cedEphemeral.eesMultiline of+ False ->+ let linesStr = "line" <> if numLines == 1 then "" else "s"+ numLines = length curContents+ in vLimit 1 $ hBox $+ prompt : if multilineContent+ then [ withDefAttr clientEmphAttr $+ str $ "[" <> show numLines <> " " <> linesStr <>+ "; Enter: send, " <> T.unpack multiLineToggleKey <>+ ": edit, Backspace: cancel] "+ , txt $ head curContents+ , showCursor MessageInput (Location (0,0)) $ str " "+ ]+ else [inputBox]+ True -> vLimit multilineHeightLimit inputBox <=> multilineHints+ in replyDisplay <=> commandBox++renderChannelHeader :: ChatState -> HighlightSet -> ClientChannel -> Widget Name+renderChannelHeader st hs chan =+ let chnType = chan^.ccInfo.cdType+ topicStr = chan^.ccInfo.cdHeader+ userHeader u = let s = T.intercalate " " $ filter (not . T.null) parts+ parts = [ chanName+ , if (all T.null names)+ then mempty+ else "is"+ ] <> names <> [+ if T.null (u^.uiEmail)+ then mempty+ else "(" <> u^.uiEmail <> ")"+ ]+ names = [ u^.uiFirstName+ , nick+ , u^.uiLastName+ ]+ quote n = "\"" <> n <> "\""+ nick = maybe "" quote $ u^.uiNickName+ in s+ firstTopicLine = case T.lines topicStr of+ [h] -> h+ (h:_:_) -> h+ _ -> ""+ maybeTopic = if T.null topicStr+ then ""+ else " - " <> if st^.csShowExpandedChannelTopics+ then topicStr+ else firstTopicLine+ channelNameString = case chnType of+ Direct ->+ case chan^.ccInfo.cdDMUserId >>= flip userById st of+ Nothing -> chanName+ Just u -> userHeader u+ Private ->+ channelNamePair <> " (Private)"+ Group ->+ channelNamePair <> " (Private group)"+ _ ->+ channelNamePair+ channelNamePair = chanName <> " - " <> (chan^.ccInfo.cdDisplayName)+ chanName = mkChannelName st (chan^.ccInfo)+ baseUrl = serverBaseUrl st++ in renderText' (Just baseUrl) (myUsername st)+ hs+ (channelNameString <> maybeTopic)++renderCurrentChannelDisplay :: ChatState -> HighlightSet -> Widget Name+renderCurrentChannelDisplay st hs = header <=> hBorder <=> messages+ where+ header =+ if st^.csShowChannelList+ then channelHeader+ else headerWithStatus++ headerWithStatus =+ -- Render the channel list header next to the channel header+ -- itself. We want them to be separated by a vertical border,+ -- but we want the border to be as high as the tallest of the+ -- two. To make that work we need to render the two and then+ -- render a border between them that is the same height as the+ -- taller of the two. We can't do that without making a custom+ -- widget which is why we take this approach here rather than+ -- just putting them all in an hBox.+ Widget Fixed Fixed $ do+ ctx <- getContext+ statusBox <- render $+ hLimit (configChannelListWidth $ st^.csResources.crConfiguration) $+ (renderChannelListHeader st)++ let channelHeaderWidth = ctx^.availWidthL -+ (Vty.imageWidth $ statusBox^.imageL) - 1++ channelHeaderResult <- render $ hLimit channelHeaderWidth channelHeader++ let maxHeight = max (Vty.imageHeight $ statusBox^.imageL)+ (Vty.imageHeight $ channelHeaderResult^.imageL)+ statusBoxWidget = Widget Fixed Fixed $ return statusBox+ headerWidget = Widget Fixed Fixed $ return channelHeaderResult+ borderWidget = vLimit maxHeight vBorder++ render $ if appMode st == ChannelSelect+ then headerWidget+ else hBox $ case st^.csChannelListOrientation of+ ChannelListLeft ->+ [ statusBoxWidget+ , borderWidget+ , headerWidget+ ]+ ChannelListRight ->+ [ headerWidget+ , borderWidget+ , statusBoxWidget+ ]++ channelHeader =+ withDefAttr channelHeaderAttr $+ padRight Max $+ renderChannelHeader st hs chan++ messages = padTop Max chatText++ chatText = case appMode st of+ MessageSelect ->+ renderMessagesWithSelect (st^.csMessageSelect) channelMessages+ MessageSelectDeleteConfirm ->+ renderMessagesWithSelect (st^.csMessageSelect) channelMessages+ _ ->+ cached (ChannelMessages cId) $+ renderLastMessages st hs editCutoff $+ retrogradeMsgsWithThreadStates $+ reverseMessages channelMessages++ renderMessagesWithSelect (MessageSelectState selMsgId) msgs =+ -- In this case, we want to fill the message list with messages+ -- but use the post ID as a cursor. To do this efficiently we+ -- only want to render enough messages to fill the screen.+ --+ -- If the message area is H rows high, this actually renders at+ -- most 2H rows' worth of messages and then does the appropriate+ -- cropping. This way we can simplify the math needed to figure+ -- out how to crop while bounding the number of messages we+ -- render around the cursor.+ --+ -- First, we sanity-check the application state because under+ -- some conditions, the selected message might be gone (e.g.+ -- deleted).+ let (s, (before, after)) = splitDirSeqOn (\(m, _) -> m^.mMessageId == selMsgId) msgsWithStates+ msgsWithStates = chronologicalMsgsWithThreadStates msgs+ in case s of+ Nothing ->+ renderLastMessages st hs editCutoff before+ Just m ->+ unsafeRenderMessageSelection (m, (before, after)) (renderSingleMessage st hs Nothing)++ cutoff = getNewMessageCutoff cId st+ editCutoff = getEditedMessageCutoff cId st+ channelMessages =+ insertTransitions (getMessageListing cId st)+ cutoff+ (getDateFormat st)+ (st ^. timeZone)++ cId = st^.csCurrentChannelId+ chan = st^.csCurrentChannel++getMessageListing :: ChannelId -> ChatState -> Messages+getMessageListing cId st =+ st ^?! csChannels.folding (findChannelById cId) . ccContents . cdMessages . to (filterMessages isShown)+ where isShown m+ | st^.csResources.crUserPreferences.userPrefShowJoinLeave = True+ | otherwise = not $ isJoinLeave m++insertTransitions :: Messages -> Maybe NewMessageIndicator -> Text -> TimeZoneSeries -> Messages+insertTransitions ms cutoff = insertDateMarkers $ foldr addMessage ms newMessagesT+ where anyNondeletedNewMessages t =+ isJust $ findLatestUserMessage (not . view mDeleted) (messagesAfter t ms)+ newMessagesT = case cutoff of+ Nothing -> []+ Just Hide -> []+ Just (NewPostsAfterServerTime t)+ | anyNondeletedNewMessages t -> [newMessagesMsg $ justAfter t]+ | otherwise -> []+ Just (NewPostsStartingAt t)+ | anyNondeletedNewMessages (justBefore t) -> [newMessagesMsg $ justBefore t]+ | otherwise -> []+ newMessagesMsg d = newMessageOfType (T.pack "New Messages")+ (C NewMessagesTransition) d++renderChannelSelectPrompt :: ChatState -> Widget Name+renderChannelSelectPrompt st =+ let e = st^.csChannelSelectState.channelSelectInput+ in withDefAttr channelSelectPromptAttr $+ (txt "Switch to channel [use ^ and $ to anchor]: ") <+>+ (renderEditor (txt . T.concat) True e)++drawMain :: Bool -> ChatState -> [Widget Name]+drawMain useColor st =+ let maybeColor = if useColor then id else forceAttr "invalid"+ in maybeColor <$>+ [ connectionLayer st+ , autocompleteLayer st+ , joinBorders $ mainInterface st+ ]++connectionLayer :: ChatState -> Widget Name+connectionLayer st =+ case st^.csConnectionStatus of+ Connected -> emptyWidget+ Disconnected ->+ Widget Fixed Fixed $ do+ ctx <- getContext+ let aw = ctx^.availWidthL+ w = length msg + 2+ msg = "NOT CONNECTED"+ render $ translateBy (Location (max 0 (aw - w), 0)) $+ withDefAttr errorMessageAttr $+ border $ str msg++messageSelectBottomBar :: ChatState -> Widget Name+messageSelectBottomBar st =+ case getSelectedMessage st of+ Nothing -> emptyWidget+ Just postMsg ->+ let optionList = if null usableOptions+ then txt "(no actions available for this message)"+ else hBox $ intersperse (txt " ") usableOptions+ usableOptions = catMaybes $ mkOption <$> options+ mkOption (f, k, desc) = if f postMsg+ then Just $ withDefAttr messageSelectStatusAttr (txt k) <+>+ txt (":" <> desc)+ else Nothing+ numURLs = Seq.length $ msgURLs postMsg+ s = if numURLs == 1 then "" else "s"+ hasURLs = numURLs > 0+ openUrlsMsg = "open " <> (T.pack $ show numURLs) <> " URL" <> s+ hasVerb = isJust (findVerbatimChunk (postMsg^.mText))+ -- make sure these keybinding pieces are up-to-date!+ ev e =+ let keyconf = st^.csResources.crConfiguration.to configUserKeys+ KeyHandlerMap keymap = messageSelectKeybindings keyconf+ in T.intercalate ","+ [ ppBinding (eventToBinding k)+ | KH { khKey = k+ , khHandler = h+ } <- M.elems keymap+ , kehEventTrigger h == ByEvent e+ ]+ options = [ ( not . isGap+ , ev YankWholeMessageEvent+ , "yank-all"+ )+ , ( \m -> isFlaggable m && not (m^.mFlagged)+ , ev FlagMessageEvent+ , "flag"+ )+ , ( \m -> isFlaggable m && m^.mFlagged+ , ev FlagMessageEvent+ , "unflag"+ )+ , ( \m -> isPinnable m && not (m^.mPinned)+ , ev PinMessageEvent+ , "pin"+ )+ , ( \m -> isPinnable m && m^.mPinned+ , ev PinMessageEvent+ , "unpin"+ )+ , ( isReplyable+ , ev ReplyMessageEvent+ , "reply"+ )+ , ( not . isGap+ , ev ViewMessageEvent+ , "view"+ )+ , ( isGap+ , ev FillGapEvent+ , "load messages"+ )+ , ( \m -> isMine st m && isEditable m+ , ev EditMessageEvent+ , "edit"+ )+ , ( \m -> isMine st m && isDeletable m+ , ev DeleteMessageEvent+ , "delete"+ )+ , ( const hasURLs+ , ev OpenMessageURLEvent+ , openUrlsMsg+ )+ , ( const hasVerb+ , ev YankMessageEvent+ , "yank-code"+ )+ , ( isReactable+ , ev ReactToMessageEvent+ , "react"+ )+ ]++ in hBox [ borderElem bsHorizontal+ , txt "["+ , txt "Message select: "+ , optionList+ , txt "]"+ , hBorder+ ]++maybePreviewViewport :: Widget Name -> Widget Name+maybePreviewViewport w =+ Widget Greedy Fixed $ do+ result <- render w+ case (Vty.imageHeight $ result^.imageL) > previewMaxHeight of+ False -> return result+ True ->+ render $ vLimit previewMaxHeight $ viewport MessagePreviewViewport Vertical $+ (Widget Fixed Fixed $ return result)++inputPreview :: ChatState -> HighlightSet -> Widget Name+inputPreview st hs | not $ st^.csShowMessagePreview = emptyWidget+ | otherwise = thePreview+ where+ uId = myUserId st+ -- Insert a cursor sentinel into the input text just before+ -- rendering the preview. We use the inserted sentinel (which is+ -- not rendered) to get brick to ensure that the line the cursor is+ -- on is visible in the preview viewport. We put the sentinel at+ -- the *end* of the line because it will still influence markdown+ -- parsing and can create undesirable/confusing churn in the+ -- rendering while the cursor moves around. If the cursor is at the+ -- end of whatever line the user is editing, that is very unlikely+ -- to be a problem.+ curContents = getText $ (gotoEOL >>> insertChar cursorSentinel) $+ st^.csEditState.cedEditor.editContentsL+ curStr = T.intercalate "\n" curContents+ overrideTy = case st^.csEditState.cedEditMode of+ Editing _ ty -> Just ty+ _ -> Nothing+ baseUrl = serverBaseUrl st+ previewMsg = previewFromInput baseUrl overrideTy uId curStr+ thePreview = let noPreview = str "(No preview)"+ msgPreview = case previewMsg of+ Nothing -> noPreview+ Just pm -> if T.null curStr+ then noPreview+ else prview pm $ getParentMessage st pm+ prview m p = renderMessage MessageData+ { mdMessage = m+ , mdUserName = m^.mUser.to (nameForUserRef st)+ , mdParentMessage = p+ , mdParentUserName = p >>= (^.mUser.to (nameForUserRef st))+ , mdHighlightSet = hs+ , mdEditThreshold = Nothing+ , mdShowOlderEdits = False+ , mdRenderReplyParent = True+ , mdIndentBlocks = True+ , mdThreadState = NoThread+ , mdShowReactions = True+ , mdMessageWidthLimit = Nothing+ , mdMyUsername = myUsername st+ , mdWrapNonhighlightedCodeBlocks = True+ }+ in (maybePreviewViewport msgPreview) <=>+ hBorderWithLabel (withDefAttr clientEmphAttr $ str "[Preview ↑]")++userInputArea :: ChatState -> HighlightSet -> Widget Name+userInputArea st hs =+ case appMode st of+ ChannelSelect -> renderChannelSelectPrompt st+ UrlSelect -> hCenter $ hBox [ txt "Press "+ , withDefAttr clientEmphAttr $ txt "Enter"+ , txt " to open the selected URL or "+ , withDefAttr clientEmphAttr $ txt "Escape"+ , txt " to cancel."+ ]+ MessageSelectDeleteConfirm -> renderDeleteConfirm+ _ -> renderUserCommandBox st hs++renderDeleteConfirm :: Widget Name+renderDeleteConfirm =+ hCenter $ txt "Are you sure you want to delete the selected message? (y/n)"++mainInterface :: ChatState -> Widget Name+mainInterface st =+ vBox [ body+ , bottomBorder+ , inputPreview st hs+ , userInputArea st hs+ ]+ where+ body = if st^.csShowChannelList || appMode st == ChannelSelect+ then case st^.csChannelListOrientation of+ ChannelListLeft ->+ hBox [channelList, vBorder, mainDisplay]+ ChannelListRight ->+ hBox [mainDisplay, vBorder, channelList]+ else mainDisplay+ channelList = hLimit channelListWidth (renderChannelList st)+ hs = getHighlightSet st+ channelListWidth = configChannelListWidth $ st^.csResources.crConfiguration+ mainDisplay = case appMode st of+ UrlSelect -> renderUrlList st+ _ -> maybeSubdue $ renderCurrentChannelDisplay st hs++ bottomBorder = case appMode st of+ MessageSelect -> messageSelectBottomBar st+ _ -> maybeSubdue $ hBox+ [ showAttachmentCount+ , hBorder+ , showTypingUsers+ , showBusy+ ]++ showAttachmentCount =+ let count = length $ listElements $ st^.csEditState.cedAttachmentList+ in if count == 0+ then emptyWidget+ else hBox [ borderElem bsHorizontal+ , withDefAttr clientMessageAttr $+ txt $ "(" <> (T.pack $ show count) <> " attachment" <>+ (if count == 1 then "" else "s") <> "; "+ , withDefAttr clientEmphAttr $+ txt $ ppBinding (getFirstDefaultBinding ShowAttachmentListEvent)+ , txt " to manage)"+ ]++ showTypingUsers =+ let format = renderText' Nothing (myUsername st) hs+ in case allTypingUsers (st^.csCurrentChannel.ccInfo.cdTypingUsers) of+ [] -> emptyWidget+ [uId] | Just un <- usernameForUserId uId st ->+ format $ "[" <> userSigil <> un <> " is typing]"+ [uId1, uId2] | Just un1 <- usernameForUserId uId1 st+ , Just un2 <- usernameForUserId uId2 st ->+ format $ "[" <> userSigil <> un1 <> " and " <> userSigil <> un2 <> " are typing]"+ _ -> format "[several people are typing]"++ showBusy = case st^.csWorkerIsBusy of+ Just (Just n) -> hLimit 2 hBorder <+> txt (T.pack $ "*" <> show n)+ Just Nothing -> hLimit 2 hBorder <+> txt "*"+ Nothing -> emptyWidget++ maybeSubdue = if appMode st == ChannelSelect+ then forceAttr ""+ else id++replyArrow :: Widget a+replyArrow =+ Widget Fixed Fixed $ do+ ctx <- getContext+ let bs = ctx^.ctxBorderStyleL+ render $ str [' ', bsCornerTL bs, '▸']
+ src/Matterhorn/Draw/ManageAttachments.hs view
@@ -0,0 +1,64 @@+module Matterhorn.Draw.ManageAttachments+ ( drawManageAttachments+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center+import qualified Brick.Widgets.FileBrowser as FB+import Brick.Widgets.List+import Data.Maybe ( fromJust )++import Matterhorn.Types+import Matterhorn.Types.KeyEvents+import Matterhorn.Events.Keybindings ( getFirstDefaultBinding )+import Matterhorn.Themes+++drawManageAttachments :: ChatState -> Widget Name+drawManageAttachments st =+ topLayer+ where+ topLayer = case appMode st of+ ManageAttachments -> drawAttachmentList st+ ManageAttachmentsBrowseFiles -> drawFileBrowser st+ _ -> error "BUG: drawManageAttachments called in invalid mode"++drawAttachmentList :: ChatState -> Widget Name+drawAttachmentList st =+ let addBinding = ppBinding $ getFirstDefaultBinding AttachmentListAddEvent+ delBinding = ppBinding $ getFirstDefaultBinding AttachmentListDeleteEvent+ escBinding = ppBinding $ getFirstDefaultBinding CancelEvent+ openBinding = ppBinding $ getFirstDefaultBinding AttachmentOpenEvent+ in centerLayer $+ hLimit 60 $+ vLimit 15 $+ joinBorders $+ borderWithLabel (withDefAttr clientEmphAttr $ txt "Attachments") $+ vBox [ renderList renderAttachmentItem True (st^.csEditState.cedAttachmentList)+ , hBorder+ , hCenter $ withDefAttr clientMessageAttr $+ txt $ addBinding <> ":add " <>+ delBinding <> ":delete " <>+ openBinding <> ":open " <>+ escBinding <> ":close"+ ]++renderAttachmentItem :: Bool -> AttachmentData -> Widget Name+renderAttachmentItem _ d =+ padRight Max $ str $ FB.fileInfoSanitizedFilename $ attachmentDataFileInfo d++drawFileBrowser :: ChatState -> Widget Name+drawFileBrowser st =+ centerLayer $+ hLimit 60 $+ vLimit 20 $+ borderWithLabel (withDefAttr clientEmphAttr $ txt "Attach File") $+ -- invariant: cedFileBrowser is not Nothing if appMode is+ -- ManageAttachmentsBrowseFiles, and that is the only way to reach+ -- this code, ergo the fromJust.+ FB.renderFileBrowser True $ fromJust (st^.csEditState.cedFileBrowser)
+ src/Matterhorn/Draw/Messages.hs view
@@ -0,0 +1,209 @@+{-# LANGUAGE MultiWayIf #-}+module Matterhorn.Draw.Messages+ ( nameForUserRef+ , renderSingleMessage+ , unsafeRenderMessageSelection+ , renderLastMessages+ )+where++import Brick+import Brick.Widgets.Border+import Control.Monad.Trans.Reader ( withReaderT )+import qualified Data.Foldable as F+import qualified Graphics.Vty as Vty+import Lens.Micro.Platform ( (.~), to )+import Network.Mattermost.Types ( ServerTime(..), userUsername )+import Prelude ()+import Matterhorn.Prelude++import Matterhorn.Draw.Util+import Matterhorn.Draw.RichText+import Matterhorn.Themes+import Matterhorn.Types+import Matterhorn.Types.DirectionalSeq++maxMessageHeight :: Int+maxMessageHeight = 200++-- | nameForUserRef converts the UserRef into a printable name, based+-- on the current known user data.+nameForUserRef :: ChatState -> UserRef -> Maybe Text+nameForUserRef st uref =+ case uref of+ NoUser -> Nothing+ UserOverride _ t -> Just t+ UserI _ uId -> displayNameForUserId uId st++-- | renderSingleMessage is the main message drawing function.+--+-- The `ind` argument specifies an "indicator boundary". Showing+-- various indicators (e.g. "edited") is not typically done for+-- messages that are older than this boundary value.+renderSingleMessage :: ChatState+ -> HighlightSet+ -> Maybe ServerTime+ -> Message+ -> ThreadState+ -> Widget Name+renderSingleMessage st hs ind m threadState =+ renderChatMessage st hs ind threadState (withBrackets . renderTime st . withServerTime) m++renderChatMessage :: ChatState+ -> HighlightSet+ -> Maybe ServerTime+ -> ThreadState+ -> (ServerTime -> Widget Name)+ -> Message+ -> Widget Name+renderChatMessage st hs ind threadState renderTimeFunc msg =+ let showOlderEdits = configShowOlderEdits config+ showTimestamp = configShowMessageTimestamps config+ config = st^.csResources.crConfiguration+ parent = case msg^.mInReplyToMsg of+ NotAReply -> Nothing+ InReplyTo pId -> getMessageForPostId st pId+ m = renderMessage MessageData+ { mdMessage = msg+ , mdUserName = msg^.mUser.to (nameForUserRef st)+ , mdParentMessage = parent+ , mdParentUserName = parent >>= (^.mUser.to (nameForUserRef st))+ , mdEditThreshold = ind+ , mdHighlightSet = hs+ , mdShowOlderEdits = showOlderEdits+ , mdRenderReplyParent = True+ , mdIndentBlocks = True+ , mdThreadState = threadState+ , mdShowReactions = True+ , mdMessageWidthLimit = Nothing+ , mdMyUsername = userUsername $ myUser st+ , mdWrapNonhighlightedCodeBlocks = True+ }+ fullMsg =+ case msg^.mUser of+ NoUser+ | isGap msg -> withDefAttr gapMessageAttr m+ | otherwise ->+ case msg^.mType of+ C DateTransition ->+ withDefAttr dateTransitionAttr (hBorderWithLabel m)+ C NewMessagesTransition ->+ withDefAttr newMessageTransitionAttr (hBorderWithLabel m)+ C Error ->+ withDefAttr errorMessageAttr m+ _ ->+ withDefAttr clientMessageAttr m+ _ | isJoinLeave msg -> withDefAttr clientMessageAttr m+ | otherwise -> m+ maybeRenderTime w =+ if showTimestamp+ then let maybePadTime = if threadState == InThreadShowParent+ then (txt " " <=>) else id+ in hBox [maybePadTime $ renderTimeFunc (msg^.mDate), txt " ", w]+ else w+ maybeRenderTimeWith f = if isTransition msg then id else f+ in maybeRenderTimeWith maybeRenderTime fullMsg++-- | Render a selected message with focus, including the messages+-- before and the messages after it. The foldable parameters exist+-- because (depending on the situation) we might use either of the+-- message list types for the 'before' and 'after' (i.e. the+-- chronological or retrograde message sequences).+unsafeRenderMessageSelection :: (Foldable f, Foldable g)+ => ((Message, ThreadState), (f (Message, ThreadState), g (Message, ThreadState)))+ -> (Message -> ThreadState -> Widget Name)+ -> Widget Name+unsafeRenderMessageSelection ((curMsg, curThreadState), (before, after)) doMsgRender =+ Widget Greedy Greedy $ do+ ctx <- getContext+ curMsgResult <- withReaderT relaxHeight $ render $+ forceAttr messageSelectAttr $+ padRight Max $ doMsgRender curMsg curThreadState++ let targetHeight = ctx^.availHeightL+ upperHeight = targetHeight `div` 2+ lowerHeight = targetHeight - upperHeight++ lowerRender img (m, tState) = render1HLimit doMsgRender Vty.vertJoin targetHeight img tState m+ upperRender img (m, tState) = render1HLimit doMsgRender (flip Vty.vertJoin) targetHeight img tState m++ lowerHalf <- foldM lowerRender Vty.emptyImage after+ upperHalf <- foldM upperRender Vty.emptyImage before++ let curHeight = Vty.imageHeight $ curMsgResult^.imageL+ uncropped = upperHalf Vty.<-> curMsgResult^.imageL Vty.<-> lowerHalf+ img = if | Vty.imageHeight lowerHalf < (lowerHeight - curHeight) ->+ Vty.cropTop targetHeight uncropped+ | Vty.imageHeight upperHalf < upperHeight ->+ Vty.cropBottom targetHeight uncropped+ | otherwise ->+ Vty.cropTop upperHeight upperHalf Vty.<-> curMsgResult^.imageL Vty.<->+ (if curHeight < lowerHeight+ then Vty.cropBottom (lowerHeight - curHeight) lowerHalf+ else Vty.cropBottom lowerHeight lowerHalf)+ return $ emptyResult & imageL .~ img++renderLastMessages :: ChatState+ -> HighlightSet+ -> Maybe ServerTime+ -> DirectionalSeq Retrograde (Message, ThreadState)+ -> Widget Name+renderLastMessages st hs editCutoff msgs =+ Widget Greedy Greedy $ do+ ctx <- getContext+ let targetHeight = ctx^.availHeightL+ doMsgRender = renderSingleMessage st hs editCutoff++ newMessagesTransitions = filterMessages (isNewMessagesTransition . fst) msgs+ newMessageTransition = fst <$> (listToMaybe $ F.toList newMessagesTransitions)++ isBelow m transition = m^.mDate > transition^.mDate++ go :: Vty.Image -> DirectionalSeq Retrograde (Message, ThreadState) -> RenderM Name Vty.Image+ go img ms | messagesLength ms == 0 = return img+ go img ms = do+ let Just (m, threadState) = messagesHead ms+ newMessagesAbove = maybe False (isBelow m) newMessageTransition+ newImg <- render1HLimit doMsgRender (flip Vty.vertJoin) targetHeight img threadState m+ -- If the new message fills the window, check whether+ -- there is still a "New Messages" transition that is+ -- not displayed. If there is, then we need to replace+ -- the top line of the new image with a "New Messages"+ -- indicator.+ if Vty.imageHeight newImg >= targetHeight && newMessagesAbove+ then do+ transitionResult <- render $ withDefAttr newMessageTransitionAttr $+ hBorderWithLabel (txt "New Messages ↑")+ let newImg2 = Vty.vertJoin (transitionResult^.imageL)+ (Vty.cropTop (targetHeight - 1) newImg)+ return newImg2+ else go newImg $ messagesDrop 1 ms++ img <- go Vty.emptyImage msgs+ return $ emptyResult & imageL .~ (Vty.cropTop targetHeight img)++relaxHeight :: Context -> Context+relaxHeight c = c & availHeightL .~ (max maxMessageHeight (c^.availHeightL))++render1HLimit :: (Message -> ThreadState -> Widget Name)+ -> (Vty.Image -> Vty.Image -> Vty.Image)+ -> Int+ -> Vty.Image+ -> ThreadState+ -> Message+ -> RenderM Name Vty.Image+render1HLimit doMsgRender fjoin lim img threadState msg+ | Vty.imageHeight img >= lim = return img+ | otherwise = fjoin img <$> render1 doMsgRender threadState msg++render1 :: (Message -> ThreadState -> Widget Name)+ -> ThreadState+ -> Message+ -> RenderM Name Vty.Image+render1 doMsgRender threadState msg = case msg^.mDeleted of+ True -> return Vty.emptyImage+ False -> do+ r <- withReaderT relaxHeight $+ render $ padRight Max $+ doMsgRender msg threadState+ return $ r^.imageL
+ src/Matterhorn/Draw/NotifyPrefs.hs view
@@ -0,0 +1,35 @@+module Matterhorn.Draw.NotifyPrefs+ ( drawNotifyPrefs+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center+import Brick.Forms (renderForm)+import Data.List (intersperse)++import Matterhorn.Draw.Util (renderKeybindingHelp)+import Matterhorn.Types+import Matterhorn.Types.KeyEvents+import Matterhorn.Themes++drawNotifyPrefs :: ChatState -> Widget Name+drawNotifyPrefs st =+ let Just form = st^.csNotifyPrefs+ label = forceAttr clientEmphAttr $ str "Notification Preferences"+ formKeys = withDefAttr clientEmphAttr <$> txt <$> ["Tab", "BackTab"]+ bindings = vBox $ hCenter <$> [ renderKeybindingHelp "Save" [FormSubmitEvent] <+> txt " " <+>+ renderKeybindingHelp "Cancel" [CancelEvent]+ , hBox ((intersperse (txt "/") formKeys) <> [txt (":Cycle form fields")])+ , hBox [withDefAttr clientEmphAttr $ txt "Space", txt ":Toggle form field"]+ ]+ in centerLayer $+ vLimit 25 $+ hLimit 39 $+ joinBorders $+ borderWithLabel label $+ (padAll 1 $ renderForm form) <=> hBorder <=> bindings
+ src/Matterhorn/Draw/PostListOverlay.hs view
@@ -0,0 +1,109 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TupleSections #-}++module Matterhorn.Draw.PostListOverlay where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center+import Control.Monad.Trans.Reader ( withReaderT )+import qualified Data.Text as T+import Lens.Micro.Platform ( (^?), (%~), to )++import Network.Mattermost.Lenses+import Network.Mattermost.Types++import Matterhorn.Constants ( userSigil )+import Matterhorn.Draw.Messages+import Matterhorn.Draw.Util+import Matterhorn.Themes+import Matterhorn.Types+++hLimitWithPadding :: Int -> Widget n -> Widget n+hLimitWithPadding pad contents = Widget+ { hSize = Fixed+ , vSize = (vSize contents)+ , render =+ withReaderT (& availWidthL %~ (\ n -> n - (2 * pad))) $ render $ cropToContext contents+ }++drawPostListOverlay :: PostListContents -> ChatState -> Widget Name+drawPostListOverlay contents st = joinBorders $ drawPostsBox contents st++-- | Draw a PostListOverlay as a floating overlay on top of whatever+-- is rendered beneath it+drawPostsBox :: PostListContents -> ChatState -> Widget Name+drawPostsBox contents st =+ centerLayer $ hLimitWithPadding 10 $ borderWithLabel contentHeader $+ padRight (Pad 1) messageListContents+ where -- The 'window title' of the overlay+ hs = getHighlightSet st+ contentHeader = withAttr channelListHeaderAttr $ txt $ case contents of+ PostListFlagged -> "Flagged posts"+ PostListPinned cId ->+ let cName = case findChannelById cId (st^.csChannels) of+ Nothing -> "<UNKNOWN>"+ Just cc -> mkChannelName st (cc^.ccInfo)+ in "Posts pinned in " <> cName+ PostListSearch terms searching -> "Search results" <> if searching+ then ": " <> terms+ else " (" <> (T.pack . show . length) messages <> "): " <> terms++ messages = insertDateMarkers+ (filterMessages knownChannel $ st^.csPostListOverlay.postListPosts)+ (getDateFormat st)+ (st^.timeZone)++ knownChannel msg =+ case msg^.mChannelId of+ Just cId | Nothing <- st^?csChannels.channelByIdL(cId) -> False+ _ -> True++ -- The overall contents, with a sensible default even if there+ -- are no messages+ messageListContents+ | null messages =+ padTopBottom 1 $+ hCenter $+ withDefAttr clientEmphAttr $+ str $ case contents of+ PostListFlagged -> "You have no flagged messages."+ PostListPinned _ -> "This channel has no pinned messages."+ PostListSearch _ searching ->+ if searching+ then "Searching ..."+ else "No search results found"+ | otherwise = vBox renderedMessageList++ -- The render-message function we're using+ renderMessageForOverlay msg tState =+ let renderedMsg = renderSingleMessage st hs Nothing msg tState+ in case msg^.mOriginalPost of+ -- We should factor out some of the channel name logic at+ -- some point, but we can do that later+ Just post+ | Just chan <- st^?csChannels.channelByIdL(post^.postChannelIdL) ->+ case chan^.ccInfo.cdType of+ Direct+ | Just u <- flip userById st =<< chan^.ccInfo.cdDMUserId ->+ (forceAttr channelNameAttr (txt (userSigil <> u^.uiName)) <=>+ (str " " <+> renderedMsg))+ _ -> (forceAttr channelNameAttr (txt (chan^.ccInfo.to (mkChannelName st))) <=>+ (str " " <+> renderedMsg))+ _ | CP _ <- msg^.mType -> str "[BUG: unknown channel]"+ | otherwise -> renderedMsg++ -- The full message list, rendered with the current selection+ renderedMessageList =+ let (s, (before, after)) = splitDirSeqOn matchesMessage messagesWithStates+ matchesMessage (m, _) = m^.mMessageId == (MessagePostId <$> st^.csPostListOverlay.postListSelected)+ messagesWithStates = (, InThreadShowParent) <$> messages+ in case s of+ Nothing ->+ map (uncurry renderMessageForOverlay) (reverse (toList messagesWithStates))+ Just curMsg ->+ [unsafeRenderMessageSelection (curMsg, (after, before)) renderMessageForOverlay]
+ src/Matterhorn/Draw/ReactionEmojiListOverlay.hs view
@@ -0,0 +1,39 @@+module Matterhorn.Draw.ReactionEmojiListOverlay+ ( drawReactionEmojiListOverlay+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.List ( listSelectedFocusedAttr )+import qualified Data.Text as T++import Matterhorn.Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )+import Matterhorn.Types+import Matterhorn.Themes+++drawReactionEmojiListOverlay :: ChatState -> Widget Name+drawReactionEmojiListOverlay st =+ let overlay = drawListOverlay (st^.csReactionEmojiListOverlay)+ (const $ txt "Search Emoji")+ (const $ txt "No matching emoji found.")+ (const $ txt "Search emoji:")+ renderEmoji+ Nothing+ OverlayCenter+ 80+ in joinBorders overlay++renderEmoji :: Bool -> (Bool, T.Text) -> Widget Name+renderEmoji sel (mine, e) =+ let maybeForce = if sel+ then forceAttr listSelectedFocusedAttr+ else id+ in maybeForce $+ padRight Max $+ hBox [ if mine then txt " * " else txt " "+ , withDefAttr emojiAttr $ txt $ ":" <> e <> ":"+ ]
+ src/Matterhorn/Draw/RichText.hs view
@@ -0,0 +1,619 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE ViewPatterns #-}+{-# LANGUAGE ParallelListComp #-}+{-# LANGUAGE RecordWildCards #-}+module Matterhorn.Draw.RichText+ ( MessageData(..)+ , renderRichText+ , renderMessage+ , renderText+ , renderText'+ , renderElementSeq+ , cursorSentinel+ , addEllipsis+ , findVerbatimChunk+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick ( (<+>), Widget, hLimit, imageL+ , raw, render, Size(..), Widget(..)+ )+import qualified Brick as B+import qualified Brick.Widgets.Border as B+import qualified Brick.Widgets.Skylighting as BS+import Control.Monad.Reader+import qualified Data.Foldable as F+import qualified Data.Map.Strict as Map+import qualified Data.Sequence as Seq+import Data.Sequence ( ViewL(..)+ , ViewR(..)+ , (<|)+ , (|>)+ , viewl+ , viewr)+import qualified Data.Sequence as S+import qualified Data.Set as Set+import qualified Data.Text as T+import qualified Graphics.Vty as V+import qualified Skylighting.Core as Sky++import Network.Mattermost.Lenses ( postEditAtL, postCreateAtL )+import Network.Mattermost.Types ( ServerTime(..), PostId )++import Matterhorn.Constants ( normalChannelSigil, userSigil )+import Matterhorn.Themes+import Matterhorn.Types ( Name, HighlightSet(..) )+import Matterhorn.Types.Messages+import Matterhorn.Types.Posts+import Matterhorn.Types.RichText+++emptyHSet :: HighlightSet+emptyHSet = HighlightSet Set.empty Set.empty mempty++omittedUsernameType :: MessageType -> Bool+omittedUsernameType = \case+ CP Join -> True+ CP Leave -> True+ CP TopicChange -> True+ _ -> False++-- Add the edit sentinel to the end of the last block in the sequence.+-- If the last block is a paragraph, append it to that paragraph.+-- Otherwise, append a new block so it appears beneath the last+-- block-level element.+addEditSentinel :: ElementData -> Seq RichTextBlock -> Seq RichTextBlock+addEditSentinel d bs =+ case viewr bs of+ EmptyR -> bs+ (rest :> b) -> rest <> appendEditSentinel d b++appendEditSentinel :: ElementData -> RichTextBlock -> Seq RichTextBlock+appendEditSentinel sentinel b =+ let s = Para (S.singleton m)+ m = Element Normal sentinel+ in case b of+ Para is -> S.singleton $ Para (is |> Element Normal ESpace |> m)+ _ -> S.fromList [b, s]++-- | A bundled structure that includes all the information necessary+-- to render a given message+data MessageData =+ MessageData { mdEditThreshold :: Maybe ServerTime+ -- ^ If specified, any messages edited before this point+ -- in time are not indicated as edited.+ , mdShowOlderEdits :: Bool+ -- ^ Indicates whether "edited" markers should be shown+ -- for old messages (i.e., ignore the mdEditThreshold+ -- value).+ , mdShowReactions :: Bool+ -- ^ Whether to render reactions.+ , mdMessage :: Message+ -- ^ The message to render.+ , mdUserName :: Maybe Text+ -- ^ The username of the message's author, if any. This+ -- is passed here rather than obtaining from the message+ -- because we need to do lookups in the ChatState to+ -- compute this, and we don't pass the ChatState into+ -- renderMessage.+ , mdParentMessage :: Maybe Message+ -- ^ The parent message of this message, if any.+ , mdParentUserName :: Maybe Text+ -- ^ The author of the parent message, if any.+ , mdThreadState :: ThreadState+ -- ^ The thread state of this message.+ , mdRenderReplyParent :: Bool+ -- ^ Whether to render the parent message.+ , mdHighlightSet :: HighlightSet+ -- ^ The highlight set to use to highlight usernames,+ -- channel names, etc.+ , mdIndentBlocks :: Bool+ -- ^ Whether to indent the message underneath the+ -- author's name (True) or just display it to the right+ -- of the author's name (False).+ , mdMessageWidthLimit :: Maybe Int+ -- ^ A width override to use to wrap non-code blocks+ -- and code blocks without syntax highlighting. If+ -- unspecified, all blocks in the message will be+ -- wrapped and truncated at the width specified by the+ -- rendering context. If specified, all non-code blocks+ -- will be wrapped at this width and highlighted code+ -- blocks will be rendered using the context's width.+ , mdMyUsername :: Text+ -- ^ The username of the user running Matterhorn.+ , mdWrapNonhighlightedCodeBlocks :: Bool+ -- ^ Whether to wrap text in non-highlighted code+ -- blocks.+ }++-- | renderMessage performs markdown rendering of the specified message.+renderMessage :: MessageData -> Widget Name+renderMessage md@MessageData { mdMessage = msg, .. } =+ let msgUsr = case mdUserName of+ Just u -> if omittedUsernameType (msg^.mType) then Nothing else Just u+ Nothing -> Nothing+ botElem = if isBotMessage msg then B.txt "[BOT]" else B.emptyWidget+ nameElems = case msgUsr of+ Just un+ | isEmote msg ->+ [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN]" else ""+ , B.txt $ (if msg^.mFlagged then "[!] " else "") <> "*"+ , colorUsername mdMyUsername un un+ , botElem+ , B.txt " "+ ]+ | otherwise ->+ [ B.withDefAttr pinnedMessageIndicatorAttr $ B.txt $ if msg^.mPinned then "[PIN] " else ""+ , colorUsername mdMyUsername un un+ , botElem+ , B.txt $ (if msg^.mFlagged then "[!]" else "") <> ": "+ ]+ Nothing -> []++ -- Use the editing threshold to determine whether to append an+ -- editing indication to this message.+ maybeAugment bs = case msg^.mOriginalPost of+ Nothing -> bs+ Just p ->+ if p^.postEditAtL > p^.postCreateAtL+ then case mdEditThreshold of+ Just cutoff | p^.postEditAtL >= cutoff ->+ addEditSentinel (EEditSentinel True) bs+ _ -> if mdShowOlderEdits+ then addEditSentinel (EEditSentinel False) bs+ else bs+ else bs++ augmentedText = maybeAugment $ msg^.mText+ msgWidget =+ vBox $ (layout mdHighlightSet mdMessageWidthLimit nameElems augmentedText . viewl) augmentedText :+ catMaybes [msgAtch, msgReac]+ replyIndent = B.Widget B.Fixed B.Fixed $ do+ ctx <- B.getContext+ -- NB: The amount subtracted here must be the total padding+ -- added below (pad 1 + vBorder)+ w <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) msgWidget+ B.render $ B.vLimit (V.imageHeight $ w^.B.imageL) $+ B.padRight (B.Pad 1) B.vBorder B.<+> (B.Widget B.Fixed B.Fixed $ return w)+ msgAtch = if Seq.null (msg^.mAttachments)+ then Nothing+ else Just $ B.withDefAttr clientMessageAttr $ vBox+ [ B.txt (" [attached: `" <> a^.attachmentName <> "`]")+ | a <- toList (msg^.mAttachments)+ ]+ msgReac = if Map.null (msg^.mReactions) || (not mdShowReactions)+ then Nothing+ else let renderR e us =+ let n = Set.size us+ in if | n == 1 -> " [" <> e <> "]"+ | n > 1 -> " [" <> e <> " " <> T.pack (show n) <> "]"+ | otherwise -> ""+ reactionMsg = Map.foldMapWithKey renderR (msg^.mReactions)+ in Just $ B.withDefAttr emojiAttr $ B.txt (" " <> reactionMsg)+ withParent p =+ case mdThreadState of+ NoThread -> msgWidget+ InThreadShowParent -> p B.<=> replyIndent+ InThread -> replyIndent+ in if not mdRenderReplyParent+ then msgWidget+ else case msg^.mInReplyToMsg of+ NotAReply -> msgWidget+ InReplyTo _ ->+ case mdParentMessage of+ Nothing -> withParent (B.str "[loading...]")+ Just pm ->+ let parentMsg = renderMessage md+ { mdShowOlderEdits = False+ , mdMessage = pm+ , mdUserName = mdParentUserName+ , mdParentMessage = Nothing+ , mdRenderReplyParent = False+ , mdIndentBlocks = False+ }+ in withParent (addEllipsis $ B.forceAttr replyParentAttr parentMsg)++ where+ layout :: HighlightSet -> Maybe Int -> [Widget Name] -> Seq RichTextBlock+ -> ViewL RichTextBlock -> Widget Name+ layout hs w nameElems bs xs | length xs > 1 = multiLnLayout hs w nameElems bs+ layout hs w nameElems bs (Blockquote {} :< _) = multiLnLayout hs w nameElems bs+ layout hs w nameElems bs (CodeBlock {} :< _) = multiLnLayout hs w nameElems bs+ layout hs w nameElems bs (HTMLBlock {} :< _) = multiLnLayout hs w nameElems bs+ layout hs w nameElems bs (List {} :< _) = multiLnLayout hs w nameElems bs+ layout hs w nameElems bs (Para inlns :< _)+ | F.any breakCheck inlns = multiLnLayout hs w nameElems bs+ layout hs w nameElems bs _ = nameNextToMessage hs w nameElems bs++ multiLnLayout hs w nameElems bs =+ if mdIndentBlocks+ then vBox [ hBox nameElems+ , hBox [B.txt " ", renderRichText mdMyUsername hs ((subtract 2) <$> w) mdWrapNonhighlightedCodeBlocks bs]+ ]+ else nameNextToMessage hs w nameElems bs++ nameNextToMessage hs w nameElems bs =+ Widget Fixed Fixed $ do+ nameResult <- render $ hBox nameElems+ let newW = subtract (V.imageWidth (nameResult^.imageL)) <$> w+ render $ hBox [raw (nameResult^.imageL), renderRichText mdMyUsername hs newW mdWrapNonhighlightedCodeBlocks bs]++ breakCheck e = eData e `elem` [ELineBreak, ESoftBreak]++addEllipsis :: Widget a -> Widget a+addEllipsis w = B.Widget (B.hSize w) (B.vSize w) $ do+ ctx <- B.getContext+ let aw = ctx^.B.availWidthL+ result <- B.render w+ let withEllipsis = (B.hLimit (aw - 3) $ B.vLimit 1 $ (B.Widget B.Fixed B.Fixed $ return result)) <+>+ B.str "..."+ if (V.imageHeight (result^.B.imageL) > 1) || (V.imageWidth (result^.B.imageL) == aw) then+ B.render withEllipsis else+ return result++-- Cursor sentinel for tracking the user's cursor position in previews.+cursorSentinel :: Char+cursorSentinel = '‸'++-- Render markdown with username highlighting+renderRichText :: Text -> HighlightSet -> Maybe Int -> Bool -> Seq RichTextBlock -> Widget a+renderRichText curUser hSet w wrap bs =+ runReader (do+ blocks <- mapM blockToWidget (addBlankLines bs)+ return $ B.vBox $ toList blocks)+ (DrawCfg { drawCurUser = curUser+ , drawHighlightSet = hSet+ , drawLineWidth = w+ , drawDoLineWrapping = wrap+ })++-- Add blank lines only between adjacent elements of the same type, to+-- save space+addBlankLines :: Seq RichTextBlock -> Seq RichTextBlock+addBlankLines = go' . viewl+ where go' EmptyL = S.empty+ go' (x :< xs) = go x (viewl xs)+ go a@Para {} (b@Para {} :< rs) =+ a <| blank <| go b (viewl rs)+ go a@Header {} (b@Header {} :< rs) =+ a <| blank <| go b (viewl rs)+ go a@Blockquote {} (b@Blockquote {} :< rs) =+ a <| blank <| go b (viewl rs)+ go a@List {} (b@List {} :< rs) =+ a <| blank <| go b (viewl rs)+ go a@CodeBlock {} (b@CodeBlock {} :< rs) =+ a <| blank <| go b (viewl rs)+ go a@HTMLBlock {} (b@HTMLBlock {} :< rs) =+ a <| blank <| go b (viewl rs)+ go x (y :< rs) = x <| go y (viewl rs)+ go x (EmptyL) = S.singleton x+ blank = Para (S.singleton (Element Normal ESpace))++-- Render text to markdown without username highlighting or permalink detection+renderText :: Text -> Widget a+renderText txt = renderText' Nothing "" emptyHSet txt++renderText' :: Maybe TeamBaseURL -> Text -> HighlightSet -> Text -> Widget a+renderText' baseUrl curUser hSet t = renderRichText curUser hSet Nothing True rtBs+ where rtBs = parseMarkdown baseUrl t++vBox :: F.Foldable f => f (Widget a) -> Widget a+vBox = B.vBox . toList++hBox :: F.Foldable f => f (Widget a) -> Widget a+hBox = B.hBox . toList++header :: Int -> Widget a+header n = B.txt (T.replicate n "#")++maybeHLimit :: Maybe Int -> Widget a -> Widget a+maybeHLimit Nothing w = w+maybeHLimit (Just i) w = hLimit i w++type M a = Reader DrawCfg a++data DrawCfg =+ DrawCfg { drawCurUser :: Text+ , drawHighlightSet :: HighlightSet+ , drawLineWidth :: Maybe Int+ , drawDoLineWrapping :: Bool+ }++blockToWidget :: RichTextBlock -> M (Widget a)+blockToWidget (Para is) =+ toElementChunk is+blockToWidget (Header n is) = do+ headerTxt <- withReader (\c -> c { drawLineWidth = subtract 1 <$> drawLineWidth c }) $+ toElementChunk is+ return $ B.withDefAttr clientHeaderAttr $+ hBox [ B.padRight (B.Pad 1) $ header n+ , headerTxt+ ]+blockToWidget (Blockquote is) = do+ w <- asks drawLineWidth+ bs <- mapM blockToWidget is+ return $ maybeHLimit w $ addQuoting $ vBox bs+blockToWidget (List _ l bs) = do+ w <- asks drawLineWidth+ lst <- blocksToList l bs+ return $ maybeHLimit w lst+blockToWidget (CodeBlock ci tx) = do+ hSet <- asks drawHighlightSet++ let f = maybe rawCodeBlockToWidget+ (codeBlockToWidget (hSyntaxMap hSet))+ mSyntax+ mSyntax = do+ lang <- codeBlockLanguage ci+ Sky.lookupSyntax lang (hSyntaxMap hSet)+ f tx+blockToWidget (HTMLBlock t) = do+ w <- asks drawLineWidth+ return $ maybeHLimit w $ textWithCursor t+blockToWidget (HRule) = do+ w <- asks drawLineWidth+ return $ maybeHLimit w $ B.vLimit 1 (B.fill '*')++quoteChar :: Char+quoteChar = '>'++addQuoting :: B.Widget n -> B.Widget n+addQuoting w =+ B.Widget B.Fixed (B.vSize w) $ do+ ctx <- B.getContext+ childResult <- B.render $ B.hLimit (ctx^.B.availWidthL - 2) w++ let quoteBorder = B.raw $ V.charFill (ctx^.B.attrL) quoteChar 1 height+ height = V.imageHeight $ childResult^.B.imageL++ B.render $ B.hBox [ B.padRight (B.Pad 1) quoteBorder+ , B.Widget B.Fixed B.Fixed $ return childResult+ ]++codeBlockToWidget :: Sky.SyntaxMap -> Sky.Syntax -> Text -> M (Widget a)+codeBlockToWidget syntaxMap syntax tx = do+ let result = Sky.tokenize cfg syntax tx+ cfg = Sky.TokenizerConfig syntaxMap False+ case result of+ Left _ -> rawCodeBlockToWidget tx+ Right tokLines -> do+ let padding = B.padLeftRight 1 (B.vLimit (length tokLines) B.vBorder)+ return $ (B.txt $ "[" <> Sky.sName syntax <> "]") B.<=>+ (padding <+> BS.renderRawSource textWithCursor tokLines)++rawCodeBlockToWidget :: Text -> M (Widget a)+rawCodeBlockToWidget tx = do+ wrap <- asks drawDoLineWrapping++ let hPolicy = if wrap then Greedy else Fixed+ return $ B.withDefAttr codeAttr $+ Widget hPolicy Fixed $ do+ c <- B.getContext+ let theLines = expandEmpty <$> T.lines tx+ expandEmpty "" = " "+ expandEmpty s = s+ wrapFunc = if wrap then wrappedTextWithCursor+ else textWithCursor+ renderedText <- render (B.hLimit (c^.B.availWidthL - 3) $ B.vBox $+ wrapFunc <$> theLines)++ let textHeight = V.imageHeight $ renderedText^.imageL+ padding = B.padLeftRight 1 (B.vLimit textHeight B.vBorder)++ render $ padding <+> (Widget Fixed Fixed $ return renderedText)++toElementChunk :: Seq Element -> M (Widget a)+toElementChunk es = do+ w <- asks drawLineWidth+ hSet <- asks drawHighlightSet+ curUser <- asks drawCurUser++ return $ B.Widget B.Fixed B.Fixed $ do+ ctx <- B.getContext+ let width = fromMaybe (ctx^.B.availWidthL) w+ ws = fmap (renderElementSeq curUser) (wrapLine width hSet es)+ B.render (vBox (fmap hBox ws))++blocksToList :: ListType -> Seq (Seq RichTextBlock) -> M (Widget a)+blocksToList lt bs = do+ let is = case lt of+ Bullet _ -> repeat ("• ")+ Numbered Period s ->+ [ T.pack (show (n :: Int)) <> ". " | n <- [s..] ]+ Numbered Paren s ->+ [ T.pack (show (n :: Int)) <> ") " | n <- [s..] ]++ results <- forM (zip is $ F.toList bs) $ \(i, b) -> do+ blocks <- mapM blockToWidget b+ return $ B.txt i <+> vBox blocks++ return $ vBox results++data SplitState = SplitState+ { splitChunks :: Seq (Seq Element)+ , splitCurrCol :: Int+ }++wrapLine :: Int -> HighlightSet -> Seq Element -> Seq (Seq Element)+wrapLine maxCols hSet = splitChunks . go (SplitState (S.singleton S.empty) 0)+ where go st (viewl-> e :< es) = go st' es+ where+ HighlightSet { hUserSet = uSet, hChannelSet = cSet } = hSet++ -- Right before we check the width of the token, we see+ -- if the token is a user or channel reference. If so, we+ -- check if it is valid. If it is valid, we leave it in+ -- place; otherwise we translate it into an ordinary text+ -- element so that it does not render highlighted as a+ -- valid user or channel reference.+ newElement = e { eData = newEData }+ newEData = case eData e of+ EUser u ->+ if u `Set.member` uSet+ then EUser u+ else EText $ userSigil <> u+ EChannel c ->+ if c `Set.member` cSet+ then EChannel c+ else EText $ normalChannelSigil <> c+ d -> d++ addHyperlink url el = setElementStyle (Hyperlink url (eStyle el)) el++ linkOpenBracket = Element Normal (EText "<")+ linkCloseBracket = Element Normal (EText ">")+ addOpenBracket l =+ case Seq.viewl l of+ EmptyL -> l+ h :< t ->+ let h' = Element Normal+ (ENonBreaking $ Seq.fromList [linkOpenBracket, h])+ in h' <| t+ addCloseBracket l =+ case Seq.viewr l of+ EmptyR -> l+ h :> t ->+ let t' = Element Normal+ (ENonBreaking $ Seq.fromList [t, linkCloseBracket])+ in h |> t'+ decorateLinkLabel = addOpenBracket . addCloseBracket++ st' =+ case newEData of+ EHyperlink url (Just labelEs) ->+ go st $ addHyperlink url <$> decorateLinkLabel labelEs+ EHyperlink url Nothing ->+ go st $ addHyperlink url <$> decorateLinkLabel (Seq.fromList [Element Normal $ EText $ unURL url])+ EPermalink _tName _pId (Just labelEs) ->+ go st $ setElementStyle Permalink <$> decorateLinkLabel labelEs+ EImage url (Just labelEs) ->+ go st $ addHyperlink url <$> decorateLinkLabel labelEs+ _ ->+ if | newEData == ESoftBreak || newEData == ELineBreak ->+ st { splitChunks = splitChunks st |> S.empty+ , splitCurrCol = 0+ }+ | available >= eWidth ->+ st { splitChunks = addElement newElement (splitChunks st)+ , splitCurrCol = splitCurrCol st + eWidth+ }+ | newEData == ESpace ->+ st { splitChunks = splitChunks st |> S.empty+ , splitCurrCol = 0+ }+ | otherwise ->+ st { splitChunks = splitChunks st |> S.singleton newElement+ , splitCurrCol = eWidth+ }+ available = maxCols - splitCurrCol st+ eWidth = elementWidth newElement+ addElement x (viewr-> ls :> l) = ( ls |> (l |> x))+ addElement _ _ = error "[unreachable]"+ go st _ = st++renderElementSeq :: Text -> Seq Element -> Seq (Widget a)+renderElementSeq curUser es = renderElement curUser <$> es++renderElement :: Text -> Element -> Widget a+renderElement curUser e = addStyle sty widget+ where+ sty = eStyle e+ dat = eData e+ addStyle s = case s of+ Normal -> id+ Emph -> B.withDefAttr clientEmphAttr+ Strikethrough -> B.withDefAttr strikeThroughAttr+ Strong -> B.withDefAttr clientStrongAttr+ Code -> B.withDefAttr codeAttr+ Permalink -> B.withDefAttr permalinkAttr+ Hyperlink (URL url) innerSty ->+ B.hyperlink url . B.withDefAttr urlAttr . addStyle innerSty+ rawText = B.txt . removeCursor+ widget = case dat of+ -- Cursor sentinels get parsed as individual text nodes by+ -- Cheapskate, meaning that we get a text node with exactly+ -- one character in it. That's why we compare accordingly+ -- above. In addition, since that character has no width, we+ -- have to replace it with something that has a size (such+ -- as a space) to get Brick's visibility logic to scroll the+ -- viewport to get it into view.+ EText t -> if t == T.singleton (cursorSentinel)+ then B.visible $ B.txt " "+ else textWithCursor t++ ESpace -> B.txt " "+ EPermalink _ pId mLabel -> drawPermalink curUser pId mLabel+ ENonBreaking es -> hBox $ renderElement curUser <$> es+ ERawHtml t -> textWithCursor t+ EEditSentinel recent -> let attr = if recent+ then editedRecentlyMarkingAttr+ else editedMarkingAttr+ in B.withDefAttr attr $ B.txt editMarking+ EUser u -> colorUsername curUser u $ userSigil <> u+ EChannel c -> B.withDefAttr channelNameAttr $+ B.txt $ normalChannelSigil <> c+ EHyperlink (URL url) Nothing -> rawText url+ EImage (URL url) Nothing -> rawText url+ EEmoji em -> B.withDefAttr emojiAttr $+ B.txt $ ":" <> em <> ":"++ -- Hyperlink and image nodes with labels should not appear+ -- at this point because line-wrapping should break them up+ -- into normal text node sequences with hyperlink styles+ -- attached.+ EHyperlink {} -> rawText "(Report renderElement bug #1)"+ EImage {} -> rawText "(Report renderElement bug #2)"++ -- Line breaks should never need to get rendered since the+ -- line-wrapping algorithm removes them.+ ESoftBreak -> B.emptyWidget+ ELineBreak -> B.emptyWidget++drawPermalink :: Text -> PostId -> Maybe (Seq Element) -> Widget a+drawPermalink _ _ Nothing =+ B.txt permalinkPlaceholder+drawPermalink curUser _ (Just label) =+ hBox $ F.toList $ B.txt "<" <| (renderElementSeq curUser label |> B.txt ">")++permalinkPlaceholder :: Text+permalinkPlaceholder = "<post link>"++textWithCursor :: Text -> Widget a+textWithCursor t+ | T.any (== cursorSentinel) t = B.visible $ B.txt $ removeCursor t+ | otherwise = B.txt t++wrappedTextWithCursor :: Text -> Widget a+wrappedTextWithCursor t+ | T.any (== cursorSentinel) t = B.visible $ B.txtWrap $ removeCursor t+ | otherwise = B.txtWrap t++removeCursor :: Text -> Text+removeCursor = T.filter (/= cursorSentinel)++editMarking :: Text+editMarking = "(edited)"++elementWidth :: Element -> Int+elementWidth e =+ case eData e of+ ENonBreaking es -> sum $ elementWidth <$> es+ EText t -> B.textWidth t+ ERawHtml t -> B.textWidth t+ EUser t -> T.length userSigil + B.textWidth t+ EChannel t -> T.length normalChannelSigil + B.textWidth t+ EEditSentinel _ -> B.textWidth editMarking+ EImage (URL url) Nothing -> B.textWidth url+ EImage _ (Just is) -> sum $ elementWidth <$> is+ EHyperlink (URL url) Nothing -> B.textWidth url+ EHyperlink _ (Just is) -> sum $ elementWidth <$> is+ EEmoji t -> B.textWidth t + 2+ EPermalink _ _ Nothing -> T.length permalinkPlaceholder+ EPermalink _ _ (Just label) -> 2 + (sum $ elementWidth <$> label)+ ESpace -> 1+ ELineBreak -> 0+ ESoftBreak -> 0
+ src/Matterhorn/Draw/ShowHelp.hs view
@@ -0,0 +1,566 @@+module Matterhorn.Draw.ShowHelp+ ( drawShowHelp+ , keybindingMarkdownTable+ , keybindingTextTable+ , commandTextTable+ , commandMarkdownTable+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Themes ( themeDescriptions )+import Brick.Widgets.Center ( hCenter )+import Brick.Widgets.List ( listSelectedFocusedAttr )+import qualified Data.Map as M+import qualified Data.Text as T+import qualified Graphics.Vty as Vty+import Lens.Micro.Platform ( singular, _Just, _2 )++import Network.Mattermost.Version ( mmApiVersion )++import Matterhorn.Command+import Matterhorn.Events+import Matterhorn.Events.ChannelSelect+import Matterhorn.Events.Keybindings+import Matterhorn.Events.Main+import Matterhorn.Events.MessageSelect+import Matterhorn.Events.ThemeListOverlay+import Matterhorn.Events.PostListOverlay+import Matterhorn.Events.ShowHelp+import Matterhorn.Events.UrlSelect+import Matterhorn.Events.UserListOverlay+import Matterhorn.Events.ChannelListOverlay+import Matterhorn.Events.ReactionEmojiListOverlay+import Matterhorn.Events.ManageAttachments+import Matterhorn.Events.TabbedWindow+import Matterhorn.Windows.ViewMessage+import Matterhorn.HelpTopics ( helpTopics )+import Matterhorn.Draw.RichText ( renderText )+import Matterhorn.Options ( mhVersion )+import Matterhorn.State.Editing ( editingKeyHandlers )+import Matterhorn.Themes+import Matterhorn.Types+import Matterhorn.Types.KeyEvents ( BindingState(..), Binding(..)+ , ppBinding, nonCharKeys, eventToBinding )+++drawShowHelp :: HelpTopic -> ChatState -> [Widget Name]+drawShowHelp topic st =+ [helpBox (helpTopicViewportName topic) $ helpTopicDraw topic st]++helpTopicDraw :: HelpTopic -> ChatState -> Widget Name+helpTopicDraw topic st =+ overrideAttr codeAttr helpEmphAttr $+ hCenter $+ hLimit helpContentWidth $+ case helpTopicScreen topic of+ MainHelp -> mainHelp (configUserKeys (st^.csResources.crConfiguration))+ ScriptHelp -> scriptHelp+ ThemeHelp -> themeHelp+ SyntaxHighlightHelp -> syntaxHighlightHelp (configSyntaxDirs $ st^.csResources.crConfiguration)+ KeybindingHelp -> keybindingHelp (configUserKeys (st^.csResources.crConfiguration))++mainHelp :: KeyConfig -> Widget Name+mainHelp kc = summary+ where+ summary = vBox entries+ entries = [ heading $ T.pack mhVersion+ , headingNoPad $ T.pack mmApiVersion+ , heading "Help Topics"+ , drawHelpTopics+ , heading "Commands"+ , padTop (Pad 1) mkCommandHelpText+ , heading "Keybindings"+ ] <>+ (mkKeybindingHelp kc <$> keybindSections)++ mkCommandHelpText :: Widget Name+ mkCommandHelpText =+ let commandNameWidth = 2 + (maximum $ T.length <$> fst <$> commandHelpInfo)+ in vBox [ (emph $ txt $ padTo commandNameWidth info) <+> renderText desc+ | (info, desc) <- commandHelpInfo+ ]++commandHelpInfo :: [(T.Text, T.Text)]+commandHelpInfo = pairs+ where+ pairs = [ (info, desc)+ | Cmd cmd desc args _ <- cs+ , let argSpec = printArgSpec args+ spc = if T.null argSpec then "" else " "+ info = T.cons '/' cmd <> spc <> argSpec+ ]+ cs = sortWith commandName commandList++commandTextTable :: T.Text+commandTextTable =+ let commandNameWidth = 4 + (maximum $ T.length <$> fst <$> commandHelpInfo)+ in T.intercalate "\n" $+ [ padTo commandNameWidth info <> desc+ | (info, desc) <- commandHelpInfo+ ]++commandMarkdownTable :: T.Text+commandMarkdownTable =+ T.intercalate "\n" $+ [ "# Commands"+ , ""+ , "| Command | Description |"+ , "| ------- | ----------- |"+ ] <>+ [ "| `" <> info <> "` | " <> desc <> " |"+ | (info, desc) <- commandHelpInfo+ ]++drawHelpTopics :: Widget Name+drawHelpTopics =+ let allHelpTopics = drawTopic <$> helpTopics+ topicNameWidth = 4 + (maximum $ T.length <$> helpTopicName <$> helpTopics)+ drawTopic t = (emph $ txt (padTo topicNameWidth $ helpTopicName t)) <+>+ txt (helpTopicDescription t)+ in vBox $ (padBottom (Pad 1) $+ para "Learn more about these topics with `/help <topic>`:")+ : allHelpTopics++helpContentWidth :: Int+helpContentWidth = 72++scriptHelp :: Widget Name+scriptHelp = heading "Using Scripts" <=> vBox scriptHelpText+ where scriptHelpText = map paraL+ [ [ "Matterhorn has a special feature that allows you to use "+ , "prewritten shell scripts to preprocess messages. "+ , "For example, this can allow you to run various filters over "+ , "your written text, do certain kinds of automated formatting, "+ , "or just automatically cowsay-ify a message." ]+ , [ "These scripts can be any kind of executable file, "+ , "as long as the file lives in "+ , "*~/.config/matterhorn/scripts* (unless you've explicitly "+ , "moved your XDG configuration directory elsewhere). "+ , "Those executables are given no arguments "+ , "on the command line and are passed your typed message on "+ , "*stdin*; whatever they produce on *stdout* is sent "+ , "as a message. If the script exits successfully, then everything "+ , "that appeared on *stderr* is discarded; if it instead exits with "+ , "a failing exit code, your message is *not* sent, and you are "+ , "presented with whatever was printed on stderr as a "+ , "local error message." ]+ , [ "To run a script, simply type" ]+ , [ "> *> /sh [script-name] [my-message]*" ]+ , [ "And the script named *[script-name]* will be invoked with "+ , "the text of *[my-message]*. If the script does not exist, "+ , "or if it exists but is not marked as executable, you'll be "+ , "presented with an appropriate error message." ]+ , [ "For example, if you want to use a basic script to "+ , "automatically ROT13 your message, you can write a shell "+ , "script using the standard Unix *tr* utility, like this:" ]+ , [ "> *#!/bin/bash -e*"+ , "> *tr '[A-Za-z]' '[N-ZA-Mn-za-m]'*" ]+ , [ "Move this script to *~/.config/matterhorn/scripts/rot13* "+ , "and be sure it's executable with" ]+ , [ "> *$ chmod u+x ~/.config/matterhorn/scripts/rot13*" ]+ , [ "after which you can send ROT13 messages with the "+ , "Matterhorn command " ]+ , [ "> *> /sh rot13 Hello, world!*" ]+ ]++keybindingMarkdownTable :: KeyConfig -> Text+keybindingMarkdownTable kc = title <> keybindSectionStrings+ where title = "# Keybindings\n"+ keybindSectionStrings = T.concat $ sectionText <$> keybindSections+ sectionText = mkKeybindEventSectionHelp kc keybindEventHelpMarkdown T.unlines mkHeading+ mkHeading n =+ "\n# " <> n <>+ "\n| Keybinding | Event Name | Description |" <>+ "\n| ---------- | ---------- | ----------- |"++keybindingTextTable :: KeyConfig -> Text+keybindingTextTable kc = title <> keybindSectionStrings+ where title = "Keybindings\n===========\n"+ keybindSectionStrings = T.concat $ sectionText <$> keybindSections+ sectionText = mkKeybindEventSectionHelp kc (keybindEventHelpText keybindingWidth eventNameWidth) T.unlines mkHeading+ keybindingWidth = 15+ eventNameWidth = 30+ mkHeading n =+ "\n" <> n <>+ "\n" <> (T.replicate (T.length n) "=")++keybindingHelp :: KeyConfig -> Widget Name+keybindingHelp kc = vBox $+ [ heading "Configurable Keybindings"+ , padBottom (Pad 1) $ vBox keybindingHelpText+ ] ++ keybindSectionWidgets+ +++ [ headingNoPad "Keybinding Syntax"+ , vBox validKeys+ ]+ where keybindSectionWidgets = sectionWidget <$> keybindSections+ sectionWidget = mkKeybindEventSectionHelp kc keybindEventHelpWidget vBox headingNoPad++ keybindingHelpText = map paraL+ [ [ "Many of the keybindings used in Matterhorn can be "+ , "modified from within Matterhorn's **config.ini** file. "+ , "To do this, include a section called **[KEYBINDINGS]** "+ , "in your config file and use the event names listed below as "+ , "keys and the desired key sequence as values. "+ , "See the end of this page for documentation on the valid "+ , "syntax for key sequences."+ ]+ , [ "For example, by default, the keybinding to move to the next "+ , "channel in the public channel list is **"+ , nextChanBinding+ , "**, and the corresponding "+ , "previous channel binding is **"+ , prevChanBinding+ , "**. You might want to remap these "+ , "to other keys: say, **C-j** and **C-k**. We can do this with the following "+ , "configuration snippet:"+ ]+ , [ "```ini\n"+ , "[KEYBINDINGS]\n"+ , "focus-next-channel = C-j\n"+ , "focus-prev-channel = C-k\n"+ , "```"+ ]+ , [ "You can remap a command to more than one key sequence, in which "+ , "case any one of the key sequences provided can be used to invoke "+ , "the relevant command. To do this, provide the desired bindings as "+ , "a comma-separated list. Additionally, some key combinations are "+ , "used in multiple modes (such as URL select or help viewing) and "+ , "therefore share the same name, such as **cancel** or **scroll-up**."+ ]+ , [ "Additionally, some keys simply cannot be remapped, mostly in the "+ , "case of editing keybindings. If you feel that a particular key "+ , "event should be rebindable and isn't, then please feel free to "+ , "let us know by posting an issue in the Matterhorn issue tracker."+ ]+ , [ "It is also possible to entirely unbind a key event by setting its "+ , "key to **unbound**, thus avoiding conflicts between default bindings "+ , "and new ones:"+ ]+ , [ "```ini\n"+ , "[KEYBINDINGS]\n"+ , "focus-next-channel = unbound\n"+ , "```"+ ]+ , [ "The rebindable key events, along with their **current** "+ , "values, are as follows:"+ ]+ ]+ nextChanBinding = ppBinding (getFirstDefaultBinding NextChannelEvent)+ prevChanBinding = ppBinding (getFirstDefaultBinding PrevChannelEvent)+ validKeys = map paraL+ [ [ "The syntax used for key sequences consists of zero or more "+ , "single-character modifier characters followed by a keystroke, "+ , "all separated by dashes. The available modifier keys are "+ , "**S** for Shift, **C** for Ctrl, **A** for Alt, and **M** for "+ , "Meta. So, for example, **"+ , ppBinding (Binding [] (Vty.KFun 2))+ , "** is the F2 key pressed with no "+ , "modifier keys; **"+ , ppBinding (Binding [Vty.MCtrl] (Vty.KChar 'x'))+ , "** is Ctrl and X pressed together, "+ , "and **"+ , ppBinding (Binding [Vty.MShift, Vty.MCtrl] (Vty.KChar 'x'))+ , "** is Shift, Ctrl, and X all pressed together. "+ , "Although Matterhorn will pretty-print all key combinations "+ , "with specific capitalization, the parser is **not** case-sensitive "+ , "and will ignore any capitalization."+ ]+ , [ "Your terminal emulator might not recognize some particular "+ , "keypress combinations, or it might reserve certain combinations of "+ , "keys for some terminal-specific operation. Matterhorn does not have a "+ , "reliable way of testing this, so it is up to you to avoid setting "+ , "keybindings that your terminal emulator does not deliver to applications."+ ]+ , [ "Letter keys, number keys, and function keys are specified with "+ , "their obvious name, such as **x** for the X key, **8** for the 8 "+ , "key, and **f5** for the F5 key. Other valid keys include: "+ , T.intercalate ", " [ "**" <> key <> "**" | key <- nonCharKeys ]+ , "."+ ]+ ]++emph :: Widget a -> Widget a+emph = withDefAttr helpEmphAttr++para :: Text -> Widget a+para t = padTop (Pad 1) $ renderText t++paraL :: [Text] -> Widget a+paraL = para . mconcat++heading :: Text -> Widget a+heading = padTop (Pad 1) . headingNoPad++headingNoPad :: Text -> Widget a+headingNoPad t = hCenter $ emph $ renderText t++syntaxHighlightHelp :: [FilePath] -> Widget a+syntaxHighlightHelp dirs = vBox+ [ heading "Syntax Highlighting"++ , para $ "Matterhorn supports syntax highlighting in Markdown code blocks when the " <>+ "name of the code block language follows the block opening sytnax:"+ , para $ "```<language>"+ , para $ "The possible values of `language` are determined by the available syntax " <>+ "definitions. The available definitions are loaded from the following " <>+ "directories according to the configuration setting `syntaxDirectories`. " <>+ "If the setting is omitted, it defaults to the following sequence of directories:"+ , para $ T.pack $ intercalate "\n" $ (\d -> "`" <> d <> "`") <$> dirs+ , para $ "Syntax definitions are in the Kate XML format. Files with an " <>+ "`xml` extension are loaded from each directory, with directories earlier " <>+ "in the list taking precedence over later directories when more than one " <>+ "directory provides a definition file for the same syntax."+ , para $ "To place custom definitions in a directory, place a Kate " <>+ "XML syntax definition in the directory and ensure that a copy of " <>+ "`language.dtd` is also present. The file `language.dtd` can be found in " <>+ "the `syntax/` directory of your Matterhorn distribution."+ ]++themeHelp :: Widget a+themeHelp = vBox+ [ heading "Using Themes"+ , para "Matterhorn provides these built-in color themes:"+ , padTop (Pad 1) $ vBox $ hCenter <$> emph <$>+ txt <$> internalThemeName <$> internalThemes+ , para $+ "These themes can be selected with the */theme* command. To automatically " <>+ "select a theme at startup, set the *theme* configuration file option to one " <>+ "of the themes listed above."++ , heading "Customizing the Theme"+ , para $+ "Theme customization is also supported. To customize the selected theme, " <>+ "create a theme customization file and set the `themeCustomizationFile` " <>+ "configuration option to the path to the customization file. If the path " <>+ "to the file is relative, Matterhorn will look for it in the same directory " <>+ "as the Matterhorn configuration file."++ , para $+ "Theme customization files are INI-style files that can customize any " <>+ "foreground color, background color, or style of any aspect of the " <>+ "Matterhorn user interface. Here is an example:"++ , para $+ "```\n" <>+ "[default]\n" <>+ "default.fg = blue\n" <>+ "default.bg = black\n" <>+ "\n" <>+ "[other]\n" <>+ attrNameToConfig codeAttr <> ".fg = magenta\n" <>+ attrNameToConfig codeAttr <> ".style = bold\n" <>+ attrNameToConfig clientEmphAttr <> ".fg = cyan\n" <>+ attrNameToConfig clientEmphAttr <> ".style = [bold, underline]\n" <>+ attrNameToConfig listSelectedFocusedAttr <> ".fg = brightGreen\n" <>+ "```"++ , para $+ "In the example above, the theme's default foreground and background colors " <>+ "are both customized to *blue* and *black*, respectively. The *default* section " <>+ "contains only customizations for the *default* attribute. All other customizations " <>+ "go in the *other* section. We can also set the style for attributes; we can either " <>+ "set just one style (as with the bold setting above) or multiple styles at once " <>+ "(as in the bold/underline example).\n"++ , para $+ "Available colors are:\n" <>+ " * black\n" <>+ " * red\n" <>+ " * green\n" <>+ " * yellow\n" <>+ " * blue\n" <>+ " * magenta\n" <>+ " * cyan\n" <>+ " * white\n" <>+ " * brightBlack\n" <>+ " * brightRed\n" <>+ " * brightGreen\n" <>+ " * brightYellow\n" <>+ " * brightBlue\n" <>+ " * brightMagenta\n" <>+ " * brightCyan\n" <>+ " * brightWhite"++ , para $+ "Available styles are:\n" <>+ " * standout\n" <>+ " * underline\n" <>+ " * italic\n" <>+ " * strikethrough\n" <>+ " * reverseVideo\n" <>+ " * blink\n" <>+ " * dim\n" <>+ " * bold\n"++ , para $+ "It is also possible to specify RGB values using HTML syntax: `#RRGGBB`. " <>+ "Bear in mind that such colors are clamped to the nearest 256-color palette " <>+ "entry, so it is not possible to get the exact color specified.\n\n" <>+ "In addition, a special value of *default* is possible for either color " <>+ "setting of an attribute. This value indicates that the attribute should " <>+ "use the terminal emulator's default foreground or background color of " <>+ "choice rather than a specific ANSI color."++ , heading "Username Highlighting"+ , para $+ "Username colors are chosen by hashing each username and then using the hash " <>+ "to choose a color from a list of predefined username colors. If you would like " <>+ "to change the color in a given entry of this list, we provide the " <>+ "\"username.N\" attributes, where N is the index in the username color list."++ , heading "Theme Attributes"+ , para $+ "This section lists all possible theme attributes for use in customization " <>+ "files along with a description of how each one is used in Matterhorn. Each " <>+ "option listed can be set in the *other* section of the customization file. " <>+ "Each provides three customization settings:"++ , para $+ " * *<option>.fg = <color>*\n" <>+ " * *<option>.bg = <color>*\n" <>+ " * *<option>.style = <style>* or *<option>.style = [<style>, ...]*\n"++ , let names = sort $+ (\(n, msg) -> (n, attrNameToConfig n, msg)) <$>+ (M.toList $ themeDescriptions themeDocs)+ mkEntry (n, opt, msg) =+ padTop (Pad 1) $+ vBox [ hBox [ withDefAttr clientEmphAttr $ txt opt+ , padLeft Max $ forceAttr n $ txt "(demo)"+ ]+ , txt msg+ ]+ in vBox $ mkEntry <$> names+ ]++keybindSections :: [(Text, [KeyEventHandler])]+keybindSections =+ [ ("Global Keybindings", globalKeyHandlers)+ , ("Help Page", helpKeyHandlers)+ , ("Main Interface", mainKeyHandlers)+ , ("Text Editing", editingKeyHandlers (csEditState.cedEditor))+ , ("Channel Select Mode", channelSelectKeyHandlers)+ , ("Message Select Mode", messageSelectKeyHandlers)+ , ("User Listings", userListOverlayKeyHandlers)+ , ("URL Select Mode", urlSelectKeyHandlers)+ , ("Theme List Window", themeListOverlayKeyHandlers)+ , ("Channel Search Window", channelListOverlayKeyHandlers)+ , ("Message Viewer: Common", tabbedWindowKeyHandlers (csViewedMessage.singular _Just._2))+ , ("Message Viewer: Message tab", viewMessageKeyHandlers)+ , ("Message Viewer: Reactions tab", viewMessageReactionsKeyHandlers)+ , ("Attachment List", attachmentListKeyHandlers)+ , ("Attachment File Browser", attachmentBrowseKeyHandlers)+ , ("Flagged Messages", postListOverlayKeyHandlers)+ , ("Reaction Emoji Search Window", reactionEmojiListOverlayKeyHandlers)+ ]++helpBox :: Name -> Widget Name -> Widget Name+helpBox n helpText =+ withDefAttr helpAttr $+ viewport HelpViewport Vertical $+ cached n helpText++kbColumnWidth :: Int+kbColumnWidth = 14++kbDescColumnWidth :: Int+kbDescColumnWidth = 60++mkKeybindingHelp :: KeyConfig -> (Text, [KeyEventHandler]) -> Widget Name+mkKeybindingHelp kc (sectionName, kbs) =+ (heading sectionName) <=>+ (padTop (Pad 1) $ hCenter $ vBox $ snd <$> sortWith fst results)+ where+ results = mkKeybindHelp kc <$> kbs++mkKeybindHelp :: KeyConfig -> KeyEventHandler -> (Text, Widget Name)+mkKeybindHelp kc h =+ let unbound = ["(unbound)"]+ label = case kehEventTrigger h of+ Static k -> ppBinding $ eventToBinding k+ ByEvent ev ->+ let bindings = case M.lookup ev kc of+ Nothing ->+ let bs = defaultBindings ev+ in if not $ null bs+ then ppBinding <$> defaultBindings ev+ else unbound+ Just Unbound -> unbound+ Just (BindingList bs) | not (null bs) -> ppBinding <$> bs+ | otherwise -> unbound+ in T.intercalate ", " bindings++ rendering = (emph $ txt $ padTo kbColumnWidth $+ label) <+> txt " " <+>+ (hLimit kbDescColumnWidth $ padRight Max $ renderText $+ ehDescription $ kehHandler h)+ in (label, rendering)++mkKeybindEventSectionHelp :: KeyConfig+ -> ((Either Text Text, Text, [Text]) -> a)+ -> ([a] -> a)+ -> (Text -> a)+ -> (Text, [KeyEventHandler])+ -> a+mkKeybindEventSectionHelp kc mkKeybindHelpFunc vertCat mkHeading (sectionName, kbs) =+ vertCat $ (mkHeading sectionName) :+ (mkKeybindHelpFunc <$> (mkKeybindEventHelp kc <$> kbs))++keybindEventHelpWidget :: (Either Text Text, Text, [Text]) -> Widget Name+keybindEventHelpWidget (evName, desc, evs) =+ let evText = T.intercalate ", " evs+ label = case evName of+ Left s -> txt $ "; " <> s+ Right s -> emph $ txt s+ in padBottom (Pad 1) $+ vBox [ txtWrap ("; " <> desc)+ , label <+> txt (" = " <> evText)+ ]++keybindEventHelpMarkdown :: (Either Text Text, Text, [Text]) -> Text+keybindEventHelpMarkdown (evName, desc, evs) =+ let quote s = "`" <> s <> "`"+ name = case evName of+ Left s -> s+ Right s -> quote s+ in "| " <> (T.intercalate ", " $ quote <$> evs) <>+ " | " <> name <>+ " | " <> desc <>+ " |"++keybindEventHelpText :: Int -> Int -> (Either Text Text, Text, [Text]) -> Text+keybindEventHelpText width eventNameWidth (evName, desc, evs) =+ let name = case evName of+ Left s -> s+ Right s -> s+ in padTo width (T.intercalate ", " evs) <> " " <>+ padTo eventNameWidth name <> " " <>+ desc++mkKeybindEventHelp :: KeyConfig -> KeyEventHandler -> (Either Text Text, Text, [Text])+mkKeybindEventHelp kc h =+ let trig = kehEventTrigger h+ unbound = ["(unbound)"]+ (label, evText) = case trig of+ Static key -> (Left "(non-customizable key)", [ppBinding $ eventToBinding key])+ ByEvent ev -> case M.lookup ev kc of+ Nothing ->+ let name = keyEventName ev+ in if not (null (defaultBindings ev))+ then (Right name, ppBinding <$> defaultBindings ev)+ else (Left name, unbound)+ Just Unbound -> (Right $ keyEventName ev, unbound)+ Just (BindingList bs) -> (Right $ keyEventName ev,+ if not (null bs)+ then ppBinding <$> bs+ else unbound+ )+ in (label, ehDescription $ kehHandler h, evText)++padTo :: Int -> Text -> Text+padTo n s = s <> T.replicate (n - T.length s) " "
+ src/Matterhorn/Draw/TabbedWindow.hs view
@@ -0,0 +1,77 @@+module Matterhorn.Draw.TabbedWindow+ ( drawTabbedWindow+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center+import Data.List ( intersperse )+import qualified Graphics.Vty as Vty++import Matterhorn.Draw.Util ( renderKeybindingHelp )+import Matterhorn.Types+import Matterhorn.Themes+import Matterhorn.Types.KeyEvents++-- | Render a tabbed window.+drawTabbedWindow :: (Eq a, Show a)+ => TabbedWindow a+ -> ChatState+ -> Widget Name+drawTabbedWindow w cs =+ let cur = getCurrentTabbedWindowEntry w+ tabBody = tweRender cur (twValue w) cs+ title = forceAttr clientEmphAttr $ twtTitle (twTemplate w) (tweValue cur)+ in centerLayer $+ vLimit (twWindowHeight w) $+ hLimit (twWindowWidth w) $+ joinBorders $+ borderWithLabel title $+ (tabBar w <=> tabBody <=> hBorder <=> hCenter keybindingHelp)++-- | Keybinding help to show at the bottom of a tabbed window.+keybindingHelp :: Widget Name+keybindingHelp =+ let pairs = [ ("Switch tabs", [SelectNextTabEvent, SelectPreviousTabEvent])+ , ("Scroll", [ScrollUpEvent, ScrollDownEvent, ScrollLeftEvent, ScrollRightEvent, PageLeftEvent, PageRightEvent])+ ]+ in hBox $ intersperse (txt " ") $ (uncurry renderKeybindingHelp) <$> pairs++-- | The scrollable tab bar to show at the top of a tabbed window.+tabBar :: (Eq a, Show a)+ => TabbedWindow a+ -> Widget Name+tabBar w =+ let cur = getCurrentTabbedWindowEntry w+ entries = twtEntries (twTemplate w)+ renderEntry e =+ let useAttr = if isCurrent+ then withDefAttr tabSelectedAttr+ else withDefAttr tabUnselectedAttr+ isCurrent = tweValue e == tweValue cur+ makeVisible = if isCurrent then visible else id+ decorateTab v = Widget Fixed Fixed $ do+ result <- render v+ let width = Vty.imageWidth (result^.imageL)+ if isCurrent+ then+ render $ padBottom (Pad 1) $ raw $ result^.imageL+ else+ render $ vBox [raw $ result^.imageL, hLimit width hBorder]+ in makeVisible $+ decorateTab $+ useAttr $+ padLeftRight 2 $+ txt $+ tweTitle e (tweValue e) isCurrent+ contents = Widget Fixed Fixed $ do+ ctx <- getContext+ let width = ctx^.availWidthL+ render $ hBox $ (intersperse divider $ renderEntry <$> entries) <>+ [divider, padTop (Pad 1) $ hLimit width hBorder]+ divider = vLimit 1 vBorder <=> joinableBorder (Edges True False False False)+ in vLimit 2 $ viewport TabbedWindowTabBar Horizontal contents
+ src/Matterhorn/Draw/ThemeListOverlay.hs view
@@ -0,0 +1,52 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++module Matterhorn.Draw.ThemeListOverlay+ ( drawThemeListOverlay+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import qualified Brick.Widgets.List as L+import Brick.Widgets.Border ( hBorder )+import Brick.Widgets.Center ( hCenter )++import Matterhorn.Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )+import Matterhorn.Themes+import Matterhorn.Types+import Matterhorn.Types.KeyEvents ( ppBinding )+import Matterhorn.Events.Keybindings+++drawThemeListOverlay :: ChatState -> Widget Name+drawThemeListOverlay st =+ let overlay = drawListOverlay (st^.csThemeListOverlay)+ (const $ txt "Themes")+ (const $ txt "No matching themes found.")+ (const $ txt "Search built-in themes:")+ renderInternalTheme+ (Just footer)+ OverlayUpperRight+ 50+ footer = hBorder <=>+ (hCenter $ hBox [ enter+ , txt $ ":choose theme "+ , close+ , txt ":close"+ ])+ enter = emph $ txt $ ppBinding (getFirstDefaultBinding ActivateListItemEvent)+ close = emph $ txt $ ppBinding (getFirstDefaultBinding CancelEvent)+ emph = withDefAttr clientEmphAttr+ in joinBorders overlay++renderInternalTheme :: Bool -> InternalTheme -> Widget Name+renderInternalTheme foc it =+ (if foc then forceAttr L.listSelectedFocusedAttr else id) $+ (padRight Max $+ withDefAttr clientEmphAttr $+ txt $ internalThemeName it) <=>+ (vLimit 2 $+ (padLeft (Pad 2) $ txtWrap $ internalThemeDesc it) <=> fill ' ')
+ src/Matterhorn/Draw/URLList.hs view
@@ -0,0 +1,64 @@+module Matterhorn.Draw.URLList+ ( renderUrlList+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border ( hBorder )+import Brick.Widgets.List ( renderList )+import qualified Data.Foldable as F+import Lens.Micro.Platform ( to )++import Network.Mattermost.Types ( ServerTime(..), idString )++import Matterhorn.Draw.Messages+import Matterhorn.Draw.Util+import Matterhorn.Draw.RichText+import Matterhorn.Themes+import Matterhorn.Types+import Matterhorn.Types.RichText ( unURL, TeamURLName(..) )+++renderUrlList :: ChatState -> Widget Name+renderUrlList st =+ header <=> urlDisplay+ where+ header = (withDefAttr channelHeaderAttr $ vLimit 1 $+ (renderText' Nothing "" (getHighlightSet st) $+ "URLs: " <> (mkChannelName st (st^.csCurrentChannel.ccInfo))) <+>+ fill ' ') <=> hBorder++ urlDisplay = if F.length urls == 0+ then str "No URLs found in this channel."+ else renderList renderItem True urls++ urls = st^.csUrlList++ me = myUsername st++ renderItem sel link =+ let time = link^.linkTime+ in attr sel $ vLimit 2 $+ (vLimit 1 $+ hBox [ let u = maybe "<server>" id (link^.linkUser.to (nameForUserRef st))+ in colorUsername me u u+ , case link^.linkLabel of+ Nothing -> emptyWidget+ Just label -> txt ": " <+> hBox (F.toList $ renderElementSeq me label)+ , fill ' '+ , renderDate st $ withServerTime time+ , str " "+ , renderTime st $ withServerTime time+ ] ) <=>+ (vLimit 1 (renderLinkTarget (link^.linkTarget)))++ renderLinkTarget (LinkPermalink (TeamURLName tName) pId) =+ renderText $ "Team: " <> tName <> ", post " <> idString pId+ renderLinkTarget (LinkURL url) = renderText $ unURL url+ renderLinkTarget (LinkFileId _) = txt " "++ attr True = forceAttr urlListSelectedAttr+ attr False = id
+ src/Matterhorn/Draw/UserListOverlay.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RecordWildCards #-}++module Matterhorn.Draw.UserListOverlay+ ( drawUserListOverlay+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import qualified Brick.Widgets.List as L+import qualified Data.Text as T+import qualified Graphics.Vty as V++import Matterhorn.Draw.Util ( userSigilFromInfo )+import Matterhorn.Draw.ListOverlay ( drawListOverlay, OverlayPosition(..) )+import Matterhorn.Themes+import Matterhorn.Types+++drawUserListOverlay :: ChatState -> Widget Name+drawUserListOverlay st =+ let overlay = drawListOverlay (st^.csUserListOverlay) userSearchScopeHeader+ userSearchScopeNoResults userSearchScopePrompt+ (renderUser (myUsername st))+ Nothing+ OverlayCenter+ 80+ in joinBorders overlay++userSearchScopePrompt :: UserSearchScope -> Widget Name+userSearchScopePrompt scope =+ txt $ case scope of+ ChannelMembers _ _ -> "Search channel members:"+ ChannelNonMembers _ _ -> "Search users:"+ AllUsers Nothing -> "Search users:"+ AllUsers (Just _) -> "Search team members:"++userSearchScopeNoResults :: UserSearchScope -> Widget Name+userSearchScopeNoResults scope =+ txt $ case scope of+ ChannelMembers _ _ -> "No users in channel."+ ChannelNonMembers _ _ -> "All users in your team are already in this channel."+ AllUsers _ -> "No users found."++userSearchScopeHeader :: UserSearchScope -> Widget Name+userSearchScopeHeader scope =+ txt $ case scope of+ ChannelMembers {} -> "Channel Members"+ ChannelNonMembers {} -> "Invite Users to Channel"+ AllUsers Nothing -> "Users On This Server"+ AllUsers (Just _) -> "Users In My Team"++renderUser :: Text -> Bool -> UserInfo -> Widget Name+renderUser myUName foc ui =+ (if foc then forceAttr L.listSelectedFocusedAttr else id) $+ vLimit 2 $+ padRight Max $+ hBox $ (padRight (Pad 1) $ colorUsername myUName (ui^.uiName) (T.singleton $ userSigilFromInfo ui))+ : (hLimit usernameWidth $ padRight Max $ colorUsername myUName (ui^.uiName) (ui^.uiName))+ : extras+ where+ sanitize = T.strip . T.replace "\t" " "+ usernameWidth = 20+ extras = padRight (Pad 1) <$> catMaybes [mFullname, mNickname, mEmail]+ mFullname = if (not (T.null (ui^.uiFirstName)) || not (T.null (ui^.uiLastName)))+ then Just $ txt $ (sanitize $ ui^.uiFirstName) <> " " <> (sanitize $ ui^.uiLastName)+ else Nothing+ mNickname = case ui^.uiNickName of+ Just n | n /= (ui^.uiName) -> Just $ txt $ "(" <> n <> ")"+ _ -> Nothing+ mEmail = if (T.null $ ui^.uiEmail)+ then Nothing+ else Just $ modifyDefAttr (`V.withURL` ("mailto:" <> ui^.uiEmail)) $+ withDefAttr urlAttr (txt ("<" <> ui^.uiEmail <> ">"))
+ src/Matterhorn/Draw/Util.hs view
@@ -0,0 +1,103 @@+module Matterhorn.Draw.Util+ ( withBrackets+ , renderTime+ , renderDate+ , renderKeybindingHelp+ , insertDateMarkers+ , getDateFormat+ , mkChannelName+ , userSigilFromInfo+ , multilineHeightLimit+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Data.List ( intersperse )+import qualified Data.Set as Set+import qualified Data.Text as T+import Lens.Micro.Platform ( to )+import Network.Mattermost.Types++import Matterhorn.Constants ( userSigil, normalChannelSigil )+import Matterhorn.Themes+import Matterhorn.TimeUtils+import Matterhorn.Types+import Matterhorn.Types.KeyEvents+import Matterhorn.Events.Keybindings ( getFirstDefaultBinding )+++defaultTimeFormat :: Text+defaultTimeFormat = "%R"++defaultDateFormat :: Text+defaultDateFormat = "%Y-%m-%d"++multilineHeightLimit :: Int+multilineHeightLimit = 5++getTimeFormat :: ChatState -> Text+getTimeFormat st =+ maybe defaultTimeFormat id (st^.csResources.crConfiguration.to configTimeFormat)++getDateFormat :: ChatState -> Text+getDateFormat st =+ maybe defaultDateFormat id (st^.csResources.crConfiguration.to configDateFormat)++renderTime :: ChatState -> UTCTime -> Widget Name+renderTime st = renderUTCTime (getTimeFormat st) (st^.timeZone)++renderDate :: ChatState -> UTCTime -> Widget Name+renderDate st = renderUTCTime (getDateFormat st) (st^.timeZone)++renderUTCTime :: Text -> TimeZoneSeries -> UTCTime -> Widget a+renderUTCTime fmt tz t =+ if T.null fmt+ then emptyWidget+ else withDefAttr timeAttr (txt $ localTimeText fmt $ asLocalTime tz t)++renderKeybindingHelp :: Text -> [KeyEvent] -> Widget Name+renderKeybindingHelp label evs =+ let ppEv ev = withDefAttr clientEmphAttr $ txt (ppBinding (getFirstDefaultBinding ev))+ in hBox $ (intersperse (txt "/") $ ppEv <$> evs) <> [txt (":" <> label)]++-- | Generates a local matterhorn-only client message that creates a+-- date marker. The server date is converted to a local time (via+-- timezone), and midnight of that timezone used to generate date+-- markers. Note that the actual time of the server and this client+-- are still not synchronized, but no manipulations here actually use+-- the client time.+insertDateMarkers :: Messages -> Text -> TimeZoneSeries -> Messages+insertDateMarkers ms datefmt tz = foldr (addMessage . dateMsg) ms dateRange+ where dateRange = foldr checkDateChange Set.empty ms+ checkDateChange m = let msgDay = startOfDay (Just tz) (withServerTime (m^.mDate))+ in if m^.mDeleted then id else Set.insert msgDay+ dateMsg d = let t = localTimeText datefmt $ asLocalTime tz d+ in newMessageOfType t (C DateTransition) (ServerTime d)+++withBrackets :: Widget a -> Widget a+withBrackets w = hBox [str "[", w, str "]"]++userSigilFromInfo :: UserInfo -> Char+userSigilFromInfo u = case u^.uiStatus of+ Offline -> ' '+ Online -> '+'+ Away -> '-'+ DoNotDisturb -> '×'+ Other _ -> '?'++mkChannelName :: ChatState -> ChannelInfo -> Text+mkChannelName st c = T.append sigil t+ where+ t = case c^.cdDMUserId >>= flip userById st of+ Nothing -> c^.cdName+ Just u -> u^.uiName+ sigil = case c^.cdType of+ Private -> mempty+ Ordinary -> normalChannelSigil+ Group -> mempty+ Direct -> userSigil+ Unknown _ -> mempty
+ src/Matterhorn/Emoji.hs view
@@ -0,0 +1,108 @@+{-# LANGUAGE ScopedTypeVariables #-}+module Matterhorn.Emoji+ ( EmojiCollection+ , loadEmoji+ , emptyEmojiCollection+ , getMatchingEmoji+ , matchesEmoji+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Control.Exception as E+import Control.Monad.Except+import qualified Data.Aeson as A+import qualified Data.ByteString.Lazy as BSL+import qualified Data.Foldable as F+import qualified Data.Text as T+import qualified Data.Sequence as Seq++import Network.Mattermost.Types ( Session )+import qualified Network.Mattermost.Endpoints as MM+++newtype EmojiData = EmojiData (Seq.Seq T.Text)++-- | The collection of all emoji names we loaded from a JSON disk file.+-- You might rightly ask: why don't we use a Trie here, for efficient+-- lookups? The answer is that we need infix lookups; prefix matches are+-- not enough. In practice it seems not to matter that much; despite the+-- O(n) search we get good enough performance that we aren't worried+-- about this. If at some point this becomes an issue, other data+-- structures with good infix lookup performance should be identified+-- (full-text search, perhaps?).+newtype EmojiCollection = EmojiCollection [T.Text]++instance A.FromJSON EmojiData where+ parseJSON = A.withArray "EmojiData" $ \v -> do+ aliasVecs <- forM v $ \val ->+ flip (A.withObject "EmojiData Entry") val $ \obj -> do+ as <- obj A..: "aliases"+ forM as $ A.withText "Alias list element" return++ return $ EmojiData $ mconcat $ F.toList aliasVecs++emptyEmojiCollection :: EmojiCollection+emptyEmojiCollection = EmojiCollection mempty++-- | Load an EmojiCollection from a JSON disk file.+loadEmoji :: FilePath -> IO (Either String EmojiCollection)+loadEmoji path = runExceptT $ do+ result <- lift $ E.try $ BSL.readFile path+ case result of+ Left (e::E.SomeException) -> throwError $ show e+ Right bs -> do+ EmojiData es <- ExceptT $ return $ A.eitherDecode bs+ return $ EmojiCollection $ T.toLower <$> F.toList es++-- | Look up matching emoji in the collection using the provided search+-- string. This does a case-insensitive infix match. The search string+-- may be provided with or without leading and trailing colons.+lookupEmoji :: EmojiCollection -> T.Text -> [T.Text]+lookupEmoji (EmojiCollection es) search =+ filter (matchesEmoji search) es++-- | Match a search string against an emoji.+matchesEmoji :: T.Text+ -- ^ The search string (will be converted to lowercase and+ -- colons will be removed)+ -> T.Text+ -- ^ The emoji string (assumed to be lowercase and without+ -- leading/trailing colons)+ -> Bool+matchesEmoji searchString e =+ sanitizeEmojiSearch searchString `T.isInfixOf` e++sanitizeEmojiSearch :: T.Text -> T.Text+sanitizeEmojiSearch = stripColons . T.toLower . T.strip++-- | Perform an emoji search against both the local EmojiCollection as+-- well as the server's custom emoji. Return the results, sorted. If the+-- empty string is specified, all local and all custom emoji will be+-- included in the returned list.+getMatchingEmoji :: Session -> EmojiCollection -> T.Text -> IO [T.Text]+getMatchingEmoji session em rawSearchString = do+ let localAlts = lookupEmoji em rawSearchString+ sanitized = sanitizeEmojiSearch rawSearchString+ customResult <- E.try $ case T.null sanitized of+ True -> MM.mmGetListOfCustomEmoji Nothing Nothing session+ False -> MM.mmSearchCustomEmoji sanitized session++ let custom = case customResult of+ Left (_::E.SomeException) -> []+ Right result -> result++ return $ sort $ (MM.emojiName <$> custom) <> localAlts++stripColons :: T.Text -> T.Text+stripColons t =+ stripHeadColon $ stripTailColon t+ where+ stripHeadColon v = if ":" `T.isPrefixOf` v+ then T.tail v+ else v+ stripTailColon v = if ":" `T.isSuffixOf` v+ then T.init v+ else v
+ src/Matterhorn/Events.hs view
@@ -0,0 +1,387 @@+module Matterhorn.Events+ ( onEvent+ , globalKeybindings+ , globalKeyHandlers+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import qualified Data.Sequence as Seq+import qualified Data.Set as Set+import qualified Data.Text as T+import qualified Graphics.Vty as Vty+import Lens.Micro.Platform ( (.=), preuse, _2, singular, _Just )++import qualified Network.Mattermost.Endpoints as MM+import Network.Mattermost.Exceptions ( mattermostErrorMessage )+import Network.Mattermost.Lenses+import Network.Mattermost.Types+import Network.Mattermost.WebSocket++import Matterhorn.Connection+import Matterhorn.Constants ( userSigil, normalChannelSigil )+import Matterhorn.HelpTopics+import Matterhorn.State.Channels+import Matterhorn.State.Common+import Matterhorn.State.Flagging+import Matterhorn.State.Help+import Matterhorn.State.Messages+import Matterhorn.State.Reactions+import Matterhorn.State.Users+import Matterhorn.Types+import Matterhorn.Types.Common++import Matterhorn.Events.ChannelSelect+import Matterhorn.Events.ChannelTopicWindow+import Matterhorn.Events.DeleteChannelConfirm+import Matterhorn.Events.Keybindings+import Matterhorn.Events.LeaveChannelConfirm+import Matterhorn.Events.Main+import Matterhorn.Events.MessageSelect+import Matterhorn.Events.ThemeListOverlay+import Matterhorn.Events.PostListOverlay+import Matterhorn.Events.ShowHelp+import Matterhorn.Events.UrlSelect+import Matterhorn.Events.UserListOverlay+import Matterhorn.Events.ChannelListOverlay+import Matterhorn.Events.ReactionEmojiListOverlay+import Matterhorn.Events.TabbedWindow+import Matterhorn.Events.ManageAttachments+import Matterhorn.Events.EditNotifyPrefs+++onEvent :: ChatState -> BrickEvent Name MHEvent -> EventM Name (Next ChatState)+onEvent st ev = runMHEvent st $ do+ onBrickEvent ev+ doPendingUserFetches+ doPendingUserStatusFetches++onBrickEvent :: BrickEvent Name MHEvent -> MH ()+onBrickEvent (AppEvent e) =+ onAppEvent e+onBrickEvent (VtyEvent (Vty.EvKey (Vty.KChar 'l') [Vty.MCtrl])) = do+ vty <- mh getVtyHandle+ liftIO $ Vty.refresh vty+onBrickEvent (VtyEvent e) =+ onVtyEvent e+onBrickEvent _ =+ return ()++onAppEvent :: MHEvent -> MH ()+onAppEvent RefreshWebsocketEvent =+ connectWebsockets+onAppEvent WebsocketDisconnect = do+ csConnectionStatus .= Disconnected+ disconnectChannels+onAppEvent WebsocketConnect = do+ csConnectionStatus .= Connected+ refreshChannelsAndUsers+ refreshClientConfig+ fetchVisibleIfNeeded+onAppEvent (RateLimitExceeded winSz) =+ mhError $ GenericError $ T.pack $+ let s = if winSz == 1 then "" else "s"+ in "The server's API request rate limit was exceeded; Matterhorn will " <>+ "retry the failed request in " <> show winSz <> " second" <> s <>+ ". Please contact your Mattermost administrator " <>+ "about API rate limiting issues."+onAppEvent RateLimitSettingsMissing =+ mhError $ GenericError $+ "A request was rate-limited but could not be retried due to rate " <>+ "limit settings missing"+onAppEvent RequestDropped =+ mhError $ GenericError $+ "An API request was retried and dropped due to a rate limit. Matterhorn " <>+ "may now be inconsistent with the server. Please contact your " <>+ "Mattermost administrator about API rate limiting issues."+onAppEvent BGIdle =+ csWorkerIsBusy .= Nothing+onAppEvent (BGBusy n) =+ csWorkerIsBusy .= Just n+onAppEvent (WSEvent we) =+ handleWSEvent we+onAppEvent (WSActionResponse r) =+ handleWSActionResponse r+onAppEvent (RespEvent f) = f+onAppEvent (WebsocketParseError e) = do+ let msg = "A websocket message could not be parsed:\n " <>+ T.pack e <>+ "\nPlease report this error at https://github.com/matterhorn-chat/matterhorn/issues"+ mhError $ GenericError msg+onAppEvent (IEvent e) = do+ handleIEvent e++handleIEvent :: InternalEvent -> MH ()+handleIEvent (DisplayError e) =+ postErrorMessage' $ formatError e+handleIEvent (LoggingStarted path) =+ postInfoMessage $ "Logging to " <> T.pack path+handleIEvent (LogDestination dest) =+ case dest of+ Nothing ->+ postInfoMessage "Logging is currently disabled. Enable it with /log-start."+ Just path ->+ postInfoMessage $ T.pack $ "Logging to " <> path+handleIEvent (LogSnapshotSucceeded path) =+ postInfoMessage $ "Log snapshot written to " <> T.pack path+handleIEvent (LoggingStopped path) =+ postInfoMessage $ "Stopped logging to " <> T.pack path+handleIEvent (LogStartFailed path err) =+ postErrorMessage' $ "Could not start logging to " <> T.pack path <>+ ", error: " <> T.pack err+handleIEvent (LogSnapshotFailed path err) =+ postErrorMessage' $ "Could not write log snapshot to " <> T.pack path <>+ ", error: " <> T.pack err++formatError :: MHError -> T.Text+formatError (GenericError msg) =+ msg+formatError (NoSuchChannel chan) =+ T.pack $ "No such channel: " <> show chan+formatError (NoSuchUser user) =+ T.pack $ "No such user: " <> show user+formatError (AmbiguousName name) =+ (T.pack $ "The input " <> show name <> " matches both channels ") <>+ "and users. Try using '" <> userSigil <> "' or '" <>+ normalChannelSigil <> "' to disambiguate."+formatError (ServerError e) =+ mattermostErrorMessage e+formatError (ClipboardError msg) =+ msg+formatError (ConfigOptionMissing opt) =+ T.pack $ "Config option " <> show opt <> " missing"+formatError (ProgramExecutionFailed progName logPath) =+ T.pack $ "An error occurred when running " <> show progName <>+ "; see " <> show logPath <> " for details."+formatError (NoSuchScript name) =+ "No script named " <> name <> " was found"+formatError (NoSuchHelpTopic topic) =+ let knownTopics = (" - " <>) <$> helpTopicName <$> helpTopics+ in "Unknown help topic: `" <> topic <> "`. " <>+ (T.unlines $ "Available topics are:" : knownTopics)+formatError (AsyncErrEvent e) =+ "An unexpected error has occurred! The exception encountered was:\n " <>+ T.pack (show e) <>+ "\nPlease report this error at https://github.com/matterhorn-chat/matterhorn/issues"++onVtyEvent :: Vty.Event -> MH ()+onVtyEvent e = do+ case e of+ (Vty.EvResize _ _) ->+ -- On resize, invalidate the entire rendering cache since+ -- many things depend on the window size.+ --+ -- Note: we fall through after this because it is sometimes+ -- important for modes to have their own additional logic+ -- to run when a resize occurs, so we don't want to stop+ -- processing here.+ mh invalidateCache+ _ -> return ()++ void $ handleKeyboardEvent globalKeybindings handleGlobalEvent e++handleGlobalEvent :: Vty.Event -> MH ()+handleGlobalEvent e = do+ mode <- gets appMode+ case mode of+ Main -> onEventMain e+ ShowHelp _ _ -> void $ onEventShowHelp e+ ChannelSelect -> void $ onEventChannelSelect e+ UrlSelect -> void $ onEventUrlSelect e+ LeaveChannelConfirm -> onEventLeaveChannelConfirm e+ MessageSelect -> onEventMessageSelect e+ MessageSelectDeleteConfirm -> onEventMessageSelectDeleteConfirm e+ DeleteChannelConfirm -> onEventDeleteChannelConfirm e+ ThemeListOverlay -> onEventThemeListOverlay e+ PostListOverlay _ -> onEventPostListOverlay e+ UserListOverlay -> onEventUserListOverlay e+ ChannelListOverlay -> onEventChannelListOverlay e+ ReactionEmojiListOverlay -> onEventReactionEmojiListOverlay e+ ViewMessage -> void $ handleTabbedWindowEvent (csViewedMessage.singular _Just._2) e+ ManageAttachments -> onEventManageAttachments e+ ManageAttachmentsBrowseFiles -> onEventManageAttachments e+ EditNotifyPrefs -> void $ onEventEditNotifyPrefs e+ ChannelTopicWindow -> onEventChannelTopicWindow e++globalKeybindings :: KeyConfig -> KeyHandlerMap+globalKeybindings = mkKeybindings globalKeyHandlers++globalKeyHandlers :: [KeyEventHandler]+globalKeyHandlers =+ [ mkKb ShowHelpEvent+ "Show this help screen"+ (showHelpScreen mainHelpTopic)+ ]++handleWSActionResponse :: WebsocketActionResponse -> MH ()+handleWSActionResponse r =+ case warStatus r of+ WebsocketActionStatusOK -> return ()++handleWSEvent :: WebsocketEvent -> MH ()+handleWSEvent we = do+ myId <- gets myUserId+ myTId <- gets myTeamId+ case weEvent we of+ WMPosted+ | Just p <- wepPost (weData we) ->+ when (wepTeamId (weData we) == Just myTId ||+ wepTeamId (weData we) == Nothing) $ do+ let wasMentioned = maybe False (Set.member myId) $ wepMentions (weData we)+ addNewPostedMessage $ RecentPost p wasMentioned+ cId <- use csCurrentChannelId+ when (postChannelId p /= cId) $+ showChannelInSidebar (p^.postChannelIdL) False+ | otherwise -> return ()++ WMPostEdited+ | Just p <- wepPost (weData we) -> do+ editMessage p+ cId <- use csCurrentChannelId+ when (postChannelId p == cId) (updateViewed False)+ when (postChannelId p /= cId) $+ showChannelInSidebar (p^.postChannelIdL) False+ | otherwise -> return ()++ WMPostDeleted+ | Just p <- wepPost (weData we) -> do+ deleteMessage p+ cId <- use csCurrentChannelId+ when (postChannelId p == cId) (updateViewed False)+ when (postChannelId p /= cId) $+ showChannelInSidebar (p^.postChannelIdL) False+ | otherwise -> return ()++ WMStatusChange+ | Just status <- wepStatus (weData we)+ , Just uId <- wepUserId (weData we) ->+ setUserStatus uId status+ | otherwise -> return ()++ WMUserAdded+ | Just cId <- webChannelId (weBroadcast we) ->+ when (wepUserId (weData we) == Just myId &&+ wepTeamId (weData we) == Just myTId) $+ handleChannelInvite cId+ | otherwise -> return ()++ WMNewUser+ | Just uId <- wepUserId $ weData we ->+ handleNewUsers (Seq.singleton uId) (return ())+ | otherwise -> return ()++ WMUserRemoved+ | Just cId <- wepChannelId (weData we) ->+ when (webUserId (weBroadcast we) == Just myId) $+ removeChannelFromState cId+ | otherwise -> return ()++ WMTyping+ | Just uId <- wepUserId $ weData we+ , Just cId <- webChannelId (weBroadcast we) -> handleTypingUser uId cId+ | otherwise -> return ()++ WMChannelDeleted+ | Just cId <- wepChannelId (weData we) ->+ when (webTeamId (weBroadcast we) == Just myTId) $+ removeChannelFromState cId+ | otherwise -> return ()++ WMDirectAdded+ | Just cId <- webChannelId (weBroadcast we) -> handleChannelInvite cId+ | otherwise -> return ()++ -- An 'ephemeral message' is just Mattermost's version of our+ -- 'client message'. This can be a little bit wacky, e.g.+ -- if the user types '/shortcuts' in the browser, we'll get+ -- an ephemeral message even in MatterHorn with the browser+ -- shortcuts, but it's probably a good idea to handle these+ -- messages anyway.+ WMEphemeralMessage+ | Just p <- wepPost $ weData we -> postInfoMessage (sanitizeUserText $ p^.postMessageL)+ | otherwise -> return ()++ WMPreferenceChanged+ | Just prefs <- wepPreferences (weData we) ->+ mapM_ applyPreferenceChange prefs+ | otherwise -> return ()++ WMPreferenceDeleted+ | Just pref <- wepPreferences (weData we)+ , Just fps <- mapM preferenceToFlaggedPost pref ->+ forM_ fps $ \f ->+ updateMessageFlag (flaggedPostId f) False+ | otherwise -> return ()++ WMReactionAdded+ | Just r <- wepReaction (weData we)+ , Just cId <- webChannelId (weBroadcast we) -> addReactions cId [r]+ | otherwise -> return ()++ WMReactionRemoved+ | Just r <- wepReaction (weData we)+ , Just cId <- webChannelId (weBroadcast we) -> removeReaction r cId+ | otherwise -> return ()++ WMChannelViewed+ | Just cId <- wepChannelId $ weData we -> refreshChannelById cId+ | otherwise -> return ()++ WMChannelUpdated+ | Just cId <- webChannelId $ weBroadcast we -> do+ mChan <- preuse (csChannel(cId))+ when (isJust mChan) $ do+ refreshChannelById cId+ updateSidebar+ | otherwise -> return ()++ WMGroupAdded+ | Just cId <- webChannelId (weBroadcast we) -> handleChannelInvite cId+ | otherwise -> return ()++ WMChannelMemberUpdated+ | Just channelMember <- wepChannelMember $ weData we ->+ when (channelMemberUserId channelMember == myId) $+ updateChannelNotifyProps+ (channelMemberChannelId channelMember)+ (channelMemberNotifyProps channelMember)+ | otherwise -> return ()++ -- We are pretty sure we should do something about these:+ WMAddedToTeam -> return ()++ -- We aren't sure whether there is anything we should do about+ -- these yet:+ WMUpdateTeam -> return ()+ WMTeamDeleted -> return ()+ WMUserUpdated -> return ()+ WMLeaveTeam -> return ()++ -- We deliberately ignore these events:+ WMChannelCreated -> return ()+ WMEmojiAdded -> return ()+ WMWebRTC -> return ()+ WMHello -> return ()+ WMAuthenticationChallenge -> return ()+ WMUserRoleUpdated -> return ()+ WMPluginStatusesChanged -> return ()+ WMPluginEnabled -> return ()+ WMPluginDisabled -> return ()+ WMUnknownEvent {} ->+ mhLog LogWebsocket $ T.pack $+ "Websocket event not handled due to unknown event type: " <> show we++-- | Refresh client-accessible server configuration information. This+-- is usually triggered when a reconnect event for the WebSocket to the+-- server occurs.+refreshClientConfig :: MH ()+refreshClientConfig = do+ session <- getSession+ doAsyncWith Preempt $ do+ cfg <- MM.mmGetClientConfiguration (Just "old") session+ return $ Just $ do+ csClientConfig .= Just cfg+ updateSidebar
+ src/Matterhorn/Events/ChannelListOverlay.hs view
@@ -0,0 +1,35 @@+module Matterhorn.Events.ChannelListOverlay+ ( onEventChannelListOverlay+ , channelListOverlayKeybindings+ , channelListOverlayKeyHandlers+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty++import Matterhorn.Events.Keybindings+import Matterhorn.State.ChannelListOverlay+import Matterhorn.State.ListOverlay+import Matterhorn.Types+++onEventChannelListOverlay :: Vty.Event -> MH ()+onEventChannelListOverlay =+ void . onEventListOverlay csChannelListOverlay channelListOverlayKeybindings++-- | The keybindings we want to use while viewing a channel list overlay+channelListOverlayKeybindings :: KeyConfig -> KeyHandlerMap+channelListOverlayKeybindings = mkKeybindings channelListOverlayKeyHandlers++channelListOverlayKeyHandlers :: [KeyEventHandler]+channelListOverlayKeyHandlers =+ [ mkKb CancelEvent "Close the channel search list" (exitListOverlay csChannelListOverlay)+ , mkKb SearchSelectUpEvent "Select the previous channel" channelListSelectUp+ , mkKb SearchSelectDownEvent "Select the next channel" channelListSelectDown+ , mkKb PageDownEvent "Page down in the channel list" channelListPageDown+ , mkKb PageUpEvent "Page up in the channel list" channelListPageUp+ , mkKb ActivateListItemEvent "Join the selected channel" (listOverlayActivateCurrent csChannelListOverlay)+ ]
+ src/Matterhorn/Events/ChannelSelect.hs view
@@ -0,0 +1,45 @@+module Matterhorn.Events.ChannelSelect where++import Prelude ()+import Matterhorn.Prelude++import Brick.Widgets.Edit ( handleEditorEvent )+import qualified Graphics.Vty as Vty++import Matterhorn.Events.Keybindings+import Matterhorn.State.Channels+import Matterhorn.State.ChannelSelect+import Matterhorn.State.Editing ( editingKeybindings )+import Matterhorn.Types+import qualified Matterhorn.Zipper as Z+++onEventChannelSelect :: Vty.Event -> MH Bool+onEventChannelSelect =+ handleKeyboardEvent channelSelectKeybindings $ \e -> do+ handled <- handleKeyboardEvent (editingKeybindings (csChannelSelectState.channelSelectInput)) (const $ return ()) e+ when (not handled) $+ mhHandleEventLensed (csChannelSelectState.channelSelectInput) handleEditorEvent e++ updateChannelSelectMatches++channelSelectKeybindings :: KeyConfig -> KeyHandlerMap+channelSelectKeybindings = mkKeybindings channelSelectKeyHandlers++channelSelectKeyHandlers :: [KeyEventHandler]+channelSelectKeyHandlers =+ [ staticKb "Switch to selected channel"+ (Vty.EvKey Vty.KEnter []) $ do+ matches <- use (csChannelSelectState.channelSelectMatches)+ case Z.focus matches of+ Nothing -> return ()+ Just match -> do+ setMode Main+ setFocus $ channelListEntryChannelId $ matchEntry match++ , mkKb CancelEvent "Cancel channel selection" $ setMode Main+ , mkKb NextChannelEvent "Select next match" channelSelectNext+ , mkKb PrevChannelEvent "Select previous match" channelSelectPrevious+ , mkKb NextChannelEventAlternate "Select next match (alternate binding)" channelSelectNext+ , mkKb PrevChannelEventAlternate "Select previous match (alternate binding)" channelSelectPrevious+ ]
+ src/Matterhorn/Events/ChannelTopicWindow.hs view
@@ -0,0 +1,48 @@+module Matterhorn.Events.ChannelTopicWindow+ ( onEventChannelTopicWindow+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Focus+import Brick.Widgets.Edit ( handleEditorEvent, getEditContents )+import qualified Data.Text as T+import Lens.Micro.Platform ( (%=) )+import qualified Graphics.Vty as Vty++import Matterhorn.Types+import Matterhorn.State.Channels ( setChannelTopic )+++onEventChannelTopicWindow :: Vty.Event -> MH ()+onEventChannelTopicWindow (Vty.EvKey (Vty.KChar '\t') []) =+ csChannelTopicDialog.channelTopicDialogFocus %= focusNext+onEventChannelTopicWindow (Vty.EvKey Vty.KBackTab []) =+ csChannelTopicDialog.channelTopicDialogFocus %= focusPrev+onEventChannelTopicWindow e@(Vty.EvKey Vty.KEnter []) = do+ f <- use (csChannelTopicDialog.channelTopicDialogFocus)+ case focusGetCurrent f of+ Just ChannelTopicSaveButton -> do+ ed <- use (csChannelTopicDialog.channelTopicDialogEditor)+ let topic = T.unlines $ getEditContents ed+ setChannelTopic topic+ setMode Main+ Just ChannelTopicEditor ->+ mhHandleEventLensed (csChannelTopicDialog.channelTopicDialogEditor)+ handleEditorEvent e+ Just ChannelTopicCancelButton ->+ setMode Main+ _ ->+ setMode Main+onEventChannelTopicWindow (Vty.EvKey Vty.KEsc []) = do+ setMode Main+onEventChannelTopicWindow e = do+ f <- use (csChannelTopicDialog.channelTopicDialogFocus)+ case focusGetCurrent f of+ Just ChannelTopicEditor ->+ mhHandleEventLensed (csChannelTopicDialog.channelTopicDialogEditor)+ handleEditorEvent e+ _ ->+ return ()
+ src/Matterhorn/Events/DeleteChannelConfirm.hs view
@@ -0,0 +1,20 @@+module Matterhorn.Events.DeleteChannelConfirm where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty++import Matterhorn.Types+import Matterhorn.State.Channels+++onEventDeleteChannelConfirm :: Vty.Event -> MH ()+onEventDeleteChannelConfirm (Vty.EvKey k []) = do+ case k of+ Vty.KChar c | c `elem` ("yY"::String) ->+ deleteCurrentChannel+ _ -> return ()+ setMode Main+onEventDeleteChannelConfirm _ = do+ setMode Main
+ src/Matterhorn/Events/EditNotifyPrefs.hs view
@@ -0,0 +1,48 @@+module Matterhorn.Events.EditNotifyPrefs+ ( onEventEditNotifyPrefs+ , editNotifyPrefsKeybindings+ , editNotifyPrefsKeyHandlers+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Forms (handleFormEvent, formState)+import Data.Maybe (fromJust)+import qualified Graphics.Vty as V+import qualified Network.Mattermost.Endpoints as MM++import Lens.Micro.Platform (_Just, (.=), singular)++import Matterhorn.Types+import Matterhorn.Types.KeyEvents+import Matterhorn.Events.Keybindings+import Matterhorn.State.NotifyPrefs+import Matterhorn.State.Async++onEventEditNotifyPrefs :: V.Event -> MH Bool+onEventEditNotifyPrefs =+ let fallback e = do+ form <- use (csNotifyPrefs.singular _Just)+ updatedForm <- mh $ handleFormEvent (VtyEvent e) form+ csNotifyPrefs .= Just updatedForm+ in handleKeyboardEvent editNotifyPrefsKeybindings fallback++editNotifyPrefsKeybindings :: KeyConfig -> KeyHandlerMap+editNotifyPrefsKeybindings = mkKeybindings editNotifyPrefsKeyHandlers++editNotifyPrefsKeyHandlers :: [KeyEventHandler]+editNotifyPrefsKeyHandlers =+ [ mkKb CancelEvent "Close channel notification preferences" exitEditNotifyPrefsMode+ , mkKb FormSubmitEvent "Save channel notification preferences" $ do+ st <- use id+ let form = fromJust $ st^.csNotifyPrefs+ cId = st^.csCurrentChannelId++ doAsyncChannelMM Preempt cId+ (\s _ -> MM.mmUpdateChannelNotifications cId (myUserId st) (formState form) s)+ (\_ _ -> Nothing)+ exitEditNotifyPrefsMode+ ]
+ src/Matterhorn/Events/Keybindings.hs view
@@ -0,0 +1,323 @@+module Matterhorn.Events.Keybindings+ ( defaultBindings+ , lookupKeybinding+ , getFirstDefaultBinding++ , mkKb+ , staticKb+ , mkKeybindings++ , handleKeyboardEvent++ , EventHandler(..)+ , KeyHandler(..)+ , KeyEventHandler(..)+ , KeyEventTrigger(..)+ , KeyHandlerMap(..)++ -- Re-exports:+ , KeyEvent (..)+ , KeyConfig+ , allEvents+ , parseBinding+ , keyEventName+ , keyEventFromName++ , ensureKeybindingConsistency+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Text as T+import qualified Data.Map.Strict as M+import qualified Graphics.Vty as Vty++import Matterhorn.Types+import Matterhorn.Types.KeyEvents+++-- * Keybindings++-- | An 'EventHandler' represents a event handler.+data EventHandler =+ EH { ehDescription :: Text+ -- ^ The description of this handler's behavior.+ , ehAction :: MH ()+ -- ^ The action to take when this handler is invoked.+ }++-- | A trigger for a key event.+data KeyEventTrigger =+ Static Vty.Event+ -- ^ The key event is always triggered by a specific key.+ | ByEvent KeyEvent+ -- ^ The key event is always triggered by an abstract key event (and+ -- thus configured to be bound to specific key(s) in the KeyConfig).+ deriving (Show, Eq, Ord)++-- | A handler for an abstract key event.+data KeyEventHandler =+ KEH { kehHandler :: EventHandler+ -- ^ The handler to invoke.+ , kehEventTrigger :: KeyEventTrigger+ -- ^ The trigger for the handler.+ }++-- | A handler for a specific key.+data KeyHandler =+ KH { khHandler :: KeyEventHandler+ -- ^ The handler to invoke.+ , khKey :: Vty.Event+ -- ^ The specific key that should trigger this handler.+ }++newtype KeyHandlerMap = KeyHandlerMap (M.Map Vty.Event KeyHandler)++-- | Find a keybinding that matches a Vty Event+lookupKeybinding :: Vty.Event -> KeyHandlerMap -> Maybe KeyHandler+lookupKeybinding e (KeyHandlerMap m) = M.lookup e m++-- | Handle a keyboard event by looking it up in a map of bindings and+-- invoking the matching binding's handler. If no match can be found,+-- invoke a fallback action instead. Return True if the key event was+-- handled with a matching binding; False if not (the fallback case).+handleKeyboardEvent :: (KeyConfig -> KeyHandlerMap)+ -- ^ The function to build a key handler map from a+ -- key configuration.+ -> (Vty.Event -> MH ())+ -- ^ The fallback action to invoke if no matching+ -- binding can be found.+ -> Vty.Event+ -- ^ The event to handle.+ -> MH Bool+handleKeyboardEvent mkKeyMap fallthrough e = do+ conf <- use (csResources.crConfiguration)+ let keyMap = mkKeyMap (configUserKeys conf)+ case lookupKeybinding e keyMap of+ Just kh -> (ehAction $ kehHandler $ khHandler kh) >> return True+ Nothing -> fallthrough e >> return False++mkHandler :: Text -> MH () -> EventHandler+mkHandler msg action =+ EH { ehDescription = msg+ , ehAction = action+ }++mkKb :: KeyEvent -> Text -> MH () -> KeyEventHandler+mkKb ev msg action =+ KEH { kehHandler = mkHandler msg action+ , kehEventTrigger = ByEvent ev+ }++keyHandlerFromConfig :: KeyConfig -> KeyEventHandler -> [KeyHandler]+keyHandlerFromConfig conf eh =+ case kehEventTrigger eh of+ Static key ->+ [ KH eh key ]+ ByEvent ev ->+ [ KH eh (bindingToEvent b) | b <- allBindings ]+ where allBindings | Just (BindingList ks) <- M.lookup ev conf = ks+ | Just Unbound <- M.lookup ev conf = []+ | otherwise = defaultBindings ev++staticKb :: Text -> Vty.Event -> MH () -> KeyEventHandler+staticKb msg event action =+ KEH { kehHandler = mkHandler msg action+ , kehEventTrigger = Static event+ }++mkKeybindings :: [KeyEventHandler] -> KeyConfig -> KeyHandlerMap+mkKeybindings ks conf = KeyHandlerMap $ M.fromList pairs+ where+ pairs = mkPair <$> handlers+ mkPair h = (khKey h, h)+ handlers = concat $ keyHandlerFromConfig conf <$> ks++bindingToEvent :: Binding -> Vty.Event+bindingToEvent binding =+ Vty.EvKey (kbKey binding) (kbMods binding)++getFirstDefaultBinding :: KeyEvent -> Binding+getFirstDefaultBinding ev =+ case defaultBindings ev of+ [] -> error $ "BUG: event " <> show ev <> " has no default bindings!"+ (b:_) -> b++defaultBindings :: KeyEvent -> [Binding]+defaultBindings ev =+ let meta binding = binding { kbMods = Vty.MMeta : kbMods binding }+ ctrl binding = binding { kbMods = Vty.MCtrl : kbMods binding }+ shift binding = binding { kbMods = Vty.MShift : kbMods binding }+ kb k = Binding { kbMods = [], kbKey = k }+ key c = Binding { kbMods = [], kbKey = Vty.KChar c }+ fn n = Binding { kbMods = [], kbKey = Vty.KFun n }+ in case ev of+ VtyRefreshEvent -> [ ctrl (key 'l') ]+ ShowHelpEvent -> [ fn 1 ]+ EnterSelectModeEvent -> [ ctrl (key 's') ]+ ReplyRecentEvent -> [ ctrl (key 'r') ]+ ToggleMessagePreviewEvent -> [ meta (key 'p') ]+ InvokeEditorEvent -> [ meta (key 'k') ]+ EnterFastSelectModeEvent -> [ ctrl (key 'g') ]+ QuitEvent -> [ ctrl (key 'q') ]+ NextChannelEvent -> [ ctrl (key 'n') ]+ PrevChannelEvent -> [ ctrl (key 'p') ]+ NextChannelEventAlternate -> [ kb Vty.KDown ]+ PrevChannelEventAlternate -> [ kb Vty.KUp ]+ NextUnreadChannelEvent -> [ meta (key 'a') ]+ ShowAttachmentListEvent -> [ ctrl (key 'x') ]+ NextUnreadUserOrChannelEvent -> [ ]+ LastChannelEvent -> [ meta (key 's') ]+ EnterOpenURLModeEvent -> [ ctrl (key 'o') ]+ ClearUnreadEvent -> [ meta (key 'l') ]+ ToggleMultiLineEvent -> [ meta (key 'e') ]+ EnterFlaggedPostsEvent -> [ meta (key '8') ]+ ToggleChannelListVisibleEvent -> [ fn 2 ]+ ToggleExpandedChannelTopicsEvent -> [ fn 3 ]+ SelectNextTabEvent -> [ key '\t' ]+ SelectPreviousTabEvent -> [ kb Vty.KBackTab ]+ LoadMoreEvent -> [ ctrl (key 'b') ]+ ScrollUpEvent -> [ kb Vty.KUp ]+ ScrollDownEvent -> [ kb Vty.KDown ]+ ScrollLeftEvent -> [ kb Vty.KLeft ]+ ScrollRightEvent -> [ kb Vty.KRight ]+ PageUpEvent -> [ kb Vty.KPageUp ]+ PageDownEvent -> [ kb Vty.KPageDown ]+ PageLeftEvent -> [ shift (kb Vty.KLeft) ]+ PageRightEvent -> [ shift (kb Vty.KRight) ]+ ScrollTopEvent -> [ kb Vty.KHome ]+ ScrollBottomEvent -> [ kb Vty.KEnd ]+ SelectOldestMessageEvent -> [ shift (kb Vty.KHome) ]+ SelectUpEvent -> [ key 'k', kb Vty.KUp ]+ SelectDownEvent -> [ key 'j', kb Vty.KDown ]+ ActivateListItemEvent -> [ kb Vty.KEnter ]+ SearchSelectUpEvent -> [ ctrl (key 'p'), kb Vty.KUp ]+ SearchSelectDownEvent -> [ ctrl (key 'n'), kb Vty.KDown ]+ ViewMessageEvent -> [ key 'v' ]+ FillGapEvent -> [ kb Vty.KEnter ]+ FlagMessageEvent -> [ key 'f' ]+ PinMessageEvent -> [ key 'p' ]+ YankMessageEvent -> [ key 'y' ]+ YankWholeMessageEvent -> [ key 'Y' ]+ DeleteMessageEvent -> [ key 'd' ]+ EditMessageEvent -> [ key 'e' ]+ ReplyMessageEvent -> [ key 'r' ]+ ReactToMessageEvent -> [ key 'a' ]+ OpenMessageURLEvent -> [ key 'o' ]+ AttachmentListAddEvent -> [ key 'a' ]+ AttachmentListDeleteEvent -> [ key 'd' ]+ AttachmentOpenEvent -> [ key 'o' ]+ CancelEvent -> [ kb Vty.KEsc, ctrl (key 'c') ]+ EditorBolEvent -> [ ctrl (key 'a') ]+ EditorEolEvent -> [ ctrl (key 'e') ]+ EditorTransposeCharsEvent -> [ ctrl (key 't') ]+ EditorDeleteCharacter -> [ ctrl (key 'd') ]+ EditorKillToBolEvent -> [ ctrl (key 'u') ]+ EditorKillToEolEvent -> [ ctrl (key 'k') ]+ EditorPrevCharEvent -> [ ctrl (key 'b') ]+ EditorNextCharEvent -> [ ctrl (key 'f') ]+ EditorPrevWordEvent -> [ meta (key 'b') ]+ EditorNextWordEvent -> [ meta (key 'f') ]+ EditorDeleteNextWordEvent -> [ meta (key 'd') ]+ EditorDeletePrevWordEvent -> [ ctrl (key 'w'), meta (kb Vty.KBS) ]+ EditorHomeEvent -> [ kb Vty.KHome ]+ EditorEndEvent -> [ kb Vty.KEnd ]+ EditorYankEvent -> [ ctrl (key 'y') ]+ FormSubmitEvent -> [ kb Vty.KEnter ]++-- | Given a configuration, we want to check it for internal consistency+-- (i.e. that a given keybinding isn't associated with multiple events+-- which both need to get generated in the same UI mode) and also for+-- basic usability (i.e. we shouldn't be binding events which can appear+-- in the main UI to a key like @e@, which would prevent us from being+-- able to type messages containing an @e@ in them!+ensureKeybindingConsistency :: KeyConfig -> [(String, KeyConfig -> KeyHandlerMap)] -> Either String ()+ensureKeybindingConsistency kc modeMaps = mapM_ checkGroup allBindings+ where+ -- This is a list of lists, grouped by keybinding, of all the+ -- keybinding/event associations that are going to be used with the+ -- provided key configuration.+ allBindings = groupWith fst $ concat+ [ case M.lookup ev kc of+ Nothing -> zip (defaultBindings ev) (repeat (False, ev))+ Just (BindingList bs) -> zip bs (repeat (True, ev))+ Just Unbound -> []+ | ev <- allEvents+ ]++ -- The invariant here is that each call to checkGroup is made with a+ -- list where the first element of every list is the same binding.+ -- The Bool value in these is True if the event was associated with+ -- the binding by the user, and False if it's a Matterhorn default.+ checkGroup :: [(Binding, (Bool, KeyEvent))] -> Either String ()+ checkGroup [] = error "[ensureKeybindingConsistency: unreachable]"+ checkGroup evs@((b, _):_) = do++ -- We find out which modes an event can be used in and then invert+ -- the map, so this is a map from mode to the events contains+ -- which are bound by the binding included above.+ let modesFor :: M.Map String [(Bool, KeyEvent)]+ modesFor = M.unionsWith (++)+ [ M.fromList [ (m, [(i, ev)]) | m <- modeMap ev ]+ | (_, (i, ev)) <- evs+ ]++ -- If there is ever a situation where the same key is bound to two+ -- events which can appear in the same mode, then we want to throw+ -- an error, and also be informative about why. It is still okay+ -- to bind the same key to two events, so long as those events+ -- never appear in the same UI mode.+ forM_ (M.assocs modesFor) $ \ (_, vs) ->+ when (length vs > 1) $+ Left $ concat $+ "Multiple overlapping events bound to `" :+ T.unpack (ppBinding b) :+ "`:\n" :+ concat [ [ " - `"+ , T.unpack (keyEventName ev)+ , "` "+ , if isFromUser+ then "(via user override)"+ else "(matterhorn default)"+ , "\n"+ ]+ | (isFromUser, ev) <- vs+ ]++ -- Check for overlap a set of built-in keybindings when we're in a+ -- mode where the user is typing. (These are perfectly fine when+ -- we're in other modes.)+ when ("main" `M.member` modesFor && isBareBinding b) $ do+ Left $ concat $+ [ "The keybinding `"+ , T.unpack (ppBinding b)+ , "` is bound to the "+ , case map (ppEvent . snd . snd) evs of+ [] -> error "unreachable"+ [e] -> "event " ++ e+ es -> "events " ++ intercalate " and " es+ , "\n"+ , "This is probably not what you want, as it will interfere "+ , "with the ability to write messages!\n"+ ]++ -- Events get some nice formatting!+ ppEvent ev = "`" ++ T.unpack (keyEventName ev) ++ "`"++ -- This check should get more nuanced, but as a first approximation,+ -- we shouldn't bind to any bare character key in the main mode.+ isBareBinding (Binding [] (Vty.KChar {})) = True+ isBareBinding _ = False++ -- We generate the which-events-are-valid-in-which-modes map from+ -- our actual keybinding set, so this should never get out of date.+ modeMap :: KeyEvent -> [String]+ modeMap ev =+ let matches kh = ByEvent ev == (kehEventTrigger $ khHandler kh)+ in [ mode+ | (mode, mkBindings) <- modeMaps+ , let KeyHandlerMap m = mkBindings kc+ in not $ null $ M.filter matches m+ ]
+ src/Matterhorn/Events/LeaveChannelConfirm.hs view
@@ -0,0 +1,19 @@+module Matterhorn.Events.LeaveChannelConfirm where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty++import Matterhorn.State.Channels+import Matterhorn.Types+++onEventLeaveChannelConfirm :: Vty.Event -> MH ()+onEventLeaveChannelConfirm (Vty.EvKey k []) = do+ case k of+ Vty.KChar c | c `elem` ("yY"::String) ->+ leaveCurrentChannel+ _ -> return ()+ setMode Main+onEventLeaveChannelConfirm _ = return ()
+ src/Matterhorn/Events/Main.hs view
@@ -0,0 +1,151 @@+{-# LANGUAGE MultiWayIf #-}+module Matterhorn.Events.Main where++import Prelude ()+import Matterhorn.Prelude++import Brick.Widgets.Edit+import qualified Graphics.Vty as Vty++import Matterhorn.Command+import Matterhorn.Events.Keybindings+import Matterhorn.State.Attachments+import Matterhorn.State.ChannelSelect+import Matterhorn.State.Channels+import Matterhorn.State.Editing+import Matterhorn.State.MessageSelect+import Matterhorn.State.PostListOverlay ( enterFlaggedPostListMode )+import Matterhorn.State.UrlSelect+import Matterhorn.Types+++onEventMain :: Vty.Event -> MH ()+onEventMain =+ void . handleKeyboardEvent mainKeybindings (\ ev -> do+ resetReturnChannel+ case ev of+ (Vty.EvPaste bytes) -> handlePaste bytes+ _ -> handleEditingInput ev+ )++mainKeybindings :: KeyConfig -> KeyHandlerMap+mainKeybindings = mkKeybindings mainKeyHandlers++mainKeyHandlers :: [KeyEventHandler]+mainKeyHandlers =+ [ mkKb EnterSelectModeEvent+ "Select a message to edit/reply/delete"+ beginMessageSelect++ , mkKb ReplyRecentEvent+ "Reply to the most recent message"+ replyToLatestMessage++ , mkKb ToggleMessagePreviewEvent "Toggle message preview"+ toggleMessagePreview++ , mkKb ToggleChannelListVisibleEvent "Toggle channel list visibility"+ toggleChannelListVisibility++ , mkKb ToggleExpandedChannelTopicsEvent "Toggle display of expanded channel topics"+ toggleExpandedChannelTopics++ , mkKb+ InvokeEditorEvent+ "Invoke `$EDITOR` to edit the current message"+ invokeExternalEditor++ , mkKb+ EnterFastSelectModeEvent+ "Enter fast channel selection mode"+ beginChannelSelect++ , mkKb+ QuitEvent+ "Quit"+ requestQuit++ , staticKb "Tab-complete forward"+ (Vty.EvKey (Vty.KChar '\t') []) $+ tabComplete Forwards++ , staticKb "Tab-complete backward"+ (Vty.EvKey (Vty.KBackTab) []) $+ tabComplete Backwards++ , mkKb+ ScrollUpEvent+ "Scroll up in the channel input history" $ do+ -- Up in multiline mode does the usual thing; otherwise we+ -- navigate the history.+ isMultiline <- use (csEditState.cedEphemeral.eesMultiline)+ case isMultiline of+ True -> mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent+ (Vty.EvKey Vty.KUp [])+ False -> channelHistoryBackward++ , mkKb+ ScrollDownEvent+ "Scroll down in the channel input history" $ do+ -- Down in multiline mode does the usual thing; otherwise+ -- we navigate the history.+ isMultiline <- use (csEditState.cedEphemeral.eesMultiline)+ case isMultiline of+ True -> mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent+ (Vty.EvKey Vty.KDown [])+ False -> channelHistoryForward++ , mkKb PageUpEvent "Page up in the channel message list (enters message select mode)" $ do+ beginMessageSelect++ , mkKb SelectOldestMessageEvent "Scroll to top of channel message list" $ do+ beginMessageSelect+ messageSelectFirst++ , mkKb NextChannelEvent "Change to the next channel in the channel list"+ nextChannel++ , mkKb PrevChannelEvent "Change to the previous channel in the channel list"+ prevChannel++ , mkKb NextUnreadChannelEvent "Change to the next channel with unread messages or return to the channel marked '~'"+ nextUnreadChannel++ , mkKb ShowAttachmentListEvent "Show the attachment list"+ showAttachmentList++ , mkKb NextUnreadUserOrChannelEvent+ "Change to the next channel with unread messages preferring direct messages"+ nextUnreadUserOrChannel++ , mkKb LastChannelEvent "Change to the most recently-focused channel"+ recentChannel++ , staticKb "Send the current message"+ (Vty.EvKey Vty.KEnter []) $ do+ isMultiline <- use (csEditState.cedEphemeral.eesMultiline)+ case isMultiline of+ -- Normally, this event causes the current message to+ -- be sent. But in multiline mode we want to insert a+ -- newline instead.+ True -> handleEditingInput (Vty.EvKey Vty.KEnter [])+ False -> do+ cId <- use csCurrentChannelId+ content <- getEditorContent+ handleInputSubmission cId content++ , mkKb EnterOpenURLModeEvent "Select and open a URL posted to the current channel"+ startUrlSelect++ , mkKb ClearUnreadEvent "Clear the current channel's unread / edited indicators" $+ clearChannelUnreadStatus =<< use csCurrentChannelId++ , mkKb ToggleMultiLineEvent "Toggle multi-line message compose mode"+ toggleMultilineEditing++ , mkKb CancelEvent "Cancel autocomplete, message reply, or edit, in that order"+ cancelAutocompleteOrReplyOrEdit++ , mkKb EnterFlaggedPostsEvent "View currently flagged posts"+ enterFlaggedPostListMode+ ]
+ src/Matterhorn/Events/ManageAttachments.hs view
@@ -0,0 +1,182 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE ScopedTypeVariables #-}+module Matterhorn.Events.ManageAttachments+ ( onEventManageAttachments+ , attachmentListKeybindings+ , attachmentBrowseKeyHandlers+ , attachmentBrowseKeybindings+ , attachmentListKeyHandlers+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Control.Exception as E+import qualified Brick.Widgets.FileBrowser as FB+import qualified Brick.Widgets.List as L+import qualified Data.ByteString as BS+import qualified Data.Text as T+import qualified Data.Vector as Vector+import qualified Graphics.Vty as V+import Lens.Micro.Platform ( (?=), (%=), to )++import Matterhorn.Types+import Matterhorn.Types.KeyEvents+import Matterhorn.Events.Keybindings+import Matterhorn.State.Attachments+import Matterhorn.State.Common+++onEventManageAttachments :: V.Event -> MH ()+onEventManageAttachments e = do+ mode <- gets appMode+ case mode of+ ManageAttachments -> void $ onEventAttachmentList e+ ManageAttachmentsBrowseFiles -> onEventBrowseFile e+ _ -> error "BUG: onEventManageAttachments called in invalid mode"++onEventAttachmentList :: V.Event -> MH Bool+onEventAttachmentList =+ handleKeyboardEvent attachmentListKeybindings $+ mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent++attachmentListKeybindings :: KeyConfig -> KeyHandlerMap+attachmentListKeybindings = mkKeybindings attachmentListKeyHandlers++attachmentListKeyHandlers :: [KeyEventHandler]+attachmentListKeyHandlers =+ [ mkKb CancelEvent "Close attachment list"+ (setMode Main)+ , mkKb SelectUpEvent "Move cursor up" $+ mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent (V.EvKey V.KUp [])+ , mkKb SelectDownEvent "Move cursor down" $+ mhHandleEventLensed (csEditState.cedAttachmentList) L.handleListEvent (V.EvKey V.KDown [])+ , mkKb AttachmentListAddEvent "Add a new attachment to the attachment list"+ showAttachmentFileBrowser+ , mkKb AttachmentOpenEvent "Open the selected attachment using the URL open command"+ openSelectedAttachment+ , mkKb AttachmentListDeleteEvent "Delete the selected attachment from the attachment list"+ deleteSelectedAttachment+ ]++attachmentBrowseKeybindings :: KeyConfig -> KeyHandlerMap+attachmentBrowseKeybindings = mkKeybindings attachmentBrowseKeyHandlers++attachmentBrowseKeyHandlers :: [KeyEventHandler]+attachmentBrowseKeyHandlers =+ [ mkKb CancelEvent "Cancel attachment file browse"+ cancelAttachmentBrowse+ , mkKb AttachmentOpenEvent "Open the selected file using the URL open command"+ openSelectedBrowserEntry+ ]++withFileBrowser :: ((FB.FileBrowser Name) -> MH ()) -> MH ()+withFileBrowser f = do+ use (csEditState.cedFileBrowser) >>= \case+ Nothing -> do+ -- The widget has not been created yet. This should+ -- normally not occur, because the ManageAttachments+ -- events should not fire when there is no FileBrowser+ -- Widget active to cause Brick to generate these events.+ -- This could therefore be implemented as an `error "BUG:+ -- ..."` handler, but the more benign approach is to+ -- simply create an available FileBrowser at this stage.+ new_b <- liftIO $ FB.newFileBrowser FB.selectNonDirectories AttachmentFileBrowser Nothing+ csEditState.cedFileBrowser ?= new_b+ f new_b+ Just b -> f b++openSelectedAttachment :: MH ()+openSelectedAttachment = do+ cur <- use (csEditState.cedAttachmentList.to L.listSelectedElement)+ case cur of+ Nothing -> return ()+ Just (_, entry) -> void $ openFilePath (FB.fileInfoFilePath $+ attachmentDataFileInfo entry)++openSelectedBrowserEntry :: MH ()+openSelectedBrowserEntry = withFileBrowser $ \b ->+ case FB.fileBrowserCursor b of+ Nothing -> return ()+ Just entry -> void $ openFilePath (FB.fileInfoFilePath entry)++onEventBrowseFile :: V.Event -> MH ()+onEventBrowseFile e = do+ withFileBrowser $ \b -> do+ case FB.fileBrowserIsSearching b of+ False ->+ void $ handleKeyboardEvent attachmentBrowseKeybindings handleFileBrowserEvent e+ True ->+ handleFileBrowserEvent e++ -- n.b. the FileBrowser may have been updated above, so re-acquire it+ withFileBrowser $ \b -> do+ case FB.fileBrowserException b of+ Nothing -> return ()+ Just ex -> do+ mhLog LogError $ T.pack $ "FileBrowser exception: " <> show ex++cancelAttachmentBrowse :: MH ()+cancelAttachmentBrowse = do+ es <- use (csEditState.cedAttachmentList.L.listElementsL)+ case length es of+ 0 -> setMode Main+ _ -> setMode ManageAttachments++handleFileBrowserEvent :: V.Event -> MH ()+handleFileBrowserEvent e = do+ let fbHandle ev = sequence . (fmap (FB.handleFileBrowserEvent ev))+ mhHandleEventLensed (csEditState.cedFileBrowser) fbHandle e++ withFileBrowser $ \b -> do+ -- TODO: Check file browser exception state+ let entries = FB.fileBrowserSelection b+ forM_ entries $ \entry -> do+ -- Is the entry already present? If so, ignore the selection.+ es <- use (csEditState.cedAttachmentList.L.listElementsL)+ let matches = (== (FB.fileInfoFilePath entry)) .+ FB.fileInfoFilePath .+ attachmentDataFileInfo+ case Vector.find matches es of+ Just _ -> return ()+ Nothing -> do+ let path = FB.fileInfoFilePath entry+ readResult <- liftIO $ E.try $ BS.readFile path+ case readResult of+ Left (_::E.SomeException) ->+ -- TODO: report the error+ return ()+ Right bytes -> do+ let a = AttachmentData { attachmentDataFileInfo = entry+ , attachmentDataBytes = bytes+ }+ oldIdx <- use (csEditState.cedAttachmentList.L.listSelectedL)+ let newIdx = if Vector.null es+ then Just 0+ else oldIdx+ csEditState.cedAttachmentList %= L.listReplace (Vector.snoc es a) newIdx++ when (not $ null entries) $ setMode Main++deleteSelectedAttachment :: MH ()+deleteSelectedAttachment = do+ es <- use (csEditState.cedAttachmentList.L.listElementsL)+ mSel <- use (csEditState.cedAttachmentList.to L.listSelectedElement)+ case mSel of+ Nothing ->+ return ()+ Just (pos, _) -> do+ oldIdx <- use (csEditState.cedAttachmentList.L.listSelectedL)+ let idx = if Vector.length es == 1+ then Nothing+ else case oldIdx of+ Nothing -> Just 0+ Just old -> if pos >= old+ then Just $ pos - 1+ else Just pos+ csEditState.cedAttachmentList %= L.listReplace (deleteAt pos es) idx++deleteAt :: Int -> Vector.Vector a -> Vector.Vector a+deleteAt p as | p < 0 || p >= length as = as+ | otherwise = Vector.take p as <> Vector.drop (p + 1) as
+ src/Matterhorn/Events/MessageSelect.hs view
@@ -0,0 +1,85 @@+module Matterhorn.Events.MessageSelect where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Text as T+import qualified Graphics.Vty as Vty++import Matterhorn.Events.Keybindings+import Matterhorn.State.MessageSelect+import Matterhorn.State.ReactionEmojiListOverlay+import Matterhorn.Types+++messagesPerPageOperation :: Int+messagesPerPageOperation = 10++onEventMessageSelect :: Vty.Event -> MH ()+onEventMessageSelect =+ void . handleKeyboardEvent messageSelectKeybindings (const $ return ())++onEventMessageSelectDeleteConfirm :: Vty.Event -> MH ()+onEventMessageSelectDeleteConfirm (Vty.EvKey (Vty.KChar 'y') []) = do+ deleteSelectedMessage+ setMode Main+onEventMessageSelectDeleteConfirm _ =+ setMode Main++messageSelectKeybindings :: KeyConfig -> KeyHandlerMap+messageSelectKeybindings = mkKeybindings messageSelectKeyHandlers++messageSelectKeyHandlers :: [KeyEventHandler]+messageSelectKeyHandlers =+ [ mkKb CancelEvent "Cancel message selection" $+ setMode Main++ , mkKb SelectUpEvent "Select the previous message" messageSelectUp+ , mkKb SelectDownEvent "Select the next message" messageSelectDown+ , mkKb ScrollTopEvent "Scroll to top and select the oldest message"+ messageSelectFirst+ , mkKb ScrollBottomEvent "Scroll to bottom and select the latest message"+ messageSelectLast+ , mkKb+ PageUpEvent+ (T.pack $ "Move the cursor up by " <> show messagesPerPageOperation <> " messages")+ (messageSelectUpBy messagesPerPageOperation)+ , mkKb+ PageDownEvent+ (T.pack $ "Move the cursor down by " <> show messagesPerPageOperation <> " messages")+ (messageSelectDownBy messagesPerPageOperation)++ , mkKb OpenMessageURLEvent "Open all URLs in the selected message"+ openSelectedMessageURLs++ , mkKb ReplyMessageEvent "Begin composing a reply to the selected message"+ beginReplyCompose++ , mkKb EditMessageEvent "Begin editing the selected message"+ beginEditMessage++ , mkKb DeleteMessageEvent "Delete the selected message (with confirmation)"+ beginConfirmDeleteSelectedMessage++ , mkKb YankMessageEvent "Copy a verbatim section or message to the clipboard"+ yankSelectedMessageVerbatim++ , mkKb YankWholeMessageEvent "Copy an entire message to the clipboard"+ yankSelectedMessage++ , mkKb PinMessageEvent "Toggle whether the selected message is pinned"+ pinSelectedMessage++ , mkKb FlagMessageEvent "Flag the selected message"+ flagSelectedMessage++ , mkKb ViewMessageEvent "View the selected message"+ viewSelectedMessage++ , mkKb FillGapEvent "Fetch messages for the selected gap"+ fillSelectedGap++ , mkKb ReactToMessageEvent "Post a reaction to the selected message"+ enterReactionEmojiListOverlayMode++ ]
+ src/Matterhorn/Events/PostListOverlay.hs view
@@ -0,0 +1,28 @@+module Matterhorn.Events.PostListOverlay where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty++import Matterhorn.Types+import Matterhorn.Events.Keybindings+import Matterhorn.State.PostListOverlay+++onEventPostListOverlay :: Vty.Event -> MH ()+onEventPostListOverlay =+ void . handleKeyboardEvent postListOverlayKeybindings (const $ return ())++-- | The keybindings we want to use while viewing a post list overlay+postListOverlayKeybindings :: KeyConfig -> KeyHandlerMap+postListOverlayKeybindings = mkKeybindings postListOverlayKeyHandlers++postListOverlayKeyHandlers :: [KeyEventHandler]+postListOverlayKeyHandlers =+ [ mkKb CancelEvent "Exit post browsing" exitPostListMode+ , mkKb SelectUpEvent "Select the previous message" postListSelectUp+ , mkKb SelectDownEvent "Select the next message" postListSelectDown+ , mkKb FlagMessageEvent "Toggle the selected message flag" postListUnflagSelected+ , mkKb ActivateListItemEvent "Jump to and select current message" postListJumpToCurrent+ ]
+ src/Matterhorn/Events/ReactionEmojiListOverlay.hs view
@@ -0,0 +1,35 @@+module Matterhorn.Events.ReactionEmojiListOverlay+ ( onEventReactionEmojiListOverlay+ , reactionEmojiListOverlayKeybindings+ , reactionEmojiListOverlayKeyHandlers+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty++import Matterhorn.Events.Keybindings+import Matterhorn.State.ReactionEmojiListOverlay+import Matterhorn.State.ListOverlay+import Matterhorn.Types+++onEventReactionEmojiListOverlay :: Vty.Event -> MH ()+onEventReactionEmojiListOverlay =+ void . onEventListOverlay csReactionEmojiListOverlay reactionEmojiListOverlayKeybindings++-- | The keybindings we want to use while viewing an emoji list overlay+reactionEmojiListOverlayKeybindings :: KeyConfig -> KeyHandlerMap+reactionEmojiListOverlayKeybindings = mkKeybindings reactionEmojiListOverlayKeyHandlers++reactionEmojiListOverlayKeyHandlers :: [KeyEventHandler]+reactionEmojiListOverlayKeyHandlers =+ [ mkKb CancelEvent "Close the emoji search window" (exitListOverlay csReactionEmojiListOverlay)+ , mkKb SearchSelectUpEvent "Select the previous emoji" reactionEmojiListSelectUp+ , mkKb SearchSelectDownEvent "Select the next emoji" reactionEmojiListSelectDown+ , mkKb PageDownEvent "Page down in the emoji list" reactionEmojiListPageDown+ , mkKb PageUpEvent "Page up in the emoji list" reactionEmojiListPageUp+ , mkKb ActivateListItemEvent "Post the selected emoji reaction" (listOverlayActivateCurrent csReactionEmojiListOverlay)+ ]
+ src/Matterhorn/Events/ShowHelp.hs view
@@ -0,0 +1,44 @@+module Matterhorn.Events.ShowHelp where++import Prelude ()+import Matterhorn.Prelude++import Brick+import qualified Graphics.Vty as Vty++import Matterhorn.Constants+import Matterhorn.Events.Keybindings+import Matterhorn.Types+++onEventShowHelp :: Vty.Event -> MH Bool+onEventShowHelp =+ handleKeyboardEvent helpKeybindings $ \ e -> case e of+ Vty.EvKey _ _ -> popMode+ _ -> return ()++helpKeybindings :: KeyConfig -> KeyHandlerMap+helpKeybindings = mkKeybindings helpKeyHandlers++helpKeyHandlers :: [KeyEventHandler]+helpKeyHandlers =+ [ mkKb ScrollUpEvent "Scroll up" $+ mh $ vScrollBy (viewportScroll HelpViewport) (-1)+ , mkKb ScrollDownEvent "Scroll down" $+ mh $ vScrollBy (viewportScroll HelpViewport) 1+ , mkKb PageUpEvent "Page up" $+ mh $ vScrollBy (viewportScroll HelpViewport) (-1 * pageAmount)+ , mkKb PageDownEvent "Page down" $+ mh $ vScrollBy (viewportScroll HelpViewport) (1 * pageAmount)+ , mkKb CancelEvent "Return to the previous interface" $+ popMode+ , mkKb ScrollBottomEvent "Scroll to the end of the help" $+ mh $ vScrollToEnd (viewportScroll HelpViewport)+ , mkKb ScrollTopEvent "Scroll to the beginning of the help" $+ mh $ vScrollToBeginning (viewportScroll HelpViewport)+ ]++popMode :: MH ()+popMode = do+ ShowHelp _ prevMode <- gets appMode+ setMode prevMode
+ src/Matterhorn/Events/TabbedWindow.hs view
@@ -0,0 +1,56 @@+{-# LANGUAGE RankNTypes #-}+module Matterhorn.Events.TabbedWindow+ ( handleTabbedWindowEvent+ , tabbedWindowKeybindings+ , tabbedWindowKeyHandlers+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty+import Lens.Micro.Platform ( Lens', (.=) )++import Matterhorn.Types+import Matterhorn.Types.KeyEvents+import Matterhorn.Events.Keybindings++handleTabbedWindowEvent :: (Show a, Eq a)+ => Lens' ChatState (TabbedWindow a)+ -> Vty.Event+ -> MH Bool+handleTabbedWindowEvent target e = do+ w <- use target+ handleKeyboardEvent (tabbedWindowKeybindings target) (forwardEvent w) e++forwardEvent :: (Show a, Eq a)+ => TabbedWindow a+ -> Vty.Event+ -> MH ()+forwardEvent w e = do+ let cur = getCurrentTabbedWindowEntry w+ tweHandleEvent cur (twValue w) e++tabbedWindowKeybindings :: (Show a, Eq a)+ => Lens' ChatState (TabbedWindow a)+ -> KeyConfig+ -> KeyHandlerMap+tabbedWindowKeybindings target = mkKeybindings $ tabbedWindowKeyHandlers target++tabbedWindowKeyHandlers :: (Show a, Eq a)+ => Lens' ChatState (TabbedWindow a)+ -> [KeyEventHandler]+tabbedWindowKeyHandlers target =+ [ mkKb CancelEvent "Close window" $ do+ w <- use target+ setMode (twReturnMode w)++ , mkKb SelectNextTabEvent "Select next tab" $ do+ w' <- tabbedWindowNextTab =<< use target+ target .= w'++ , mkKb SelectPreviousTabEvent "Select previous tab" $ do+ w' <- tabbedWindowPreviousTab =<< use target+ target .= w'+ ]
+ src/Matterhorn/Events/ThemeListOverlay.hs view
@@ -0,0 +1,30 @@+module Matterhorn.Events.ThemeListOverlay where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty++import Matterhorn.Events.Keybindings+import Matterhorn.State.ThemeListOverlay+import Matterhorn.State.ListOverlay+import Matterhorn.Types+++onEventThemeListOverlay :: Vty.Event -> MH ()+onEventThemeListOverlay =+ void . onEventListOverlay csThemeListOverlay themeListOverlayKeybindings++-- | The keybindings we want to use while viewing a user list overlay+themeListOverlayKeybindings :: KeyConfig -> KeyHandlerMap+themeListOverlayKeybindings = mkKeybindings themeListOverlayKeyHandlers++themeListOverlayKeyHandlers :: [KeyEventHandler]+themeListOverlayKeyHandlers =+ [ mkKb CancelEvent "Close the theme list" (exitListOverlay csThemeListOverlay)+ , mkKb SearchSelectUpEvent "Select the previous theme" themeListSelectUp+ , mkKb SearchSelectDownEvent "Select the next theme" themeListSelectDown+ , mkKb PageDownEvent "Page down in the theme list" themeListPageDown+ , mkKb PageUpEvent "Page up in the theme list" themeListPageUp+ , mkKb ActivateListItemEvent "Switch to the selected color theme" (listOverlayActivateCurrent csThemeListOverlay)+ ]
+ src/Matterhorn/Events/UrlSelect.hs view
@@ -0,0 +1,39 @@+module Matterhorn.Events.UrlSelect where++import Prelude ()+import Matterhorn.Prelude++import Brick.Widgets.List+import qualified Graphics.Vty as Vty++import Matterhorn.Events.Keybindings+import Matterhorn.State.UrlSelect+import Matterhorn.Types+++onEventUrlSelect :: Vty.Event -> MH Bool+onEventUrlSelect =+ handleKeyboardEvent urlSelectKeybindings $ \ ev ->+ mhHandleEventLensed csUrlList handleListEvent ev++urlSelectKeybindings :: KeyConfig -> KeyHandlerMap+urlSelectKeybindings = mkKeybindings urlSelectKeyHandlers++urlSelectKeyHandlers :: [KeyEventHandler]+urlSelectKeyHandlers =+ [ staticKb "Open the selected URL, if any"+ (Vty.EvKey Vty.KEnter []) $+ openSelectedURL++ , mkKb CancelEvent "Cancel URL selection" stopUrlSelect++ , mkKb SelectUpEvent "Move cursor up" $+ mhHandleEventLensed csUrlList handleListEvent (Vty.EvKey Vty.KUp [])++ , mkKb SelectDownEvent "Move cursor down" $+ mhHandleEventLensed csUrlList handleListEvent (Vty.EvKey Vty.KDown [])++ , staticKb "Cancel URL selection"+ (Vty.EvKey (Vty.KChar 'q') []) $ stopUrlSelect++ ]
+ src/Matterhorn/Events/UserListOverlay.hs view
@@ -0,0 +1,30 @@+module Matterhorn.Events.UserListOverlay where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty++import Matterhorn.Events.Keybindings+import Matterhorn.State.UserListOverlay+import Matterhorn.State.ListOverlay+import Matterhorn.Types+++onEventUserListOverlay :: Vty.Event -> MH ()+onEventUserListOverlay =+ void . onEventListOverlay csUserListOverlay userListOverlayKeybindings++-- | The keybindings we want to use while viewing a user list overlay+userListOverlayKeybindings :: KeyConfig -> KeyHandlerMap+userListOverlayKeybindings = mkKeybindings userListOverlayKeyHandlers++userListOverlayKeyHandlers :: [KeyEventHandler]+userListOverlayKeyHandlers =+ [ mkKb CancelEvent "Close the user search list" (exitListOverlay csUserListOverlay)+ , mkKb SearchSelectUpEvent "Select the previous user" userListSelectUp+ , mkKb SearchSelectDownEvent "Select the next user" userListSelectDown+ , mkKb PageDownEvent "Page down in the user list" userListPageDown+ , mkKb PageUpEvent "Page up in the user list" userListPageUp+ , mkKb ActivateListItemEvent "Interact with the selected user" (listOverlayActivateCurrent csUserListOverlay)+ ]
+ src/Matterhorn/FilePaths.hs view
@@ -0,0 +1,167 @@+{-# LANGUAGE TupleSections #-}+module Matterhorn.FilePaths+ ( historyFilePath+ , historyFileName++ , lastRunStateFilePath+ , lastRunStateFileName++ , configFileName++ , xdgName+ , locateConfig+ , xdgSyntaxDir+ , syntaxDirName+ , userEmojiJsonPath+ , bundledEmojiJsonPath+ , emojiJsonFilename++ , Script(..)+ , locateScriptPath+ , getAllScripts+ )+where++import Prelude ()+import Matterhorn.Prelude++import Data.Text ( unpack )+import System.Directory ( doesFileExist+ , doesDirectoryExist+ , getDirectoryContents+ , getPermissions+ , executable+ )+import System.Environment ( getExecutablePath )+import System.Environment.XDG.BaseDir ( getUserConfigFile+ , getAllConfigFiles+ , getUserConfigDir+ )+import System.FilePath ( (</>), takeBaseName, takeDirectory, splitPath, joinPath )+++xdgName :: String+xdgName = "matterhorn"++historyFileName :: FilePath+historyFileName = "history.txt"++lastRunStateFileName :: Text -> FilePath+lastRunStateFileName teamId = "last_run_state_" ++ unpack teamId ++ ".json"++configFileName :: FilePath+configFileName = "config.ini"++historyFilePath :: IO FilePath+historyFilePath = getUserConfigFile xdgName historyFileName++lastRunStateFilePath :: Text -> IO FilePath+lastRunStateFilePath teamId =+ getUserConfigFile xdgName (lastRunStateFileName teamId)++-- | Get the XDG path to the user-specific syntax definition directory.+-- The path does not necessarily exist.+xdgSyntaxDir :: IO FilePath+xdgSyntaxDir = (</> syntaxDirName) <$> getUserConfigDir xdgName++-- | Get the XDG path to the user-specific emoji JSON file. The path+-- does not necessarily exist.+userEmojiJsonPath :: IO FilePath+userEmojiJsonPath = (</> emojiJsonFilename) <$> getUserConfigDir xdgName++-- | Get the emoji JSON path relative to the development binary location+-- or the release binary location.+bundledEmojiJsonPath :: IO FilePath+bundledEmojiJsonPath = do+ selfPath <- getExecutablePath+ let distDir = "dist-newstyle/"+ pathBits = splitPath selfPath++ return $ if distDir `elem` pathBits+ then+ -- We're in development, so use the development+ -- executable path to locate the emoji path in the+ -- development tree.+ (joinPath $ takeWhile (/= distDir) pathBits) </> emojiDirName </> emojiJsonFilename+ else+ -- In this case we assume the binary is being run from+ -- a release, in which case the syntax directory is a+ -- sibling of the executable path.+ takeDirectory selfPath </> emojiDirName </> emojiJsonFilename++emojiJsonFilename :: FilePath+emojiJsonFilename = "emoji.json"++emojiDirName :: FilePath+emojiDirName = "emoji"++syntaxDirName :: FilePath+syntaxDirName = "syntax"++-- | Find a specified configuration file by looking in all of the+-- supported locations.+locateConfig :: FilePath -> IO (Maybe FilePath)+locateConfig filename = do+ xdgLocations <- getAllConfigFiles xdgName filename+ let confLocations = ["./" <> filename] +++ xdgLocations +++ ["/etc/matterhorn/" <> filename]+ results <- forM confLocations $ \fp -> (fp,) <$> doesFileExist fp+ return $ listToMaybe $ fst <$> filter snd results++scriptDirName :: FilePath+scriptDirName = "scripts"++data Script+ = ScriptPath FilePath+ | NonexecScriptPath FilePath+ | ScriptNotFound+ deriving (Eq, Read, Show)++toScript :: FilePath -> IO (Script)+toScript fp = do+ perm <- getPermissions fp+ return $ if executable perm+ then ScriptPath fp+ else NonexecScriptPath fp++isExecutable :: FilePath -> IO Bool+isExecutable fp = do+ perm <- getPermissions fp+ return (executable perm)++locateScriptPath :: FilePath -> IO Script+locateScriptPath name+ | head name == '.' = return ScriptNotFound+ | otherwise = do+ xdgLocations <- getAllConfigFiles xdgName scriptDirName+ let cmdLocations = [ xdgLoc ++ "/" ++ name+ | xdgLoc <- xdgLocations+ ] ++ [ "/etc/matterhorn/scripts/" <> name ]+ existingFiles <- filterM doesFileExist cmdLocations+ executables <- mapM toScript existingFiles+ return $ case executables of+ (path:_) -> path+ _ -> ScriptNotFound++-- | This returns a list of valid scripts, and a list of non-executable+-- scripts.+getAllScripts :: IO ([FilePath], [FilePath])+getAllScripts = do+ xdgLocations <- getAllConfigFiles xdgName scriptDirName+ let cmdLocations = xdgLocations ++ ["/etc/matterhorn/scripts"]+ let getCommands dir = do+ exists <- doesDirectoryExist dir+ if exists+ then map ((dir ++ "/") ++) `fmap` getDirectoryContents dir+ else return []+ let isNotHidden f = case f of+ ('.':_) -> False+ [] -> False+ _ -> True+ allScripts <- concat `fmap` mapM getCommands cmdLocations+ execs <- filterM isExecutable allScripts+ nonexecs <- filterM (fmap not . isExecutable) allScripts+ return ( filter isNotHidden $ map takeBaseName execs+ , filter isNotHidden $ map takeBaseName nonexecs+ )
+ src/Matterhorn/HelpTopics.hs view
@@ -0,0 +1,49 @@+{-# LANGUAGE OverloadedStrings #-}+module Matterhorn.HelpTopics+ ( helpTopics+ , lookupHelpTopic+ , themeHelpTopic+ , mainHelpTopic+ )+where++import Prelude ()+import Matterhorn.Prelude++import Matterhorn.Types+++helpTopics :: [HelpTopic]+helpTopics =+ [ mainHelpTopic+ , scriptHelpTopic+ , themeHelpTopic+ , keybindingHelpTopic+ , syntaxHighlightingHelpTopic+ ]++mainHelpTopic :: HelpTopic+mainHelpTopic =+ HelpTopic "main" "This help page" MainHelp HelpText++scriptHelpTopic :: HelpTopic+scriptHelpTopic =+ HelpTopic "scripts" "Help on available scripts" ScriptHelp ScriptHelpText++themeHelpTopic :: HelpTopic+themeHelpTopic =+ HelpTopic "themes" "Help on color themes" ThemeHelp ThemeHelpText++keybindingHelpTopic :: HelpTopic+keybindingHelpTopic =+ HelpTopic "keybindings" "Help on overriding keybindings"+ KeybindingHelp KeybindingHelpText++syntaxHighlightingHelpTopic :: HelpTopic+syntaxHighlightingHelpTopic =+ HelpTopic "syntax" "Help on syntax highlighing"+ SyntaxHighlightHelp SyntaxHighlightHelpText++lookupHelpTopic :: Text -> Maybe HelpTopic+lookupHelpTopic topic =+ listToMaybe $ filter ((== topic) . helpTopicName) helpTopics
+ src/Matterhorn/IOUtil.hs view
@@ -0,0 +1,20 @@+module Matterhorn.IOUtil+ ( convertIOException+ )+where++import Prelude ()+import Matterhorn.Prelude++import Control.Exception+import Control.Monad.Trans.Except+import System.IO.Error ( ioeGetErrorString )+++convertIOException :: IO a -> ExceptT String IO a+convertIOException act = do+ result <- liftIO $ (Right <$> act) `catch`+ (\(e::IOError) -> return $ Left $ ioeGetErrorString e)+ case result of+ Left e -> throwE e+ Right v -> return v
+ src/Matterhorn/InputHistory.hs view
@@ -0,0 +1,76 @@+{-# LANGUAGE TemplateHaskell #-}+module Matterhorn.InputHistory+ ( InputHistory+ , newHistory+ , readHistory+ , writeHistory+ , addHistoryEntry+ , getHistoryEntry+ , removeChannelHistory+ )+where++import Prelude ()+import Matterhorn.Prelude++import Control.Monad.Trans.Except+import qualified Data.HashMap.Strict as HM+import qualified Data.Vector as V+import Lens.Micro.Platform ( (.~), (^?), (%~), at, ix, makeLenses )+import System.Directory ( createDirectoryIfMissing )+import System.FilePath ( dropFileName )+import qualified System.IO.Strict as S+import qualified System.Posix.Files as P+import qualified System.Posix.Types as P++import Network.Mattermost.Types ( ChannelId )++import Matterhorn.FilePaths+import Matterhorn.IOUtil+++data InputHistory =+ InputHistory { _historyEntries :: HashMap ChannelId (V.Vector Text)+ }+ deriving (Show)++makeLenses ''InputHistory++newHistory :: InputHistory+newHistory = InputHistory mempty++removeChannelHistory :: ChannelId -> InputHistory -> InputHistory+removeChannelHistory cId ih = ih & historyEntries.at cId .~ Nothing++historyFileMode :: P.FileMode+historyFileMode = P.unionFileModes P.ownerReadMode P.ownerWriteMode++writeHistory :: InputHistory -> IO ()+writeHistory ih = do+ historyFile <- historyFilePath+ createDirectoryIfMissing True $ dropFileName historyFile+ let entries = (\(cId, z) -> (cId, V.toList z)) <$>+ (HM.toList $ ih^.historyEntries)+ writeFile historyFile $ show entries+ P.setFileMode historyFile historyFileMode++readHistory :: IO (Either String InputHistory)+readHistory = runExceptT $ do+ contents <- convertIOException (S.readFile =<< historyFilePath)+ case reads contents of+ [(val, "")] -> do+ let entries = (\(cId, es) -> (cId, V.fromList es)) <$> val+ return $ InputHistory $ HM.fromList entries+ _ -> throwE "Failed to parse history file"++addHistoryEntry :: Text -> ChannelId -> InputHistory -> InputHistory+addHistoryEntry e cId ih = ih & historyEntries.at cId %~ insertEntry+ where+ insertEntry Nothing = Just $ V.singleton e+ insertEntry (Just v) =+ Just $ V.cons e (V.filter (/= e) v)++getHistoryEntry :: ChannelId -> Int -> InputHistory -> Maybe Text+getHistoryEntry cId i ih = do+ es <- ih^.historyEntries.at cId+ es ^? ix i
+ src/Matterhorn/KeyMap.hs view
@@ -0,0 +1,28 @@+module Matterhorn.KeyMap+ ( keybindingModeMap+ )+where++import Prelude ()+import Matterhorn.Prelude++import Matterhorn.Events.Keybindings+import Matterhorn.Events.ChannelSelect+import Matterhorn.Events.Main+import Matterhorn.Events.MessageSelect+import Matterhorn.Events.PostListOverlay+import Matterhorn.Events.ShowHelp+import Matterhorn.Events.UrlSelect+import Matterhorn.Events.ManageAttachments++keybindingModeMap :: [(String, KeyConfig -> KeyHandlerMap)]+keybindingModeMap =+ [ ("main", mainKeybindings)+ , ("help screen", helpKeybindings)+ , ("channel select", channelSelectKeybindings)+ , ("url select", urlSelectKeybindings)+ , ("message select", messageSelectKeybindings)+ , ("post list overlay", postListOverlayKeybindings)+ , ("attachment list", attachmentListKeybindings)+ , ("attachment file browse", attachmentBrowseKeybindings)+ ]
+ src/Matterhorn/LastRunState.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE TemplateHaskell #-}+module Matterhorn.LastRunState+ ( LastRunState+ , lrsHost+ , lrsPort+ , lrsUserId+ , lrsSelectedChannelId+ , writeLastRunState+ , readLastRunState+ , isValidLastRunState+ )+where++import Prelude ()+import Matterhorn.Prelude++import Control.Monad.Trans.Except+import qualified Data.Aeson as A+import qualified Data.ByteString as BS+import qualified Data.ByteString.Lazy as LBS+import Lens.Micro.Platform ( makeLenses )+import System.Directory ( createDirectoryIfMissing )+import System.FilePath ( dropFileName )+import qualified System.Posix.Files as P+import qualified System.Posix.Types as P++import Network.Mattermost.Lenses+import Network.Mattermost.Types++import Matterhorn.FilePaths+import Matterhorn.IOUtil+import Matterhorn.Types+++-- | Run state of the program. This is saved in a file on program exit and+-- | looked up from the file on program startup.+data LastRunState = LastRunState+ { _lrsHost :: Hostname -- ^ Host of the server+ , _lrsPort :: Port -- ^ Post of the server+ , _lrsUserId :: UserId -- ^ ID of the logged-in user+ , _lrsSelectedChannelId :: ChannelId -- ^ ID of the last selected channel+ }++instance A.ToJSON LastRunState where+ toJSON lrs = A.object [ "host" A..= _lrsHost lrs+ , "port" A..= _lrsPort lrs+ , "user_id" A..= _lrsUserId lrs+ , "sel_channel_id" A..= _lrsSelectedChannelId lrs+ ]++instance A.FromJSON LastRunState where+ parseJSON = A.withObject "LastRunState" $ \v ->+ LastRunState+ <$> v A..: "host"+ <*> v A..: "port"+ <*> v A..: "user_id"+ <*> v A..: "sel_channel_id"++makeLenses ''LastRunState++toLastRunState :: ChatState -> LastRunState+toLastRunState cs = LastRunState+ { _lrsHost = cs^.csResources.crConn.cdHostnameL+ , _lrsPort = cs^.csResources.crConn.cdPortL+ , _lrsUserId = myUserId cs+ , _lrsSelectedChannelId = cs^.csCurrentChannelId+ }++lastRunStateFileMode :: P.FileMode+lastRunStateFileMode = P.unionFileModes P.ownerReadMode P.ownerWriteMode++-- | Writes the run state to a file. The file is specific to the current team.+-- | Writes only if the current channel is an ordrinary or a private channel.+writeLastRunState :: ChatState -> IO ()+writeLastRunState cs =+ when (cs^.csCurrentChannel.ccInfo.cdType `elem` [Ordinary, Private]) $ do+ let runState = toLastRunState cs+ tId = myTeamId cs++ lastRunStateFile <- lastRunStateFilePath $ unId $ toId tId+ createDirectoryIfMissing True $ dropFileName lastRunStateFile+ BS.writeFile lastRunStateFile $ LBS.toStrict $ A.encode runState+ P.setFileMode lastRunStateFile lastRunStateFileMode++-- | Reads the last run state from a file given the current team ID.+readLastRunState :: TeamId -> IO (Either String LastRunState)+readLastRunState tId = runExceptT $ do+ contents <- convertIOException $+ lastRunStateFilePath (unId $ toId tId) >>= BS.readFile+ case A.eitherDecodeStrict' contents of+ Right val -> return val+ Left err -> throwE $ "Failed to parse lastRunState file: " ++ err++-- | Checks if the given last run state is valid for the current server and user.+isValidLastRunState :: ChatResources -> User -> LastRunState -> Bool+isValidLastRunState cr me rs =+ rs^.lrsHost == cr^.crConn.cdHostnameL+ && rs^.lrsPort == cr^.crConn.cdPortL+ && rs^.lrsUserId == me^.userIdL
+ src/Matterhorn/Login.hs view
@@ -0,0 +1,594 @@+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE RankNTypes #-}+-- | This module provides the login interface for Matterhorn.+--+-- * Overview+--+-- The interface provides a set of form fields for the user to use to+-- enter their server information and credentials. The user enters+-- this information and presses Enter, and then this module+-- attempts to connect to the server. The module's main function,+-- interactiveGetLoginSession, returns the result of that connection+-- attempt, if any.+--+-- * Details+--+-- The interactiveGetLoginSession function takes the Matterhorn+-- configuration's initial connection information as input. If the+-- configuration provided a complete set of values needed to make a+-- login attempt, this module goes ahead and immediately makes a login+-- attempt before even showing the user the login form. This case is+-- the case where the configuration provided all four values needed:+-- server host name, port, username, and password. When the interface+-- immediately makes a login attempt under these conditions, this is+-- referred to as an "initial" attempt in various docstrings below.+-- Otherwise, the user is prompted to fill out the form to enter any+-- missing values. On pressing Enter, a login attempt is made.+--+-- A status message about whether a connection is underway is shown in+-- both cases: in the case where the user has edited the credentials and+-- pressed Enter, and in the case where the original credentials+-- provided to interactiveGetLoginSession caused an initial connection+-- attempt.+--+-- The "initial" login case is special because in addition to not+-- showing the form, we want to ensure that the "connecting to..."+-- status message that is shown is shown long enough for the user to+-- see what is happening (rather than just flashing by in the case+-- of a fast server connection). For this usability reason, we have+-- a "startup timer" thread: the thread waits a specified number+-- of milliseconds (see 'startupTimerMilliseconds' below) and then+-- notifies the interface that it has timed out. If there is an initial+-- connection attempt underway that succeeds *before* the timer+-- fires, we wait until the timer fires before quitting the Login+-- application and returning control to Matterhorn. This ensures that+-- the "connecting to..." message stays on the screen long enough to not+-- be jarring, and to show the user what is happening. If the connection+-- fails before the timer fires, we just resume normal operation and+-- show the login form so the user can intervene.+module Matterhorn.Login+ ( LoginAttempt(..)+ , interactiveGetLoginSession+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.BChan+import Brick.Focus+import Brick.Forms+import Brick.Widgets.Border+import Brick.Widgets.Center+import Brick.Widgets.Edit+import Control.Concurrent ( forkIO, threadDelay )+import Control.Exception ( SomeException, catch, try )+import Data.Char (isHexDigit)+import Data.List (tails, inits)+import System.IO.Error ( catchIOError )+import qualified Data.Text as T+import Graphics.Vty hiding (mkVty)+import Lens.Micro.Platform ( (.~), Lens', makeLenses )+import qualified System.IO.Error as Err+import Network.URI ( URI(..), URIAuth(..), parseURI )++import Network.Mattermost ( ConnectionData )+import Network.Mattermost.Types.Internal ( Token(..) )+import Network.Mattermost.Types ( Session(..), User, Login(..), ConnectionPoolConfig(..)+ , initConnectionData, ConnectionType(..), UserParam(..) )+import Network.Mattermost.Exceptions ( LoginFailureException(..) )+import Network.Mattermost.Endpoints ( mmGetUser, mmGetLimitedClientConfiguration, mmLogin )++import Matterhorn.Draw.RichText+import Matterhorn.Themes ( clientEmphAttr )+import Matterhorn.Types ( ConnectionInfo(..)+ , ciPassword, ciUsername, ciHostname, ciUrlPath+ , ciPort, ciType, AuthenticationException(..)+ , LogManager, LogCategory(..), ioLogWithManager+ , ciAccessToken+ )+++-- | Resource names for the login interface.+data Name =+ Server+ | Username+ | Password+ | AccessToken+ deriving (Ord, Eq, Show)++-- | The result of an authentication attempt.+data LoginAttempt =+ AttemptFailed AuthenticationException+ -- ^ The attempt failed with the corresponding error.+ | AttemptSucceeded ConnectionInfo ConnectionData Session User (Maybe Text) --team+ -- ^ The attempt succeeded.++-- | The state of the login interface: whether a login attempt is+-- currently in progress.+data LoginState =+ Idle+ -- ^ No login attempt is in progress.+ | Connecting Bool Text+ -- ^ A login attempt to the specified host is in progress. The+ -- boolean flag indicates whether this login was user initiated+ -- (False) or triggered immediately when starting the interface+ -- (True). This "initial" flag is used to determine whether the+ -- login form is shown while the connection attempt is underway.+ deriving (Eq)++-- | Requests that we can make to the login worker thead.+data LoginRequest =+ DoLogin Bool ConnectionInfo+ -- ^ Request a login using the specified connection information.+ -- The boolean flag is the "initial" flag value corresponding to the+ -- "Connecting" constructor flag of the "LoginState" type.++-- | The messages that the login worker thread can send to the user+-- interface event handler.+data LoginEvent =+ StartConnect Bool Text+ -- ^ A connection to the specified host has begun. The boolean+ -- value is whether this was an "initial" connection attempt (see+ -- LoginState).+ | LoginResult LoginAttempt+ -- ^ A login attempt finished with the specified result.+ | StartupTimeout+ -- ^ The startup timer thread fired.++-- | The login application state.+data State =+ State { _loginForm :: Form ConnectionInfo LoginEvent Name+ , _lastAttempt :: Maybe LoginAttempt+ , _currentState :: LoginState+ , _reqChan :: BChan LoginRequest+ , _timeoutFired :: Bool+ }++makeLenses ''State++-- | The HTTP connection pool settings for the login worker thread.+poolCfg :: ConnectionPoolConfig+poolCfg = ConnectionPoolConfig { cpIdleConnTimeout = 60+ , cpStripesCount = 1+ , cpMaxConnCount = 5+ }++-- | Run an IO action and convert various kinds of thrown exceptions+-- into a returned AuthenticationException.+convertLoginExceptions :: IO a -> IO (Either AuthenticationException a)+convertLoginExceptions act =+ (Right <$> act)+ `catch` (\e -> return $ Left $ ResolveError e)+ `catch` (\e -> return $ Left $ ConnectError e)+ `catchIOError` (\e -> return $ Left $ AuthIOError e)+ `catch` (\e -> return $ Left $ OtherAuthError e)++-- | The login worker thread.+loginWorker :: (ConnectionData -> ConnectionData)+ -- ^ The function used to set the logger on the+ -- ConnectionData that results from a successful login+ -- attempt.+ -> LogManager+ -- ^ The log manager used to do logging.+ -> BChan LoginRequest+ -- ^ The channel on which we'll await requests.+ -> BChan LoginEvent+ -- ^ The channel to which we'll send login attempt results.+ -> IO ()+loginWorker setLogger logMgr requestChan respChan = forever $ do+ req <- readBChan requestChan+ case req of+ DoLogin initial connInfo -> do+ writeBChan respChan $ StartConnect initial $ connInfo^.ciHostname+ let doLog = ioLogWithManager logMgr Nothing LogGeneral++ doLog $ "Attempting authentication to " <> connInfo^.ciHostname++ cdResult <- findConnectionData connInfo+ case cdResult of+ Left e ->+ do writeBChan respChan $ LoginResult $ AttemptFailed $ OtherAuthError e+ Right (cd_, mbTeam) -> do+ let cd = setLogger cd_+ token = connInfo^.ciAccessToken+ case T.null token of+ False -> do+ let sess = Session cd $ Token $ T.unpack token++ userResult <- try $ mmGetUser UserMe sess+ writeBChan respChan $ case userResult of+ Left (e::SomeException) ->+ LoginResult $ AttemptFailed $ OtherAuthError e+ Right user ->+ LoginResult $ AttemptSucceeded connInfo cd sess user mbTeam+ True -> do+ let login = Login { username = connInfo^.ciUsername+ , password = connInfo^.ciPassword+ }++ result <- convertLoginExceptions $ mmLogin cd login+ case result of+ Left e -> do+ doLog $ "Error authenticating to " <> connInfo^.ciHostname <> ": " <> (T.pack $ show e)+ writeBChan respChan $ LoginResult $ AttemptFailed e+ Right (Left e) -> do+ doLog $ "Error authenticating to " <> connInfo^.ciHostname <> ": " <> (T.pack $ show e)+ writeBChan respChan $ LoginResult $ AttemptFailed $ LoginError e+ Right (Right (sess, user)) -> do+ doLog $ "Authenticated successfully to " <> connInfo^.ciHostname <> " as " <> connInfo^.ciUsername+ writeBChan respChan $ LoginResult $ AttemptSucceeded connInfo cd sess user mbTeam++++-- | Searches prefixes of the given URL to determine Mattermost API URL+-- path and team name+findConnectionData :: ConnectionInfo -> IO (Either SomeException (ConnectionData, Maybe Text))+findConnectionData connInfo = startSearch+ where+ components = filter (not . T.null) (T.split ('/'==) (connInfo^.ciUrlPath))++ -- the candidates list is never empty because inits never returns an+ -- empty list+ primary:alternatives =+ reverse+ [ (T.intercalate "/" l, listToMaybe r)+ | (l,r) <- zip (inits components) (tails components)+ ]++ tryCandidate :: (Text, Maybe Text)+ -> IO (Either SomeException (ConnectionData, Maybe Text))+ tryCandidate (path, team) =+ do cd <- initConnectionData (connInfo^.ciHostname)+ (fromIntegral (connInfo^.ciPort))+ path (connInfo^.ciType) poolCfg+ res <- try (mmGetLimitedClientConfiguration cd)+ pure $! case res of+ Left e -> Left e+ Right{} -> Right (cd, team)++ -- This code prefers to report the error from the URL corresponding+ -- to what the user actually provided. Errors from derived URLs are+ -- lost in favor of this first error.+ startSearch =+ do res1 <- tryCandidate primary+ case res1 of+ Left e -> search e alternatives+ Right cd -> pure (Right cd)++ search e [] = pure (Left e)+ search e (x:xs) =+ do res <- tryCandidate x+ case res of+ Left {} -> search e xs+ Right cd -> pure (Right cd)+++-- | The amount of time that the startup timer thread will wait before+-- firing.+startupTimerMilliseconds :: Int+startupTimerMilliseconds = 750++-- | The startup timer thread.+startupTimer :: BChan LoginEvent -> IO ()+startupTimer respChan = do+ threadDelay $ startupTimerMilliseconds * 1000+ writeBChan respChan StartupTimeout++-- | The main function of this module: interactively present a login+-- interface, get the user's input, and attempt to log into the user's+-- specified mattermost server.+--+-- This always returns the final terminal state handle. If the user+-- makes no login attempt, this returns Nothing. Otherwise it returns+-- Just the result of the latest attempt.+interactiveGetLoginSession :: Vty+ -- ^ The initial terminal state handle to use.+ -> IO Vty+ -- ^ An action to build a new state handle+ -- if one is needed. (In practice this+ -- never fires since the login app doesn't+ -- use suspendAndResume, but we need it to+ -- satisfy the Brick API.)+ -> (ConnectionData -> ConnectionData)+ -- ^ The function to set the logger on+ -- connection handles.+ -> LogManager+ -- ^ The log manager used to do logging.+ -> ConnectionInfo+ -- ^ Initial connection info to use to+ -- populate the login form. If the connection+ -- info provided here is fully populated, an+ -- initial connection attempt is made without+ -- first getting the user to hit Enter.+ -> IO (Maybe LoginAttempt, Vty)+interactiveGetLoginSession vty mkVty setLogger logMgr initialConfig = do+ requestChan <- newBChan 10+ respChan <- newBChan 10++ void $ forkIO $ loginWorker setLogger logMgr requestChan respChan+ void $ forkIO $ startupTimer respChan++ let initialState = mkState initialConfig requestChan++ startState <- case (populatedConnectionInfo initialConfig) of+ True -> do+ writeBChan requestChan $ DoLogin True initialConfig+ return $ initialState & currentState .~ Connecting True (initialConfig^.ciHostname)+ False -> do+ return initialState++ (finalSt, finalVty) <- customMainWithVty vty mkVty (Just respChan) app startState++ return (finalSt^.lastAttempt, finalVty)++-- | Is the specified ConnectionInfo sufficiently populated for us to+-- bother attempting to use it to connect?+populatedConnectionInfo :: ConnectionInfo -> Bool+populatedConnectionInfo ci =+ and [ not $ T.null $ ci^.ciHostname+ , or [ and [ not $ T.null $ ci^.ciUsername+ , not $ T.null $ ci^.ciPassword+ ]+ , not $ T.null $ ci^.ciAccessToken+ ]+ , ci^.ciPort > 0+ ]++-- | Make an initial login application state.+mkState :: ConnectionInfo -> BChan LoginRequest -> State+mkState cInfo chan = state+ where+ state = State { _loginForm = form { formFocus = focusSetCurrent initialFocus (formFocus form)+ }+ , _currentState = Idle+ , _lastAttempt = Nothing+ , _reqChan = chan+ , _timeoutFired = False+ }+ form = mkForm cInfo+ initialFocus = if | T.null (cInfo^.ciHostname) -> Server+ | T.null (cInfo^.ciUsername) -> Username+ | T.null (cInfo^.ciPassword) -> Password+ | otherwise -> Server++app :: App State LoginEvent Name+app = App+ { appDraw = credsDraw+ , appChooseCursor = showFirstCursor+ , appHandleEvent = onEvent+ , appStartEvent = return+ , appAttrMap = const colorTheme+ }++onEvent :: State -> BrickEvent Name LoginEvent -> EventM Name (Next State)+onEvent st (VtyEvent (EvKey KEsc [])) = do+ halt $ st & lastAttempt .~ Nothing+onEvent st (AppEvent (StartConnect initial host)) = do+ continue $ st & currentState .~ Connecting initial host+ & lastAttempt .~ Nothing+onEvent st (AppEvent StartupTimeout) = do+ -- If the startup timer fired and we have already succeeded, halt.+ case st^.lastAttempt of+ Just (AttemptSucceeded {}) -> halt st+ _ -> continue $ st & timeoutFired .~ True+onEvent st (AppEvent (LoginResult attempt)) = do+ let st' = st & lastAttempt .~ Just attempt+ & currentState .~ Idle++ case attempt of+ AttemptSucceeded {} -> do+ -- If the startup timer already fired, halt. Otherwise wait+ -- until that timer fires.+ case st^.timeoutFired of+ True -> halt st'+ False -> continue st'+ AttemptFailed {} -> continue st'++onEvent st (VtyEvent (EvKey KEnter [])) = do+ -- Ignore the keypress if we are already attempting a connection, or+ -- if have already successfully connected but are waiting on the+ -- startup timer.+ case st^.currentState of+ Connecting {} -> return ()+ Idle ->+ case st^.lastAttempt of+ Just (AttemptSucceeded {}) -> return ()+ _ -> do+ let ci = formState $ st^.loginForm+ when (populatedConnectionInfo ci) $ do+ liftIO $ writeBChan (st^.reqChan) $ DoLogin False ci++ continue st+onEvent st e = do+ f' <- handleFormEvent e (st^.loginForm)+ continue $ st & loginForm .~ f'++mkForm :: ConnectionInfo -> Form ConnectionInfo e Name+mkForm =+ let label s w = padBottom (Pad 1) $+ (vLimit 1 $ hLimit 22 $ str s <+> fill ' ') <+> w+ above s w = hCenter (str s) <=> w+ in newForm [ label "Server URL:" @@= editServer+ , (above "Provide a username and password:" .+ label "Username:") @@= editTextField ciUsername Username (Just 1)+ , label "Password:" @@= editPasswordField ciPassword Password+ , (above "Or provide a Session or Personal Access Token:" .+ label "Access Token:") @@= editPasswordField ciAccessToken AccessToken+ ]++serverLens :: Lens' ConnectionInfo (Text, Int, Text, ConnectionType)+serverLens f ci = fmap (\(x,y,z,w) -> ci { _ciHostname = x, _ciPort = y, _ciUrlPath = z, _ciType = w})+ (f (ci^.ciHostname, ci^.ciPort, ci^.ciUrlPath, ci^.ciType))++-- | Validate a server URI @hostname[:port][/path]@. Result is either an error message+-- indicating why validation failed or a tuple of (hostname, port, path)+validServer :: [Text] -> Either String (Text, Int, Text, ConnectionType)+validServer ts =++ do t <- case ts of+ [] -> Left "No input"+ [t] -> Right t+ _ -> Left "Too many lines"++ let inputWithScheme+ | "://" `T.isInfixOf` t = t+ | otherwise = "https://" <> t++ uri <- case parseURI (T.unpack inputWithScheme) of+ Nothing -> Left "Unable to parse URI"+ Just uri -> Right uri++ auth <- case uriAuthority uri of+ Nothing -> Left "Missing authority part"+ Just auth -> Right auth++ ty <- case uriScheme uri of+ "http:" -> Right ConnectHTTP+ "https:" -> Right (ConnectHTTPS True)+ "https-insecure:" -> Right (ConnectHTTPS False)+ _ -> Left "Unknown scheme"++ port <- case (ty, uriPort auth) of+ (ConnectHTTP , "") -> Right 80+ (ConnectHTTPS{}, "") -> Right 443+ (_, ':':portStr)+ | Just port <- readMaybe portStr -> Right port+ _ -> Left "Invalid port"++ let host+ | not (null (uriRegName auth))+ , '[' == head (uriRegName auth)+ , ']' == last (uriRegName auth)+ = init (tail (uriRegName auth))+ | otherwise = uriRegName auth++ if null (uriRegName auth) then Left "Missing server name" else Right ()+ if null (uriQuery uri) then Right () else Left "Unexpected URI query"+ if null (uriFragment uri) then Right () else Left "Unexpected URI fragment"+ if null (uriUserInfo auth) then Right () else Left "Unexpected credentials"++ Right (T.pack host, port, T.pack (uriPath uri), ty)+++renderServer :: (Text, Int, Text, ConnectionType) -> Text+renderServer (h,p,u,t) = scheme <> hStr <> portStr <> uStr+ where+ hStr+ | T.all (\x -> isHexDigit x || ':'==x) h+ , T.any (':'==) h = "[" <> h <> "]"+ | otherwise = h++ scheme =+ case t of+ ConnectHTTP -> "http://"+ ConnectHTTPS True -> ""+ ConnectHTTPS False -> "https-insecure://"++ uStr+ | T.null u = u+ | otherwise = T.cons '/' (T.dropWhile ('/'==) u)++ portStr =+ case t of+ ConnectHTTP | p == 80 -> T.empty+ ConnectHTTPS{} | p == 443 -> T.empty+ _ -> T.pack (':':show p)++editServer :: ConnectionInfo -> FormFieldState ConnectionInfo e Name+editServer =+ let val ts = case validServer ts of+ Left{} -> Nothing+ Right x-> Just x+ limit = Just 1+ renderTxt [""] = str "(Paste your Mattermost URL here)"+ renderTxt ts = txt (T.unlines ts)+ in editField serverLens Server limit renderServer val renderTxt id++errorAttr :: AttrName+errorAttr = "errorMessage"++colorTheme :: AttrMap+colorTheme = attrMap defAttr+ [ (editAttr, black `on` white)+ , (editFocusedAttr, black `on` yellow)+ , (errorAttr, fg red)+ , (focusedFormInputAttr, black `on` yellow)+ , (invalidFormInputAttr, white `on` red)+ , (clientEmphAttr, fg white `withStyle` bold)+ ]++credsDraw :: State -> [Widget Name]+credsDraw st =+ [ center $ vBox [ if shouldShowForm st then credentialsForm st else emptyWidget+ , currentStateDisplay st+ , lastAttemptDisplay (st^.lastAttempt)+ ]+ ]++-- | Whether the login form should be displayed.+shouldShowForm :: State -> Bool+shouldShowForm st =+ case st^.currentState of+ -- If we're connecting, only show the form if the connection+ -- attempt is not an initial one.+ Connecting initial _ -> not initial++ -- If we're idle, we want to show the form - unless we have+ -- already connected and are waiting for the startup timer to+ -- fire.+ Idle -> case st^.lastAttempt of+ Just (AttemptSucceeded {}) -> st^.timeoutFired+ _ -> True++-- | The "current state" of the login process. Show a connecting status+-- message if a connection is underway, or if one is already established+-- and the startup timer has not fired.+currentStateDisplay :: State -> Widget Name+currentStateDisplay st =+ let msg host = padTop (Pad 1) $ hCenter $+ txt "Connecting to " <+> withDefAttr clientEmphAttr (txt host) <+> txt "..."+ in case st^.currentState of+ Idle -> case st^.lastAttempt of+ Just (AttemptSucceeded ci _ _ _ _) -> msg (ci^.ciHostname)+ _ -> emptyWidget+ (Connecting _ host) -> msg host++lastAttemptDisplay :: Maybe LoginAttempt -> Widget Name+lastAttemptDisplay Nothing = emptyWidget+lastAttemptDisplay (Just (AttemptSucceeded {})) = emptyWidget+lastAttemptDisplay (Just (AttemptFailed e)) =+ hCenter $ hLimit uiWidth $+ padTop (Pad 1) $ renderError $ renderText $+ "Error: " <> renderAuthError e++renderAuthError :: AuthenticationException -> Text+renderAuthError (ConnectError _) =+ "Could not connect to server"+renderAuthError (ResolveError _) =+ "Could not resolve server hostname"+renderAuthError (AuthIOError err)+ | Err.isDoesNotExistErrorType (Err.ioeGetErrorType err) =+ "Unable to connect to the network"+ | otherwise = "GetAddrInfo: " <> T.pack (Err.ioeGetErrorString err)+renderAuthError (OtherAuthError e) =+ T.pack $ show e+renderAuthError (LoginError (LoginFailureException msg)) =+ T.pack msg++renderError :: Widget a -> Widget a+renderError = withDefAttr errorAttr++uiWidth :: Int+uiWidth = 60++credentialsForm :: State -> Widget Name+credentialsForm st =+ hCenter $ hLimit uiWidth $ vLimit 15 $+ border $+ vBox [ renderText "Please enter your Mattermost credentials to log in."+ , padTop (Pad 1) $ renderForm (st^.loginForm)+ , hCenter $ renderText "Press Enter to log in or Esc to exit."+ ]
+ src/Matterhorn/Options.hs view
@@ -0,0 +1,157 @@+{-# LANGUAGE TemplateHaskell #-}++module Matterhorn.Options where++import Prelude ()+import Matterhorn.Prelude++import Data.Char ( toLower )+import Data.Foldable (traverse_)+import Data.Tuple ( swap )+import Data.Version ( showVersion )+import Development.GitRev+import Network.Mattermost.Version ( mmApiVersion )+import Paths_matterhorn ( version )+import System.Console.GetOpt+import System.Environment ( getArgs )+import System.Exit ( exitFailure, exitSuccess )+import System.IO ( hPutStrLn, stderr )++import Matterhorn.Config+++data Behaviour+ = Normal+ | ShowVersion+ | ShowHelp+ | CheckConfig+ deriving (Eq, Show)++data PrintFormat =+ Markdown | Plain deriving (Eq, Show)++data Options = Options+ { optConfLocation :: Maybe FilePath+ , optLogLocation :: Maybe FilePath+ , optBehaviour :: Behaviour+ , optIgnoreConfig :: Bool+ , optPrintKeybindings :: Bool+ , optPrintCommands :: Bool+ , optPrintFormat :: PrintFormat+ } deriving (Eq, Show)++defaultOptions :: Options+defaultOptions = Options+ { optConfLocation = Nothing+ , optLogLocation = Nothing+ , optBehaviour = Normal+ , optIgnoreConfig = False+ , optPrintKeybindings = False+ , optPrintCommands = False+ , optPrintFormat = Plain+ }++optDescrs :: [OptDescr (Options -> Options)]+optDescrs =+ [ Option ['c'] ["config"]+ (ReqArg (\ path c -> c { optConfLocation = Just path }) "PATH")+ "Path to the configuration file"+ , Option ['l'] ["logs"]+ (ReqArg (\ path c -> c { optLogLocation = Just path }) "PATH")+ "Debug log output path"+ , Option ['v'] ["version"]+ (NoArg (\ c -> c { optBehaviour = ShowVersion }))+ "Print version information and exit"+ , Option ['h'] ["help"]+ (NoArg (\ c -> c { optBehaviour = ShowHelp }))+ "Print help for command-line flags and exit"+ , Option ['i'] ["ignore-config"]+ (NoArg (\ c -> c { optIgnoreConfig = True }))+ "Start with no configuration"+ , Option ['k'] ["keybindings"]+ (NoArg (\ c -> c { optPrintKeybindings = True }))+ "Print keybindings effective for the current configuration"+ , Option ['m'] ["commands"]+ (NoArg (\ c -> c { optPrintCommands = True }))+ "Print available commands"+ , Option ['f'] ["format"]+ (ReqArg handleFormat "FORMAT")+ ("Print keybinding or command output in the specified format " <>+ "(options: " <> formatChoicesStr <> ", default: " <>+ formatStringFor (optPrintFormat defaultOptions) <> ")")+ , Option [] ["check-config"]+ (NoArg (\ c -> c { optBehaviour = CheckConfig }))+ "Validate configuration file"+ ]++formatChoices :: [(String, PrintFormat)]+formatChoices =+ [ ("plain", Plain)+ , ("markdown", Markdown)+ ]++formatStringFor :: PrintFormat -> String+formatStringFor fmt =+ case lookup fmt (swap <$> formatChoices) of+ Nothing -> error $ "BUG: no format string for " <> show fmt+ Just s -> s++formatChoicesStr :: String+formatChoicesStr = intercalate ", " $ fst <$> formatChoices++handleFormat :: String -> Options -> Options+handleFormat fmtStr c =+ let fmt = case lookup (toLower <$> fmtStr) formatChoices of+ Just f -> f+ Nothing ->+ error $ "Invalid format: " <> show fmtStr <> ", choices: " <>+ formatChoicesStr+ in c { optPrintFormat = fmt }++mhVersion :: String+mhVersion+ | $(gitHash) == ("UNKNOWN" :: String) = "matterhorn " ++ showVersion version+ | otherwise = "matterhorn " ++ showVersion version ++ " (" +++ $(gitBranch) ++ "@" ++ take 7 $(gitHash) ++ ")"++fullVersionString :: String+fullVersionString = mhVersion ++ "\n using " ++ mmApiVersion++usage :: IO ()+usage = putStr (usageInfo "matterhorn" optDescrs)++grabOptions :: IO Options+grabOptions = do+ args <- getArgs+ case getOpt Permute optDescrs args of+ (aps, [], []) -> do+ let rs = foldr (.) id aps defaultOptions+ case optBehaviour rs of+ Normal -> return rs+ ShowHelp -> usage >> exitSuccess+ ShowVersion -> putStrLn fullVersionString >> exitSuccess+ CheckConfig -> checkConfiguration (optConfLocation rs)+ (_, _, errs) -> do+ mapM_ putStr errs+ usage+ exitFailure++checkConfiguration :: Maybe FilePath -> IO a+checkConfiguration mb =+ do res <- findConfig mb+ let writeLn = hPutStrLn stderr+ printLocation Nothing = "No configuration file"+ printLocation (Just fp) = "Location: " ++ fp+ case res of+ Left e ->+ do writeLn e+ exitFailure+ Right ([], config) ->+ do writeLn "Configuration file valid"+ writeLn (printLocation (configAbsPath config))+ exitSuccess+ Right (ws, config) ->+ do writeLn "Configuration file generated warnings"+ writeLn (printLocation (configAbsPath config))+ traverse_ writeLn ws+ exitFailure
+ src/Matterhorn/Prelude.hs view
@@ -0,0 +1,115 @@+{-# LANGUAGE NoImplicitPrelude #-}+{-# LANGUAGE CPP #-}++{-| This module is for internal re-exports of commonly-used+ functions. This also lets us avoid churn between versions of GHC by+ putting changed functions behind CPP in a single place.+-}++{-+ GHC version <--> base version (https://wiki.haskell.org/Base_package) includes:+ 8.0.1 4.9.0.0+ 8.2.1 4.10.0.0+ 8.4.1 4.11.0.0++ GHC distributions include a set of core packages; overriding these+ with new versions is unwise. Core packages include (see+ https://downloads.haskell.org/~ghc/X.Y.Z/docs/html/users_guide/X.Y.Z-notes.html#included-libraries+ ):++ base, Cabal, array, bytestring, containers, deepseq, directory,+ filepath, mtl, parsec, process, text, time, unix+-}++module Matterhorn.Prelude+ ( module P+#if !MIN_VERSION_base(4,9,0)+ , (<>)+#endif+ , (<|>)+ -- commonly-used functions from Maybe+ , Maybe.isJust+ , Maybe.isNothing+ , Maybe.listToMaybe+ , Maybe.maybeToList+ , Maybe.fromMaybe+ , Maybe.catMaybes++ -- a non-partial Read function+ , Read.readMaybe++ -- commonly-used functions from Monad+ , Monad.forM+ , Monad.forM_+ , Monad.filterM+ , Monad.when+ , Monad.unless+ , Monad.void+ , Monad.join+ , Monad.forever+ , Monad.foldM+ , Monad.MonadIO(..)++ -- commonly-used functions from List+ , Foldable.toList+ , List.find+ , List.sort+ , List.intercalate+ , Exts.sortWith+ , Exts.groupWith++ -- common read-only lens operators+ , (Lens.&)+ , (Lens.^.)+ , Lens.use++ -- not available in all versions of GHC currently in use+#if MIN_VERSION_base(4,10,0)+ , Clock.nominalDay+#else+ , nominalDay+#endif++ -- various type aliases+ , Text+ , HashMap+ , Seq+ , Set+ , Time.UTCTime+ , Time.TimeZoneSeries+ , Time.NominalDiffTime+ )+where+++#if !MIN_VERSION_base(4,11,0)+import Data.Semigroup ( (<>) )+#endif++import Control.Applicative ( (<|>) )+import qualified Control.Monad as Monad+import qualified Control.Monad.IO.Class as Monad+import qualified Data.Foldable as Foldable+import qualified Data.List as List+import qualified Data.Maybe as Maybe+import qualified Data.Time as Time+#if MIN_VERSION_base(4,10,0)+import qualified Data.Time.Clock as Clock+#endif+import qualified Data.Time.LocalTime.TimeZone.Series as Time+import qualified GHC.Exts as Exts+import qualified Lens.Micro.Platform as Lens+import Prelude.Compat+import qualified Prelude.Compat as P+import qualified Text.Read as Read++-- these below we import only for type aliases+import Data.HashMap.Strict ( HashMap )+import Data.Sequence ( Seq )+import Data.Set ( Set )+import Data.Text ( Text )++#if !MIN_VERSION_base(4,10,0)+nominalDay :: Time.NominalDiffTime+nominalDay = 86400+#endif
+ src/Matterhorn/Scripts.hs view
@@ -0,0 +1,81 @@+module Matterhorn.Scripts+ ( findAndRunScript+ , listScripts+ )+where++import Prelude ()+import Matterhorn.Prelude++import Control.Concurrent ( takeMVar, newEmptyMVar )+import qualified Control.Concurrent.STM as STM+import qualified Data.Text as T+import System.Exit ( ExitCode(..) )++import Network.Mattermost.Types ( ChannelId )++import Matterhorn.FilePaths ( Script(..), getAllScripts, locateScriptPath )+import Matterhorn.State.Common+import Matterhorn.State.Messages ( sendMessage )+import Matterhorn.Types+++findAndRunScript :: ChannelId -> Text -> Text -> MH ()+findAndRunScript cId scriptName input = do+ fpMb <- liftIO $ locateScriptPath (T.unpack scriptName)+ outputChan <- use (csResources.crSubprocessLog)+ case fpMb of+ ScriptPath scriptPath -> do+ doAsyncWith Preempt $ runScript cId outputChan scriptPath input+ NonexecScriptPath scriptPath -> do+ let msg = ("The script `" <> T.pack scriptPath <> "` cannot be " <>+ "executed. Try running\n" <>+ "```\n" <>+ "$ chmod u+x " <> T.pack scriptPath <> "\n" <>+ "```\n" <>+ "to correct this error. " <> scriptHelpAddendum)+ mhError $ GenericError msg+ ScriptNotFound -> do+ mhError $ NoSuchScript scriptName++runScript :: ChannelId -> STM.TChan ProgramOutput -> FilePath -> Text -> IO (Maybe (MH ()))+runScript cId outputChan fp text = do+ outputVar <- newEmptyMVar+ runLoggedCommand outputChan fp [] (Just $ T.unpack text) (Just outputVar)+ po <- takeMVar outputVar+ return $ case programExitCode po of+ ExitSuccess -> do+ case null $ programStderr po of+ True -> Just $ do+ mode <- use (csEditState.cedEditMode)+ sendMessage cId mode (T.pack $ programStdout po) []+ False -> Nothing+ ExitFailure _ -> Nothing++listScripts :: MH ()+listScripts = do+ (execs, nonexecs) <- liftIO getAllScripts+ let scripts = ("Available scripts are:\n" <>+ mconcat [ " - " <> T.pack cmd <> "\n"+ | cmd <- execs+ ])+ postInfoMessage scripts+ case nonexecs of+ [] -> return ()+ _ -> do+ let errMsg = ("Some non-executable script files are also " <>+ "present. If you want to run these as scripts " <>+ "in Matterhorn, mark them executable with \n" <>+ "```\n" <>+ "$ chmod u+x [script path]\n" <>+ "```\n" <>+ "\n" <>+ mconcat [ " - " <> T.pack cmd <> "\n"+ | cmd <- nonexecs+ ] <> "\n" <> scriptHelpAddendum)+ mhError $ GenericError errMsg++scriptHelpAddendum :: Text+scriptHelpAddendum =+ "For more help with scripts, run the command\n" <>+ "```\n/help scripts\n```\n"
+ src/Matterhorn/State/Async.hs view
@@ -0,0 +1,161 @@+module Matterhorn.State.Async+ ( AsyncPriority(..)+ , doAsync+ , doAsyncIO+ , doAsyncWith+ , doAsyncChannelMM+ , doAsyncWithIO+ , doAsyncMM+ , tryMM+ , endAsyncNOP+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Control.Concurrent.STM as STM+import Control.Exception ( try )++import Network.Mattermost.Types++import Matterhorn.Types+++-- | Try to run a computation, posting an informative error+-- message if it fails with a 'MattermostServerError'.+tryMM :: IO a+ -- ^ The action to try (usually a MM API call)+ -> (a -> IO (Maybe (MH ())))+ -- ^ What to do on success+ -> IO (Maybe (MH ()))+tryMM act onSuccess = do+ result <- liftIO $ try act+ case result of+ Left e -> return $ Just $ mhError $ ServerError e+ Right value -> liftIO $ onSuccess value++-- * Background Computation++-- $background_computation+--+-- The main context for Matterhorn is the EventM context provided by+-- the 'Brick' library. This context is normally waiting for user+-- input (or terminal resizing, etc.) which gets turned into an+-- MHEvent and the 'onEvent' event handler is called to process that+-- event, after which the display is redrawn as necessary and brick+-- awaits the next input.+--+-- However, it is often convenient to communicate with the Mattermost+-- server in the background, so that large numbers of+-- synchronously-blocking events (e.g. on startup) or refreshes can+-- occur whenever needed and without negatively impacting the UI+-- updates or responsiveness. This is handled by a 'forkIO' context+-- that waits on an STM channel for work to do, performs the work, and+-- then sends brick an MHEvent containing the completion or failure+-- information for that work.+--+-- The /doAsyncWith/ family of functions here facilitates that+-- asynchronous functionality. This is typically used in the+-- following fashion:+--+-- > doSomething :: MH ()+-- > doSomething = do+-- > got <- something+-- > doAsyncWith Normal $ do+-- > r <- mmFetchR ....+-- > return $ do+-- > csSomething.here %= processed r+--+-- The second argument is an IO monad operation (because 'forkIO' runs+-- in the IO Monad context), but it returns an MH monad operation.+-- The IO monad has access to the closure of 'doSomething' (e.g. the+-- 'got' value), but it should be aware that the state of the MH monad+-- may have been changed by the time the IO monad runs in the+-- background, so the closure is a snapshot of information at the time+-- the 'doAsyncWith' was called.+--+-- Similarly, the returned MH monad operation is *not* run in the+-- context of the 'forkIO' background, but it is instead passed via an+-- MHEvent back to the main brick thread, where it is executed in an+-- EventM handler's MH monad context. This operation therefore has+-- access to the combined closure of the pre- 'doAsyncWith' code and+-- the closure of the IO operation. It is important that the final MH+-- monad operation should *re-obtain* state information from the MH+-- monad instead of using or setting the state obtained prior to the+-- 'doAsyncWith' call.++-- | Priority setting for asynchronous work items. Preempt means that+-- the queued item will be the next work item begun (i.e. it goes to the+-- front of the queue); normal means it will go last in the queue.+data AsyncPriority = Preempt | Normal++-- | Run a computation in the background, ignoring any results from it.+doAsync :: AsyncPriority -> IO () -> MH ()+doAsync prio act = doAsyncWith prio (act >> return Nothing)++-- | Run a computation in the background, returning a computation to be+-- called on the 'ChatState' value.+doAsyncWith :: AsyncPriority -> IO (Maybe (MH ())) -> MH ()+doAsyncWith prio act = do+ let putChan = case prio of+ Preempt -> STM.unGetTChan+ Normal -> STM.writeTChan+ queue <- use (csResources.crRequestQueue)+ liftIO $ STM.atomically $ putChan queue act++doAsyncIO :: AsyncPriority -> ChatState -> IO () -> IO ()+doAsyncIO prio st act =+ doAsyncWithIO prio st (act >> return Nothing)++-- | Run a computation in the background, returning a computation to be+-- called on the 'ChatState' value.+doAsyncWithIO :: AsyncPriority -> ChatState -> IO (Maybe (MH ())) -> IO ()+doAsyncWithIO prio st act = do+ let putChan = case prio of+ Preempt -> STM.unGetTChan+ Normal -> STM.writeTChan+ let queue = st^.csResources.crRequestQueue+ STM.atomically $ putChan queue act++-- | Performs an asynchronous IO operation. On completion, the final+-- argument a completion function is executed in an MH () context in the+-- main (brick) thread.+doAsyncMM :: AsyncPriority+ -- ^ the priority for this async operation+ -> (Session -> IO a)+ -- ^ the async MM channel-based IO operation+ -> (a -> Maybe (MH ()))+ -- ^ function to process the results in brick event handling+ -- context+ -> MH ()+doAsyncMM prio mmOp eventHandler = do+ session <- getSession+ doAsyncWith prio $ do+ r <- mmOp session+ return $ eventHandler r++-- | Helper type for a function to perform an asynchronous MM operation+-- on a channel and then invoke an MH completion event.+type DoAsyncChannelMM a =+ AsyncPriority+ -- ^ the priority for this async operation+ -> ChannelId+ -- ^ The channel+ -> (Session -> ChannelId -> IO a)+ -- ^ the asynchronous Mattermost channel-based IO operation+ -> (ChannelId -> a -> Maybe (MH ()))+ -- ^ function to process the results in brick event handling context+ -> MH ()++-- | Performs an asynchronous IO operation on a specific channel. On+-- completion, the final argument a completion function is executed in+-- an MH () context in the main (brick) thread.+doAsyncChannelMM :: DoAsyncChannelMM a+doAsyncChannelMM prio cId mmOp eventHandler =+ doAsyncMM prio (\s -> mmOp s cId) (eventHandler cId)++-- | Use this convenience function if no operation needs to be+-- performed in the MH state after an async operation completes.+endAsyncNOP :: ChannelId -> a -> Maybe (MH ())+endAsyncNOP _ _ = Nothing
+ src/Matterhorn/State/Attachments.hs view
@@ -0,0 +1,53 @@+module Matterhorn.State.Attachments+ ( showAttachmentList+ , resetAttachmentList+ , showAttachmentFileBrowser+ )+where++import Prelude ()+import Matterhorn.Prelude+import qualified Control.Exception as E+import Data.Either ( isRight )+import System.Directory ( doesDirectoryExist, getDirectoryContents )+import Data.Bool ( bool )++import Brick ( vScrollToBeginning, viewportScroll )+import qualified Brick.Widgets.List as L+import qualified Brick.Widgets.FileBrowser as FB+import Lens.Micro.Platform ( (.=) )++import Matterhorn.Types++validateAttachmentPath :: FilePath -> IO (Maybe FilePath)+validateAttachmentPath path = bool Nothing (Just path) <$> do+ ex <- doesDirectoryExist path+ case ex of+ False -> return False+ True -> do+ result :: Either E.SomeException [FilePath]+ <- E.try $ getDirectoryContents path+ return $ isRight result++defaultAttachmentsPath :: Config -> IO (Maybe FilePath)+defaultAttachmentsPath = maybe (return Nothing) validateAttachmentPath . configDefaultAttachmentPath++showAttachmentList :: MH ()+showAttachmentList = do+ lst <- use (csEditState.cedAttachmentList)+ case length (L.listElements lst) of+ 0 -> showAttachmentFileBrowser+ _ -> setMode ManageAttachments++resetAttachmentList :: MH ()+resetAttachmentList = do+ csEditState.cedAttachmentList .= L.list AttachmentList mempty 1+ mh $ vScrollToBeginning $ viewportScroll AttachmentList++showAttachmentFileBrowser :: MH ()+showAttachmentFileBrowser = do+ config <- use (csResources.crConfiguration)+ filePath <- liftIO $ defaultAttachmentsPath config+ browser <- liftIO $ Just <$> FB.newFileBrowser FB.selectNonDirectories AttachmentFileBrowser filePath+ csEditState.cedFileBrowser .= browser+ setMode ManageAttachmentsBrowseFiles
+ src/Matterhorn/State/Autocomplete.hs view
@@ -0,0 +1,373 @@+module Matterhorn.State.Autocomplete+ ( AutocompleteContext(..)+ , checkForAutocompletion+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Main ( viewportScroll, vScrollToBeginning )+import Brick.Widgets.Edit ( editContentsL )+import qualified Brick.Widgets.List as L+import Data.Char ( isSpace )+import qualified Data.Foldable as F+import qualified Data.HashMap.Strict as HM+import Data.List ( sortBy, partition )+import qualified Data.Map as M+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import qualified Data.Text.Zipper as Z+import qualified Data.Vector as V+import Lens.Micro.Platform ( (%=), (.=), (.~), _Just, preuse )+import qualified Skylighting.Types as Sky++import Network.Mattermost.Types (userId, channelId, Command(..))+import qualified Network.Mattermost.Endpoints as MM++import Matterhorn.Constants ( userSigil, normalChannelSigil )+import {-# SOURCE #-} Matterhorn.Command ( commandList, printArgSpec )+import Matterhorn.State.Common+import {-# SOURCE #-} Matterhorn.State.Editing ( Direction(..), tabComplete )+import Matterhorn.Types hiding ( newState )+import Matterhorn.Emoji+++data AutocompleteContext =+ AutocompleteContext { autocompleteManual :: Bool+ -- ^ Whether the autocompletion was manual+ -- (True) or automatic (False). The automatic+ -- case is the case where the autocomplete+ -- lookups and UI are triggered merely by+ -- entering some initial text (such as "@").+ -- The manual case is the case where the+ -- autocomplete lookups and UI are triggered+ -- explicitly by a user's TAB keypress.+ , autocompleteFirstMatch :: Bool+ -- ^ Once the results of the autocomplete lookup+ -- are available, this flag determines whether+ -- the user's input is replaced immediately+ -- with the first available match (True) or not+ -- (False).+ }++-- | Check for whether the currently-edited word in the message editor+-- should cause an autocompletion UI to appear. If so, initiate a server+-- query or local cache lookup to present the completion alternatives+-- for the word at the cursor.+checkForAutocompletion :: AutocompleteContext -> MH ()+checkForAutocompletion ctx = do+ result <- getCompleterForInput ctx+ case result of+ Nothing -> resetAutocomplete+ Just (ty, runUpdater, searchString) -> do+ prevResult <- use (csEditState.cedAutocomplete)+ -- We should update the completion state if EITHER:+ --+ -- 1) The type changed+ --+ -- or+ --+ -- 2) The search string changed but the type did NOT change+ let shouldUpdate = ((maybe True ((/= searchString) . _acPreviousSearchString)+ prevResult) &&+ (maybe True ((== ty) . _acType) prevResult)) ||+ (maybe False ((/= ty) . _acType) prevResult)+ when shouldUpdate $ do+ csEditState.cedAutocompletePending .= Just searchString+ runUpdater ty ctx searchString++getCompleterForInput :: AutocompleteContext+ -> MH (Maybe (AutocompletionType, AutocompletionType -> AutocompleteContext -> Text -> MH (), Text))+getCompleterForInput ctx = do+ z <- use (csEditState.cedEditor.editContentsL)++ let col = snd $ Z.cursorPosition z+ curLine = Z.currentLine z++ return $ case wordAtColumn col curLine of+ Just (startCol, w)+ | userSigil `T.isPrefixOf` w ->+ Just (ACUsers, doUserAutoCompletion, T.tail w)+ | normalChannelSigil `T.isPrefixOf` w ->+ Just (ACChannels, doChannelAutoCompletion, T.tail w)+ | ":" `T.isPrefixOf` w && autocompleteManual ctx ->+ Just (ACEmoji, doEmojiAutoCompletion, T.tail w)+ | "```" `T.isPrefixOf` w ->+ Just (ACCodeBlockLanguage, doSyntaxAutoCompletion, T.drop 3 w)+ | "/" `T.isPrefixOf` w && startCol == 0 ->+ Just (ACCommands, doCommandAutoCompletion, T.tail w)+ _ -> Nothing++-- Completion implementations++doEmojiAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()+doEmojiAutoCompletion ty ctx searchString = do+ session <- getSession+ em <- use (csResources.crEmoji)+ withCachedAutocompleteResults ctx ty searchString $+ doAsyncWith Preempt $ do+ results <- getMatchingEmoji session em searchString+ let alts = EmojiCompletion <$> results+ return $ Just $ setCompletionAlternatives ctx searchString alts ty++doSyntaxAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()+doSyntaxAutoCompletion ty ctx searchString = do+ mapping <- use (csResources.crSyntaxMap)+ let allNames = Sky.sShortname <$> M.elems mapping+ (prefixed, notPrefixed) = partition isPrefixed $ filter match allNames+ match = (((T.toLower searchString) `T.isInfixOf`) . T.toLower)+ isPrefixed = (((T.toLower searchString) `T.isPrefixOf`) . T.toLower)+ alts = SyntaxCompletion <$> (sort prefixed <> sort notPrefixed)+ setCompletionAlternatives ctx searchString alts ty++-- | This list of server commands should be hidden because they make+-- assumptions about a web-based client or otherwise just don't make+-- sense for Matterhorn.+--+-- It's worth mentioning that other official mattermost client+-- implementations use this technique, too. The web client maintains+-- a list of commands to exclude when they aren't supported in the+-- mobile client. (Really this is a design flaw; they should never be+-- advertised by the server to begin with.)+hiddenServerCommands :: [Text]+hiddenServerCommands =+ -- These commands all only work in the web client.+ [ "settings"+ , "help"+ , "collapse"+ , "expand"++ -- We don't think this command makes sense for Matterhorn.+ , "logout"++ -- We provide a version of /leave with confirmation.+ , "leave"++ -- We provide our own join UI.+ , "join"++ -- We provide our own version of this command that opens our own+ -- help UI.+ , "shortcuts"++ -- Hidden because we provide other mechanisms to switch between+ -- channels.+ , "open"+ ]++hiddenCommand :: Command -> Bool+hiddenCommand c = (T.toLower $ commandTrigger c) `elem` hiddenServerCommands++isDeletedCommand :: Command -> Bool+isDeletedCommand cmd = commandDeleteAt cmd > commandCreateAt cmd++doCommandAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()+doCommandAutoCompletion ty ctx searchString = do+ session <- getSession+ myTid <- gets myTeamId++ mCache <- preuse (csEditState.cedAutocomplete._Just.acCachedResponses)+ mActiveTy <- preuse (csEditState.cedAutocomplete._Just.acType)++ -- Command completion works a little differently than the other+ -- modes. To do command autocompletion, we want to query the server+ -- for the list of available commands and merge that list with+ -- our own list of client-provided commands. But the server's API+ -- doesn't support *searching* commands; we can only ask for the+ -- full list. That means that, unlike the other completion modes+ -- where we want to ask the server repeatedly as the search string+ -- is refined, in this case we want to ask the server only once+ -- and avoid repeating the request for the same data as the user+ -- types more of the search string. To accomplish that, we use a+ -- special cache key -- the empty string, which normal user input+ -- processing will never use -- as the cache key for the "full" list+ -- of commands obtained by merging the server's list with our own.+ -- We populate that cache entry when completion starts and then+ -- subsequent completions consult *that* list instead of asking the+ -- server again. Subsequent completions then filter and match the+ -- cached list against the user's search string.+ let entry = HM.lookup serverResponseKey =<< mCache+ -- The special cache key to use to store the merged server and+ -- client command list, sorted but otherwise unfiltered except+ -- for eliminating deleted or hidden commands.+ serverResponseKey = ""+ lowerSearch = T.toLower searchString+ matches (CommandCompletion _ name _ desc) =+ lowerSearch `T.isInfixOf` (T.toLower name) ||+ lowerSearch `T.isInfixOf` (T.toLower desc)+ matches _ = False++ if (isNothing entry || (mActiveTy /= (Just ACCommands)))+ then doAsyncWith Preempt $ do+ let clientAlts = mkAlt <$> commandList+ mkAlt (Cmd name desc args _) =+ (Client, name, printArgSpec args, desc)++ serverCommands <- MM.mmListCommandsForTeam myTid False session+ let filteredServerCommands =+ filter (\c -> not (hiddenCommand c || isDeletedCommand c)) $+ F.toList serverCommands+ serverAlts = mkTuple <$> filteredServerCommands+ mkTuple cmd =+ ( Server+ , commandTrigger cmd+ , commandAutoCompleteHint cmd+ , commandAutoCompleteDesc cmd+ )+ mkCompletion (src, name, args, desc) =+ CommandCompletion src name args desc+ alts = fmap mkCompletion $+ clientAlts <> serverAlts++ return $ Just $ do+ -- Store the complete list of alterantives in the cache+ setCompletionAlternatives ctx serverResponseKey alts ty++ -- Also store the list of alternatives specific to+ -- this search string+ let newAlts = sortBy (compareCommandAlts searchString) $+ filter matches alts+ setCompletionAlternatives ctx searchString newAlts ty++ else case entry of+ Just alts | mActiveTy == Just ACCommands ->+ let newAlts = sortBy (compareCommandAlts searchString) $+ filter matches alts+ in setCompletionAlternatives ctx searchString newAlts ty+ _ -> return ()++compareCommandAlts :: Text -> AutocompleteAlternative -> AutocompleteAlternative -> Ordering+compareCommandAlts s (CommandCompletion _ nameA _ _)+ (CommandCompletion _ nameB _ _) =+ let isAPrefix = s `T.isPrefixOf` nameA+ isBPrefix = s `T.isPrefixOf` nameB+ in if isAPrefix == isBPrefix+ then compare nameA nameB+ else if isAPrefix+ then LT+ else GT+compareCommandAlts _ _ _ = LT++doUserAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()+doUserAutoCompletion ty ctx searchString = do+ session <- getSession+ myTid <- gets myTeamId+ myUid <- gets myUserId+ cId <- use csCurrentChannelId++ withCachedAutocompleteResults ctx ty searchString $+ doAsyncWith Preempt $ do+ ac <- MM.mmAutocompleteUsers (Just myTid) (Just cId) searchString session++ let active = Seq.filter (\u -> userId u /= myUid && (not $ userDeleted u))+ alts = F.toList $+ ((\u -> UserCompletion u True) <$> (active $ MM.userAutocompleteUsers ac)) <>+ (maybe mempty (fmap (\u -> UserCompletion u False) . active) $+ MM.userAutocompleteOutOfChannel ac)++ specials = [ MentionAll+ , MentionChannel+ ]+ extras = [ SpecialMention m | m <- specials+ , (T.toLower searchString) `T.isPrefixOf` specialMentionName m+ ]++ return $ Just $ setCompletionAlternatives ctx searchString (alts <> extras) ty++doChannelAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()+doChannelAutoCompletion ty ctx searchString = do+ session <- getSession+ tId <- gets myTeamId+ cs <- use csChannels++ withCachedAutocompleteResults ctx ty searchString $ do+ doAsyncWith Preempt $ do+ results <- MM.mmAutocompleteChannels tId searchString session+ let alts = F.toList $ (ChannelCompletion True <$> inChannels) <>+ (ChannelCompletion False <$> notInChannels)+ (inChannels, notInChannels) = Seq.partition isMember results+ isMember c = isJust $ findChannelById (channelId c) cs+ return $ Just $ setCompletionAlternatives ctx searchString alts ty++-- Utility functions++-- | Attempt to re-use a cached autocomplete alternative list for+-- a given search string. If the cache contains no such entry (keyed+-- on search string), run the specified action, which is assumed to be+-- responsible for fetching the completion results from the server.+withCachedAutocompleteResults :: AutocompleteContext+ -- ^ The autocomplete context+ -> AutocompletionType+ -- ^ The type of autocompletion we're+ -- doing+ -> Text+ -- ^ The search string to look for in the+ -- cache+ -> MH ()+ -- ^ The action to execute on a cache miss+ -> MH ()+withCachedAutocompleteResults ctx ty searchString act = do+ mCache <- preuse (csEditState.cedAutocomplete._Just.acCachedResponses)+ mActiveTy <- preuse (csEditState.cedAutocomplete._Just.acType)++ case Just ty == mActiveTy of+ True ->+ -- Does the cache have results for this search string? If+ -- so, use them; otherwise invoke the specified action.+ case HM.lookup searchString =<< mCache of+ Just alts -> setCompletionAlternatives ctx searchString alts ty+ Nothing -> act+ False -> act++setCompletionAlternatives :: AutocompleteContext+ -> Text+ -> [AutocompleteAlternative]+ -> AutocompletionType+ -> MH ()+setCompletionAlternatives ctx searchString alts ty = do+ let list = L.list CompletionList (V.fromList $ F.toList alts) 1+ state = AutocompleteState { _acPreviousSearchString = searchString+ , _acCompletionList =+ list & L.listSelectedL .~ Nothing+ , _acCachedResponses = HM.fromList [(searchString, alts)]+ , _acType = ty+ }++ pending <- use (csEditState.cedAutocompletePending)+ case pending of+ Just val | val == searchString -> do++ -- If there is already state, update it, but also cache the+ -- search results.+ csEditState.cedAutocomplete %= \prev ->+ let newState = case prev of+ Nothing ->+ state+ Just oldState ->+ state & acCachedResponses .~+ HM.insert searchString alts (oldState^.acCachedResponses)+ in Just newState++ mh $ vScrollToBeginning $ viewportScroll CompletionList++ when (autocompleteFirstMatch ctx) $+ tabComplete Forwards+ _ ->+ -- Do not update the state if this result does not+ -- correspond to the search string we used most recently.+ -- This happens when the editor changes faster than the+ -- async completion responses arrive from the server. If we+ -- don't check this, we show completion results that are+ -- wrong for the editor state.+ return ()++wordAtColumn :: Int -> Text -> Maybe (Int, Text)+wordAtColumn i t =+ let tokens = T.groupBy (\a b -> isSpace a == isSpace b) t+ go _ j _ | j < 0 = Nothing+ go col j ts = case ts of+ [] -> Nothing+ (w:rest) | j <= T.length w && not (isSpace $ T.head w) -> Just (col, w)+ | otherwise -> go (col + T.length w) (j - T.length w) rest+ in go 0 i tokens
+ src/Matterhorn/State/ChannelListOverlay.hs view
@@ -0,0 +1,82 @@+module Matterhorn.State.ChannelListOverlay+ ( enterChannelListOverlayMode++ , channelListSelectDown+ , channelListSelectUp+ , channelListPageDown+ , channelListPageUp+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Brick.Widgets.List as L+import qualified Data.Vector as Vec+import qualified Data.Sequence as Seq+import Data.Function ( on )+import Lens.Micro.Platform ( to )++import Network.Mattermost.Types+import qualified Network.Mattermost.Endpoints as MM++import Matterhorn.State.ListOverlay+import Matterhorn.State.Channels+import Matterhorn.Types+++enterChannelListOverlayMode :: MH ()+enterChannelListOverlayMode = do+ myTId <- gets myTeamId+ myChannels <- use (csChannels.to (filteredChannelIds (const True)))+ enterListOverlayMode csChannelListOverlay ChannelListOverlay+ AllChannels enterHandler (fetchResults myTId myChannels)++enterHandler :: Channel -> MH Bool+enterHandler chan = do+ joinChannel (getId chan)+ return True++fetchResults :: TeamId+ -> [ChannelId]+ -- ^ The channels to exclude from the results+ -> ChannelSearchScope+ -- ^ The scope to search+ -> Session+ -- ^ The connection session+ -> Text+ -- ^ The search string+ -> IO (Vec.Vector Channel)+fetchResults myTId exclude AllChannels session searchString = do+ resultChans <- MM.mmSearchChannels myTId searchString session+ let filteredChans = Seq.filter (\ c -> not (channelId c `elem` exclude)) resultChans+ sortedChans = Vec.fromList $ toList $ Seq.sortBy (compare `on` channelName) filteredChans+ return sortedChans++-- | Move the selection up in the channel list overlay by one channel.+channelListSelectUp :: MH ()+channelListSelectUp = channelListMove L.listMoveUp++-- | Move the selection down in the channel list overlay by one channel.+channelListSelectDown :: MH ()+channelListSelectDown = channelListMove L.listMoveDown++-- | Move the selection up in the channel list overlay by a page of channels+-- (channelListPageSize).+channelListPageUp :: MH ()+channelListPageUp = channelListMove (L.listMoveBy (-1 * channelListPageSize))++-- | Move the selection down in the channel list overlay by a page of channels+-- (channelListPageSize).+channelListPageDown :: MH ()+channelListPageDown = channelListMove (L.listMoveBy channelListPageSize)++-- | Transform the channel list results in some way, e.g. by moving the+-- cursor, and then check to see whether the modification warrants a+-- prefetch of more search results.+channelListMove :: (L.List Name Channel -> L.List Name Channel) -> MH ()+channelListMove = listOverlayMove csChannelListOverlay++-- | The number of channels in a "page" for cursor movement purposes.+channelListPageSize :: Int+channelListPageSize = 10
+ src/Matterhorn/State/ChannelSelect.hs view
@@ -0,0 +1,155 @@+{-# LANGUAGE MultiWayIf #-}+module Matterhorn.State.ChannelSelect+ ( beginChannelSelect+ , updateChannelSelectMatches+ , channelSelectNext+ , channelSelectPrevious+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Widgets.Edit ( getEditContents )+import Data.Char ( isUpper )+import qualified Data.Text as T+import Lens.Micro.Platform++import qualified Network.Mattermost.Types as MM++import Matterhorn.Constants ( userSigil, normalChannelSigil )+import Matterhorn.Types+import qualified Matterhorn.Zipper as Z++beginChannelSelect :: MH ()+beginChannelSelect = do+ setMode ChannelSelect+ csChannelSelectState .= emptyChannelSelectState+ updateChannelSelectMatches++ -- Preserve the current channel selection when initializing channel+ -- selection mode+ zipper <- use csFocus+ let isCurrentFocus m = Just (matchEntry m) == Z.focus zipper+ csChannelSelectState.channelSelectMatches %= Z.findRight isCurrentFocus++-- Select the next match in channel selection mode.+channelSelectNext :: MH ()+channelSelectNext = updateSelectedMatch Z.right++-- Select the previous match in channel selection mode.+channelSelectPrevious :: MH ()+channelSelectPrevious = updateSelectedMatch Z.left++updateChannelSelectMatches :: MH ()+updateChannelSelectMatches = do+ st <- use id++ input <- use (csChannelSelectState.channelSelectInput)+ cconfig <- use csClientConfig+ prefs <- use (csResources.crUserPreferences)++ let pat = parseChannelSelectPattern $ T.concat $ getEditContents input+ chanNameMatches e = case pat of+ Nothing -> const Nothing+ Just p -> applySelectPattern p e+ patTy = case pat of+ Nothing -> Nothing+ Just CSPAny -> Nothing+ Just (CSP ty _) -> Just ty++ let chanMatches e chan =+ if patTy == Just PrefixDMOnly+ then Nothing+ else if chan^.ccInfo.cdType /= MM.Group+ then chanNameMatches e $ chan^.ccInfo.cdDisplayName+ else Nothing+ groupChanMatches e chan =+ if patTy == Just PrefixNonDMOnly+ then Nothing+ else if chan^.ccInfo.cdType == MM.Group+ then chanNameMatches e $ chan^.ccInfo.cdDisplayName+ else Nothing+ displayName uInfo = displayNameForUser uInfo cconfig prefs+ userMatches e uInfo =+ if patTy == Just PrefixNonDMOnly+ then Nothing+ else (chanNameMatches e . displayName) uInfo+ matches e@(CLChannel cId) = findChannelById cId (st^.csChannels) >>= chanMatches e+ matches e@(CLUserDM _ uId) = userById uId st >>= userMatches e+ matches e@(CLGroupDM cId) = findChannelById cId (st^.csChannels) >>= groupChanMatches e++ preserveFocus Nothing _ = False+ preserveFocus (Just m) m2 = matchEntry m == matchEntry m2++ csChannelSelectState.channelSelectMatches %= (Z.updateListBy preserveFocus $ Z.toList $ Z.maybeMapZipper matches (st^.csFocus))++applySelectPattern :: ChannelSelectPattern -> ChannelListEntry -> Text -> Maybe ChannelSelectMatch+applySelectPattern CSPAny entry chanName = do+ return $ ChannelSelectMatch "" "" chanName chanName entry+applySelectPattern (CSP ty pat) entry chanName = do+ let applyType Infix | pat `T.isInfixOf` normalizedChanName =+ case T.breakOn pat normalizedChanName of+ (pre, _) ->+ return ( T.take (T.length pre) chanName+ , T.take (T.length pat) $ T.drop (T.length pre) chanName+ , T.drop (T.length pat + T.length pre) chanName+ )++ applyType Prefix | pat `T.isPrefixOf` normalizedChanName = do+ let (b, a) = T.splitAt (T.length pat) chanName+ return ("", b, a)++ applyType PrefixDMOnly | pat `T.isPrefixOf` normalizedChanName = do+ let (b, a) = T.splitAt (T.length pat) chanName+ return ("", b, a)++ applyType PrefixNonDMOnly | pat `T.isPrefixOf` normalizedChanName = do+ let (b, a) = T.splitAt (T.length pat) chanName+ return ("", b, a)++ applyType Suffix | pat `T.isSuffixOf` normalizedChanName = do+ let (b, a) = T.splitAt (T.length chanName - T.length pat) chanName+ return (b, a, "")++ applyType Equal | pat == normalizedChanName =+ return ("", chanName, "")++ applyType _ = Nothing++ caseSensitive = T.any isUpper pat+ normalizedChanName = if caseSensitive+ then chanName+ else T.toLower chanName++ (pre, m, post) <- applyType ty+ return $ ChannelSelectMatch pre m post chanName entry++parseChannelSelectPattern :: Text -> Maybe ChannelSelectPattern+parseChannelSelectPattern "" = return CSPAny+parseChannelSelectPattern pat = do+ let only = if | userSigil `T.isPrefixOf` pat -> Just $ CSP PrefixDMOnly $ T.tail pat+ | normalChannelSigil `T.isPrefixOf` pat -> Just $ CSP PrefixNonDMOnly $ T.tail pat+ | otherwise -> Nothing++ (pat1, pfx) <- case "^" `T.isPrefixOf` pat of+ True -> return (T.tail pat, Just Prefix)+ False -> return (pat, Nothing)++ (pat2, sfx) <- case "$" `T.isSuffixOf` pat1 of+ True -> return (T.init pat1, Just Suffix)+ False -> return (pat1, Nothing)++ only <|> case (pfx, sfx) of+ (Nothing, Nothing) -> return $ CSP Infix pat2+ (Just Prefix, Nothing) -> return $ CSP Prefix pat2+ (Nothing, Just Suffix) -> return $ CSP Suffix pat2+ (Just Prefix, Just Suffix) -> return $ CSP Equal pat2+ tys -> error $ "BUG: invalid channel select case: " <> show tys++-- Update the channel selection mode match cursor. The argument function+-- determines how to navigate to the next item.+updateSelectedMatch :: (Z.Zipper ChannelListGroup ChannelSelectMatch -> Z.Zipper ChannelListGroup ChannelSelectMatch)+ -> MH ()+updateSelectedMatch nextItem =+ csChannelSelectState.channelSelectMatches %= nextItem
+ src/Matterhorn/State/ChannelTopicWindow.hs view
@@ -0,0 +1,16 @@+module Matterhorn.State.ChannelTopicWindow+ ( openChannelTopicWindow+ )+where++import Lens.Micro.Platform ( (.=) )++import Matterhorn.Types+import Matterhorn.State.Channels ( getCurrentChannelTopic )+++openChannelTopicWindow :: MH ()+openChannelTopicWindow = do+ t <- getCurrentChannelTopic+ csChannelTopicDialog .= newChannelTopicDialog t+ setMode ChannelTopicWindow
+ src/Matterhorn/State/Channels.hs view
@@ -0,0 +1,1134 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE MultiWayIf #-}+module Matterhorn.State.Channels+ ( updateSidebar+ , updateViewed+ , updateViewedChan+ , refreshChannel+ , refreshChannelsAndUsers+ , setFocus+ , refreshChannelById+ , applyPreferenceChange+ , leaveChannel+ , leaveCurrentChannel+ , getNextUnreadChannel+ , getNextUnreadUserOrChannel+ , nextUnreadChannel+ , nextUnreadUserOrChannel+ , createOrFocusDMChannel+ , clearChannelUnreadStatus+ , prevChannel+ , nextChannel+ , recentChannel+ , setReturnChannel+ , resetReturnChannel+ , hideDMChannel+ , createGroupChannel+ , showGroupChannelPref+ , channelHistoryForward+ , channelHistoryBackward+ , handleNewChannel+ , createOrdinaryChannel+ , handleChannelInvite+ , addUserByNameToCurrentChannel+ , addUserToCurrentChannel+ , removeUserFromCurrentChannel+ , removeChannelFromState+ , isRecentChannel+ , isReturnChannel+ , isCurrentChannel+ , deleteCurrentChannel+ , startLeaveCurrentChannel+ , joinChannel+ , joinChannel'+ , joinChannelByName+ , changeChannelByName+ , setChannelTopic+ , getCurrentChannelTopic+ , beginCurrentChannelDeleteConfirm+ , toggleChannelListVisibility+ , toggleExpandedChannelTopics+ , showChannelInSidebar+ , updateChannelNotifyProps+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Main ( getVtyHandle, viewportScroll, vScrollToBeginning+ , invalidateCache, invalidateCacheEntry )+import Brick.Widgets.Edit ( applyEdit, getEditContents, editContentsL )+import Control.Concurrent.Async ( runConcurrently, Concurrently(..) )+import Control.Exception ( SomeException, try )+import Data.Char ( isAlphaNum )+import qualified Data.HashMap.Strict as HM+import qualified Data.Foldable as F+import Data.List ( nub )+import Data.Maybe ( fromJust )+import qualified Data.Set as S+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import Data.Text.Zipper ( textZipper, clearZipper, insertMany, gotoEOL )+import Data.Time.Clock ( getCurrentTime )+import qualified Graphics.Vty as Vty+import Lens.Micro.Platform++import qualified Network.Mattermost.Endpoints as MM+import Network.Mattermost.Lenses+import Network.Mattermost.Types++import Matterhorn.Constants ( normalChannelSigil )+import Matterhorn.InputHistory+import Matterhorn.State.Common+import {-# SOURCE #-} Matterhorn.State.Messages ( fetchVisibleIfNeeded )+import Matterhorn.State.Users+import Matterhorn.State.Flagging+import Matterhorn.Types+import Matterhorn.Types.Common+import Matterhorn.Zipper ( Zipper )+import qualified Matterhorn.Zipper as Z+++updateSidebar :: MH ()+updateSidebar = do+ -- Invalidate the cached sidebar rendering since we are about to+ -- change the underlying state+ mh $ invalidateCacheEntry ChannelSidebar++ -- Get the currently-focused channel ID so we can compare after the+ -- zipper is rebuilt+ cconfig <- use csClientConfig+ oldCid <- use csCurrentChannelId++ -- Update the zipper+ cs <- use csChannels+ us <- getUsers+ prefs <- use (csResources.crUserPreferences)+ now <- liftIO getCurrentTime+ config <- use (csResources.crConfiguration)++ let zl = mkChannelZipperList now config cconfig prefs cs us+ csFocus %= Z.updateList zl++ -- Schedule the current sidebar for user status updates at the end+ -- of this MH action.+ newZ <- use csFocus+ myId <- gets myUserId+ scheduleUserStatusFetches $ myId : userIdsFromZipper newZ++ -- Update the window title based on the unread status of the+ -- channels.+ let unread = sum $ (channelListGroupUnread . fst) <$> zl+ title = "matterhorn" <> if unread > 0 then "(" <> show unread <> ")" else ""++ vty <- mh getVtyHandle+ liftIO $ Vty.setWindowTitle vty title++ -- If the zipper rebuild caused the current channel to change, such+ -- as when the previously-focused channel was removed, we need to+ -- call fetchVisibleIfNeeded on the newly-focused channel to ensure+ -- that it gets loaded.+ newCid <- use csCurrentChannelId+ when (newCid /= oldCid) $+ fetchVisibleIfNeeded++updateViewed :: Bool -> MH ()+updateViewed updatePrev = do+ csCurrentChannel.ccInfo.cdMentionCount .= 0+ updateViewedChan updatePrev =<< use csCurrentChannelId++-- | When a new channel has been selected for viewing, this will+-- notify the server of the change, and also update the local channel+-- state to set the last-viewed time for the previous channel and+-- update the viewed time to now for the newly selected channel.+--+-- The boolean argument indicates whether the view time of the previous+-- channel (if any) should be updated, too. We typically want to do that+-- only on channel switching; when we just want to update the view time+-- of the specified channel, False should be provided.+updateViewedChan :: Bool -> ChannelId -> MH ()+updateViewedChan updatePrev cId = use csConnectionStatus >>= \case+ Connected -> do+ -- Only do this if we're connected to avoid triggering noisy+ -- exceptions.+ pId <- if updatePrev+ then use csRecentChannel+ else return Nothing+ doAsyncChannelMM Preempt cId+ (\s c -> MM.mmViewChannel UserMe c pId s)+ (\c () -> Just $ setLastViewedFor pId c)+ Disconnected ->+ -- Cannot update server; make no local updates to avoid getting+ -- out of sync with the server. Assumes that this is a temporary+ -- break in connectivity and that after the connection is+ -- restored, the user's normal activities will update state as+ -- appropriate. If connectivity is permanently lost, managing+ -- this state is irrelevant.+ return ()++toggleChannelListVisibility :: MH ()+toggleChannelListVisibility = do+ mh invalidateCache+ csShowChannelList %= not++toggleExpandedChannelTopics :: MH ()+toggleExpandedChannelTopics = do+ mh invalidateCache+ csShowExpandedChannelTopics %= not++-- | If the current channel is a DM channel with a single user or a+-- group of users, hide it from the sidebar and adjust the server-side+-- preference to hide it persistently.+--+-- If the current channel is any other kind of channel, complain with a+-- usage error.+hideDMChannel :: ChannelId -> MH ()+hideDMChannel cId = do+ me <- gets myUser+ session <- getSession+ withChannel cId $ \chan -> do+ case chan^.ccInfo.cdType of+ Direct -> do+ let pref = showDirectChannelPref (me^.userIdL) uId False+ Just uId = chan^.ccInfo.cdDMUserId+ csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing+ doAsyncWith Preempt $ do+ MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session+ return Nothing+ Group -> do+ let pref = hideGroupChannelPref cId (me^.userIdL)+ csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing+ doAsyncWith Preempt $ do+ MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session+ return Nothing+ _ -> do+ mhError $ GenericError "Cannot hide this channel. Consider using /leave instead."++-- | Called on async completion when the currently viewed channel has+-- been updated (i.e., just switched to this channel) to update local+-- state.+setLastViewedFor :: Maybe ChannelId -> ChannelId -> MH ()+setLastViewedFor prevId cId = do+ chan <- use (csChannels.to (findChannelById cId))+ -- Update new channel's viewed time, creating the channel if needed+ case chan of+ Nothing ->+ -- It's possible for us to get spurious WMChannelViewed+ -- events from the server, e.g. for channels that have been+ -- deleted. So here we ignore the request since it's hard to+ -- detect it before this point.+ return ()+ Just _ ->+ -- The server has been sent a viewed POST update, but there is+ -- no local information on what timestamp the server actually+ -- recorded. There are a couple of options for setting the+ -- local value of the viewed time:+ --+ -- 1. Attempting to locally construct a value, which would+ -- involve scanning all (User) messages in the channel+ -- to find the maximum of the created date, the modified+ -- date, or the deleted date, and assuming that maximum+ -- mostly matched the server's viewed time.+ --+ -- 2. Issuing a channel metadata request to get the server's+ -- new concept of the viewed time.+ --+ -- 3. Having the "chan/viewed" POST that was just issued+ -- return a value from the server. See+ -- https://github.com/mattermost/platform/issues/6803.+ --+ -- Method 3 would be the best and most lightweight. Until that+ -- is available, Method 2 will be used. The downside to Method+ -- 2 is additional client-server messaging, and a delay in+ -- updating the client data, but it's also immune to any new+ -- or removed Message date fields, or anything else that would+ -- contribute to the viewed/updated times on the server.+ doAsyncChannelMM Preempt cId (\ s _ ->+ (,) <$> MM.mmGetChannel cId s+ <*> MM.mmGetChannelMember cId UserMe s)+ (\pcid (cwd, member) -> Just $ csChannel(pcid).ccInfo %= channelInfoFromChannelWithData cwd member)++ -- Update the old channel's previous viewed time (allows tracking of+ -- new messages)+ case prevId of+ Nothing -> return ()+ Just p -> clearChannelUnreadStatus p++-- | Refresh information about all channels and users. This is usually+-- triggered when a reconnect event for the WebSocket to the server+-- occurs.+refreshChannelsAndUsers :: MH ()+refreshChannelsAndUsers = do+ session <- getSession+ myTId <- gets myTeamId+ me <- gets myUser+ knownUsers <- gets allUserIds+ doAsyncWith Preempt $ do+ (chans, datas) <- runConcurrently $ (,)+ <$> Concurrently (MM.mmGetChannelsForUser UserMe myTId session)+ <*> Concurrently (MM.mmGetChannelMembersForUser UserMe myTId session)++ -- Collect all user IDs associated with DM channels so we can+ -- bulk-fetch their user records.+ let dmUsers = catMaybes $ flip map (F.toList chans) $ \chan ->+ case chan^.channelTypeL of+ Direct -> case userIdForDMChannel (userId me) (sanitizeUserText $ channelName chan) of+ Nothing -> Nothing+ Just otherUserId -> Just otherUserId+ _ -> Nothing+ uIdsToFetch = nub $ userId me : knownUsers <> dmUsers++ dataMap = HM.fromList $ toList $ (\d -> (channelMemberChannelId d, d)) <$> datas+ mkPair chan = (chan, fromJust $ HM.lookup (channelId chan) dataMap)+ chansWithData = mkPair <$> chans++ return $ Just $+ -- Fetch user data associated with DM channels+ handleNewUsers (Seq.fromList uIdsToFetch) $ do+ -- Then refresh all loaded channels+ forM_ chansWithData $ uncurry (refreshChannel SidebarUpdateDeferred)+ updateSidebar++-- | Refresh information about a specific channel. The channel+-- metadata is refreshed, and if this is a loaded channel, the+-- scrollback is updated as well.+--+-- The sidebar update argument indicates whether this refresh should+-- also update the sidebar. Ordinarily you want this, so pass+-- SidebarUpdateImmediate unless you are very sure you know what you are+-- doing, i.e., you are very sure that a call to refreshChannel will+-- be followed immediately by a call to updateSidebar. We provide this+-- control so that channel refreshes can be batched and then a single+-- updateSidebar call can be used instead of the default behavior of+-- calling it once per refreshChannel call, which is the behavior if the+-- immediate setting is passed here.+refreshChannel :: SidebarUpdate -> Channel -> ChannelMember -> MH ()+refreshChannel upd chan member = do+ let cId = getId chan+ myTId <- gets myTeamId+ let ourTeam = channelTeamId chan == Nothing ||+ Just myTId == channelTeamId chan++ case not ourTeam of+ True -> return ()+ False -> do+ -- If this channel is unknown, register it first.+ mChan <- preuse (csChannel(cId))+ when (isNothing mChan) $+ handleNewChannel False upd chan member++ updateChannelInfo cId chan member++handleNewChannel :: Bool -> SidebarUpdate -> Channel -> ChannelMember -> MH ()+handleNewChannel = handleNewChannel_ True++handleNewChannel_ :: Bool+ -- ^ Whether to permit this call to recursively+ -- schedule itself for later if it can't locate+ -- a DM channel user record. This is to prevent+ -- uncontrolled recursion.+ -> Bool+ -- ^ Whether to switch to the new channel once it has+ -- been installed.+ -> SidebarUpdate+ -- ^ Whether to update the sidebar, in case the caller+ -- wants to batch these before updating it. Pass+ -- SidebarUpdateImmediate unless you know what+ -- you are doing, i.e., unless you intend to call+ -- updateSidebar yourself after calling this.+ -> Channel+ -- ^ The channel to install.+ -> ChannelMember+ -> MH ()+handleNewChannel_ permitPostpone switch sbUpdate nc member = do+ -- Only add the channel to the state if it isn't already known.+ me <- gets myUser+ mChan <- preuse (csChannel(getId nc))+ case mChan of+ Just _ -> when switch $ setFocus (getId nc)+ Nothing -> do+ -- Create a new ClientChannel structure+ cChannel <- (ccInfo %~ channelInfoFromChannelWithData nc member) <$>+ makeClientChannel (me^.userIdL) nc++ st <- use id++ -- Add it to the message map, and to the name map so we+ -- can look it up by name. The name we use for the channel+ -- depends on its type:+ let chType = nc^.channelTypeL++ -- Get the channel name. If we couldn't, that means we have+ -- async work to do before we can register this channel (in+ -- which case abort because we got rescheduled).+ register <- case chType of+ Direct -> case userIdForDMChannel (myUserId st) (sanitizeUserText $ channelName nc) of+ Nothing -> return True+ Just otherUserId ->+ case userById otherUserId st of+ -- If we found a user ID in the channel+ -- name string but don't have that user's+ -- metadata, postpone adding this channel+ -- until we have fetched the metadata. This+ -- can happen when we have a channel record+ -- for a user that is no longer in the+ -- current team. To avoid recursion due to a+ -- problem, ensure that the rescheduled new+ -- channel handler is not permitted to try+ -- this again.+ --+ -- If we're already in a recursive attempt+ -- to register this channel and still+ -- couldn't find a username, just bail and+ -- use the synthetic name (this has the same+ -- problems as above).+ Nothing -> do+ case permitPostpone of+ False -> return True+ True -> do+ mhLog LogAPI $ T.pack $ "handleNewChannel_: about to call handleNewUsers for " <> show otherUserId+ handleNewUsers (Seq.singleton otherUserId) (return ())+ doAsyncWith Normal $+ return $ Just $ handleNewChannel_ False switch sbUpdate nc member+ return False+ Just _ -> return True+ _ -> return True++ when register $ do+ csChannels %= addChannel (getId nc) cChannel+ when (sbUpdate == SidebarUpdateImmediate) $ do+ -- Note that we only check for whether we should+ -- switch to this channel when doing a sidebar+ -- update, since that's the only case where it's+ -- possible to do so.+ updateSidebar++ -- Finally, set our focus to the newly created+ -- channel if the caller requested a change of+ -- channel. Also consider the last join request+ -- state field in case this is an asynchronous+ -- channel addition triggered by a /join.+ pending1 <- checkPendingChannelChange (getId nc)+ pending2 <- case cChannel^.ccInfo.cdDMUserId of+ Nothing -> return False+ Just uId -> checkPendingChannelChangeByUserId uId++ when (switch || isJust pending1 || pending2) $ do+ setFocus (getId nc)+ case pending1 of+ Just (Just act) -> act+ _ -> return ()++-- | Check to see whether the specified channel has been queued up to+-- be switched to. Note that this condition is only cleared by the+-- actual setFocus switch to the channel because there may be multiple+-- operations that must complete before the channel is fully ready for+-- display/use.+--+-- Returns Just if the specified channel has a pending switch. The+-- result is an optional action to invoke after changing to the+-- specified channel.+checkPendingChannelChange :: ChannelId -> MH (Maybe (Maybe (MH ())))+checkPendingChannelChange cId = do+ ch <- use csPendingChannelChange+ return $ case ch of+ Just (ChangeByChannelId i act) ->+ if i == cId then Just act else Nothing+ _ -> Nothing++-- | Check to see whether the specified channel has been queued up to+-- be switched to. Note that this condition is only cleared by the+-- actual setFocus switch to the channel because there may be multiple+-- operations that must complete before the channel is fully ready for+-- display/use.+--+-- Returns Just if the specified channel has a pending switch. The+-- result is an optional action to invoke after changing to the+-- specified channel.+checkPendingChannelChangeByUserId :: UserId -> MH Bool+checkPendingChannelChangeByUserId uId = do+ ch <- use csPendingChannelChange+ return $ case ch of+ Just (ChangeByUserId i) ->+ i == uId+ _ ->+ False++-- | Update the indicated Channel entry with the new data retrieved from+-- the Mattermost server. Also update the channel name if it changed.+updateChannelInfo :: ChannelId -> Channel -> ChannelMember -> MH ()+updateChannelInfo cid new member = do+ mh $ invalidateCacheEntry $ ChannelMessages cid+ csChannel(cid).ccInfo %= channelInfoFromChannelWithData new member+ updateSidebar++setFocus :: ChannelId -> MH ()+setFocus cId = do+ showChannelInSidebar cId True+ setFocusWith True (Z.findRight ((== cId) . channelListEntryChannelId)) (return ())++showChannelInSidebar :: ChannelId -> Bool -> MH ()+showChannelInSidebar cId setPending = do+ mChan <- preuse $ csChannel cId+ me <- gets myUser+ prefs <- use (csResources.crUserPreferences)+ session <- getSession++ case mChan of+ Nothing ->+ -- The requested channel doesn't actually exist yet, so no+ -- action can be taken. It's likely that this is a+ -- pendingChannel situation and not all of the operations to+ -- locally define the channel have completed, in which case+ -- this code will be re-entered later and the mChan will be+ -- known.+ return ()+ Just ch -> do++ -- Able to successfully switch to a known channel. This+ -- should clear any pending channel intention. If the+ -- intention was for this channel, then: done. If the+ -- intention was for a different channel, reaching this+ -- point means that the pending is still outstanding but+ -- that the user identified a new channel which *was*+ -- displayable, and the UI should always prefer to SATISFY+ -- the user's latest request over any pending/background+ -- task.+ csPendingChannelChange .= Nothing++ now <- liftIO getCurrentTime+ csChannel(cId).ccInfo.cdSidebarShowOverride .= Just now+ updateSidebar++ case ch^.ccInfo.cdType of+ Direct -> do+ let Just uId = ch^.ccInfo.cdDMUserId+ case dmChannelShowPreference prefs uId of+ Just False -> do+ let pref = showDirectChannelPref (me^.userIdL) uId True+ when setPending $+ csPendingChannelChange .= Just (ChangeByChannelId (ch^.ccInfo.cdChannelId) Nothing)+ doAsyncWith Preempt $ do+ MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session+ return Nothing+ _ -> return ()++ Group ->+ case groupChannelShowPreference prefs cId of+ Just False -> do+ let pref = showGroupChannelPref cId (me^.userIdL)+ when setPending $+ csPendingChannelChange .= Just (ChangeByChannelId (ch^.ccInfo.cdChannelId) Nothing)+ doAsyncWith Preempt $ do+ MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session+ return Nothing+ _ -> return ()++ _ -> return ()++setFocusWith :: Bool+ -> (Zipper ChannelListGroup ChannelListEntry+ -> Zipper ChannelListGroup ChannelListEntry)+ -> MH ()+ -> MH ()+setFocusWith updatePrev f onNoChange = do+ oldZipper <- use csFocus+ let newZipper = f oldZipper+ newFocus = Z.focus newZipper+ oldFocus = Z.focus oldZipper++ -- If we aren't changing anything, skip all the book-keeping because+ -- we'll end up clobbering things like csRecentChannel.+ if newFocus /= oldFocus+ then do+ mh $ invalidateCacheEntry ChannelSidebar+ resetAutocomplete+ preChangeChannelCommon+ csFocus .= newZipper++ now <- liftIO getCurrentTime+ newCid <- use csCurrentChannelId+ csChannel(newCid).ccInfo.cdSidebarShowOverride .= Just now++ updateViewed updatePrev+ postChangeChannelCommon+ else onNoChange++postChangeChannelCommon :: MH ()+postChangeChannelCommon = do+ resetEditorState+ updateChannelListScroll+ loadLastEdit+ fetchVisibleIfNeeded++loadLastEdit :: MH ()+loadLastEdit = do+ cId <- use csCurrentChannelId++ oldEphemeral <- preuse (csChannel(cId).ccEditState)+ case oldEphemeral of+ Nothing -> return ()+ Just e -> csEditState.cedEphemeral .= e++ loadLastChannelInput++loadLastChannelInput :: MH ()+loadLastChannelInput = do+ cId <- use csCurrentChannelId+ inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)+ case inputHistoryPos of+ Just i -> loadHistoryEntryToEditor cId i+ Nothing -> do+ (lastEdit, lastEditMode) <- use (csEditState.cedEphemeral.eesLastInput)+ csEditState.cedEditor %= (applyEdit $ insertMany lastEdit . clearZipper)+ csEditState.cedEditMode .= lastEditMode++updateChannelListScroll :: MH ()+updateChannelListScroll = do+ mh $ vScrollToBeginning (viewportScroll ChannelList)++preChangeChannelCommon :: MH ()+preChangeChannelCommon = do+ cId <- use csCurrentChannelId+ csRecentChannel .= Just cId+ saveCurrentEdit++resetEditorState :: MH ()+resetEditorState = do+ csEditState.cedEditMode .= NewPost+ clearEditor++clearEditor :: MH ()+clearEditor = csEditState.cedEditor %= applyEdit clearZipper++saveCurrentEdit :: MH ()+saveCurrentEdit = do+ saveCurrentChannelInput++ oldEphemeral <- use (csEditState.cedEphemeral)+ cId <- use csCurrentChannelId+ csChannel(cId).ccEditState .= oldEphemeral++saveCurrentChannelInput :: MH ()+saveCurrentChannelInput = do+ cmdLine <- use (csEditState.cedEditor)+ mode <- use (csEditState.cedEditMode)++ -- Only save the editor contents if the user is not navigating the+ -- history.+ inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)++ when (isNothing inputHistoryPos) $+ csEditState.cedEphemeral.eesLastInput .=+ (T.intercalate "\n" $ getEditContents $ cmdLine, mode)++hideGroupChannelPref :: ChannelId -> UserId -> Preference+hideGroupChannelPref cId uId =+ Preference { preferenceCategory = PreferenceCategoryGroupChannelShow+ , preferenceValue = PreferenceValue "false"+ , preferenceName = PreferenceName $ idString cId+ , preferenceUserId = uId+ }++showGroupChannelPref :: ChannelId -> UserId -> Preference+showGroupChannelPref cId uId =+ Preference { preferenceCategory = PreferenceCategoryGroupChannelShow+ , preferenceValue = PreferenceValue "true"+ , preferenceName = PreferenceName $ idString cId+ , preferenceUserId = uId+ }++showDirectChannelPref :: UserId -> UserId -> Bool -> Preference+showDirectChannelPref myId otherId s =+ Preference { preferenceCategory = PreferenceCategoryDirectChannelShow+ , preferenceValue = if s then PreferenceValue "true"+ else PreferenceValue "false"+ , preferenceName = PreferenceName $ idString otherId+ , preferenceUserId = myId+ }++applyPreferenceChange :: Preference -> MH ()+applyPreferenceChange pref = do+ -- always update our user preferences accordingly+ csResources.crUserPreferences %= setUserPreferences (Seq.singleton pref)++ -- Invalidate the entire rendering cache since many things depend on+ -- user preferences+ mh invalidateCache++ if+ | Just f <- preferenceToFlaggedPost pref -> do+ updateMessageFlag (flaggedPostId f) (flaggedPostStatus f)++ | Just d <- preferenceToDirectChannelShowStatus pref -> do+ updateSidebar++ cs <- use csChannels++ -- We need to check on whether this preference was to show a+ -- channel and, if so, whether it was the one we attempted to+ -- switch to (thus triggering the preference change). If so,+ -- we need to switch to it now.+ let Just cId = getDmChannelFor (directChannelShowUserId d) cs+ case directChannelShowValue d of+ True -> do+ pending <- checkPendingChannelChange cId+ case pending of+ Just mAct -> do+ setFocus cId+ fromMaybe (return ()) mAct+ Nothing -> return ()+ False -> do+ csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing++ | Just g <- preferenceToGroupChannelPreference pref -> do+ updateSidebar++ -- We need to check on whether this preference was to show a+ -- channel and, if so, whether it was the one we attempted to+ -- switch to (thus triggering the preference change). If so,+ -- we need to switch to it now.+ let cId = groupChannelId g+ case groupChannelShow g of+ True -> do+ pending <- checkPendingChannelChange cId+ case pending of+ Just mAct -> do+ setFocus cId+ fromMaybe (return ()) mAct+ Nothing -> return ()+ False -> do+ csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing++ | otherwise -> return ()++refreshChannelById :: ChannelId -> MH ()+refreshChannelById cId = do+ session <- getSession+ doAsyncWith Preempt $ do+ cwd <- MM.mmGetChannel cId session+ member <- MM.mmGetChannelMember cId UserMe session+ return $ Just $ do+ refreshChannel SidebarUpdateImmediate cwd member++removeChannelFromState :: ChannelId -> MH ()+removeChannelFromState cId = do+ withChannel cId $ \ chan -> do+ when (chan^.ccInfo.cdType /= Direct) $ do+ origFocus <- use csCurrentChannelId+ when (origFocus == cId) nextChannelSkipPrevView+ -- Update input history+ csEditState.cedInputHistory %= removeChannelHistory cId+ -- Update msgMap+ csChannels %= removeChannel cId+ -- Remove from focus zipper+ csFocus %= Z.filterZipper ((/= cId) . channelListEntryChannelId)+ updateSidebar++nextChannel :: MH ()+nextChannel = do+ resetReturnChannel+ setFocusWith True Z.right (return ())++-- | This is almost never what you want; we use this when we delete a+-- channel and we don't want to update the deleted channel's view time.+nextChannelSkipPrevView :: MH ()+nextChannelSkipPrevView = setFocusWith False Z.right (return ())++prevChannel :: MH ()+prevChannel = do+ resetReturnChannel+ setFocusWith True Z.left (return ())++recentChannel :: MH ()+recentChannel = do+ recent <- use csRecentChannel+ case recent of+ Nothing -> return ()+ Just cId -> do+ ret <- use csReturnChannel+ when (ret == Just cId) resetReturnChannel+ setFocus cId++resetReturnChannel :: MH ()+resetReturnChannel = do+ val <- use csReturnChannel+ case val of+ Nothing -> return ()+ Just _ -> do+ mh $ invalidateCacheEntry ChannelSidebar+ csReturnChannel .= Nothing++gotoReturnChannel :: MH ()+gotoReturnChannel = do+ ret <- use csReturnChannel+ case ret of+ Nothing -> return ()+ Just cId -> do+ resetReturnChannel+ setFocus cId++setReturnChannel :: MH ()+setReturnChannel = do+ ret <- use csReturnChannel+ case ret of+ Nothing -> do+ cId <- use csCurrentChannelId+ csReturnChannel .= Just cId+ mh $ invalidateCacheEntry ChannelSidebar+ Just _ -> return ()++nextUnreadChannel :: MH ()+nextUnreadChannel = do+ st <- use id+ setReturnChannel+ setFocusWith True (getNextUnreadChannel st) gotoReturnChannel++nextUnreadUserOrChannel :: MH ()+nextUnreadUserOrChannel = do+ st <- use id+ setReturnChannel+ setFocusWith True (getNextUnreadUserOrChannel st) gotoReturnChannel++leaveChannel :: ChannelId -> MH ()+leaveChannel cId = leaveChannelIfPossible cId False++leaveChannelIfPossible :: ChannelId -> Bool -> MH ()+leaveChannelIfPossible cId delete = do+ st <- use id+ me <- gets myUser+ let isMe u = u^.userIdL == me^.userIdL++ case st ^? csChannel(cId).ccInfo of+ Nothing -> return ()+ Just cInfo -> case canLeaveChannel cInfo of+ False -> return ()+ True ->+ -- The server will reject an attempt to leave a private+ -- channel if we're the only member. To check this, we+ -- just ask for the first two members of the channel.+ -- If there is only one, it must be us: hence the "all+ -- isMe" check below. If there are two members, it+ -- doesn't matter who they are, because we just know+ -- that we aren't the only remaining member, so we can't+ -- delete the channel.+ doAsyncChannelMM Preempt cId+ (\s _ ->+ let query = MM.defaultUserQuery+ { MM.userQueryPage = Just 0+ , MM.userQueryPerPage = Just 2+ , MM.userQueryInChannel = Just cId+ }+ in toList <$> MM.mmGetUsers query s)+ (\_ members -> Just $ do+ -- If the channel is private:+ -- * leave it if we aren't the last member.+ -- * delete it if we are.+ --+ -- Otherwise:+ -- * leave (or delete) the channel as specified+ -- by the delete argument.+ let func = case cInfo^.cdType of+ Private -> case all isMe members of+ True -> (\ s c -> MM.mmDeleteChannel c s)+ False -> (\ s c -> MM.mmRemoveUserFromChannel c UserMe s)+ Group ->+ \s _ ->+ let pref = hideGroupChannelPref cId (me^.userIdL)+ in MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) s+ _ -> if delete+ then (\ s c -> MM.mmDeleteChannel c s)+ else (\ s c -> MM.mmRemoveUserFromChannel c UserMe s)++ doAsyncChannelMM Preempt cId func endAsyncNOP+ )++getNextUnreadChannel :: ChatState+ -> (Zipper a ChannelListEntry -> Zipper a ChannelListEntry)+getNextUnreadChannel st =+ -- The next channel with unread messages must also be a channel+ -- other than the current one, since the zipper may be on a channel+ -- that has unread messages and will stay that way until we leave+ -- it- so we need to skip that channel when doing the zipper search+ -- for the next candidate channel.+ Z.findRight (\e ->+ let cId = channelListEntryChannelId e+ in hasUnread st cId && (cId /= st^.csCurrentChannelId))++getNextUnreadUserOrChannel :: ChatState+ -> Zipper a ChannelListEntry+ -> Zipper a ChannelListEntry+getNextUnreadUserOrChannel st z =+ -- Find the next unread channel, prefering direct messages+ let cur = st^.csCurrentChannelId+ matches e = entryIsDMEntry e && isFresh (channelListEntryChannelId e)+ isFresh c = hasUnread st c && (c /= cur)+ in fromMaybe (Z.findRight (isFresh . channelListEntryChannelId) z)+ (Z.maybeFindRight matches z)++leaveCurrentChannel :: MH ()+leaveCurrentChannel = use csCurrentChannelId >>= leaveChannel++createGroupChannel :: Text -> MH ()+createGroupChannel usernameList = do+ me <- gets myUser+ session <- getSession+ cs <- use csChannels++ doAsyncWith Preempt $ do+ let usernames = Seq.fromList $ fmap trimUserSigil $ T.words usernameList+ results <- MM.mmGetUsersByUsernames usernames session++ -- If we found all of the users mentioned, then create the group+ -- channel.+ case length results == length usernames of+ True -> do+ chan <- MM.mmCreateGroupMessageChannel (userId <$> results) session+ return $ Just $ do+ case findChannelById (channelId chan) cs of+ Just _ ->+ -- If we already know about the channel ID,+ -- that means the channel already exists so+ -- we can just switch to it.+ setFocus (channelId chan)+ Nothing -> do+ csPendingChannelChange .= (Just $ ChangeByChannelId (channelId chan) Nothing)+ let pref = showGroupChannelPref (channelId chan) (me^.userIdL)+ doAsyncWith Normal $ do+ MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session+ return $ Just $ applyPreferenceChange pref+ False -> do+ let foundUsernames = userUsername <$> results+ missingUsernames = S.toList $+ S.difference (S.fromList $ F.toList usernames)+ (S.fromList $ F.toList foundUsernames)+ return $ Just $ do+ forM_ missingUsernames (mhError . NoSuchUser)++channelHistoryForward :: MH ()+channelHistoryForward = do+ resetAutocomplete++ cId <- use csCurrentChannelId+ inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)+ case inputHistoryPos of+ Just i+ | i == 0 -> do+ -- Transition out of history navigation+ csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing+ loadLastChannelInput+ | otherwise -> do+ let newI = i - 1+ loadHistoryEntryToEditor cId newI+ csEditState.cedEphemeral.eesInputHistoryPosition .= (Just newI)+ _ -> return ()++loadHistoryEntryToEditor :: ChannelId -> Int -> MH ()+loadHistoryEntryToEditor cId idx = do+ inputHistory <- use (csEditState.cedInputHistory)+ case getHistoryEntry cId idx inputHistory of+ Nothing -> return ()+ Just entry -> do+ let eLines = T.lines entry+ mv = if length eLines == 1 then gotoEOL else id+ csEditState.cedEditor.editContentsL .= (mv $ textZipper eLines Nothing)++channelHistoryBackward :: MH ()+channelHistoryBackward = do+ resetAutocomplete++ cId <- use csCurrentChannelId+ inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)+ saveCurrentChannelInput++ let newI = maybe 0 (+ 1) inputHistoryPos+ loadHistoryEntryToEditor cId newI+ csEditState.cedEphemeral.eesInputHistoryPosition .= (Just newI)++createOrdinaryChannel :: Bool -> Text -> MH ()+createOrdinaryChannel public name = do+ session <- getSession+ myTId <- gets myTeamId+ doAsyncWith Preempt $ do+ -- create a new chat channel+ let slug = T.map (\ c -> if isAlphaNum c then c else '-') (T.toLower name)+ minChannel = MinChannel+ { minChannelName = slug+ , minChannelDisplayName = name+ , minChannelPurpose = Nothing+ , minChannelHeader = Nothing+ , minChannelType = if public then Ordinary else Private+ , minChannelTeamId = myTId+ }+ tryMM (do c <- MM.mmCreateChannel minChannel session+ chan <- MM.mmGetChannel (getId c) session+ member <- MM.mmGetChannelMember (getId c) UserMe session+ return (chan, member)+ )+ (return . Just . uncurry (handleNewChannel True SidebarUpdateImmediate))++-- | When we are added to a channel not locally known about, we need+-- to fetch the channel info for that channel.+handleChannelInvite :: ChannelId -> MH ()+handleChannelInvite cId = do+ session <- getSession+ doAsyncWith Normal $ do+ member <- MM.mmGetChannelMember cId UserMe session+ tryMM (MM.mmGetChannel cId session)+ (\cwd -> return $ Just $ do+ pending <- checkPendingChannelChange cId+ handleNewChannel (isJust pending) SidebarUpdateImmediate cwd member)++addUserByNameToCurrentChannel :: Text -> MH ()+addUserByNameToCurrentChannel uname =+ withFetchedUser (UserFetchByUsername uname) addUserToCurrentChannel++addUserToCurrentChannel :: UserInfo -> MH ()+addUserToCurrentChannel u = do+ cId <- use csCurrentChannelId+ session <- getSession+ let channelMember = MinChannelMember (u^.uiId) cId+ doAsyncWith Normal $ do+ tryMM (void $ MM.mmAddUser cId channelMember session)+ (const $ return Nothing)++removeUserFromCurrentChannel :: Text -> MH ()+removeUserFromCurrentChannel uname =+ withFetchedUser (UserFetchByUsername uname) $ \u -> do+ cId <- use csCurrentChannelId+ session <- getSession+ doAsyncWith Normal $ do+ tryMM (void $ MM.mmRemoveUserFromChannel cId (UserById $ u^.uiId) session)+ (const $ return Nothing)++startLeaveCurrentChannel :: MH ()+startLeaveCurrentChannel = do+ cInfo <- use (csCurrentChannel.ccInfo)+ case cInfo^.cdType of+ Direct -> hideDMChannel (cInfo^.cdChannelId)+ Group -> hideDMChannel (cInfo^.cdChannelId)+ _ -> setMode LeaveChannelConfirm++deleteCurrentChannel :: MH ()+deleteCurrentChannel = do+ setMode Main+ cId <- use csCurrentChannelId+ leaveChannelIfPossible cId True++isCurrentChannel :: ChatState -> ChannelId -> Bool+isCurrentChannel st cId = st^.csCurrentChannelId == cId++isRecentChannel :: ChatState -> ChannelId -> Bool+isRecentChannel st cId = st^.csRecentChannel == Just cId++isReturnChannel :: ChatState -> ChannelId -> Bool+isReturnChannel st cId = st^.csReturnChannel == Just cId++joinChannelByName :: Text -> MH ()+joinChannelByName rawName = do+ session <- getSession+ tId <- gets myTeamId+ doAsyncWith Preempt $ do+ result <- try $ MM.mmGetChannelByName tId (trimChannelSigil rawName) session+ return $ Just $ case result of+ Left (_::SomeException) -> mhError $ NoSuchChannel rawName+ Right chan -> joinChannel $ getId chan++-- | If the user is not a member of the specified channel, submit a+-- request to join it. Otherwise switch to the channel.+joinChannel :: ChannelId -> MH ()+joinChannel chanId = joinChannel' chanId Nothing++joinChannel' :: ChannelId -> Maybe (MH ()) -> MH ()+joinChannel' chanId act = do+ setMode Main+ mChan <- preuse (csChannel(chanId))+ case mChan of+ Just _ -> do+ setFocus chanId+ fromMaybe (return ()) act+ Nothing -> do+ myId <- gets myUserId+ let member = MinChannelMember myId chanId+ csPendingChannelChange .= (Just $ ChangeByChannelId chanId act)+ doAsyncChannelMM Preempt chanId (\ s c -> MM.mmAddUser c member s) (const $ return act)++createOrFocusDMChannel :: UserInfo -> Maybe (ChannelId -> MH ()) -> MH ()+createOrFocusDMChannel user successAct = do+ cs <- use csChannels+ case getDmChannelFor (user^.uiId) cs of+ Just cId -> do+ setFocus cId+ case successAct of+ Nothing -> return ()+ Just act -> act cId+ Nothing -> do+ -- We have a user of that name but no channel. Time to make one!+ myId <- gets myUserId+ session <- getSession+ csPendingChannelChange .= (Just $ ChangeByUserId $ user^.uiId)+ doAsyncWith Normal $ do+ -- create a new channel+ chan <- MM.mmCreateDirectMessageChannel (user^.uiId, myId) session+ return $ successAct <*> pure (channelId chan)++-- | This switches to the named channel or creates it if it is a missing+-- but valid user channel.+changeChannelByName :: Text -> MH ()+changeChannelByName name = do+ mCId <- gets (channelIdByChannelName name)+ mDMCId <- gets (channelIdByUsername name)++ withFetchedUserMaybe (UserFetchByUsername name) $ \foundUser -> do+ let err = mhError $ AmbiguousName name+ case (mCId, mDMCId) of+ (Nothing, Nothing) ->+ case foundUser of+ -- We know about the user but there isn't already a DM+ -- channel, so create one.+ Just user -> createOrFocusDMChannel user Nothing+ -- There were no matches of any kind.+ Nothing -> mhError $ NoSuchChannel name+ (Just cId, Nothing)+ -- We matched a channel and there was an explicit sigil, so we+ -- don't care about the username match.+ | normalChannelSigil `T.isPrefixOf` name -> setFocus cId+ -- We matched both a channel and a user, even though there is+ -- no DM channel.+ | Just _ <- foundUser -> err+ -- We matched a channel only.+ | otherwise -> setFocus cId+ (Nothing, Just cId) ->+ -- We matched a DM channel only.+ setFocus cId+ (Just _, Just _) ->+ -- We matched both a channel and a DM channel.+ err++setChannelTopic :: Text -> MH ()+setChannelTopic msg = do+ cId <- use csCurrentChannelId+ let patch = defaultChannelPatch { channelPatchHeader = Just msg }+ doAsyncChannelMM Preempt cId+ (\s _ -> MM.mmPatchChannel cId patch s)+ (\_ _ -> Nothing)++getCurrentChannelTopic :: MH Text+getCurrentChannelTopic = do+ ch <- use csCurrentChannel+ return $ ch^.ccInfo.cdHeader++beginCurrentChannelDeleteConfirm :: MH ()+beginCurrentChannelDeleteConfirm = do+ cId <- use csCurrentChannelId+ withChannel cId $ \chan -> do+ let chType = chan^.ccInfo.cdType+ if chType /= Direct+ then setMode DeleteChannelConfirm+ else mhError $ GenericError "Direct message channels cannot be deleted."++updateChannelNotifyProps :: ChannelId -> ChannelNotifyProps -> MH ()+updateChannelNotifyProps cId notifyProps = do+ mh $ invalidateCacheEntry ChannelSidebar+ csChannel(cId).ccInfo.cdNotifyProps .= notifyProps
+ src/Matterhorn/State/Common.hs view
@@ -0,0 +1,374 @@+module Matterhorn.State.Common+ (+ -- * System interface+ openFilePath+ , openWithOpener+ , runLoggedCommand+ , prepareAttachment++ -- * Posts+ , installMessagesFromPosts+ , updatePostMap++ -- * Utilities+ , postInfoMessage+ , postErrorMessageIO+ , postErrorMessage'+ , addEmoteFormatting+ , removeEmoteFormatting++ , fetchMentionedUsers+ , doPendingUserFetches+ , doPendingUserStatusFetches++ , module Matterhorn.State.Async+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Main ( invalidateCacheEntry )+import Control.Concurrent ( MVar, putMVar, forkIO )+import Control.Concurrent.Async ( concurrently )+import qualified Control.Concurrent.STM as STM+import Control.Exception ( SomeException, try )+import qualified Data.ByteString as BS+import qualified Data.HashMap.Strict as HM+import qualified Data.Sequence as Seq+import qualified Data.Set as Set+import qualified Data.Text as T+import Lens.Micro.Platform ( (.=), (%=), (%~), (.~) )+import System.Directory ( createDirectoryIfMissing )+import System.Environment.XDG.BaseDir ( getUserCacheDir )+import System.Exit ( ExitCode(..) )+import System.FilePath+import System.IO ( hGetContents, hFlush, hPutStrLn )+import System.Process ( proc, std_in, std_out, std_err, StdStream(..)+ , createProcess, waitForProcess )++import Network.Mattermost.Endpoints+import Network.Mattermost.Lenses+import Network.Mattermost.Types++import Matterhorn.FilePaths ( xdgName )+import Matterhorn.State.Async+import Matterhorn.Types+import Matterhorn.Types.Common+++-- * Client Messages++-- | Given a collection of posts from the server, save the posts in the+-- global post map. Also convert the posts to Matterhorn's Message type+-- and return them along with the set of all usernames mentioned in the+-- text of the resulting messages.+--+-- This also sets the mFlagged field of each message based on whether+-- its post ID is a flagged post according to crFlaggedPosts at the time+-- of this call.+installMessagesFromPosts :: Posts -> MH Messages+installMessagesFromPosts postCollection = do+ flags <- use (csResources.crFlaggedPosts)++ -- Add all posts in this collection to the global post cache+ updatePostMap postCollection++ baseUrl <- getServerBaseUrl++ -- Build the ordered list of posts. Note that postsOrder lists the+ -- posts most recent first, but we want most recent last.+ let postsInOrder = findPost <$> (Seq.reverse $ postsOrder postCollection)+ mkClientPost p = toClientPost baseUrl p (postId <$> parent p)+ clientPosts = mkClientPost <$> postsInOrder++ addNext cp (msgs, us) =+ let (msg, mUsernames) = clientPostToMessage cp+ in (addMessage (maybeFlag flags msg) msgs, Set.union us mUsernames)+ (ms, mentions) = foldr addNext (noMessages, mempty) clientPosts++ fetchMentionedUsers mentions+ return ms+ where+ maybeFlag flagSet msg+ | Just (MessagePostId pId) <- msg^.mMessageId, pId `Set.member` flagSet+ = msg & mFlagged .~ True+ | otherwise = msg+ parent x = do+ parentId <- x^.postRootIdL+ HM.lookup parentId (postCollection^.postsPostsL)+ findPost pId = case HM.lookup pId (postsPosts postCollection) of+ Nothing -> error $ "BUG: could not find post for post ID " <> show pId+ Just post -> post++-- Add all posts in this collection to the global post cache+updatePostMap :: Posts -> MH ()+updatePostMap postCollection = do+ -- Build a map from post ID to Matterhorn message, then add the new+ -- messages to the global post map. We use the "postsPosts" field for+ -- this because that might contain more messages than the "postsOrder"+ -- list, since the former can contain other messages in threads that+ -- the server sent us, even if those messages are not part of the+ -- ordered post listing of "postsOrder."+ baseUrl <- getServerBaseUrl+ let postMap = HM.fromList+ [ ( pId+ , fst $ clientPostToMessage (toClientPost baseUrl x Nothing)+ )+ | (pId, x) <- HM.toList (postCollection^.postsPostsL)+ ]+ csPostMap %= HM.union postMap++-- | Add a 'ClientMessage' to the current channel's message list+addClientMessage :: ClientMessage -> MH ()+addClientMessage msg = do+ cid <- use csCurrentChannelId+ uuid <- generateUUID+ let addCMsg = ccContents.cdMessages %~+ (addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))+ csChannels %= modifyChannelById cid addCMsg+ mh $ invalidateCacheEntry $ ChannelMessages cid+ mh $ invalidateCacheEntry ChannelSidebar++ let msgTy = case msg^.cmType of+ Error -> LogError+ _ -> LogGeneral++ mhLog msgTy $ T.pack $ show msg++-- | Add a new 'ClientMessage' representing an error message to+-- the current channel's message list+postInfoMessage :: Text -> MH ()+postInfoMessage info =+ addClientMessage =<< newClientMessage Informative (sanitizeUserText' info)++-- | Add a new 'ClientMessage' representing an error message to+-- the current channel's message list+postErrorMessage' :: Text -> MH ()+postErrorMessage' err =+ addClientMessage =<< newClientMessage Error (sanitizeUserText' err)++postErrorMessageIO :: Text -> ChatState -> IO ChatState+postErrorMessageIO err st = do+ msg <- newClientMessage Error err+ uuid <- generateUUID_IO+ let cId = st ^. csCurrentChannelId+ addEMsg = ccContents.cdMessages %~+ (addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))+ return $ st & csChannels %~ modifyChannelById cId addEMsg++openFilePath :: FilePath -> MH Bool+openFilePath path = openWithOpener (return path)++openWithOpener :: MH String -> MH Bool+openWithOpener getTarget = do+ cfg <- use (csResources.crConfiguration)+ case configURLOpenCommand cfg of+ Nothing ->+ return False+ Just urlOpenCommand -> do+ target <- getTarget++ -- Is the URL-opening command interactive? If so, pause+ -- Matterhorn and run the opener interactively. Otherwise+ -- run the opener asynchronously and continue running+ -- Matterhorn interactively.+ case configURLOpenCommandInteractive cfg of+ False -> do+ outputChan <- use (csResources.crSubprocessLog)+ doAsyncWith Preempt $ do+ runLoggedCommand outputChan (T.unpack urlOpenCommand)+ [target] Nothing Nothing+ return Nothing+ True -> do+ -- If there isn't a new message cutoff showing in+ -- the current channel, set one. This way, while the+ -- user is gone using their interactive URL opener,+ -- when they return, any messages that arrive in the+ -- current channel will be displayed as new.+ curChan <- use csCurrentChannel+ let msgs = curChan^.ccContents.cdMessages+ case findLatestUserMessage isEditable msgs of+ Nothing -> return ()+ Just m ->+ case m^.mOriginalPost of+ Nothing -> return ()+ Just p ->+ case curChan^.ccInfo.cdNewMessageIndicator of+ Hide ->+ csCurrentChannel.ccInfo.cdNewMessageIndicator .= (NewPostsAfterServerTime (p^.postCreateAtL))+ _ -> return ()+ -- No need to add a gap here: the websocket+ -- disconnect/reconnect events will automatically+ -- handle management of messages delivered while+ -- suspended.++ mhSuspendAndResume $ \st -> do+ result <- runInteractiveCommand (T.unpack urlOpenCommand) [target]++ let waitForKeypress = do+ putStrLn "Press any key to return to Matterhorn."+ void getChar++ case result of+ Right ExitSuccess -> return ()+ Left err -> do+ putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>+ " could not be run: " <> err+ waitForKeypress+ Right (ExitFailure code) -> do+ putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>+ " exited with non-zero status " <> show code+ waitForKeypress++ return $ setMode' Main st++ return True++runInteractiveCommand :: String+ -> [String]+ -> IO (Either String ExitCode)+runInteractiveCommand cmd args = do+ let opener = (proc cmd args) { std_in = Inherit+ , std_out = Inherit+ , std_err = Inherit+ }+ result <- try $ createProcess opener+ case result of+ Left (e::SomeException) -> return $ Left $ show e+ Right (_, _, _, ph) -> do+ ec <- waitForProcess ph+ return $ Right ec++runLoggedCommand :: STM.TChan ProgramOutput+ -- ^ The output channel to send the output to+ -> String+ -- ^ The program name+ -> [String]+ -- ^ Arguments+ -> Maybe String+ -- ^ The stdin to send, if any+ -> Maybe (MVar ProgramOutput)+ -- ^ Where to put the program output when it is ready+ -> IO ()+runLoggedCommand outputChan cmd args mInput mOutputVar = void $ forkIO $ do+ let stdIn = maybe NoStream (const CreatePipe) mInput+ opener = (proc cmd args) { std_in = stdIn+ , std_out = CreatePipe+ , std_err = CreatePipe+ }+ result <- try $ createProcess opener+ case result of+ Left (e::SomeException) -> do+ let po = ProgramOutput cmd args "" (show e) (ExitFailure 1)+ STM.atomically $ STM.writeTChan outputChan po+ maybe (return ()) (flip putMVar po) mOutputVar+ Right (stdinResult, Just outh, Just errh, ph) -> do+ case stdinResult of+ Just inh -> do+ let Just input = mInput+ hPutStrLn inh input+ hFlush inh+ Nothing -> return ()++ ec <- waitForProcess ph+ outResult <- hGetContents outh+ errResult <- hGetContents errh+ let po = ProgramOutput cmd args outResult errResult ec+ STM.atomically $ STM.writeTChan outputChan po+ maybe (return ()) (flip putMVar po) mOutputVar+ Right _ ->+ error $ "BUG: createProcess returned unexpected result, report this at " <>+ "https://github.com/matterhorn-chat/matterhorn"++prepareAttachment :: FileId -> Session -> IO String+prepareAttachment fId sess = do+ -- The link is for an attachment, so fetch it and then+ -- open the local copy.++ (info, contents) <- concurrently (mmGetMetadataForFile fId sess) (mmGetFile fId sess)+ cacheDir <- getUserCacheDir xdgName++ let dir = cacheDir </> "files" </> T.unpack (idString fId)+ fname = dir </> T.unpack (fileInfoName info)++ createDirectoryIfMissing True dir+ BS.writeFile fname contents+ return fname++removeEmoteFormatting :: T.Text -> T.Text+removeEmoteFormatting t+ | "*" `T.isPrefixOf` t &&+ "*" `T.isSuffixOf` t = T.init $ T.drop 1 t+ | otherwise = t++addEmoteFormatting :: T.Text -> T.Text+addEmoteFormatting t = "*" <> t <> "*"++fetchMentionedUsers :: Set.Set MentionedUser -> MH ()+fetchMentionedUsers ms+ | Set.null ms = return ()+ | otherwise = do+ let convertMention (UsernameMention u) = UserFetchByUsername u+ convertMention (UserIdMention i) = UserFetchById i+ scheduleUserFetches $ convertMention <$> Set.toList ms++doPendingUserStatusFetches :: MH ()+doPendingUserStatusFetches = do+ mz <- getScheduledUserStatusFetches+ case mz of+ Nothing -> return ()+ Just z -> do+ statusChan <- use (csResources.crStatusUpdateChan)+ liftIO $ STM.atomically $ STM.writeTChan statusChan z++doPendingUserFetches :: MH ()+doPendingUserFetches = do+ fs <- getScheduledUserFetches++ let getUsername (UserFetchByUsername u) = Just u+ getUsername _ = Nothing++ getUserId (UserFetchById i) = Just i+ getUserId _ = Nothing++ fetchUsers (catMaybes $ getUsername <$> fs) (catMaybes $ getUserId <$> fs)++-- | Given a list of usernames, ensure that we have a user record for+-- each one in the state, either by confirming that a local record+-- exists or by issuing a request for user records.+fetchUsers :: [Text] -> [UserId] -> MH ()+fetchUsers rawUsernames uids = do+ st <- use id+ session <- getSession+ let usernames = trimUserSigil <$> rawUsernames+ missingUsernames = filter isMissing usernames+ isMissing n = and [ not $ T.null n+ , not $ isSpecialMention n+ , isNothing $ userByUsername n st+ ]+ missingIds = filter (\i -> isNothing $ userById i st) uids++ when (not $ null missingUsernames) $ do+ mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingUsernames++ when (not $ null missingIds) $ do+ mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingIds++ when ((not $ null missingUsernames) || (not $ null missingIds)) $ do+ doAsyncWith Normal $ do+ act1 <- case null missingUsernames of+ True -> return $ return ()+ False -> do+ results <- mmGetUsersByUsernames (Seq.fromList missingUsernames) session+ return $ do+ forM_ results (\u -> addNewUser $ userInfoFromUser u True)++ act2 <- case null missingIds of+ True -> return $ return ()+ False -> do+ results <- mmGetUsersByIds (Seq.fromList missingIds) session+ return $ do+ forM_ results (\u -> addNewUser $ userInfoFromUser u True)++ return $ Just $ act1 >> act2
+ src/Matterhorn/State/Editing.hs view
@@ -0,0 +1,525 @@+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE RankNTypes #-}+module Matterhorn.State.Editing+ ( requestSpellCheck+ , editingKeybindings+ , editingKeyHandlers+ , messageEditingKeybindings+ , toggleMultilineEditing+ , invokeExternalEditor+ , handlePaste+ , handleInputSubmission+ , getEditorContent+ , handleEditingInput+ , cancelAutocompleteOrReplyOrEdit+ , replyToLatestMessage+ , Direction(..)+ , tabComplete+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Main ( invalidateCache, invalidateCacheEntry )+import Brick.Widgets.Edit ( Editor, applyEdit , handleEditorEvent+ , getEditContents, editContentsL )+import qualified Brick.Widgets.List as L+import qualified Codec.Binary.UTF8.Generic as UTF8+import Control.Arrow+import qualified Control.Concurrent.STM as STM+import qualified Data.ByteString as BS+import Data.Char ( isSpace )+import qualified Data.Foldable as F+import qualified Data.Map as M+import Data.Maybe ( fromJust )+import qualified Data.Set as S+import qualified Data.Text as T+import qualified Data.Text.Encoding as T+import qualified Data.Text.Zipper as Z+import qualified Data.Text.Zipper.Generic.Words as Z+import Data.Time ( getCurrentTime )+import Graphics.Vty ( Event(..), Key(..) )+import Lens.Micro.Platform ( Lens', (%=), (.=), (.~), to, _Just )+import qualified System.Environment as Sys+import qualified System.Exit as Sys+import qualified System.IO as Sys+import qualified System.IO.Temp as Sys+import qualified System.Process as Sys+import Text.Aspell ( AspellResponse(..), mistakeWord, askAspell )++import Network.Mattermost.Types ( Post(..), ChannelId )++import Matterhorn.Config+import {-# SOURCE #-} Matterhorn.Command ( dispatchCommand )+import Matterhorn.InputHistory+import Matterhorn.Events.Keybindings+import Matterhorn.State.Common+import Matterhorn.State.Autocomplete+import Matterhorn.State.Attachments+import Matterhorn.State.Messages+import Matterhorn.Types hiding ( newState )+import Matterhorn.Types.Common ( sanitizeUserText' )+++startMultilineEditing :: MH ()+startMultilineEditing = do+ mh invalidateCache+ csEditState.cedEphemeral.eesMultiline .= True++toggleMultilineEditing :: MH ()+toggleMultilineEditing = do+ mh invalidateCache+ csEditState.cedEphemeral.eesMultiline %= not++ -- If multiline is now disabled and there is more than one line in+ -- the editor, that means we're showing the multiline message status+ -- (see Draw.Main.renderUserCommandBox.commandBox) so we want to be+ -- sure no autocomplete UI is present in case the cursor was left on+ -- a word that would otherwise show completion alternatives.+ multiline <- use (csEditState.cedEphemeral.eesMultiline)+ numLines <- use (csEditState.cedEditor.to getEditContents.to length)+ when (not multiline && numLines > 1) resetAutocomplete++invokeExternalEditor :: MH ()+invokeExternalEditor = do+ -- If EDITOR is in the environment, write the current message to a+ -- temp file, invoke EDITOR on it, read the result, remove the temp+ -- file, and update the program state.+ --+ -- If EDITOR is not present, fall back to 'vi'.+ mEnv <- liftIO $ Sys.lookupEnv "EDITOR"+ let editorProgram = maybe "vi" id mEnv++ mhSuspendAndResume $ \ st -> do+ Sys.withSystemTempFile "matterhorn_editor.md" $ \tmpFileName tmpFileHandle -> do+ -- Write the current message to the temp file+ Sys.hPutStr tmpFileHandle $ T.unpack $ T.intercalate "\n" $+ getEditContents $ st^.csEditState.cedEditor+ Sys.hClose tmpFileHandle++ -- Run the editor+ status <- Sys.system (editorProgram <> " " <> tmpFileName)++ -- On editor exit, if exited with zero status, read temp file.+ -- If non-zero status, skip temp file read.+ case status of+ Sys.ExitSuccess -> do+ tmpBytes <- BS.readFile tmpFileName+ case T.decodeUtf8' tmpBytes of+ Left _ -> do+ postErrorMessageIO "Failed to decode file contents as UTF-8" st+ Right t -> do+ let tmpLines = T.lines $ sanitizeUserText' t+ return $ st & csEditState.cedEditor.editContentsL .~ (Z.textZipper tmpLines Nothing)+ Sys.ExitFailure _ -> return st++handlePaste :: BS.ByteString -> MH ()+handlePaste bytes = do+ let pasteStr = T.pack (UTF8.toString bytes)+ csEditState.cedEditor %= applyEdit (Z.insertMany (sanitizeUserText' pasteStr))+ contents <- use (csEditState.cedEditor.to getEditContents)+ case length contents > 1 of+ True -> startMultilineEditing+ False -> return ()++editingPermitted :: ChatState -> Bool+editingPermitted st =+ (length (getEditContents $ st^.csEditState.cedEditor) == 1) ||+ st^.csEditState.cedEphemeral.eesMultiline++messageEditingKeybindings :: KeyConfig -> KeyHandlerMap+messageEditingKeybindings kc =+ let KeyHandlerMap m = editingKeybindings (csEditState.cedEditor) kc+ in KeyHandlerMap $ M.map withUserTypingAction m++withUserTypingAction :: KeyHandler -> KeyHandler+withUserTypingAction kh =+ kh { khHandler = newH }+ where+ oldH = khHandler kh+ newH = oldH { kehHandler = newKEH }+ oldKEH = kehHandler oldH+ newKEH = oldKEH { ehAction = ehAction oldKEH >> sendUserTypingAction }++editingKeybindings :: Lens' ChatState (Editor T.Text Name) -> KeyConfig -> KeyHandlerMap+editingKeybindings editor = mkKeybindings $ editingKeyHandlers editor++editingKeyHandlers :: Lens' ChatState (Editor T.Text Name) -> [KeyEventHandler]+editingKeyHandlers editor =+ [ mkKb EditorTransposeCharsEvent+ "Transpose the final two characters"+ (editor %= applyEdit Z.transposeChars)+ , mkKb EditorBolEvent+ "Go to the start of the current line"+ (editor %= applyEdit Z.gotoBOL)+ , mkKb EditorEolEvent+ "Go to the end of the current line"+ (editor %= applyEdit Z.gotoEOL)+ , mkKb EditorDeleteCharacter+ "Delete the character at the cursor"+ (editor %= applyEdit Z.deleteChar)+ , mkKb EditorKillToBolEvent+ "Delete from the cursor to the start of the current line"+ (editor %= applyEdit Z.killToBOL)+ , mkKb EditorKillToEolEvent+ "Kill the line to the right of the current position and copy it" $ do+ z <- use (editor.editContentsL)+ let restOfLine = Z.currentLine (Z.killToBOL z)+ csEditState.cedYankBuffer .= restOfLine+ editor %= applyEdit Z.killToEOL+ , mkKb EditorNextCharEvent+ "Move one character to the right"+ (editor %= applyEdit Z.moveRight)+ , mkKb EditorPrevCharEvent+ "Move one character to the left"+ (editor %= applyEdit Z.moveLeft)+ , mkKb EditorNextWordEvent+ "Move one word to the right"+ (editor %= applyEdit Z.moveWordRight)+ , mkKb EditorPrevWordEvent+ "Move one word to the left"+ (editor %= applyEdit Z.moveWordLeft)+ , mkKb EditorDeletePrevWordEvent+ "Delete the word to the left of the cursor" $ do+ editor %= applyEdit Z.deletePrevWord+ , mkKb EditorDeleteNextWordEvent+ "Delete the word to the right of the cursor" $ do+ editor %= applyEdit Z.deleteWord+ , mkKb EditorHomeEvent+ "Move the cursor to the beginning of the input" $ do+ editor %= applyEdit gotoHome+ , mkKb EditorEndEvent+ "Move the cursor to the end of the input" $ do+ editor %= applyEdit gotoEnd+ , mkKb EditorYankEvent+ "Paste the current buffer contents at the cursor" $ do+ buf <- use (csEditState.cedYankBuffer)+ editor %= applyEdit (Z.insertMany buf)+ ]++getEditorContent :: MH Text+getEditorContent = do+ cmdLine <- use (csEditState.cedEditor)+ let (line:rest) = getEditContents cmdLine+ return $ T.intercalate "\n" $ line : rest++-- | Handle an input submission in the message editor.+--+-- This handles the specified input text as if it were user input for+-- the specified channel. This means that if the specified input text+-- contains a command ("/...") then it is executed as normal. Otherwise+-- the text is sent as a message to the specified channel.+--+-- However, this function assumes that the message editor is the+-- *source* of the text, so it also takes care of clearing the editor,+-- resetting the edit mode, updating the input history for the specified+-- channel, etc.+handleInputSubmission :: ChannelId -> Text -> MH ()+handleInputSubmission cId content = do+ -- We clean up before dispatching the command or sending the message+ -- since otherwise the command could change the state and then doing+ -- cleanup afterwards could clean up the wrong things.+ csEditState.cedEditor %= applyEdit Z.clearZipper+ csEditState.cedInputHistory %= addHistoryEntry content cId+ csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing++ case T.uncons content of+ Just ('/', cmd) ->+ dispatchCommand cmd+ _ -> do+ attachments <- use (csEditState.cedAttachmentList.L.listElementsL)+ mode <- use (csEditState.cedEditMode)+ sendMessage cId mode content $ F.toList attachments++ -- Reset the autocomplete UI+ resetAutocomplete++ -- Empty the attachment list+ resetAttachmentList++ -- Reset the edit mode *after* handling the input so that the input+ -- handler can tell whether we're editing, replying, etc.+ csEditState.cedEditMode .= NewPost++closingPunctuationMarks :: String+closingPunctuationMarks = ".,'\";:)]!?"++isSmartClosingPunctuation :: Event -> Bool+isSmartClosingPunctuation (EvKey (KChar c) []) = c `elem` closingPunctuationMarks+isSmartClosingPunctuation _ = False++handleEditingInput :: Event -> MH ()+handleEditingInput e = do+ -- Only handle input events to the editor if we permit editing:+ -- if multiline mode is off, or if there is only one line of text+ -- in the editor. This means we skip input this catch-all handler+ -- if we're *not* in multiline mode *and* there are multiple lines,+ -- i.e., we are showing the user the status message about the+ -- current editor state and editing is not permitted.++ -- Record the input line count before handling the editing event+ -- so we can tell whether the editing event changes the line count+ -- later.+ beforeLineCount <- use (csEditState.cedEditor.to getEditContents.to length)++ smartBacktick <- use (csResources.crConfiguration.to configSmartBacktick)+ let smartChars = "*`_"+ st <- use id+ csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing++ smartEditing <- use (csResources.crConfiguration.to configSmartEditing)+ justCompleted <- use (csEditState.cedJustCompleted)++ conf <- use (csResources.crConfiguration)+ let keyMap = editingKeybindings (csEditState.cedEditor) (configUserKeys conf)+ case lookupKeybinding e keyMap of+ Just kb | editingPermitted st -> (ehAction $ kehHandler $ khHandler kb)+ _ -> do+ case e of+ -- Not editing; backspace here means cancel multi-line message+ -- composition+ EvKey KBS [] | (not $ editingPermitted st) ->+ csEditState.cedEditor %= applyEdit Z.clearZipper++ -- Backspace in editing mode with smart pair insertion means+ -- smart pair removal when possible+ EvKey KBS [] | editingPermitted st && smartBacktick ->+ let backspace = csEditState.cedEditor %= applyEdit Z.deletePrevChar+ in case cursorAtOneOf smartChars (st^.csEditState.cedEditor) of+ Nothing -> backspace+ Just ch ->+ -- Smart char removal:+ if | (cursorAtChar ch $ applyEdit Z.moveLeft $ st^.csEditState.cedEditor) &&+ (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.csEditState.cedEditor) ->+ csEditState.cedEditor %= applyEdit (Z.deleteChar >>> Z.deletePrevChar)+ | otherwise -> backspace++ EvKey (KChar ch) []+ | editingPermitted st && smartBacktick && ch `elem` smartChars ->+ -- Smart char insertion:+ let doInsertChar = do+ csEditState.cedEditor %= applyEdit (Z.insertChar ch)+ sendUserTypingAction+ curLine = Z.currentLine $ st^.csEditState.cedEditor.editContentsL+ -- First case: if the cursor is at the end of the current+ -- line and it contains "``" and the user entered a third+ -- "`", enable multi-line mode since they're likely typing+ -- a code block.+ in if | (cursorIsAtEnd $ st^.csEditState.cedEditor) &&+ curLine == "``" &&+ ch == '`' -> do+ csEditState.cedEditor %= applyEdit (Z.insertMany (T.singleton ch))+ csEditState.cedEphemeral.eesMultiline .= True+ -- Second case: user entered some smart character+ -- (don't care which) on an empty line or at the end+ -- of the line after whitespace, so enter a pair of+ -- the smart chars and put the cursor between them.+ | (editorEmpty $ st^.csEditState.cedEditor) ||+ ((cursorAtChar ' ' (applyEdit Z.moveLeft $ st^.csEditState.cedEditor)) &&+ (cursorIsAtEnd $ st^.csEditState.cedEditor)) ->+ csEditState.cedEditor %= applyEdit (Z.insertMany (T.pack $ ch:ch:[]) >>> Z.moveLeft)+ -- Third case: the cursor is already on a smart+ -- character and that character is the last one+ -- on the line, so instead of inserting a new+ -- character, just move past it.+ | (cursorAtChar ch $ st^.csEditState.cedEditor) &&+ (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.csEditState.cedEditor) ->+ csEditState.cedEditor %= applyEdit Z.moveRight+ -- Fall-through case: just insert one of the chars+ -- without doing anything smart.+ | otherwise -> doInsertChar+ | editingPermitted st -> do++ -- If the most recent editing event was a tab completion,+ -- there is a trailing space that we want to remove if the+ -- next input character is punctuation.+ when (smartEditing && justCompleted && isSmartClosingPunctuation e) $+ csEditState.cedEditor %= applyEdit Z.deletePrevChar++ csEditState.cedEditor %= applyEdit (Z.insertMany (sanitizeUserText' $ T.singleton ch))+ sendUserTypingAction+ _ | editingPermitted st -> do+ mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent e+ sendUserTypingAction+ | otherwise -> return ()++ let ctx = AutocompleteContext { autocompleteManual = False+ , autocompleteFirstMatch = False+ }+ checkForAutocompletion ctx+ liftIO $ resetSpellCheckTimer $ st^.csEditState++ -- If the preview is enabled and multi-line editing is enabled and+ -- the line count changed, we need to invalidate the rendering cache+ -- entry for the channel messages because we want to redraw them to+ -- fit in the space just changed by the size of the preview area.+ afterLineCount <- use (csEditState.cedEditor.to getEditContents.to length)+ isMultiline <- use (csEditState.cedEphemeral.eesMultiline)+ isPreviewing <- use csShowMessagePreview+ when (beforeLineCount /= afterLineCount && isMultiline && isPreviewing) $ do+ cId <- use csCurrentChannelId+ mh $ invalidateCacheEntry $ ChannelMessages cId++ -- Reset the recent autocompletion flag to stop smart punctuation+ -- handling.+ when justCompleted $+ csEditState.cedJustCompleted .= False++-- | Send the user_typing action to the server asynchronously, over the+-- connected websocket. If the websocket is not connected, drop the+-- action silently.+sendUserTypingAction :: MH ()+sendUserTypingAction = do+ st <- use id+ when (configShowTypingIndicator (st^.csResources.crConfiguration)) $+ case st^.csConnectionStatus of+ Connected -> do+ let pId = case st^.csEditState.cedEditMode of+ Replying _ post -> Just $ postId post+ _ -> Nothing+ liftIO $ do+ now <- getCurrentTime+ let action = UserTyping now (st^.csCurrentChannelId) pId+ STM.atomically $ STM.writeTChan (st^.csResources.crWebsocketActionChan) action+ Disconnected -> return ()++-- Kick off an async request to the spell checker for the current editor+-- contents.+requestSpellCheck :: MH ()+requestSpellCheck = do+ st <- use id+ case st^.csEditState.cedSpellChecker of+ Nothing -> return ()+ Just (checker, _) -> do+ -- Get the editor contents.+ contents <- getEditContents <$> use (csEditState.cedEditor)+ doAsyncWith Preempt $ do+ -- For each line in the editor, submit an aspell request.+ let query = concat <$> mapM (askAspell checker) contents+ postMistakes :: [AspellResponse] -> MH ()+ postMistakes responses = do+ let getMistakes AllCorrect = []+ getMistakes (Mistakes ms) = mistakeWord <$> ms+ allMistakes = S.fromList $ concat $ getMistakes <$> responses+ csEditState.cedMisspellings .= allMistakes++ tryMM query (return . Just . postMistakes)++editorEmpty :: Editor Text a -> Bool+editorEmpty e = cursorIsAtEnd e &&+ cursorIsAtBeginning e++cursorIsAtEnd :: Editor Text a -> Bool+cursorIsAtEnd e =+ let col = snd $ Z.cursorPosition z+ curLine = Z.currentLine z+ z = e^.editContentsL+ in col == T.length curLine++cursorIsAtBeginning :: Editor Text a -> Bool+cursorIsAtBeginning e =+ let col = snd $ Z.cursorPosition z+ z = e^.editContentsL+ in col == 0++cursorAtOneOf :: [Char] -> Editor Text a -> Maybe Char+cursorAtOneOf [] _ = Nothing+cursorAtOneOf (c:cs) e =+ if cursorAtChar c e+ then Just c+ else cursorAtOneOf cs e++cursorAtChar :: Char -> Editor Text a -> Bool+cursorAtChar ch e =+ let col = snd $ Z.cursorPosition z+ curLine = Z.currentLine z+ z = e^.editContentsL+ in (T.singleton ch) `T.isPrefixOf` T.drop col curLine++gotoHome :: Z.TextZipper Text -> Z.TextZipper Text+gotoHome = Z.moveCursor (0, 0)++gotoEnd :: Z.TextZipper Text -> Z.TextZipper Text+gotoEnd z =+ let zLines = Z.getText z+ numLines = length zLines+ lastLineLength = T.length $ last zLines+ in if numLines > 0+ then Z.moveCursor (numLines - 1, lastLineLength) z+ else z++cancelAutocompleteOrReplyOrEdit :: MH ()+cancelAutocompleteOrReplyOrEdit = do+ cId <- use csCurrentChannelId+ mh $ invalidateCacheEntry $ ChannelMessages cId+ ac <- use (csEditState.cedAutocomplete)+ case ac of+ Just _ -> do+ resetAutocomplete+ Nothing -> do+ mode <- use (csEditState.cedEditMode)+ case mode of+ NewPost -> return ()+ _ -> do+ csEditState.cedEditMode .= NewPost+ csEditState.cedEditor %= applyEdit Z.clearZipper+ resetAttachmentList++replyToLatestMessage :: MH ()+replyToLatestMessage = do+ msgs <- use (csCurrentChannel . ccContents . cdMessages)+ case findLatestUserMessage isReplyable msgs of+ Just msg | isReplyable msg ->+ do rootMsg <- getReplyRootMessage msg+ setMode Main+ cId <- use csCurrentChannelId+ mh $ invalidateCacheEntry $ ChannelMessages cId+ csEditState.cedEditMode .= Replying rootMsg (fromJust $ rootMsg^.mOriginalPost)+ _ -> return ()++data Direction = Forwards | Backwards++tabComplete :: Direction -> MH ()+tabComplete dir = do+ let transform list =+ let len = list^.L.listElementsL.to length+ in case dir of+ Forwards ->+ if (L.listSelected list == Just (len - 1)) ||+ (L.listSelected list == Nothing && len > 0)+ then L.listMoveTo 0 list+ else L.listMoveBy 1 list+ Backwards ->+ if (L.listSelected list == Just 0) ||+ (L.listSelected list == Nothing && len > 0)+ then L.listMoveTo (len - 1) list+ else L.listMoveBy (-1) list+ csEditState.cedAutocomplete._Just.acCompletionList %= transform++ mac <- use (csEditState.cedAutocomplete)+ case mac of+ Nothing -> do+ let ctx = AutocompleteContext { autocompleteManual = True+ , autocompleteFirstMatch = True+ }+ checkForAutocompletion ctx+ Just ac -> do+ case ac^.acCompletionList.to L.listSelectedElement of+ Nothing -> return ()+ Just (_, alternative) -> do+ let replacement = autocompleteAlternativeReplacement alternative+ maybeEndOfWord z =+ if maybe True isSpace (Z.currentChar z)+ then z+ else Z.moveWordRight z+ csEditState.cedEditor %=+ applyEdit (Z.insertChar ' ' . Z.insertMany replacement . Z.deletePrevWord .+ maybeEndOfWord)+ csEditState.cedJustCompleted .= True++ -- If there was only one completion alternative,+ -- hide the autocomplete listing now that we've+ -- completed the only completion.+ when (ac^.acCompletionList.to L.listElements.to length == 1) resetAutocomplete
+ src/Matterhorn/State/Editing.hs-boot view
@@ -0,0 +1,10 @@+module Matterhorn.State.Editing+ ( Direction(..)+ , tabComplete+ )+where++import Matterhorn.Types ( MH )++data Direction = Forwards | Backwards+tabComplete :: Direction -> MH ()
+ src/Matterhorn/State/Flagging.hs view
@@ -0,0 +1,66 @@+module Matterhorn.State.Flagging+ ( loadFlaggedMessages+ , updateMessageFlag+ )+where++import Prelude ()+import Matterhorn.Prelude++import Data.Function ( on )+import qualified Data.Set as Set+import Lens.Micro.Platform++import Network.Mattermost.Types++import Matterhorn.State.Common+import Matterhorn.Types+++loadFlaggedMessages :: Seq FlaggedPost -> ChatState -> IO ()+loadFlaggedMessages prefs st = doAsyncWithIO Normal st $ do+ return $ Just $ do+ sequence_ [ updateMessageFlag (flaggedPostId fp) True+ | fp <- toList prefs+ , flaggedPostStatus fp+ ]+++-- | Update the UI to reflect the flagged/unflagged state of a+-- message. This __does not__ talk to the Mattermost server, but+-- rather is the function we call when the Mattermost server notifies+-- us of flagged or unflagged messages.+updateMessageFlag :: PostId -> Bool -> MH ()+updateMessageFlag pId f = do+ if f+ then csResources.crFlaggedPosts %= Set.insert pId+ else csResources.crFlaggedPosts %= Set.delete pId+ msgMb <- use (csPostMap.at(pId))+ case msgMb of+ Just msg+ | Just cId <- msg^.mChannelId -> do+ let isTargetMessage m = m^.mMessageId == Just (MessagePostId pId)+ csChannel(cId).ccContents.cdMessages.traversed.filtered isTargetMessage.mFlagged .= f+ csPostMap.ix(pId).mFlagged .= f+ -- We also want to update the post overlay if this happens while+ -- we're we're observing it+ mode <- gets appMode+ case mode of+ PostListOverlay PostListFlagged+ | f ->+ csPostListOverlay.postListPosts %=+ addMessage (msg & mFlagged .~ True)+ -- deleting here is tricky, because it means that we need to+ -- move the focus somewhere: we'll try moving it _up_ unless+ -- we can't, in which case we'll try moving it down.+ | otherwise -> do+ selId <- use (csPostListOverlay.postListSelected)+ posts <- use (csPostListOverlay.postListPosts)+ let nextId = case getNextPostId selId posts of+ Nothing -> getPrevPostId selId posts+ Just x -> Just x+ csPostListOverlay.postListSelected .= nextId+ csPostListOverlay.postListPosts %=+ filterMessages (((/=) `on` _mMessageId) msg)+ _ -> return ()+ _ -> return ()
+ src/Matterhorn/State/Help.hs view
@@ -0,0 +1,21 @@+module Matterhorn.State.Help+ ( showHelpScreen+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Main ( viewportScroll, vScrollToBeginning )++import Matterhorn.Types+++showHelpScreen :: HelpTopic -> MH ()+showHelpScreen topic = do+ curMode <- gets appMode+ case curMode of+ ShowHelp {} -> return ()+ _ -> do+ mh $ vScrollToBeginning (viewportScroll HelpViewport)+ setMode $ ShowHelp topic curMode
+ src/Matterhorn/State/Links.hs view
@@ -0,0 +1,23 @@+module Matterhorn.State.Links+ ( openLinkTarget+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Text as T++import Matterhorn.State.Common+import Matterhorn.State.Messages ( jumpToPost )+import Matterhorn.Types+import Matterhorn.Types.RichText ( unURL )+++openLinkTarget :: LinkTarget -> MH Bool+openLinkTarget target = do+ session <- getSession+ case target of+ LinkURL url -> openWithOpener (return $ T.unpack $ unURL url)+ LinkFileId fId -> openWithOpener (liftIO $ prepareAttachment fId session)+ LinkPermalink _ pId -> jumpToPost pId >> return True
+ src/Matterhorn/State/ListOverlay.hs view
@@ -0,0 +1,145 @@+{-# LANGUAGE RankNTypes #-}+module Matterhorn.State.ListOverlay+ ( listOverlayActivateCurrent+ , listOverlaySearchString+ , listOverlayMove+ , exitListOverlay+ , enterListOverlayMode+ , resetListOverlaySearch+ , onEventListOverlay+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Brick.Widgets.List as L+import qualified Brick.Widgets.Edit as E+import qualified Data.Text.Zipper as Z+import qualified Data.Vector as Vec+import Lens.Micro.Platform ( Lens', (%=), (.=) )+import Network.Mattermost.Types ( Session )+import qualified Graphics.Vty as Vty++import Matterhorn.Types+import Matterhorn.State.Common+import Matterhorn.State.Editing ( editingKeybindings )+import Matterhorn.Events.Keybindings ( KeyConfig, KeyHandlerMap, handleKeyboardEvent )+++-- | Activate the specified list overlay's selected item by invoking the+-- overlay's configured enter keypress handler function.+listOverlayActivateCurrent :: Lens' ChatState (ListOverlayState a b) -> MH ()+listOverlayActivateCurrent which = do+ mItem <- L.listSelectedElement <$> use (which.listOverlaySearchResults)+ case mItem of+ Nothing -> return ()+ Just (_, val) -> do+ handler <- use (which.listOverlayEnterHandler)+ activated <- handler val+ if activated+ then setMode Main+ else return ()++-- | Get the current search string for the specified overlay.+listOverlaySearchString :: Lens' ChatState (ListOverlayState a b) -> MH Text+listOverlaySearchString which =+ (head . E.getEditContents) <$> use (which.listOverlaySearchInput)++-- | Move the list cursor in the specified overlay.+listOverlayMove :: Lens' ChatState (ListOverlayState a b)+ -- ^ Which overlay+ -> (L.List Name a -> L.List Name a)+ -- ^ How to transform the list in the overlay+ -> MH ()+listOverlayMove which how = which.listOverlaySearchResults %= how++-- | Clear the state of the specified list overlay and return to the+-- Main mode.+exitListOverlay :: Lens' ChatState (ListOverlayState a b)+ -- ^ Which overlay to reset+ -> MH ()+exitListOverlay which = do+ st <- use which+ newList <- use (which.listOverlayNewList)+ which.listOverlaySearchResults .= newList mempty+ which.listOverlayEnterHandler .= (const $ return False)+ setMode (st^.listOverlayReturnMode)++-- | Initialize a list overlay with the specified arguments and switch+-- to the specified mode.+enterListOverlayMode :: (Lens' ChatState (ListOverlayState a b))+ -- ^ Which overlay to initialize+ -> Mode+ -- ^ The mode to change to+ -> b+ -- ^ The overlay's initial search scope+ -> (a -> MH Bool)+ -- ^ The overlay's enter keypress handler+ -> (b -> Session -> Text -> IO (Vec.Vector a))+ -- ^ The overlay's results fetcher function+ -> MH ()+enterListOverlayMode which mode scope enterHandler fetcher = do+ which.listOverlaySearchScope .= scope+ which.listOverlaySearchInput.E.editContentsL %= Z.clearZipper+ which.listOverlayEnterHandler .= enterHandler+ which.listOverlayFetchResults .= fetcher+ which.listOverlaySearching .= False+ newList <- use (which.listOverlayNewList)+ which.listOverlaySearchResults .= newList mempty+ setMode mode+ resetListOverlaySearch which++-- | Reset the overlay's search by initiating a new search request for+-- the string that is currently in the overlay's editor. This does+-- nothing if a search for this overlay is already in progress.+resetListOverlaySearch :: Lens' ChatState (ListOverlayState a b) -> MH ()+resetListOverlaySearch which = do+ searchPending <- use (which.listOverlaySearching)++ when (not searchPending) $ do+ searchString <- listOverlaySearchString which+ which.listOverlaySearching .= True+ newList <- use (which.listOverlayNewList)+ session <- getSession+ scope <- use (which.listOverlaySearchScope)+ fetcher <- use (which.listOverlayFetchResults)+ doAsyncWith Preempt $ do+ results <- fetcher scope session searchString+ return $ Just $ do+ which.listOverlaySearchResults .= newList results+ which.listOverlaySearching .= False++ -- Now that the results are available, check to see if the+ -- search string changed since this request was submitted.+ -- If so, issue another search.+ afterSearchString <- listOverlaySearchString which+ when (searchString /= afterSearchString) $ resetListOverlaySearch which++-- | Generically handle an event for the list overlay state targeted+-- by the specified lens. Automatically dispatches new searches in the+-- overlay's editor if the editor contents change.+onEventListOverlay :: Lens' ChatState (ListOverlayState a b)+ -- ^ Which overlay to dispatch to?+ -> (KeyConfig -> KeyHandlerMap)+ -- ^ The keybinding builder+ -> Vty.Event+ -- ^ The event+ -> MH Bool+onEventListOverlay which keybindings =+ handleKeyboardEvent keybindings $ \e -> do+ -- Get the editor content before the event.+ before <- listOverlaySearchString which++ -- First find a matching keybinding in the keybinding list.+ handled <- handleKeyboardEvent (editingKeybindings (which.listOverlaySearchInput)) (const $ return ()) e++ -- If we didn't find a matching binding, just handle the event+ -- as a normal editor input event.+ when (not handled) $+ mhHandleEventLensed (which.listOverlaySearchInput) E.handleEditorEvent e++ -- Get the editor content after the event. If the string changed,+ -- start a new search.+ after <- listOverlaySearchString which+ when (before /= after) $ resetListOverlaySearch which
+ src/Matterhorn/State/Logging.hs view
@@ -0,0 +1,33 @@+module Matterhorn.State.Logging+ ( startLogging+ , stopLogging+ , logSnapshot+ , getLogDestination+ )+where++import Prelude ()+import Matterhorn.Prelude++import Matterhorn.Types+++startLogging :: FilePath -> MH ()+startLogging path = do+ mgr <- use (csResources.crLogManager)+ liftIO $ startLoggingToFile mgr path++stopLogging :: MH ()+stopLogging = do+ mgr <- use (csResources.crLogManager)+ liftIO $ stopLoggingToFile mgr++logSnapshot :: FilePath -> MH ()+logSnapshot path = do+ mgr <- use (csResources.crLogManager)+ liftIO $ requestLogSnapshot mgr path++getLogDestination :: MH ()+getLogDestination = do+ mgr <- use (csResources.crLogManager)+ liftIO $ requestLogDestination mgr
+ src/Matterhorn/State/MessageSelect.hs view
@@ -0,0 +1,294 @@+module Matterhorn.State.MessageSelect+ (+ -- * Message selection mode+ beginMessageSelect+ , flagSelectedMessage+ , pinSelectedMessage+ , viewSelectedMessage+ , fillSelectedGap+ , yankSelectedMessageVerbatim+ , yankSelectedMessage+ , openSelectedMessageURLs+ , beginConfirmDeleteSelectedMessage+ , messageSelectUp+ , messageSelectUpBy+ , messageSelectDown+ , messageSelectDownBy+ , messageSelectFirst+ , messageSelectLast+ , deleteSelectedMessage+ , beginReplyCompose+ , beginEditMessage+ , flagMessage+ , getSelectedMessage+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Widgets.Edit ( applyEdit )+import Data.Text.Zipper ( clearZipper, insertMany )+import Lens.Micro.Platform++import qualified Network.Mattermost.Endpoints as MM+import Network.Mattermost.Types++import Matterhorn.Clipboard ( copyToClipboard )+import Matterhorn.State.Common+import Matterhorn.State.Links+import Matterhorn.State.Messages+import Matterhorn.Types+import Matterhorn.Types.RichText ( findVerbatimChunk )+import Matterhorn.Types.Common+import Matterhorn.Windows.ViewMessage+++-- | In these modes, we allow access to the selected message state.+messageSelectCompatibleModes :: [Mode]+messageSelectCompatibleModes =+ [ MessageSelect+ , MessageSelectDeleteConfirm+ , ReactionEmojiListOverlay+ ]++getSelectedMessage :: ChatState -> Maybe Message+getSelectedMessage st+ | not (appMode st `elem` messageSelectCompatibleModes) = Nothing+ | otherwise = do+ selMsgId <- selectMessageId $ st^.csMessageSelect+ let chanMsgs = st ^. csCurrentChannel . ccContents . cdMessages+ findMessage selMsgId chanMsgs++beginMessageSelect :: MH ()+beginMessageSelect = do+ -- Get the number of messages in the current channel and set the+ -- currently selected message index to be the most recently received+ -- message that corresponds to a Post (i.e. exclude informative+ -- messages).+ --+ -- If we can't find one at all, we ignore the mode switch request+ -- and just return.+ chanMsgs <- use(csCurrentChannel . ccContents . cdMessages)+ let recentMsg = getLatestSelectableMessage chanMsgs++ when (isJust recentMsg) $ do+ setMode MessageSelect+ csMessageSelect .= MessageSelectState (recentMsg >>= _mMessageId)++-- | Tell the server that the message we currently have selected+-- should have its flagged state toggled.+flagSelectedMessage :: MH ()+flagSelectedMessage = do+ selected <- use (to getSelectedMessage)+ case selected of+ Just msg+ | isFlaggable msg, Just pId <- messagePostId msg ->+ flagMessage pId (not (msg^.mFlagged))+ _ -> return ()++-- | Tell the server that the message we currently have selected+-- should have its pinned state toggled.+pinSelectedMessage :: MH ()+pinSelectedMessage = do+ selected <- use (to getSelectedMessage)+ case selected of+ Just msg+ | isPinnable msg, Just pId <- messagePostId msg ->+ pinMessage pId (not (msg^.mPinned))+ _ -> return ()++viewSelectedMessage :: MH ()+viewSelectedMessage = do+ selected <- use (to getSelectedMessage)+ case selected of+ Just msg+ | not (isGap msg) -> viewMessage msg+ _ -> return ()++fillSelectedGap :: MH ()+fillSelectedGap = do+ selected <- use (to getSelectedMessage)+ case selected of+ Just msg+ | isGap msg -> do cId <- use csCurrentChannelId+ asyncFetchMessagesForGap cId msg+ _ -> return ()++viewMessage :: Message -> MH ()+viewMessage m = do+ let w = tabbedWindow VMTabMessage viewMessageWindowTemplate MessageSelect (78, 25)+ csViewedMessage .= Just (m, w)+ runTabShowHandlerFor (twValue w) w+ setMode ViewMessage++yankSelectedMessageVerbatim :: MH ()+yankSelectedMessageVerbatim = do+ selectedMessage <- use (to getSelectedMessage)+ case selectedMessage of+ Nothing -> return ()+ Just m -> do+ setMode Main+ case findVerbatimChunk (m^.mText) of+ Just txt -> copyToClipboard txt+ Nothing -> return ()++yankSelectedMessage :: MH ()+yankSelectedMessage = do+ selectedMessage <- use (to getSelectedMessage)+ case selectedMessage of+ Nothing -> return ()+ Just m -> do+ setMode Main+ copyToClipboard $ m^.mMarkdownSource++openSelectedMessageURLs :: MH ()+openSelectedMessageURLs = whenMode MessageSelect $ do+ mCurMsg <- use (to getSelectedMessage)+ curMsg <- case mCurMsg of+ Nothing -> error "BUG: openSelectedMessageURLs: no selected message available"+ Just m -> return m++ let urls = msgURLs curMsg+ when (not (null urls)) $ do+ openedAll <- and <$> mapM (openLinkTarget . _linkTarget) urls+ case openedAll of+ True -> return ()+ False ->+ mhError $ ConfigOptionMissing "urlOpenCommand"++beginConfirmDeleteSelectedMessage :: MH ()+beginConfirmDeleteSelectedMessage = do+ st <- use id+ selected <- use (to getSelectedMessage)+ case selected of+ Just msg | isDeletable msg && isMine st msg ->+ setMode MessageSelectDeleteConfirm+ _ -> return ()++messageSelectUp :: MH ()+messageSelectUp = do+ mode <- gets appMode+ selected <- use (csMessageSelect.to selectMessageId)+ case selected of+ Just _ | mode == MessageSelect -> do+ chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)+ let nextMsgId = getPrevMessageId selected chanMsgs+ csMessageSelect .= MessageSelectState (nextMsgId <|> selected)+ _ -> return ()++messageSelectDown :: MH ()+messageSelectDown = do+ selected <- use (csMessageSelect.to selectMessageId)+ case selected of+ Just _ -> whenMode MessageSelect $ do+ chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)+ let nextMsgId = getNextMessageId selected chanMsgs+ csMessageSelect .= MessageSelectState (nextMsgId <|> selected)+ _ -> return ()++messageSelectDownBy :: Int -> MH ()+messageSelectDownBy amt+ | amt <= 0 = return ()+ | otherwise =+ messageSelectDown >> messageSelectDownBy (amt - 1)++messageSelectUpBy :: Int -> MH ()+messageSelectUpBy amt+ | amt <= 0 = return ()+ | otherwise =+ messageSelectUp >> messageSelectUpBy (amt - 1)++messageSelectFirst :: MH ()+messageSelectFirst = do+ selected <- use (csMessageSelect.to selectMessageId)+ case selected of+ Just _ -> whenMode MessageSelect $ do+ chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)+ case getEarliestSelectableMessage chanMsgs of+ Just firstMsg ->+ csMessageSelect .= MessageSelectState (firstMsg^.mMessageId <|> selected)+ Nothing -> mhLog LogError "No first message found from current message?!"+ _ -> return ()++messageSelectLast :: MH ()+messageSelectLast = do+ selected <- use (csMessageSelect.to selectMessageId)+ case selected of+ Just _ -> whenMode MessageSelect $ do+ chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)+ case getLatestSelectableMessage chanMsgs of+ Just lastSelMsg ->+ csMessageSelect .= MessageSelectState (lastSelMsg^.mMessageId <|> selected)+ Nothing -> mhLog LogError "No last message found from current message?!"+ _ -> return ()++deleteSelectedMessage :: MH ()+deleteSelectedMessage = do+ selectedMessage <- use (to getSelectedMessage)+ st <- use id+ cId <- use csCurrentChannelId+ case selectedMessage of+ Just msg | isMine st msg && isDeletable msg ->+ case msg^.mOriginalPost of+ Just p ->+ doAsyncChannelMM Preempt cId+ (\s _ -> MM.mmDeletePost (postId p) s)+ (\_ _ -> Just $ do+ csEditState.cedEditMode .= NewPost+ setMode Main)+ Nothing -> return ()+ _ -> return ()++beginReplyCompose :: MH ()+beginReplyCompose = do+ selected <- use (to getSelectedMessage)+ case selected of+ Just msg | isReplyable msg -> do+ rootMsg <- getReplyRootMessage msg+ let Just p = rootMsg^.mOriginalPost+ setMode Main+ csEditState.cedEditMode .= Replying rootMsg p+ _ -> return ()++beginEditMessage :: MH ()+beginEditMessage = do+ selected <- use (to getSelectedMessage)+ st <- use id+ case selected of+ Just msg | isMine st msg && isEditable msg -> do+ let Just p = msg^.mOriginalPost+ setMode Main+ csEditState.cedEditMode .= Editing p (msg^.mType)+ -- If the post that we're editing is an emote, we need+ -- to strip the formatting because that's only there to+ -- indicate that the post is an emote. This is annoying and+ -- can go away one day when there is an actual post type+ -- value of "emote" that we can look at. Note that the+ -- removed formatting needs to be reinstated just prior to+ -- issuing the API call to update the post.+ let sanitized = sanitizeUserText $ postMessage p+ let toEdit = if isEmote msg+ then removeEmoteFormatting sanitized+ else sanitized+ csEditState.cedEditor %= applyEdit (insertMany toEdit . clearZipper)+ _ -> return ()++-- | Tell the server that we have flagged or unflagged a message.+flagMessage :: PostId -> Bool -> MH ()+flagMessage pId f = do+ session <- getSession+ myId <- gets myUserId+ doAsyncWith Normal $ do+ let doFlag = if f then MM.mmFlagPost else MM.mmUnflagPost+ doFlag myId pId session+ return Nothing++-- | Tell the server that we have pinned or unpinned a message.+pinMessage :: PostId -> Bool -> MH ()+pinMessage pId f = do+ session <- getSession+ doAsyncWith Normal $ do+ let doPin = if f then MM.mmPinPostToChannel else MM.mmUnpinPostToChannel+ void $ doPin pId session+ return Nothing
+ src/Matterhorn/State/Messages.hs view
@@ -0,0 +1,929 @@+{-# LANGUAGE MultiWayIf #-}+module Matterhorn.State.Messages+ ( PostToAdd(..)+ , addDisconnectGaps+ , lastMsg+ , sendMessage+ , editMessage+ , deleteMessage+ , addNewPostedMessage+ , addObtainedMessages+ , asyncFetchMoreMessages+ , asyncFetchMessagesForGap+ , asyncFetchMessagesSurrounding+ , fetchVisibleIfNeeded+ , disconnectChannels+ , toggleMessageTimestamps+ , jumpToPost+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Main ( getVtyHandle, invalidateCacheEntry, invalidateCache )+import qualified Brick.Widgets.FileBrowser as FB+import Control.Exception ( SomeException, try )+import qualified Data.Foldable as F+import qualified Data.HashMap.Strict as HM+import qualified Data.Set as Set+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import Graphics.Vty ( outputIface )+import Graphics.Vty.Output.Interface ( ringTerminalBell )+import Lens.Micro.Platform ( Traversal', (.=), (%=), (%~), (.~), (^?)+ , to, at, traversed, filtered, ix, _1 )++import Network.Mattermost+import qualified Network.Mattermost.Endpoints as MM+import Network.Mattermost.Lenses+import Network.Mattermost.Types++import Matterhorn.Constants+import Matterhorn.State.Channels+import Matterhorn.State.Common+import Matterhorn.State.Reactions+import Matterhorn.State.Users+import Matterhorn.TimeUtils+import Matterhorn.Types+import Matterhorn.Types.Common ( sanitizeUserText )+import Matterhorn.Types.DirectionalSeq ( DirectionalSeq, SeqDirection )+++-- ----------------------------------------------------------------------+-- Message gaps+++-- | Called to add an UnknownGap to the end of the Messages collection+-- for all channels when the client has become disconnected from the+-- server. This gaps will later be removed by successful fetching+-- overlaps if the connection is re-established. Note that the+-- disconnect is re-iterated periodically via a re-connect timer+-- attempt, so do not duplicate gaps. Also clear any flags+-- representing a pending exchange with the server (which will now+-- never complete).+addDisconnectGaps :: MH ()+addDisconnectGaps = mapM_ onEach . filteredChannelIds (const True) =<< use csChannels+ where onEach c = do addEndGap c+ clearPendingFlags c+ mh $ invalidateCacheEntry (ChannelMessages c)++-- | Websocket was disconnected, so all channels may now miss some+-- messages+disconnectChannels :: MH ()+disconnectChannels = addDisconnectGaps++toggleMessageTimestamps :: MH ()+toggleMessageTimestamps = do+ mh invalidateCache+ let toggle c = c { configShowMessageTimestamps = not (configShowMessageTimestamps c)+ }+ csResources.crConfiguration %= toggle++clearPendingFlags :: ChannelId -> MH ()+clearPendingFlags c = csChannel(c).ccContents.cdFetchPending .= False++addEndGap :: ChannelId -> MH ()+addEndGap cId = withChannel cId $ \chan ->+ let lastmsg_ = chan^.ccContents.cdMessages.to reverseMessages.to lastMsg+ lastIsGap = maybe False isGap lastmsg_+ gapMsg = newGapMessage timeJustAfterLast+ timeJustAfterLast = maybe t0 (justAfter . _mDate) lastmsg_+ t0 = ServerTime $ originTime -- use any time for a channel with no messages yet+ newGapMessage = newMessageOfType+ (T.pack "Disconnected. Will refresh when connected.")+ (C UnknownGapAfter)+ in unless lastIsGap+ (csChannels %= modifyChannelById cId (ccContents.cdMessages %~ addMessage gapMsg))++lastMsg :: RetrogradeMessages -> Maybe Message+lastMsg = withFirstMessage id++-- | Send a message and attachments to the specified channel.+sendMessage :: ChannelId -> EditMode -> Text -> [AttachmentData] -> MH ()+sendMessage chanId mode msg attachments =+ when (not $ shouldSkipMessage msg) $ do+ status <- use csConnectionStatus+ case status of+ Disconnected -> do+ let m = T.concat [ "Cannot send messages while disconnected. Enable logging to "+ , "get disconnection information. If Matterhorn's reconnection "+ , "attempts are failing, use `/reconnect` to attempt to "+ , "reconnect manually."+ ]+ mhError $ GenericError m+ Connected -> do+ session <- getSession+ doAsync Preempt $ do+ -- Upload attachments+ fileInfos <- forM attachments $ \a -> do+ MM.mmUploadFile chanId (FB.fileInfoFilename $ attachmentDataFileInfo a)+ (attachmentDataBytes a) session++ let fileIds = Seq.fromList $+ fmap fileInfoId $+ concat $+ (F.toList . MM.uploadResponseFileInfos) <$> fileInfos++ case mode of+ NewPost -> do+ let pendingPost = (rawPost msg chanId) { rawPostFileIds = fileIds }+ void $ MM.mmCreatePost pendingPost session+ Replying _ p -> do+ let pendingPost = (rawPost msg chanId) { rawPostRootId = postRootId p <|> (Just $ postId p)+ , rawPostFileIds = fileIds+ }+ void $ MM.mmCreatePost pendingPost session+ Editing p ty -> do+ let body = case ty of+ CP Emote -> addEmoteFormatting msg+ _ -> msg+ update = (postUpdateBody body) { postUpdateFileIds = if null fileIds+ then Nothing+ else Just fileIds+ }+ void $ MM.mmPatchPost (postId p) update session++shouldSkipMessage :: Text -> Bool+shouldSkipMessage "" = True+shouldSkipMessage s = T.all (`elem` (" \t"::String)) s++editMessage :: Post -> MH ()+editMessage new = do+ myId <- gets myUserId+ baseUrl <- getServerBaseUrl+ let isEditedMessage m = m^.mMessageId == Just (MessagePostId $ new^.postIdL)+ (msg, mentionedUsers) = clientPostToMessage (toClientPost baseUrl new (new^.postRootIdL))+ chan = csChannel (new^.postChannelIdL)+ chan . ccContents . cdMessages . traversed . filtered isEditedMessage .= msg+ mh $ invalidateCacheEntry (ChannelMessages $ new^.postChannelIdL)++ fetchMentionedUsers mentionedUsers++ when (postUserId new /= Just myId) $+ chan %= adjustEditedThreshold new++ csPostMap.ix(postId new) .= msg+ asyncFetchReactionsForPost (postChannelId new) new+ asyncFetchAttachments new++deleteMessage :: Post -> MH ()+deleteMessage new = do+ let isDeletedMessage m = m^.mMessageId == Just (MessagePostId $ new^.postIdL) ||+ isReplyTo (new^.postIdL) m+ chan :: Traversal' ChatState ClientChannel+ chan = csChannel (new^.postChannelIdL)+ chan.ccContents.cdMessages.traversed.filtered isDeletedMessage %= (& mDeleted .~ True)+ chan %= adjustUpdated new+ mh $ invalidateCacheEntry (ChannelMessages $ new^.postChannelIdL)++addNewPostedMessage :: PostToAdd -> MH ()+addNewPostedMessage p =+ addMessageToState True True p >>= postProcessMessageAdd++-- | Adds the set of Posts to the indicated channel. The Posts must+-- all be for the specified Channel. The reqCnt argument indicates+-- how many posts were requested, which will determine whether a gap+-- message is added to either end of the posts list or not.+--+-- The addTrailingGap is only True when fetching the very latest messages+-- for the channel, and will suppress the generation of a Gap message+-- following the added block of messages.+addObtainedMessages :: ChannelId -> Int -> Bool -> Posts -> MH PostProcessMessageAdd+addObtainedMessages cId reqCnt addTrailingGap posts =+ if null $ posts^.postsOrderL+ then do when addTrailingGap $+ -- Fetched at the end of the channel, but nothing was+ -- available. This is common if this is a new channel+ -- with no messages in it. Need to remove any gaps that+ -- exist at the end of the channel.+ csChannels %= modifyChannelById cId+ (ccContents.cdMessages %~+ \msgs -> let startPoint = join $ _mMessageId <$> getLatestPostMsg msgs+ in fst $ removeMatchesFromSubset isGap startPoint Nothing msgs)+ return NoAction+ else+ -- Adding a block of server-provided messages, which are known to+ -- be contiguous. Locally this may overlap with some UnknownGap+ -- messages, which can therefore be removed. Alternatively the+ -- new block may be discontiguous with the local blocks, in which+ -- case the new block should be surrounded by UnknownGaps.+ withChannelOrDefault cId NoAction $ \chan -> do+ let pIdList = toList (posts^.postsOrderL)+ -- the first and list PostId in the batch to be added+ earliestPId = last pIdList+ latestPId = head pIdList+ earliestDate = postCreateAt $ (posts^.postsPostsL) HM.! earliestPId+ latestDate = postCreateAt $ (posts^.postsPostsL) HM.! latestPId++ localMessages = chan^.ccContents . cdMessages++ -- Get a list of the duplicated message PostIds between+ -- the messages already in the channel and the new posts+ -- to be added.++ match = snd $ removeMatchesFromSubset+ (\m -> maybe False (\p -> p `elem` pIdList) (messagePostId m))+ (Just (MessagePostId earliestPId))+ (Just (MessagePostId latestPId))+ localMessages++ accum m l =+ case messagePostId m of+ Just pId -> pId : l+ Nothing -> l+ dupPIds = foldr accum [] match++ -- If there were any matches, then there was overlap of+ -- the new messages with existing messages.++ -- Don't re-add matching messages (avoid overhead like+ -- re-checking/re-fetching related post information, and+ -- do not signal action needed for notifications), and+ -- remove any gaps in the overlapping region.++ newGapMessage d isOlder =+ -- newGapMessage is a helper for generating a gap+ -- message+ do uuid <- generateUUID+ let txt = "Load " <>+ (if isOlder then "older" else "newer") <>+ " messages" <>+ (if isOlder then " ↥↥↥" else " ↧↧↧")+ ty = if isOlder+ then C UnknownGapBefore+ else C UnknownGapAfter+ return (newMessageOfType txt ty d+ & mMessageId .~ Just (MessageUUID uuid))++ -- If this batch contains the latest known messages, do+ -- not add a following gap. A gap at this point is added+ -- by a websocket disconnect, and any fetches thereafter+ -- are assumed to be the most current information (until+ -- another disconnect), so no gap is needed.+ -- Additionally, the presence of a gap at the end for a+ -- connected client causes a fetch of messages at this+ -- location, so adding the gap here would cause an+ -- infinite update loop.++ addingAtEnd = maybe True (latestDate >=) $+ (^.mDate) <$> getLatestPostMsg localMessages++ addingAtStart = maybe True (earliestDate <=) $+ (^.mDate) <$> getEarliestPostMsg localMessages+ removeStart = if addingAtStart && noMoreBefore+ then Nothing+ else Just (MessagePostId earliestPId)+ removeEnd = if addTrailingGap || (addingAtEnd && noMoreAfter)+ then Nothing+ else Just (MessagePostId latestPId)++ noMoreBefore = reqCnt < 0 && length pIdList < (-reqCnt)+ noMoreAfter = addTrailingGap || reqCnt > 0 && length pIdList < reqCnt++ reAddGapBefore = earliestPId `elem` dupPIds || noMoreBefore+ -- addingAtEnd used to be in reAddGapAfter but does not+ -- seem to be needed. I may have missed a specific use+ -- case/scenario, so I've left it commented out here for+ -- debug assistance.+ reAddGapAfter = latestPId `elem` dupPIds || {- addingAtEnd || -} noMoreAfter++ -- The post map returned by the server will *already* have+ -- all thread messages for each post that is part of a+ -- thread. By calling installMessagesFromPosts here, we go ahead+ -- and populate the csPostMap with those posts so that below, in+ -- addMessageToState, we notice that we already know about reply+ -- parent messages and can avoid fetching them. This converts+ -- the posts to Messages and stores those and also returns+ -- them, but we don't need them here. We just want the post+ -- map update. This also gathers up the set of all mentioned+ -- usernames in the text of the messages which we need to use to+ -- submit a single batch request for user metadata so we don't+ -- submit one request per mention.+ void $ installMessagesFromPosts posts++ -- Add all the new *unique* posts into the existing channel+ -- corpus, generating needed fetches of data associated with+ -- the post, and determining an notification action to be+ -- taken (if any).+ action <- foldr andProcessWith NoAction <$>+ mapM (addMessageToState False False . OldPost)+ [ (posts^.postsPostsL) HM.! p+ | p <- toList (posts^.postsOrderL)+ , not (p `elem` dupPIds)+ ]++ -- The channel messages now include all the fetched messages.+ -- Things to do at this point are:+ --+ -- 1. Remove any duplicates just added, as well as any gaps+ -- 2. Add new gaps (if needed) at either end of the added+ -- messages.+ -- 3. Update the "current selection" if it was on a removed message.+ --+ -- Do this with the updated copy of the channel's messages.++ withChannelOrDefault cId () $ \updchan -> do+ let updMsgs = updchan ^. ccContents . cdMessages++ -- Remove any gaps in the added region. If there was an+ -- active message selection and it is one of the removed+ -- gaps, reset the selection to the beginning or end of the+ -- added region (if there are any added selectable messages,+ -- otherwise just the end if the message list in it's+ -- entirety, or no selection at all).++ let (resultMessages, removedMessages) =+ removeMatchesFromSubset isGap removeStart removeEnd updMsgs+ csChannels %= modifyChannelById cId+ (ccContents.cdMessages .~ resultMessages)++ -- Determine if the current selected message was one of the+ -- removed messages.++ selMsgId <- use (csMessageSelect.to selectMessageId)+ let rmvdSel = do+ i <- selMsgId -- :: Maybe MessageId+ findMessage i removedMessages+ rmvdSelType = _mType <$> rmvdSel++ case rmvdSel of+ Nothing -> return ()+ Just rm ->+ if isGap rm+ then return () -- handled during gap insertion below+ else do+ -- Replaced a selected message that wasn't a gap.+ -- This is unlikely, but may occur if the previously+ -- selected message was just deleted by another user+ -- and is in the fetched region. The choices here are+ -- to move the selection, or cancel the selection.+ -- Both will be unpleasant surprises for the user, but+ -- cancelling the selection is probably the better+ -- choice than allowing the user to perform select+ -- actions on a message that isn't the one they just+ -- selected.+ setMode Main+ csMessageSelect .= MessageSelectState Nothing++ -- Add a gap at each end of the newly fetched data, unless:+ -- 1. there is an overlap+ -- 2. there is no more in the indicated direction+ -- a. indicated by adding messages later than any currently+ -- held messages (see note above re 'addingAtEnd').+ -- b. the amount returned was less than the amount requested++ if reAddGapBefore+ then+ -- No more gaps. If the selected gap was removed, move+ -- select to first (earliest) message)+ case rmvdSelType of+ Just (C UnknownGapBefore) ->+ csMessageSelect .= MessageSelectState (pure $ MessagePostId earliestPId)+ _ -> return ()+ else do+ -- add a gap at the start of the newly fetched block and+ -- make that the active selection if this fetch removed+ -- the previously selected gap in this direction.+ gapMsg <- newGapMessage (justBefore earliestDate) True+ csChannels %= modifyChannelById cId+ (ccContents.cdMessages %~ addMessage gapMsg)+ -- Move selection from old gap to new gap+ case rmvdSelType of+ Just (C UnknownGapBefore) -> do+ csMessageSelect .= MessageSelectState (gapMsg^.mMessageId)+ _ -> return ()++ if reAddGapAfter+ then+ -- No more gaps. If the selected gap was removed, move+ -- select to last (latest) message.+ case rmvdSelType of+ Just (C UnknownGapAfter) ->+ csMessageSelect .= MessageSelectState (pure $ MessagePostId latestPId)+ _ -> return ()+ else do+ -- add a gap at the end of the newly fetched block and+ -- make that the active selection if this fetch removed+ -- the previously selected gap in this direction.+ gapMsg <- newGapMessage (justAfter latestDate) False+ csChannels %= modifyChannelById cId+ (ccContents.cdMessages %~ addMessage gapMsg)+ -- Move selection from old gap to new gap+ case rmvdSelType of+ Just (C UnknownGapAfter) ->+ csMessageSelect .= MessageSelectState (gapMsg^.mMessageId)+ _ -> return ()++ -- Now initiate fetches for use information for any+ -- as-yet-unknown users related to this new set of messages++ let users = foldr (\post s -> maybe s (flip Set.insert s) (postUserId post))+ Set.empty (posts^.postsPostsL)+ addUnknownUsers inputUserIds = do+ knownUserIds <- Set.fromList <$> gets allUserIds+ let unknownUsers = Set.difference inputUserIds knownUserIds+ if Set.null unknownUsers+ then return ()+ else handleNewUsers (Seq.fromList $ toList unknownUsers) (return ())++ addUnknownUsers users++ -- Return the aggregated user notification action needed+ -- relative to the set of added messages.++ return action++-- | Adds a possibly new message to the associated channel contents.+-- Returns an indicator of whether the user should be potentially+-- notified of a change (a new message not posted by this user, a+-- mention of the user, etc.). This operation has no effect on any+-- existing UnknownGap entries and should be called when those are+-- irrelevant.+--+-- The first boolean argument ('doFetchMentionedUsers') indicates+-- whether this function should schedule a fetch for any mentioned+-- users in the message. This is provided so that callers can batch+-- this operation if a large collection of messages is being added+-- together, in which case we don't want this function to schedule a+-- single request per message (worst case). If you're calling this as+-- part of scrollback processing, you should pass False. Otherwise if+-- you're adding only a single message, you should pass True.+--+-- The second boolean argument ('fetchAuthor') is similar to the first+-- boolean argument but it refers to the author of the message instead+-- of any user mentions within the message body.+--+-- The third argument ('newPostData') indicates whether this message+-- is being added as part of a fetch of old messages (e.g. scrollback)+-- or if ti is a new message and affects things like whether+-- notifications are generated and if the "New Messages" marker gets+-- updated.+addMessageToState :: Bool -> Bool -> PostToAdd -> MH PostProcessMessageAdd+addMessageToState doFetchMentionedUsers fetchAuthor newPostData = do+ let (new, wasMentioned) = case newPostData of+ -- A post from scrollback history has no mention data, and+ -- that's okay: we only need to track mentions to tell the+ -- user that recent posts contained mentions.+ OldPost p -> (p, False)+ RecentPost p m -> (p, m)++ st <- use id+ case st ^? csChannel(postChannelId new) of+ Nothing -> do+ session <- getSession+ doAsyncWith Preempt $ do+ nc <- MM.mmGetChannel (postChannelId new) session+ member <- MM.mmGetChannelMember (postChannelId new) UserMe session++ let chType = nc^.channelTypeL+ pref = showGroupChannelPref (postChannelId new) (myUserId st)++ -- If the channel has been archived, we don't want to+ -- post this message or add the channel to the state.+ case channelDeleted nc of+ True -> return Nothing+ False -> return $ Just $ do+ -- If the incoming message is for a group+ -- channel we don't know about, that's because+ -- it was previously hidden by the user. We need+ -- to show it, and to do that we need to update+ -- the server-side preference. (That, in turn,+ -- triggers a channel refresh.)+ if chType == Group+ then applyPreferenceChange pref+ else refreshChannel SidebarUpdateImmediate nc member++ addMessageToState doFetchMentionedUsers fetchAuthor newPostData >>=+ postProcessMessageAdd++ return NoAction+ Just _ -> do+ baseUrl <- getServerBaseUrl+ let cp = toClientPost baseUrl new (new^.postRootIdL)+ fromMe = (cp^.cpUser == (Just $ myUserId st)) &&+ (isNothing $ cp^.cpUserOverride)+ userPrefs = st^.csResources.crUserPreferences+ isJoinOrLeave = case cp^.cpType of+ Join -> True+ Leave -> True+ _ -> False+ ignoredJoinLeaveMessage =+ not (userPrefs^.userPrefShowJoinLeave) && isJoinOrLeave+ cId = postChannelId new++ doAddMessage = do+ -- Do we have the user data for the post author?+ case cp^.cpUser of+ Nothing -> return ()+ Just authorId -> when fetchAuthor $ do+ authorResult <- gets (userById authorId)+ when (isNothing authorResult) $+ handleNewUsers (Seq.singleton authorId) (return ())++ currCId <- use csCurrentChannelId+ flags <- use (csResources.crFlaggedPosts)+ let (msg', mentionedUsers) = clientPostToMessage cp+ & _1.mFlagged .~ ((cp^.cpPostId) `Set.member` flags)++ when doFetchMentionedUsers $+ fetchMentionedUsers mentionedUsers++ csPostMap.at(postId new) .= Just msg'+ mh $ invalidateCacheEntry (ChannelMessages cId)+ csChannels %= modifyChannelById cId+ ((ccContents.cdMessages %~ addMessage msg') .+ (if not ignoredJoinLeaveMessage then adjustUpdated new else id) .+ (\c -> if currCId == cId+ then c+ else case newPostData of+ OldPost _ -> c+ RecentPost _ _ ->+ updateNewMessageIndicator new c) .+ (\c -> if wasMentioned+ then c & ccInfo.cdMentionCount %~ succ+ else c)+ )+ asyncFetchReactionsForPost cId new+ asyncFetchAttachments new+ postedChanMessage++ doHandleAddedMessage = do+ -- If the message is in reply to another message,+ -- try to find it in the scrollback for the post's+ -- channel. If the message isn't there, fetch it. If+ -- we have to fetch it, don't post this message to the+ -- channel until we have fetched the parent.+ case cp^.cpInReplyToPost of+ Just parentId ->+ case getMessageForPostId st parentId of+ Nothing -> do+ doAsyncChannelMM Preempt cId+ (\s _ -> MM.mmGetThread parentId s)+ (\_ p -> Just $ updatePostMap p)+ _ -> return ()+ _ -> return ()++ doAddMessage++ postedChanMessage =+ withChannelOrDefault (postChannelId new) NoAction $ \chan -> do+ currCId <- use csCurrentChannelId++ let notifyPref = notifyPreference (myUser st) chan+ curChannelAction = if postChannelId new == currCId+ then UpdateServerViewed+ else NoAction+ originUserAction =+ if | fromMe -> NoAction+ | ignoredJoinLeaveMessage -> NoAction+ | notifyPref == NotifyOptionAll -> NotifyUser [newPostData]+ | notifyPref == NotifyOptionMention+ && wasMentioned -> NotifyUser [newPostData]+ | otherwise -> NoAction++ return $ curChannelAction `andProcessWith` originUserAction++ doHandleAddedMessage++-- | PostProcessMessageAdd is an internal value that informs the main+-- code whether the user should be notified (e.g., ring the bell) or+-- the server should be updated (e.g., that the channel has been+-- viewed). This is a monoid so that it can be folded over when there+-- are multiple inbound posts to be processed.+data PostProcessMessageAdd = NoAction+ | NotifyUser [PostToAdd]+ | UpdateServerViewed+ | NotifyUserAndServer [PostToAdd]++andProcessWith+ :: PostProcessMessageAdd -> PostProcessMessageAdd -> PostProcessMessageAdd+andProcessWith NoAction x = x+andProcessWith x NoAction = x+andProcessWith (NotifyUserAndServer p) UpdateServerViewed = NotifyUserAndServer p+andProcessWith (NotifyUserAndServer p1) (NotifyUser p2) = NotifyUserAndServer (p1 <> p2)+andProcessWith (NotifyUserAndServer p1) (NotifyUserAndServer p2) = NotifyUserAndServer (p1 <> p2)+andProcessWith (NotifyUser p1) (NotifyUserAndServer p2) = NotifyUser (p1 <> p2)+andProcessWith (NotifyUser p1) (NotifyUser p2) = NotifyUser (p1 <> p2)+andProcessWith (NotifyUser p) UpdateServerViewed = NotifyUserAndServer p+andProcessWith UpdateServerViewed UpdateServerViewed = UpdateServerViewed+andProcessWith UpdateServerViewed (NotifyUserAndServer p) = NotifyUserAndServer p+andProcessWith UpdateServerViewed (NotifyUser p) = NotifyUserAndServer p++-- | postProcessMessageAdd performs the actual actions indicated by+-- the corresponding input value.+postProcessMessageAdd :: PostProcessMessageAdd -> MH ()+postProcessMessageAdd ppma = postOp ppma+ where+ postOp NoAction = return ()+ postOp UpdateServerViewed = updateViewed False+ postOp (NotifyUser p) = maybeRingBell >> mapM_ maybeNotify p+ postOp (NotifyUserAndServer p) = updateViewed False >> maybeRingBell >> mapM_ maybeNotify p++maybeNotify :: PostToAdd -> MH ()+maybeNotify (OldPost _) = do+ return ()+maybeNotify (RecentPost post mentioned) = runNotifyCommand post mentioned++maybeRingBell :: MH ()+maybeRingBell = do+ doBell <- use (csResources.crConfiguration.to configActivityBell)+ when doBell $ do+ vty <- mh getVtyHandle+ liftIO $ ringTerminalBell $ outputIface vty++-- | When we add posts to the application state, we either get them+-- from the server during scrollback fetches (here called 'OldPost') or+-- we get them from websocket events when they are posted in real time+-- (here called 'RecentPost').+data PostToAdd =+ OldPost Post+ -- ^ A post from the server's history+ | RecentPost Post Bool+ -- ^ A message posted to the channel since the user connected, along+ -- with a flag indicating whether the post triggered any of the+ -- user's mentions. We need an extra flag because the server+ -- determines whether the post has any mentions, and that data is+ -- only available in websocket events (and then provided to this+ -- constructor).++runNotifyCommand :: Post -> Bool -> MH ()+runNotifyCommand post mentioned = do+ outputChan <- use (csResources.crSubprocessLog)+ st <- use id+ notifyCommand <- use (csResources.crConfiguration.to configActivityNotifyCommand)+ case notifyCommand of+ Nothing -> return ()+ Just cmd ->+ doAsyncWith Preempt $ do+ let messageString = T.unpack $ sanitizeUserText $ postMessage post+ notified = if mentioned then "1" else "2"+ sender = T.unpack $ maybePostUsername st post+ runLoggedCommand outputChan (T.unpack cmd)+ [notified, sender, messageString] Nothing Nothing+ return Nothing++maybePostUsername :: ChatState -> Post -> T.Text+maybePostUsername st p =+ fromMaybe T.empty $ do+ uId <- postUserId p+ usernameForUserId uId st++-- | Fetches additional message history for the current channel. This+-- is generally called when in ChannelScroll mode, in which state the+-- output is cached and seen via a scrolling viewport; new messages+-- received in this mode are not normally shown, but this explicit+-- user-driven fetch should be displayed, so this also invalidates the+-- cache.+--+-- This function assumes it is being called to add "older" messages to+-- the message history (i.e. near the beginning of the known+-- messages). It will normally try to overlap the fetch with the+-- known existing messages so that when the fetch results are+-- processed (which should be a contiguous set of messages as provided+-- by the server) there will be an overlap with existing messages; if+-- there is no overlap, then a special "gap" must be inserted in the+-- area between the existing messages and the newly fetched messages+-- to indicate that this client does not know if there are missing+-- messages there or not.+--+-- In order to achieve an overlap, this code attempts to get the+-- second oldest messages as the message ID to pass to the server as+-- the "older than" marker ('postQueryBefore'), so that the oldest+-- message here overlaps with the fetched results to ensure no gap+-- needs to be inserted. However, there may already be a gap between+-- the oldest and second-oldest messages, so this code must actually+-- search for the first set of two *contiguous* messages it is aware+-- of to avoid adding additional gaps. (It's OK if gaps are added, but+-- the user must explicitly request a check for messages in order to+-- eliminate them, so it's better to avoid adding them in the first+-- place). This code is nearly always used to extend the older+-- history of a channel that information has already been retrieved+-- from, so it's almost certain that there are at least two contiguous+-- messages to use as a starting point, but exceptions are new+-- channels and empty channels.+asyncFetchMoreMessages :: MH ()+asyncFetchMoreMessages = do+ cId <- use csCurrentChannelId+ withChannel cId $ \chan ->+ let offset = max 0 $ length (chan^.ccContents.cdMessages) - 2+ page = offset `div` pageAmount+ usefulMsgs = getTwoContiguousPosts Nothing (chan^.ccContents.cdMessages.to reverseMessages)+ sndOldestId = (messagePostId . snd) =<< usefulMsgs+ query = MM.defaultPostQuery+ { MM.postQueryPage = maybe (Just page) (const Nothing) sndOldestId+ , MM.postQueryPerPage = Just pageAmount+ , MM.postQueryBefore = sndOldestId+ }+ addTrailingGap = MM.postQueryBefore query == Nothing &&+ MM.postQueryPage query == Just 0+ in doAsyncChannelMM Preempt cId+ (\s c -> MM.mmGetPostsForChannel c query s)+ (\c p -> Just $ do+ pp <- addObtainedMessages c (-pageAmount) addTrailingGap p+ postProcessMessageAdd pp+ mh $ invalidateCacheEntry (ChannelMessages cId))+++-- | Given a starting point and a direction to move from that point,+-- returns the closest two adjacent messages on that direction (as a+-- tuple of closest and next-closest), or Nothing if there are no+-- adjacent messages in the indicated direction.+getTwoContiguousPosts :: SeqDirection dir =>+ Maybe Message+ -> DirectionalSeq dir Message+ -> Maybe (Message, Message)+getTwoContiguousPosts startMsg msgs =+ let go start =+ do anchor <- getRelMessageId (_mMessageId =<< start) msgs+ hinge <- getRelMessageId (anchor^.mMessageId) msgs+ if isGap anchor || isGap hinge+ then go $ Just anchor+ else Just (anchor, hinge)+ in go startMsg+++asyncFetchMessagesForGap :: ChannelId -> Message -> MH ()+asyncFetchMessagesForGap cId gapMessage =+ when (isGap gapMessage) $+ withChannel cId $ \chan ->+ let offset = max 0 $ length (chan^.ccContents.cdMessages) - 2+ page = offset `div` pageAmount+ chanMsgs = chan^.ccContents.cdMessages+ fromMsg = Just gapMessage+ fetchNewer = case gapMessage^.mType of+ C UnknownGapAfter -> True+ C UnknownGapBefore -> False+ _ -> error "fetch gap messages: unknown gap message type"+ baseId = messagePostId . snd =<<+ case gapMessage^.mType of+ C UnknownGapAfter -> getTwoContiguousPosts fromMsg $+ reverseMessages chanMsgs+ C UnknownGapBefore -> getTwoContiguousPosts fromMsg chanMsgs+ _ -> error "fetch gap messages: unknown gap message type"+ query = MM.defaultPostQuery+ { MM.postQueryPage = maybe (Just page) (const Nothing) baseId+ , MM.postQueryPerPage = Just pageAmount+ , MM.postQueryBefore = if fetchNewer then Nothing else baseId+ , MM.postQueryAfter = if fetchNewer then baseId else Nothing+ }+ addTrailingGap = MM.postQueryBefore query == Nothing &&+ MM.postQueryPage query == Just 0+ in doAsyncChannelMM Preempt cId+ (\s c -> MM.mmGetPostsForChannel c query s)+ (\c p -> Just $ do+ void $ addObtainedMessages c (-pageAmount) addTrailingGap p+ mh $ invalidateCacheEntry (ChannelMessages cId))++-- | Given a particular message ID, this fetches n messages before and+-- after immediately before and after the specified message in order+-- to establish some context for that message. This is frequently+-- used as a background operation when looking at search or flag+-- results so that jumping to message select mode for one of those+-- messages will show a bit of context (and it also prevents showing+-- gap messages for adjacent targets).+--+-- The result will be adding at most 2n messages to the channel, with+-- the input post ID being somewhere in the middle of the added+-- messages.+--+-- Note that this fetch will add messages to the channel, but it+-- performs no notifications or updates of new-unread indicators+-- because it is assumed to be used for non-current (previously-seen)+-- messages in background mode.+asyncFetchMessagesSurrounding :: ChannelId -> PostId -> MH ()+asyncFetchMessagesSurrounding cId pId = do+ let query = MM.defaultPostQuery+ { MM.postQueryBefore = Just pId+ , MM.postQueryPerPage = Just reqAmt+ }+ reqAmt = 5 -- both before and after+ doAsyncChannelMM Preempt cId+ -- first get some messages before the target, no overlap+ (\s c -> MM.mmGetPostsForChannel c query s)+ (\c p -> Just $ do+ let last2ndId = secondToLastPostId p+ void $ addObtainedMessages c (-reqAmt) False p+ mh $ invalidateCacheEntry (ChannelMessages cId)+ -- now start 2nd from end of this fetch to fetch some+ -- messages forward, also overlapping with this fetch and+ -- the original message ID to eliminate all gaps in this+ -- surrounding set of messages.+ let query' = MM.defaultPostQuery+ { MM.postQueryAfter = last2ndId+ , MM.postQueryPerPage = Just $ reqAmt + 2+ }+ doAsyncChannelMM Preempt cId+ (\s' c' -> MM.mmGetPostsForChannel c' query' s')+ (\c' p' -> Just $ do+ void $ addObtainedMessages c' (reqAmt + 2) False p'+ mh $ invalidateCacheEntry (ChannelMessages cId)+ )+ )+ where secondToLastPostId posts =+ let pl = toList $ postsOrder posts+ in if length pl > 1 then Just $ last $ init pl else Nothing+++fetchVisibleIfNeeded :: MH ()+fetchVisibleIfNeeded = do+ sts <- use csConnectionStatus+ when (sts == Connected) $ do+ cId <- use csCurrentChannelId+ withChannel cId $ \chan ->+ let msgs = chan^.ccContents.cdMessages.to reverseMessages+ (numRemaining, gapInDisplayable, _, rel'pId, overlap) =+ foldl gapTrail (numScrollbackPosts, False, Nothing, Nothing, 2) msgs+ gapTrail a@(_, True, _, _, _) _ = a+ gapTrail a@(0, _, _, _, _) _ = a+ gapTrail (a, False, b, c, d) m | isGap m = (a, True, b, c, d)+ gapTrail (remCnt, _, prev'pId, prev''pId, ovl) msg =+ (remCnt - 1, False, msg^.mMessageId <|> prev'pId, prev'pId <|> prev''pId,+ ovl + if not (isPostMessage msg) then 1 else 0)+ numToReq = numRemaining + overlap+ query = MM.defaultPostQuery+ { MM.postQueryPage = Just 0+ , MM.postQueryPerPage = Just numToReq+ }+ finalQuery = case rel'pId of+ Just (MessagePostId pid) -> query { MM.postQueryBefore = Just pid }+ _ -> query+ op = \s c -> MM.mmGetPostsForChannel c finalQuery s+ addTrailingGap = MM.postQueryBefore finalQuery == Nothing &&+ MM.postQueryPage finalQuery == Just 0+ in when ((not $ chan^.ccContents.cdFetchPending) && gapInDisplayable) $ do+ csChannel(cId).ccContents.cdFetchPending .= True+ doAsyncChannelMM Preempt cId op+ (\c p -> Just $ do+ addObtainedMessages c (-numToReq) addTrailingGap p >>= postProcessMessageAdd+ csChannel(c).ccContents.cdFetchPending .= False)++asyncFetchAttachments :: Post -> MH ()+asyncFetchAttachments p = do+ let cId = p^.postChannelIdL+ pId = p^.postIdL+ session <- getSession+ host <- use (csResources.crConn.cdHostnameL)+ F.forM_ (p^.postFileIdsL) $ \fId -> doAsyncWith Normal $ do+ info <- MM.mmGetMetadataForFile fId session+ let scheme = "https://"+ attUrl = scheme <> host <> urlForFile fId+ attachment = mkAttachment (fileInfoName info) attUrl fId+ addIfMissing a as =+ if isNothing $ Seq.elemIndexL a as+ then a Seq.<| as+ else as+ addAttachment m+ | m^.mMessageId == Just (MessagePostId pId) =+ m & mAttachments %~ (addIfMissing attachment)+ | otherwise =+ m+ return $ Just $ do+ csChannel(cId).ccContents.cdMessages.traversed %= addAttachment+ mh $ invalidateCacheEntry $ ChannelMessages cId++-- | Given a post ID, switch to that post's channel and select the post+-- in message selection mode.+--+-- This function will do what it can to honor the request even when we+-- don't know about the post because it hasn't been fetched, or when+-- the post is in a channel that we aren't a member of. In each case a+-- reasonable effort will be made (fetch the post, join the channel)+-- before giving up.+jumpToPost :: PostId -> MH ()+jumpToPost pId = do+ st <- use id+ case getMessageForPostId st pId of+ Just msg ->+ case msg ^. mChannelId of+ Just cId -> do+ -- Are we a member of the channel?+ case findChannelById cId (st^.csChannels) of+ Nothing ->+ joinChannel' cId (Just $ jumpToPost pId)+ Just _ -> do+ setFocus cId+ setMode MessageSelect+ csMessageSelect .= MessageSelectState (msg^.mMessageId)+ Nothing ->+ error "INTERNAL: selected Post ID not associated with a channel"+ Nothing -> do+ session <- getSession+ doAsyncWith Preempt $ do+ result <- try $ MM.mmGetPost pId session+ return $ Just $ do+ case result of+ Right p -> do+ -- Are we a member of the channel?+ case findChannelById (postChannelId p) (st^.csChannels) of+ -- If not, join it and then try jumping to+ -- the post if the channel join is successful.+ Nothing -> do+ joinChannel' (postChannelId p) (Just $ jumpToPost pId)+ -- Otherwise add the post to the state and+ -- then jump.+ Just _ -> do+ void $ addMessageToState True True (OldPost p)+ jumpToPost pId+ Left (_::SomeException) ->+ postErrorMessage' "Could not fetch linked post"
+ src/Matterhorn/State/Messages.hs-boot view
@@ -0,0 +1,11 @@+module Matterhorn.State.Messages+ ( fetchVisibleIfNeeded+ )+where++import Matterhorn.Types ( MH )++-- State.Channels needs this in setFocusWith, but that creates an+-- import cycle because several things in State.Messages need things in+-- State.Channels.+fetchVisibleIfNeeded :: MH ()
+ src/Matterhorn/State/NotifyPrefs.hs view
@@ -0,0 +1,88 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+module Matterhorn.State.NotifyPrefs+ ( enterEditNotifyPrefsMode+ , exitEditNotifyPrefsMode+ )+where++import Prelude ()+import Matterhorn.Prelude++import Network.Mattermost.Types ( ChannelNotifyProps+ , User(..)+ , UserNotifyProps(..)+ , Type(..)+ , channelNotifyPropsMarkUnread+ , channelNotifyPropsIgnoreChannelMentions+ , WithDefault(..)+ , NotifyOption(..)+ )++import Brick+import Brick.Forms+import Lens.Micro.Platform ( Lens', (.=), lens )++import Matterhorn.Types+++muteLens :: Lens' ChannelNotifyProps Bool+muteLens = lens (\props -> props^.channelNotifyPropsMarkUnreadL == IsValue NotifyOptionMention)+ (\props muted -> props { channelNotifyPropsMarkUnread =+ if muted+ then IsValue NotifyOptionMention+ else IsValue NotifyOptionAll+ })++channelMentionLens :: Lens' ChannelNotifyProps Bool+channelMentionLens = lens (\props -> props^.channelNotifyPropsIgnoreChannelMentionsL == IsValue True)+ (\props ignoreChannelMentions ->+ props { channelNotifyPropsIgnoreChannelMentions = if ignoreChannelMentions+ then IsValue True+ else Default+ })++notifyOptionName :: NotifyOption -> Text+notifyOptionName NotifyOptionAll = "All activity"+notifyOptionName NotifyOptionMention = "Mentions"+notifyOptionName NotifyOptionNone = "Never"++mkNotifyButtons :: ((WithDefault NotifyOption) -> Name)+ -> Lens' ChannelNotifyProps (WithDefault NotifyOption)+ -> NotifyOption+ -> ChannelNotifyProps+ -> FormFieldState ChannelNotifyProps e Name+mkNotifyButtons mkName l globalDefault =+ let optTuple opt = (IsValue opt, mkName $ IsValue opt, notifyOptionName opt)+ defaultField = (Default, mkName Default, "Global default (" <> notifyOptionName globalDefault <> ")")+ nonDefault = optTuple <$> [ NotifyOptionAll+ , NotifyOptionMention+ , NotifyOptionNone+ ]+ in radioField l (defaultField : nonDefault)++notifyPrefsForm :: UserNotifyProps -> ChannelNotifyProps -> Form ChannelNotifyProps e Name+notifyPrefsForm globalDefaults =+ newForm [ checkboxField muteLens MuteToggleField "Mute channel"+ , (padTop $ Pad 1) @@= checkboxField channelMentionLens ChannelMentionsField "Ignore channel mentions"+ , radioStyle "Desktop notifications" @@=+ mkNotifyButtons DesktopNotificationsField channelNotifyPropsDesktopL (userNotifyPropsDesktop globalDefaults)+ , radioStyle "Push notifications" @@=+ mkNotifyButtons PushNotificationsField channelNotifyPropsPushL (userNotifyPropsPush globalDefaults)+ ]+ where radioStyle label = (padTop $ Pad 1 ) . (str label <=>) . (padLeft $ Pad 1)++enterEditNotifyPrefsMode :: MH ()+enterEditNotifyPrefsMode = do+ chanInfo <- use (csCurrentChannel.ccInfo)+ case chanInfo^.cdType of+ Direct -> mhError $ GenericError "Cannot open notification preferences for DM channel."+ _ -> do+ let props = chanInfo^.cdNotifyProps+ user <- use csMe+ csNotifyPrefs .= (Just (notifyPrefsForm (userNotifyProps user) props))+ setMode EditNotifyPrefs++exitEditNotifyPrefsMode :: MH ()+exitEditNotifyPrefsMode = do+ setMode Main
+ src/Matterhorn/State/PostListOverlay.hs view
@@ -0,0 +1,148 @@+module Matterhorn.State.PostListOverlay+ ( enterFlaggedPostListMode+ , enterPinnedPostListMode+ , enterSearchResultPostListMode+ , postListJumpToCurrent+ , postListSelectUp+ , postListSelectDown+ , postListUnflagSelected+ , exitPostListMode+ )+where++import GHC.Exts ( IsList(..) )+import Prelude ()+import Matterhorn.Prelude++import qualified Data.Foldable as F+import qualified Data.Text as T+import Lens.Micro.Platform ( (.=) )+import Network.Mattermost.Endpoints+import Network.Mattermost.Types++import Matterhorn.State.Messages ( jumpToPost )+import Matterhorn.State.Common+import Matterhorn.State.MessageSelect+import Matterhorn.State.Messages ( addObtainedMessages+ , asyncFetchMessagesSurrounding )+import Matterhorn.Types+import Matterhorn.Types.DirectionalSeq (emptyDirSeq)+++-- | Create a PostListOverlay with the given content description and+-- with a specified list of messages.+enterPostListMode :: PostListContents -> Messages -> MH ()+enterPostListMode contents msgs = do+ csPostListOverlay.postListPosts .= msgs+ let mlatest = getLatestPostMsg msgs+ pId = mlatest >>= messagePostId+ cId = mlatest >>= \m -> m^.mChannelId+ csPostListOverlay.postListSelected .= pId+ setMode $ PostListOverlay contents+ case (pId, cId) of+ (Just p, Just c) -> asyncFetchMessagesSurrounding c p+ _ -> return ()++-- | Clear out the state of a PostListOverlay+exitPostListMode :: MH ()+exitPostListMode = do+ csPostListOverlay.postListPosts .= emptyDirSeq+ csPostListOverlay.postListSelected .= Nothing+ setMode Main+++createPostList :: PostListContents -> (Session -> IO Posts) -> MH ()+createPostList contentsType fetchOp = do+ session <- getSession+ doAsyncWith Preempt $ do+ posts <- fetchOp session+ return $ Just $ do+ messages <- installMessagesFromPosts posts+ -- n.b. do not use addNewPostedMessage because these messages+ -- are not new, and so no notifications or channel highlighting+ -- or other post-processing should be performed.+ let plist = F.toList $ postsPosts posts+ postsSpec p = Posts { postsPosts = fromList [(postId p, p)]+ , postsOrder = fromList [postId p]+ }+ mapM_ (\p -> addObtainedMessages (postChannelId p) 0 False $ postsSpec p) plist+ enterPostListMode contentsType messages+++-- | Create a PostListOverlay with flagged messages from the server.+enterFlaggedPostListMode :: MH ()+enterFlaggedPostListMode = createPostList PostListFlagged $+ mmGetListOfFlaggedPosts UserMe defaultFlaggedPostsQuery++-- | Create a PostListOverlay with pinned messages from the server for+-- the current channel.+enterPinnedPostListMode :: MH ()+enterPinnedPostListMode = do+ cId <- use csCurrentChannelId+ createPostList (PostListPinned cId) $ mmGetChannelPinnedPosts cId++-- | Create a PostListOverlay with post search result messages from the+-- server.+enterSearchResultPostListMode :: Text -> MH ()+enterSearchResultPostListMode terms+ | T.null (T.strip terms) = postInfoMessage "Search command requires at least one search term."+ | otherwise = do+ enterPostListMode (PostListSearch terms True) noMessages+ tId <- gets myTeamId+ createPostList (PostListSearch terms False) $+ mmSearchForTeamPosts tId (SearchPosts terms False)+++-- | Move the selection up in the PostListOverlay, which corresponds+-- to finding a chronologically /newer/ message.+postListSelectUp :: MH ()+postListSelectUp = do+ selId <- use (csPostListOverlay.postListSelected)+ posts <- use (csPostListOverlay.postListPosts)+ let nextMsg = getNextMessage (MessagePostId <$> selId) posts+ case nextMsg of+ Nothing -> return ()+ Just m -> do+ let pId = m^.mMessageId >>= messageIdPostId+ csPostListOverlay.postListSelected .= pId+ case (m^.mChannelId, pId) of+ (Just c, Just p) -> asyncFetchMessagesSurrounding c p+ o -> mhLog LogError+ (T.pack $ "postListSelectUp" <>+ " unable to get channel or post ID: " <> show o)++-- | Move the selection down in the PostListOverlay, which corresponds+-- to finding a chronologically /old/ message.+postListSelectDown :: MH ()+postListSelectDown = do+ selId <- use (csPostListOverlay.postListSelected)+ posts <- use (csPostListOverlay.postListPosts)+ let prevMsg = getPrevMessage (MessagePostId <$> selId) posts+ case prevMsg of+ Nothing -> return ()+ Just m -> do+ let pId = m^.mMessageId >>= messageIdPostId+ csPostListOverlay.postListSelected .= pId+ case (m^.mChannelId, pId) of+ (Just c, Just p) -> asyncFetchMessagesSurrounding c p+ o -> mhLog LogError+ (T.pack $ "postListSelectDown" <>+ " unable to get channel or post ID: " <> show o)++-- | Unflag the post currently selected in the PostListOverlay, if any+postListUnflagSelected :: MH ()+postListUnflagSelected = do+ msgId <- use (csPostListOverlay.postListSelected)+ case msgId of+ Nothing -> return ()+ Just pId -> flagMessage pId False+++-- | Jumps to the specified message in the message's main channel+-- display and changes to MessageSelectState.+postListJumpToCurrent :: MH ()+postListJumpToCurrent = do+ msgId <- use (csPostListOverlay.postListSelected)+ case msgId of+ Nothing -> return ()+ Just pId -> jumpToPost pId
+ src/Matterhorn/State/ReactionEmojiListOverlay.hs view
@@ -0,0 +1,125 @@+{-# LANGUAGE TupleSections #-}+module Matterhorn.State.ReactionEmojiListOverlay+ ( enterReactionEmojiListOverlayMode++ , reactionEmojiListSelectDown+ , reactionEmojiListSelectUp+ , reactionEmojiListPageDown+ , reactionEmojiListPageUp+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Brick.Widgets.List as L+import qualified Data.Vector as Vec+import qualified Data.Text as T+import qualified Data.Map as M+import qualified Data.Set as Set+import Data.Function ( on )+import Data.List ( nubBy )+import Lens.Micro.Platform ( to )++import Network.Mattermost.Types+import Network.Mattermost.Endpoints ( mmPostReaction, mmDeleteReaction )++import Matterhorn.Emoji+import Matterhorn.State.ListOverlay+import Matterhorn.State.MessageSelect+import Matterhorn.State.Async+import Matterhorn.Types+++enterReactionEmojiListOverlayMode :: MH ()+enterReactionEmojiListOverlayMode = do+ selectedMessage <- use (to getSelectedMessage)+ case selectedMessage of+ Nothing -> return ()+ Just msg -> do+ em <- use (csResources.crEmoji)+ myId <- gets myUserId+ enterListOverlayMode csReactionEmojiListOverlay ReactionEmojiListOverlay+ () enterHandler (fetchResults myId msg em)++enterHandler :: (Bool, T.Text) -> MH Bool+enterHandler (mine, e) = do+ session <- getSession+ myId <- gets myUserId++ selectedMessage <- use (to getSelectedMessage)+ case selectedMessage of+ Nothing -> return False+ Just m -> do+ case m^.mOriginalPost of+ Nothing -> return False+ Just p -> do+ case mine of+ False ->+ doAsyncWith Preempt $ do+ mmPostReaction (postId p) myId e session+ return Nothing+ True ->+ doAsyncWith Preempt $ do+ mmDeleteReaction (postId p) myId e session+ return Nothing+ return True++fetchResults :: UserId+ -- ^ My user ID, so we can see which reactions I haven't+ -- posted+ -> Message+ -- ^ The selected message, so we can include its current+ -- reactions in the list+ -> EmojiCollection+ -- ^ The emoji collection+ -> ()+ -- ^ The scope to search+ -> Session+ -- ^ The connection session+ -> Text+ -- ^ The search string+ -> IO (Vec.Vector (Bool, T.Text))+fetchResults myId msg em () session searchString = do+ let currentReactions = [ (myId `Set.member` uIds, k)+ | (k, uIds) <- M.toList (msg^.mReactions)+ ]+ matchingCurrentOtherReactions = [ (mine, r) | (mine, r) <- currentReactions+ , matchesEmoji searchString r+ , not mine+ ]+ matchingCurrentMyReactions = [ (mine, r) | (mine, r) <- currentReactions+ , matchesEmoji searchString r+ , mine+ ]+ serverMatches <- getMatchingEmoji session em searchString+ return $ Vec.fromList $ nubBy ((==) `on` snd) $+ matchingCurrentOtherReactions <> matchingCurrentMyReactions <> ((False,) <$> serverMatches)++-- | Move the selection up in the emoji list overlay by one emoji.+reactionEmojiListSelectUp :: MH ()+reactionEmojiListSelectUp = reactionEmojiListMove L.listMoveUp++-- | Move the selection down in the emoji list overlay by one emoji.+reactionEmojiListSelectDown :: MH ()+reactionEmojiListSelectDown = reactionEmojiListMove L.listMoveDown++-- | Move the selection up in the emoji list overlay by a page of emoji+-- (ReactionEmojiListPageSize).+reactionEmojiListPageUp :: MH ()+reactionEmojiListPageUp = reactionEmojiListMove (L.listMoveBy (-1 * reactionEmojiListPageSize))++-- | Move the selection down in the emoji list overlay by a page of emoji+-- (ReactionEmojiListPageSize).+reactionEmojiListPageDown :: MH ()+reactionEmojiListPageDown = reactionEmojiListMove (L.listMoveBy reactionEmojiListPageSize)++-- | Transform the emoji list results in some way, e.g. by moving the+-- cursor, and then check to see whether the modification warrants a+-- prefetch of more search results.+reactionEmojiListMove :: (L.List Name (Bool, T.Text) -> L.List Name (Bool, T.Text)) -> MH ()+reactionEmojiListMove = listOverlayMove csReactionEmojiListOverlay++-- | The number of emoji in a "page" for cursor movement purposes.+reactionEmojiListPageSize :: Int+reactionEmojiListPageSize = 10
+ src/Matterhorn/State/Reactions.hs view
@@ -0,0 +1,52 @@+module Matterhorn.State.Reactions+ ( asyncFetchReactionsForPost+ , addReactions+ , removeReaction+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Main ( invalidateCacheEntry )+import qualified Data.Map.Strict as Map+import Lens.Micro.Platform+import qualified Data.Set as S++import Network.Mattermost.Endpoints+import Network.Mattermost.Lenses+import Network.Mattermost.Types++import Matterhorn.State.Async+import Matterhorn.State.Common ( fetchMentionedUsers )+import Matterhorn.Types+++asyncFetchReactionsForPost :: ChannelId -> Post -> MH ()+asyncFetchReactionsForPost cId p+ | not (p^.postHasReactionsL) = return ()+ | otherwise = doAsyncChannelMM Normal cId+ (\s _ -> fmap toList (mmGetReactionsForPost (p^.postIdL) s))+ (\_ rs -> Just $ addReactions cId rs)++addReactions :: ChannelId -> [Reaction] -> MH ()+addReactions cId rs = do+ mh $ invalidateCacheEntry $ ChannelMessages cId+ csChannel(cId).ccContents.cdMessages %= fmap upd+ let mentions = S.fromList $ UserIdMention <$> reactionUserId <$> rs+ fetchMentionedUsers mentions+ where upd msg = msg & mReactions %~ insertAll (msg^.mMessageId)+ insert mId r+ | mId == Just (MessagePostId (r^.reactionPostIdL)) =+ Map.insertWith S.union (r^.reactionEmojiNameL) (S.singleton $ r^.reactionUserIdL)+ | otherwise = id+ insertAll mId msg = foldr (insert mId) msg rs++removeReaction :: Reaction -> ChannelId -> MH ()+removeReaction r cId = do+ mh $ invalidateCacheEntry $ ChannelMessages cId+ csChannel(cId).ccContents.cdMessages %= fmap upd+ where upd m | m^.mMessageId == Just (MessagePostId $ r^.reactionPostIdL) =+ m & mReactions %~ (Map.alter delReaction (r^.reactionEmojiNameL))+ | otherwise = m+ delReaction mUs = S.delete (r^.reactionUserIdL) <$> mUs
+ src/Matterhorn/State/Setup.hs view
@@ -0,0 +1,281 @@+{-# LANGUAGE TypeFamilies #-}+module Matterhorn.State.Setup+ ( setupState+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.BChan ( newBChan )+import Brick.Themes ( themeToAttrMap, loadCustomizations )+import qualified Control.Concurrent.STM as STM+import Data.Maybe ( fromJust )+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import Data.Time.Clock ( getCurrentTime )+import qualified Graphics.Vty as Vty+import Lens.Micro.Platform ( (.~) )+import System.Exit ( exitFailure, exitSuccess )+import System.FilePath ( (</>), isRelative, dropFileName )++import Network.Mattermost.Endpoints+import Network.Mattermost.Types++import Matterhorn.Config+import Matterhorn.InputHistory+import Matterhorn.LastRunState+import Matterhorn.Login+import Matterhorn.State.Flagging+import Matterhorn.State.Messages ( fetchVisibleIfNeeded )+import Matterhorn.State.Setup.Threads+import Matterhorn.TeamSelect+import Matterhorn.Themes+import Matterhorn.TimeUtils ( lookupLocalTimeZone )+import Matterhorn.Types+import Matterhorn.Types.Common+import Matterhorn.Emoji+import Matterhorn.FilePaths ( userEmojiJsonPath, bundledEmojiJsonPath )+import qualified Matterhorn.Zipper as Z+++incompleteCredentials :: Config -> ConnectionInfo+incompleteCredentials config = ConnectionInfo+ { _ciHostname = fromMaybe "" (configHost config)+ , _ciPort = configPort config+ , _ciUrlPath = fromMaybe "" (configUrlPath config)+ , _ciUsername = fromMaybe "" (configUser config)+ , _ciPassword = case configPass config of+ Just (PasswordString s) -> s+ _ -> ""+ , _ciAccessToken = case configToken config of+ Just (TokenString s) -> s+ _ -> ""+ , _ciType = configConnectionType config+ }++apiLogEventToLogMessage :: LogEvent -> IO LogMessage+apiLogEventToLogMessage ev = do+ now <- getCurrentTime+ let msg = T.pack $ "Function: " <> logFunction ev <>+ ", event: " <> show (logEventType ev)+ return $ LogMessage { logMessageCategory = LogAPI+ , logMessageText = msg+ , logMessageContext = Nothing+ , logMessageTimestamp = now+ }++setupState :: IO Vty.Vty -> Maybe FilePath -> Config -> IO (ChatState, Vty.Vty)+setupState mkVty mLogLocation config = do+ initialVty <- mkVty++ eventChan <- newBChan 2500+ logMgr <- newLogManager eventChan (configLogMaxBufferSize config)++ -- If we got an initial log location, start logging there.+ case mLogLocation of+ Nothing -> return ()+ Just loc -> startLoggingToFile logMgr loc++ let logApiEvent ev = apiLogEventToLogMessage ev >>= sendLogMessage logMgr+ setLogger cd = cd `withLogger` logApiEvent++ (mLastAttempt, loginVty) <- interactiveGetLoginSession initialVty mkVty+ setLogger+ logMgr+ (incompleteCredentials config)++ let shutdown vty = do+ Vty.shutdown vty+ exitSuccess++ (session, me, cd, mbTeam) <- case mLastAttempt of+ Nothing ->+ -- The user never attempted a connection and just chose to+ -- quit.+ shutdown loginVty+ Just (AttemptFailed {}) ->+ -- The user attempted a connection and failed, and then chose+ -- to quit.+ shutdown loginVty+ Just (AttemptSucceeded _ cd sess user mbTeam) ->+ -- The user attempted a connection and succeeded so continue+ -- with setup.+ return (sess, user, cd, mbTeam)++ teams <- mmGetUsersTeams UserMe session+ when (Seq.null teams) $ do+ putStrLn "Error: your account is not a member of any teams"+ exitFailure++ (myTeam, teamSelVty) <- do+ let foundTeam = do+ tName <- mbTeam <|> configTeam config+ let matchingTeam = listToMaybe $ filter matches $ toList teams+ matches t = (sanitizeUserText $ teamName t) == tName+ matchingTeam++ case foundTeam of+ Just t -> return (t, loginVty)+ Nothing -> do+ (mTeam, vty) <- interactiveTeamSelection loginVty mkVty $ toList teams+ case mTeam of+ Nothing -> shutdown vty+ Just team -> return (team, vty)++ userStatusChan <- STM.newTChanIO+ slc <- STM.newTChanIO+ wac <- STM.newTChanIO++ prefs <- mmGetUsersPreferences UserMe session+ let userPrefs = setUserPreferences prefs defaultUserPreferences+ themeName = case configTheme config of+ Nothing -> internalThemeName defaultTheme+ Just t -> t+ baseTheme = internalTheme $ fromMaybe defaultTheme (lookupTheme themeName)++ -- Did the configuration specify a theme customization file? If so,+ -- load it and customize the theme.+ custTheme <- case configThemeCustomizationFile config of+ Nothing -> return baseTheme+ Just path ->+ -- If we have no configuration path (i.e. we used the default+ -- config) then ignore theme customization.+ let pathStr = T.unpack path+ in if isRelative pathStr && isNothing (configAbsPath config)+ then return baseTheme+ else do+ let absPath = if isRelative pathStr+ then (dropFileName $ fromJust $ configAbsPath config) </> pathStr+ else pathStr+ result <- loadCustomizations absPath baseTheme+ case result of+ Left e -> do+ Vty.shutdown teamSelVty+ putStrLn $ "Error loading theme customization from " <> show absPath <> ": " <> e+ exitFailure+ Right t -> return t++ requestChan <- STM.atomically STM.newTChan++ emoji <- either (const emptyEmojiCollection) id <$> do+ result1 <- loadEmoji =<< userEmojiJsonPath+ case result1 of+ Right e -> return $ Right e+ Left _ -> loadEmoji =<< bundledEmojiJsonPath++ let cr = ChatResources { _crSession = session+ , _crWebsocketThreadId = Nothing+ , _crConn = cd+ , _crRequestQueue = requestChan+ , _crEventQueue = eventChan+ , _crSubprocessLog = slc+ , _crWebsocketActionChan = wac+ , _crTheme = themeToAttrMap custTheme+ , _crStatusUpdateChan = userStatusChan+ , _crConfiguration = config+ , _crFlaggedPosts = mempty+ , _crUserPreferences = userPrefs+ , _crSyntaxMap = mempty+ , _crLogManager = logMgr+ , _crEmoji = emoji+ }++ st <- initializeState cr myTeam me++ return (st, teamSelVty)++initializeState :: ChatResources -> Team -> User -> IO ChatState+initializeState cr myTeam me = do+ let session = getResourceSession cr+ requestChan = cr^.crRequestQueue+ myTId = getId myTeam++ -- Create a predicate to find the last selected channel by reading the+ -- run state file. If unable to read or decode or validate the file, this+ -- predicate is just `isTownSquare`.+ isLastSelectedChannel <- do+ result <- readLastRunState $ teamId myTeam+ case result of+ Right lrs | isValidLastRunState cr me lrs -> return $ \c ->+ channelId c == lrs^.lrsSelectedChannelId+ _ -> return isTownSquare++ -- Get all channels, but filter down to just the one we want to start+ -- in. We get all, rather than requesting by name or ID, because+ -- we don't know whether the server will give us a last-viewed preference.+ -- We first try to find a channel matching with the last selected channel ID,+ -- failing which we look for the Town Square channel by name.+ userChans <- mmGetChannelsForUser UserMe myTId session+ let lastSelectedChans = Seq.filter isLastSelectedChannel userChans+ chans = if Seq.null lastSelectedChans+ then Seq.filter isTownSquare userChans+ else lastSelectedChans++ -- Since the only channel we are dealing with is by construction the+ -- last channel, we don't have to consider other cases here:+ chanPairs <- forM (toList chans) $ \c -> do+ cChannel <- makeClientChannel (userId me) c+ return (getId c, cChannel)++ tz <- lookupLocalTimeZone+ hist <- do+ result <- readHistory+ case result of+ Left _ -> return newHistory+ Right h -> return h++ --------------------------------------------------------------------+ -- Start background worker threads:+ --+ -- * Syntax definition loader+ startSyntaxMapLoaderThread (cr^.crConfiguration) (cr^.crEventQueue)++ -- * Main async queue worker thread+ startAsyncWorkerThread (cr^.crConfiguration) (cr^.crRequestQueue) (cr^.crEventQueue)++ -- * User status thread+ startUserStatusUpdateThread (cr^.crStatusUpdateChan) session requestChan++ -- * Refresher for users who are typing currently+ when (configShowTypingIndicator (cr^.crConfiguration)) $+ startTypingUsersRefreshThread requestChan++ -- * Timezone change monitor+ startTimezoneMonitorThread tz requestChan++ -- * Subprocess logger+ startSubprocessLoggerThread (cr^.crSubprocessLog) requestChan++ -- * Spell checker and spell check timer, if configured+ spResult <- maybeStartSpellChecker (cr^.crConfiguration) (cr^.crEventQueue)++ -- End thread startup ----------------------------------------------++ now <- getCurrentTime+ let chanIds = mkChannelZipperList now (cr^.crConfiguration) Nothing (cr^.crUserPreferences) clientChans noUsers+ chanZip = Z.fromList chanIds+ clientChans = foldr (uncurry addChannel) noChannels chanPairs+ startupState =+ StartupStateInfo { startupStateResources = cr+ , startupStateChannelZipper = chanZip+ , startupStateConnectedUser = me+ , startupStateTeam = myTeam+ , startupStateTimeZone = tz+ , startupStateInitialHistory = hist+ , startupStateSpellChecker = spResult+ }++ initialState <- newState startupState+ let st = initialState & csChannels .~ clientChans++ loadFlaggedMessages (cr^.crUserPreferences.userPrefFlaggedPostList) st++ -- Trigger an initial websocket refresh+ writeBChan (cr^.crEventQueue) RefreshWebsocketEvent++ -- Refresh initial channel(s)+ writeBChan (cr^.crEventQueue) $ RespEvent $ do+ fetchVisibleIfNeeded++ return st
+ src/Matterhorn/State/Setup/Threads.hs view
@@ -0,0 +1,367 @@+{-# LANGUAGE OverloadedStrings #-}+module Matterhorn.State.Setup.Threads+ ( startUserStatusUpdateThread+ , startTypingUsersRefreshThread+ , startSubprocessLoggerThread+ , startTimezoneMonitorThread+ , maybeStartSpellChecker+ , startAsyncWorkerThread+ , startSyntaxMapLoaderThread+ , module Matterhorn.State.Setup.Threads.Logging+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.BChan ( BChan )+import Brick.Main ( invalidateCache )+import Control.Concurrent ( threadDelay, forkIO )+import qualified Control.Concurrent.STM as STM+import Control.Concurrent.STM.Delay+import Control.Exception ( SomeException, try, fromException, catch )+import Data.List ( isInfixOf )+import qualified Data.Map as M+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import Data.Time ( getCurrentTime, addUTCTime )+import Lens.Micro.Platform ( (.=), (%=), (%~), mapped )+import Skylighting.Loader ( loadSyntaxesFromDir )+import System.Directory ( getTemporaryDirectory )+import System.Exit ( ExitCode(ExitSuccess) )+import System.IO ( hPutStrLn, hFlush )+import System.IO.Temp ( openTempFile )+import System.Timeout ( timeout )+import Text.Aspell ( Aspell, AspellOption(..), startAspell )++import Network.Mattermost.Exceptions ( RateLimitException+ , rateLimitExceptionReset )+import Network.Mattermost.Endpoints+import Network.Mattermost.Types++import Matterhorn.Constants+import Matterhorn.State.Editing ( requestSpellCheck )+import Matterhorn.State.Setup.Threads.Logging+import Matterhorn.TimeUtils ( lookupLocalTimeZone )+import Matterhorn.Types+++updateUserStatuses :: [UserId] -> Session -> IO (Maybe (MH ()))+updateUserStatuses uIds session =+ case null uIds of+ False -> do+ statuses <- mmGetUserStatusByIds (Seq.fromList uIds) session+ return $ Just $ do+ forM_ statuses $ \s ->+ setUserStatus (statusUserId s) (statusStatus s)+ True -> return Nothing++startUserStatusUpdateThread :: STM.TChan [UserId] -> Session -> RequestChan -> IO ()+startUserStatusUpdateThread zipperChan session requestChan = void $ forkIO body+ where+ seconds = (* (1000 * 1000))+ userRefreshInterval = 30+ body = refresh []+ refresh prev = do+ result <- timeout (seconds userRefreshInterval)+ (STM.atomically $ STM.readTChan zipperChan)+ let (uIds, update) = case result of+ Nothing -> (prev, True)+ Just ids -> (ids, ids /= prev)++ when update $ do+ STM.atomically $ STM.writeTChan requestChan $ do+ rs <- try $ updateUserStatuses uIds session+ case rs of+ Left (_ :: SomeException) -> return Nothing+ Right upd -> return upd++ refresh uIds++-- This thread refreshes the map of typing users every second, forever,+-- to expire users who have stopped typing. Expiry time is 3 seconds.+startTypingUsersRefreshThread :: RequestChan -> IO ()+startTypingUsersRefreshThread requestChan = void $ forkIO $ forever refresh+ where+ seconds = (* (1000 * 1000))+ refreshIntervalMicros = ceiling $ seconds $ userTypingExpiryInterval / 2+ refresh = do+ STM.atomically $ STM.writeTChan requestChan $ return $ Just $ do+ now <- liftIO getCurrentTime+ let expiry = addUTCTime (- userTypingExpiryInterval) now+ let expireUsers c = c & ccInfo.cdTypingUsers %~ expireTypingUsers expiry+ csChannels . mapped %= expireUsers++ threadDelay refreshIntervalMicros++startSubprocessLoggerThread :: STM.TChan ProgramOutput -> RequestChan -> IO ()+startSubprocessLoggerThread logChan requestChan = do+ let logMonitor mPair = do+ ProgramOutput progName args out err ec <-+ STM.atomically $ STM.readTChan logChan++ case ec == ExitSuccess of+ -- the "good" case, no output and exit sucess+ True -> logMonitor mPair+ False -> do+ (logPath, logHandle) <- case mPair of+ Just p ->+ return p+ Nothing -> do+ tmp <- getTemporaryDirectory+ openTempFile tmp "matterhorn-subprocess.log"++ hPutStrLn logHandle $+ unlines [ "Program: " <> progName+ , "Arguments: " <> show args+ , "Exit code: " <> show ec+ , "Stdout:"+ , out+ , "Stderr:"+ , err+ ]+ hFlush logHandle++ STM.atomically $ STM.writeTChan requestChan $ do+ return $ Just $ mhError $ ProgramExecutionFailed (T.pack progName)+ (T.pack logPath)++ logMonitor (Just (logPath, logHandle))++ void $ forkIO $ logMonitor Nothing++startTimezoneMonitorThread :: TimeZoneSeries -> RequestChan -> IO ()+startTimezoneMonitorThread tz requestChan = do+ -- Start the timezone monitor thread+ let timezoneMonitorSleepInterval = minutes 5+ minutes = (* (seconds 60))+ seconds = (* (1000 * 1000))+ timezoneMonitor prevTz = do+ threadDelay timezoneMonitorSleepInterval++ newTz <- lookupLocalTimeZone+ when (newTz /= prevTz) $+ STM.atomically $ STM.writeTChan requestChan $ do+ return $ Just $ do+ timeZone .= newTz+ mh invalidateCache++ timezoneMonitor newTz++ void $ forkIO (timezoneMonitor tz)++maybeStartSpellChecker :: Config -> BChan MHEvent -> IO (Maybe (Aspell, IO ()))+maybeStartSpellChecker config eventQueue = do+ case configEnableAspell config of+ False -> return Nothing+ True -> do+ let aspellOpts = catMaybes [ UseDictionary <$> (configAspellDictionary config)+ ]+ spellCheckerTimeout = 500 * 1000 -- 500k us = 500ms+ asResult <- either (const Nothing) Just <$> startAspell aspellOpts+ case asResult of+ Nothing -> return Nothing+ Just as -> do+ resetSCChan <- startSpellCheckerThread eventQueue spellCheckerTimeout+ let resetSCTimer = STM.atomically $ STM.writeTChan resetSCChan ()+ return $ Just (as, resetSCTimer)++-- Start the background spell checker delay thread.+--+-- The purpose of this thread is to postpone the spell checker query+-- while the user is actively typing and only wait until they have+-- stopped typing before bothering with a query. This is to avoid spell+-- checker queries when the editor contents are changing rapidly.+-- Avoiding such queries reduces system load and redraw frequency.+--+-- We do this by starting a thread whose job is to wait for the event+-- loop to tell it to schedule a spell check. Spell checks are scheduled+-- by writing to the channel returned by this function. The scheduler+-- thread reads from that channel and then works with another worker+-- thread as follows:+--+-- A wakeup of the main spell checker thread causes it to determine+-- whether the worker thread is already waiting on a timer. When that+-- timer goes off, a spell check will be requested. If there is already+-- an active timer that has not yet expired, the timer's expiration is+-- extended. This is the case where typing is occurring and we want to+-- continue postponing the spell check. If there is not an active timer+-- or the active timer has expired, we create a new timer and send it to+-- the worker thread for waiting.+--+-- The worker thread works by reading a timer from its queue, waiting+-- until the timer expires, and then injecting an event into the main+-- event loop to request a spell check.+startSpellCheckerThread :: BChan MHEvent+ -- ^ The main event loop's event channel.+ -> Int+ -- ^ The number of microseconds to wait before+ -- requesting a spell check.+ -> IO (STM.TChan ())+startSpellCheckerThread eventChan spellCheckTimeout = do+ delayWakeupChan <- STM.atomically STM.newTChan+ delayWorkerChan <- STM.atomically STM.newTChan+ delVar <- STM.atomically $ STM.newTVar Nothing++ -- The delay worker actually waits on the delay to expire and then+ -- requests a spell check.+ void $ forkIO $ forever $ do+ STM.atomically $ waitDelay =<< STM.readTChan delayWorkerChan+ writeBChan eventChan (RespEvent requestSpellCheck)++ -- The delay manager waits for requests to start a delay timer and+ -- signals the worker to begin waiting.+ void $ forkIO $ forever $ do+ () <- STM.atomically $ STM.readTChan delayWakeupChan++ oldDel <- STM.atomically $ STM.readTVar delVar+ mNewDel <- case oldDel of+ Nothing -> Just <$> newDelay spellCheckTimeout+ Just del -> do+ -- It's possible that between this check for expiration and+ -- the updateDelay below, the timer will expire -- at which+ -- point this will mean that we won't extend the timer as+ -- originally desired. But that's alright, because future+ -- keystrokes will trigger another timer anyway.+ expired <- tryWaitDelayIO del+ case expired of+ True -> Just <$> newDelay spellCheckTimeout+ False -> do+ updateDelay del spellCheckTimeout+ return Nothing++ case mNewDel of+ Nothing -> return ()+ Just newDel -> STM.atomically $ do+ STM.writeTVar delVar $ Just newDel+ STM.writeTChan delayWorkerChan newDel++ return delayWakeupChan++startSyntaxMapLoaderThread :: Config -> BChan MHEvent -> IO ()+startSyntaxMapLoaderThread config eventChan = void $ forkIO $ do+ -- Iterate over the configured syntax directories, loading syntax+ -- maps. Ensure that entries loaded in earlier directories in the+ -- sequence take precedence over entries loaded later.+ mMaps <- forM (configSyntaxDirs config) $ \dir -> do+ result <- try $ loadSyntaxesFromDir dir+ case result of+ Left (_::SomeException) -> return Nothing+ Right (Left _) -> return Nothing+ Right (Right m) -> return $ Just m++ let maps = catMaybes mMaps+ finalMap = foldl M.union mempty maps++ writeBChan eventChan $ RespEvent $ do+ csResources.crSyntaxMap .= finalMap+ mh invalidateCache++-------------------------------------------------------------------+-- Async worker thread++startAsyncWorkerThread :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()+startAsyncWorkerThread c r e = void $ forkIO $ asyncWorker c r e++asyncWorker :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()+asyncWorker c r e = forever $ doAsyncWork c r e++doAsyncWork :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()+doAsyncWork config requestChan eventChan = do+ let rateLimitNotify sec = do+ writeBChan eventChan $ RateLimitExceeded sec++ startWork <- case configShowBackground config of+ Disabled -> return $ return ()+ Active -> do chk <- STM.atomically $ STM.tryPeekTChan requestChan+ case chk of+ Nothing -> do writeBChan eventChan BGIdle+ return $ writeBChan eventChan $ BGBusy Nothing+ _ -> return $ return ()+ ActiveCount -> do+ chk <- STM.atomically $ do+ chanCopy <- STM.cloneTChan requestChan+ let cntMsgs = do m <- STM.tryReadTChan chanCopy+ case m of+ Nothing -> return 0+ Just _ -> (1 +) <$> cntMsgs+ cntMsgs+ case chk of+ 0 -> do writeBChan eventChan BGIdle+ return (writeBChan eventChan $ BGBusy (Just 1))+ _ -> do writeBChan eventChan $ BGBusy (Just chk)+ return $ return ()++ req <- STM.atomically $ STM.readTChan requestChan+ startWork+ -- Run the IO action with up to one additional attempt if it makes+ -- rate-limited API requests.+ res <- try $ rateLimitRetry rateLimitNotify req+ case res of+ Left e -> do+ when (not $ shouldIgnore e) $ do+ case fromException e of+ Just (_::RateLimitException) ->+ writeBChan eventChan RequestDropped+ Nothing -> do+ let err = case fromException e of+ Nothing -> AsyncErrEvent e+ Just mmErr -> ServerError mmErr+ writeBChan eventChan $ IEvent $ DisplayError err+ Right upd ->+ case upd of+ -- The IO action triggered a rate limit error but could+ -- not be retried due to rate limiting information being+ -- missing.+ Nothing -> writeBChan eventChan RateLimitSettingsMissing++ -- The IO action was run successfully but returned no+ -- state transformation.+ Just Nothing -> return ()++ -- The IO action was run successfully and returned a state+ -- transformation.+ Just (Just action) -> writeBChan eventChan (RespEvent action)++-- | Run an IO action. If the action raises a RateLimitException, invoke+-- the provided rate limit exception handler with the rate limit window+-- size (time in seconds until rate limit resets). Then block until the+-- rate limit resets and attempt to run the action one more time.+--+-- If the rate limit exception does not contain a rate limit reset+-- interval, return Nothing. Otherwise return IO action's result.+rateLimitRetry :: (Int -> IO ()) -> IO a -> IO (Maybe a)+rateLimitRetry rateLimitNotify act = do+ let retry e = do+ case rateLimitExceptionReset e of+ -- The rate limit exception contains no metadata so we+ -- cannot retry the action.+ Nothing -> return Nothing++ -- The rate limit exception contains the size of the+ -- rate limit reset interval, so block until that has+ -- passed and retry the action (only) one more time.+ Just sec -> do+ let adjusted = sec + 1+ rateLimitNotify adjusted+ threadDelay $ adjusted * 1000000+ Just <$> act++ (Just <$> act) `catch` retry++-- Filter for exceptions that we don't want to report to the user,+-- probably because they are not actionable and/or contain no useful+-- information.+--+-- E.g.+-- https://github.com/matterhorn-chat/matterhorn/issues/391+shouldIgnore :: SomeException -> Bool+shouldIgnore e =+ let eStr = show e+ in or [ "getAddrInfo" `isInfixOf` eStr+ , "Network.Socket.recvBuf" `isInfixOf` eStr+ , "Network.Socket.sendBuf" `isInfixOf` eStr+ , "resource vanished" `isInfixOf` eStr+ , "timeout" `isInfixOf` eStr+ , "partial packet" `isInfixOf` eStr+ ]
+ src/Matterhorn/State/Setup/Threads/Logging.hs view
@@ -0,0 +1,317 @@+{-# LANGUAGE RecordWildCards #-}+-- | This module implements the logging thread. This thread can+-- optionally write logged messages to an output file. When the thread+-- is created, it is initially not writing to any output file and+-- must be told to do so by issuing a LogCommand using the LogManager+-- interface.+--+-- The logging thread has an internal bounded log message buffer. Logged+-- messages always get written to the buffer (potentially evicting old+-- messages to maintain the size bound). If the thread is also writing+-- to a file, such messages also get written to the file. When the+-- thread begins logging to a file, the entire buffer is written to the+-- file so that a historical snapshot of log activity can be saved in+-- cases where logging is turned on at runtime only once a problematic+-- behavior is observed.+module Matterhorn.State.Setup.Threads.Logging+ ( newLogManager+ , shutdownLogManager+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.BChan ( BChan )+import Control.Concurrent.Async ( Async, async, wait )+import qualified Control.Concurrent.STM as STM+import Control.Exception ( SomeException, try )+import Control.Monad.State.Strict+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import Data.Time ( getCurrentTime )+import System.IO ( Handle, IOMode(AppendMode), hPutStr, hPutStrLn+ , hFlush, openFile, hClose )++import Matterhorn.Types+++-- | Used to remember the last logging output point for various log output targets.+newtype LogMemory = LogMemory { logMem :: [(FilePath, LogMessage)] }++blankLogMemory :: LogMemory+blankLogMemory = LogMemory []++-- | Adds or updates the last log message output to this destination.+-- This is used for the case when logging is re-enabled to that+-- destination to avoid repeating logging output.+--+-- The number of log memories is limited by forgetting the oldest ones+-- if over a threshold. This handles a pathological case where the+-- user has redirected output to a very large number of logfiles.+-- This is very unlikely to happen (a script or bad paste buffer+-- maybe?) but this provides defensive behavior to prevent+-- uncontrolled memory consumption in this case. The limit here is+-- arbitrary, but it should suffice and once it's reached the+-- degradation case is simply the potential for duplicated messages+-- when returning to logfiles that haven't been recently logged to.+rememberOutputPoint :: FilePath -> LogMessage -> LogMemory -> LogMemory+rememberOutputPoint logPath logMsg oldLogMem =+ LogMemory $+ take 50 $ -- upper limit on number of logpath+message memories retained+ (logPath, logMsg) : filter ((/=) logPath . fst) (logMem oldLogMem)++-- | Returns the last LogMessage logged to the specified output log+-- file path if it was previously logged to.+memoryOfOutputPath :: FilePath -> LogMemory -> Maybe LogMessage+memoryOfOutputPath p = lookup p . logMem++-- | The state of the logging thread.+data LogThreadState =+ LogThreadState { logThreadDestination :: Maybe (FilePath, Handle)+ -- ^ The logging thread's active logging destination.+ -- Nothing means log messages are not being written+ -- anywhere except the internal buffer.+ , logThreadEventChan :: BChan MHEvent+ -- ^ The application event channel that we'll use to+ -- notify of logging events.+ , logThreadCommandChan :: STM.TChan LogCommand+ -- ^ The channel on which the logging thread will+ -- wait for logging commands.+ , logThreadMessageBuffer :: Seq.Seq LogMessage+ -- ^ The internal bounded log message buffer.+ , logThreadMaxBufferSize :: Int+ -- ^ The size bound of the logThreadMessageBuffer.+ , logPreviousStopPoint :: LogMemory+ -- ^ Previous logging stop points to avoid+ -- duplication if logging is re-enabled to the same+ -- output+ }++-- | Create a new log manager and start a logging thread for it.+newLogManager :: BChan MHEvent -> Int -> IO LogManager+newLogManager eventChan maxBufferSize = do+ chan <- STM.newTChanIO+ self <- startLoggingThread eventChan chan maxBufferSize+ let mgr = LogManager { logManagerCommandChannel = chan+ , logManagerHandle = self+ }+ return mgr++-- | Shuts down the log manager and blocks until shutdown is complete.+shutdownLogManager :: LogManager -> IO ()+shutdownLogManager mgr = do+ STM.atomically $ STM.writeTChan (logManagerCommandChannel mgr) ShutdownLogging+ wait $ logManagerHandle mgr++-- | The logging thread.+startLoggingThread :: BChan MHEvent -> STM.TChan LogCommand -> Int -> IO (Async ())+startLoggingThread eventChan logChan maxBufferSize = do+ let initialState = LogThreadState { logThreadDestination = Nothing+ , logThreadEventChan = eventChan+ , logThreadCommandChan = logChan+ , logThreadMessageBuffer = mempty+ , logThreadMaxBufferSize = maxBufferSize+ , logPreviousStopPoint = blankLogMemory+ }+ async $ void $ runStateT logThreadBody initialState++logThreadBody :: StateT LogThreadState IO ()+logThreadBody = do+ cmd <- nextLogCommand+ continue <- handleLogCommand cmd+ when continue logThreadBody++-- | Get the next pending log thread command.+nextLogCommand :: StateT LogThreadState IO LogCommand+nextLogCommand = do+ chan <- gets logThreadCommandChan+ liftIO $ STM.atomically $ STM.readTChan chan++putMarkerMessage :: String -> Handle -> IO ()+putMarkerMessage msg h = do+ now <- getCurrentTime+ hPutStrLn h $ "[" <> show now <> "] " <> msg++-- | Emit a log stop marker to the file.+putLogEndMarker :: Handle -> IO ()+putLogEndMarker = putMarkerMessage "<<< Logging end >>>"++-- | Emit a log start marker to the file.+putLogStartMarker :: Handle -> IO ()+putLogStartMarker = putMarkerMessage "<<< Logging start >>>"++-- | Emit a log stop marker to the file and close it, then notify the+-- application that we have stopped logging.+finishLog :: BChan MHEvent -> FilePath -> Handle -> StateT LogThreadState IO ()+finishLog eventChan oldPath oldHandle = do+ liftIO $ do+ putLogEndMarker oldHandle+ hClose oldHandle+ writeBChan eventChan $ IEvent $ LoggingStopped oldPath+ modify $ \s ->+ let buf = logThreadMessageBuffer s+ lastLm = Seq.index buf (Seq.length buf - 1) -- n.b. putLogEndMarker ensures buf not empty+ stops = rememberOutputPoint oldPath lastLm $ logPreviousStopPoint s+ in s { logThreadDestination = Nothing+ , logPreviousStopPoint = stops+ }++stopLogOutput :: StateT LogThreadState IO ()+stopLogOutput = do+ oldDest <- gets logThreadDestination+ case oldDest of+ Nothing -> return ()+ Just (oldPath, oldHandle) -> do+ eventChan <- gets logThreadEventChan+ finishLog eventChan oldPath oldHandle++-- | Handle a single logging command.+handleLogCommand :: LogCommand -> StateT LogThreadState IO Bool+handleLogCommand (LogSnapshot path) = do+ -- LogSnapshot: write the current log message buffer to the+ -- specified path. Ignore the request if it is for the path that we+ -- are already logging to.+ eventChan <- gets logThreadEventChan+ dest <- gets logThreadDestination++ let shouldWrite = case dest of+ Nothing -> True+ Just (curPath, _) -> curPath /= path++ when shouldWrite $ do+ result <- liftIO $ try $ openFile path AppendMode+ case result of+ Left (e::SomeException) -> do+ liftIO $ writeBChan eventChan $ IEvent $ LogSnapshotFailed path (show e)+ Right handle -> do+ flushLogMessageBuffer path handle+ liftIO $ hClose handle+ liftIO $ writeBChan eventChan $ IEvent $ LogSnapshotSucceeded path++ return True+handleLogCommand GetLogDestination = do+ -- GetLogDestination: the application asked us to provide the+ -- current log destination.+ dest <- gets logThreadDestination+ eventChan <- gets logThreadEventChan+ liftIO $ writeBChan eventChan $ IEvent $ LogDestination $ fst <$> dest+ return True+handleLogCommand ShutdownLogging = do+ -- ShutdownLogging: if we were logging to a file, close it. Then+ -- unlock the shutdown lock.+ stopLogOutput+ return False+handleLogCommand StopLogging = do+ -- StopLogging: if we were logging to a file, close it and notify+ -- the application. Otherwise do nothing.+ stopLogOutput+ return True+handleLogCommand (LogToFile newPath) = do+ -- LogToFile: if we were logging to a file, close that file, notify+ -- the application, then attempt to open the new file. If that+ -- failed, notify the application of the error. If it succeeded,+ -- start logging and notify the application.+ eventChan <- gets logThreadEventChan+ oldDest <- gets logThreadDestination++ shouldChange <- case oldDest of+ Nothing ->+ return True+ Just (oldPath, _) ->+ return (oldPath /= newPath)++ when shouldChange $ do+ result <- liftIO $ try $ openFile newPath AppendMode+ case result of+ Left (e::SomeException) -> liftIO $ do+ let msg = "Error in log thread: could not open " <> show newPath <>+ ": " <> show e+ writeBChan eventChan $ IEvent $ LogStartFailed newPath msg+ Right handle -> do+ stopLogOutput++ modify $ \s -> s { logThreadDestination = Just (newPath, handle) }+ flushLogMessageBuffer newPath handle+ liftIO $ putLogStartMarker handle+ liftIO $ writeBChan eventChan $ IEvent $ LoggingStarted newPath++ return True+handleLogCommand (LogAMessage lm) = do+ -- LogAMessage: log a single message. Write the message to the+ -- bounded internal buffer (which may cause an older message to be+ -- evicted). Then, if we are actively logging to a file, write the+ -- message to that file and flush the output stream.+ maxBufSize <- gets logThreadMaxBufferSize++ let addMessageToBuffer s =+ -- Ensure that newSeq is always at most maxBufSize elements+ -- long.+ let newSeq = s Seq.|> lm+ toDrop = Seq.length s - maxBufSize+ in Seq.drop toDrop newSeq++ -- Append the message to the internal buffer, maintaining the bound+ -- on the internal buffer size.+ modify $ \s -> s { logThreadMessageBuffer = addMessageToBuffer (logThreadMessageBuffer s) }++ -- If we have an active log destination, write the message to the+ -- output file.+ dest <- gets logThreadDestination+ case dest of+ Nothing -> return ()+ Just (_, handle) -> liftIO $ do+ hPutLogMessage handle lm+ hFlush handle++ return True++-- | Write a single log message to the output handle.+hPutLogMessage :: Handle -> LogMessage -> IO ()+hPutLogMessage handle (LogMessage {..}) = do+ hPutStr handle $ "[" <> show logMessageTimestamp <> "] "+ hPutStr handle $ "[" <> show logMessageCategory <> "] "+ case logMessageContext of+ Nothing -> hPutStr handle "[*] "+ Just c -> hPutStr handle $ "[" <> show c <> "] "+ hPutStrLn handle $ T.unpack logMessageText++-- | Flush the contents of the internal log message buffer.+flushLogMessageBuffer :: FilePath -> Handle -> StateT LogThreadState IO ()+flushLogMessageBuffer pathOfHandle handle = do+ buf <- gets logThreadMessageBuffer+ when (not $ Seq.null buf) $ do+ lastPoint <- memoryOfOutputPath pathOfHandle <$> gets logPreviousStopPoint+ case lastPoint of+ Nothing ->+ -- never logged to this output point before, so dump the+ -- current internal buffer of log messages to the beginning of+ -- the output file.+ dumpBuf buf+ Just lm ->+ -- There was previous logging to this file. If the log buffer+ -- contains the entirety of the log messages during the+ -- logging disable, then just dump that portion; otherwise+ -- dump the entire buffer and indicate there may be missing+ -- entries before the buffer.+ let unseen = Seq.takeWhileR (not . (==) lm) buf+ firstM = Seq.index buf 0 -- above ensures that buf is not empty here+ in do when (Seq.length buf == Seq.length unseen) $ liftIO $+ hPutStrLn handle $ mkMsg (logMessageTimestamp firstM)+ "<<< Potentially missing log messages here... >>>"+ dumpBuf unseen+ where+ mkMsg t m = "[" <> show t <> "] " <> m+ dumpBuf buf = liftIO $ do+ let firstLm = Seq.index buf 0+ lastLm = Seq.index buf (Seq.length buf - 1)++ hPutStrLn handle $ mkMsg (logMessageTimestamp firstLm)+ "<<< Log message buffer begin >>>"++ forM_ buf (hPutLogMessage handle)++ hPutStrLn handle $ mkMsg (logMessageTimestamp lastLm)+ "<<< Log message buffer end >>>"++ hFlush handle
+ src/Matterhorn/State/ThemeListOverlay.hs view
@@ -0,0 +1,86 @@+module Matterhorn.State.ThemeListOverlay+ ( enterThemeListMode++ , themeListSelectDown+ , themeListSelectUp+ , themeListPageDown+ , themeListPageUp++ , setTheme+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick ( invalidateCache )+import Brick.Themes ( themeToAttrMap )+import qualified Brick.Widgets.List as L+import qualified Data.Text as T+import qualified Data.Vector as Vec+import Lens.Micro.Platform ( (.=) )++import Network.Mattermost.Types++import Matterhorn.State.ListOverlay+import Matterhorn.Themes+import Matterhorn.Types+++-- | Show the user list overlay with the given search scope, and issue a+-- request to gather the first search results.+enterThemeListMode :: MH ()+enterThemeListMode =+ enterListOverlayMode csThemeListOverlay ThemeListOverlay () setInternalTheme getThemesMatching++-- | Move the selection up in the user list overlay by one user.+themeListSelectUp :: MH ()+themeListSelectUp = themeListMove L.listMoveUp++-- | Move the selection down in the user list overlay by one user.+themeListSelectDown :: MH ()+themeListSelectDown = themeListMove L.listMoveDown++-- | Move the selection up in the user list overlay by a page of users+-- (themeListPageSize).+themeListPageUp :: MH ()+themeListPageUp = themeListMove (L.listMoveBy (-1 * themeListPageSize))++-- | Move the selection down in the user list overlay by a page of users+-- (themeListPageSize).+themeListPageDown :: MH ()+themeListPageDown = themeListMove (L.listMoveBy themeListPageSize)++-- | Transform the user list results in some way, e.g. by moving the+-- cursor, and then check to see whether the modification warrants a+-- prefetch of more search results.+themeListMove :: (L.List Name InternalTheme -> L.List Name InternalTheme) -> MH ()+themeListMove = listOverlayMove csThemeListOverlay++-- | The number of users in a "page" for cursor movement purposes.+themeListPageSize :: Int+themeListPageSize = 10++getThemesMatching :: ()+ -> Session+ -> Text+ -> IO (Vec.Vector InternalTheme)+getThemesMatching _ _ searchString = do+ let matching = filter matches internalThemes+ search = T.toLower searchString+ matches t = search `T.isInfixOf` T.toLower (internalThemeName t) ||+ search `T.isInfixOf` T.toLower (internalThemeDesc t)+ return $ Vec.fromList matching++setInternalTheme :: InternalTheme -> MH Bool+setInternalTheme t = do+ setTheme $ internalThemeName t+ return False++setTheme :: Text -> MH ()+setTheme name =+ case lookupTheme name of+ Nothing -> enterThemeListMode+ Just it -> do+ mh invalidateCache+ csResources.crTheme .= (themeToAttrMap $ internalTheme it)
+ src/Matterhorn/State/UrlSelect.hs view
@@ -0,0 +1,48 @@+module Matterhorn.State.UrlSelect+ (+ -- * URL selection mode+ startUrlSelect+ , stopUrlSelect+ , openSelectedURL+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick.Widgets.List ( list, listMoveTo, listSelectedElement )+import qualified Data.Vector as V+import Lens.Micro.Platform ( (.=), to )++import Matterhorn.State.Links+import Matterhorn.Types+import Matterhorn.Util+++startUrlSelect :: MH ()+startUrlSelect = do+ urls <- use (csCurrentChannel.to findUrls.to V.fromList)+ setMode UrlSelect+ csUrlList .= (listMoveTo (length urls - 1) $ list UrlList urls 2)++stopUrlSelect :: MH ()+stopUrlSelect = setMode Main++openSelectedURL :: MH ()+openSelectedURL = whenMode UrlSelect $ do+ selected <- use (csUrlList.to listSelectedElement)+ case selected of+ Nothing -> setMode Main+ Just (_, link) -> do+ opened <- openLinkTarget (link^.linkTarget)+ when (not opened) $ do+ mhError $ ConfigOptionMissing "urlOpenCommand"+ setMode Main++findUrls :: ClientChannel -> [LinkChoice]+findUrls chan =+ let msgs = chan^.ccContents.cdMessages+ in removeDuplicates $ concat $ toList $ toList <$> msgURLs <$> msgs++removeDuplicates :: [LinkChoice] -> [LinkChoice]+removeDuplicates = nubOn (\ l -> (l^.linkTarget, l^.linkUser, l^.linkLabel))
+ src/Matterhorn/State/UserListOverlay.hs view
@@ -0,0 +1,174 @@+module Matterhorn.State.UserListOverlay+ ( enterChannelMembersUserList+ , enterChannelInviteUserList+ , enterDMSearchUserList++ , userListSelectDown+ , userListSelectUp+ , userListPageDown+ , userListPageUp+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Brick.Widgets.List as L+import qualified Data.HashMap.Strict as HM+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import qualified Data.Vector as Vec+import Lens.Micro.Platform ( (.~), (.=) )++import qualified Network.Mattermost.Endpoints as MM+import qualified Network.Mattermost.Types.Config as MM+import Network.Mattermost.Types++import Matterhorn.State.Async ( doAsyncWith, AsyncPriority(Preempt) )+import Matterhorn.State.Channels ( createOrFocusDMChannel, addUserToCurrentChannel )+import Matterhorn.State.ListOverlay+import Matterhorn.Types+++-- | Show the user list overlay for searching/showing members of the+-- current channel.+enterChannelMembersUserList :: MH ()+enterChannelMembersUserList = do+ cId <- use csCurrentChannelId+ myId <- gets myUserId+ myTId <- gets myTeamId+ session <- getSession++ doAsyncWith Preempt $ do+ stats <- MM.mmGetChannelStatistics cId session+ return $ Just $ do+ enterUserListMode (ChannelMembers cId myTId) (Just $ channelStatsMemberCount stats)+ (\u -> case u^.uiId /= myId of+ True -> createOrFocusDMChannel u Nothing >> return True+ False -> return False+ )++-- | Show the user list overlay for showing users that are not members+-- of the current channel for the purpose of adding them to the+-- channel.+enterChannelInviteUserList :: MH ()+enterChannelInviteUserList = do+ cId <- use csCurrentChannelId+ myId <- gets myUserId+ myTId <- gets myTeamId+ enterUserListMode (ChannelNonMembers cId myTId) Nothing+ (\u -> case u^.uiId /= myId of+ True -> addUserToCurrentChannel u >> return True+ False -> return False+ )++-- | Show the user list overlay for showing all users for the purpose of+-- starting a direct message channel with another user.+enterDMSearchUserList :: MH ()+enterDMSearchUserList = do+ myId <- gets myUserId+ myTId <- gets myTeamId+ config <- use csClientConfig+ let restrictTeam = case MM.clientConfigRestrictDirectMessage <$> config of+ Just MM.RestrictTeam -> Just myTId+ _ -> Nothing+ enterUserListMode (AllUsers restrictTeam) Nothing+ (\u -> case u^.uiId /= myId of+ True -> createOrFocusDMChannel u Nothing >> return True+ False -> return False+ )++-- | Show the user list overlay with the given search scope, and issue a+-- request to gather the first search results.+enterUserListMode :: UserSearchScope -> Maybe Int -> (UserInfo -> MH Bool) -> MH ()+enterUserListMode scope resultCount enterHandler = do+ csUserListOverlay.listOverlayRecordCount .= resultCount+ enterListOverlayMode csUserListOverlay UserListOverlay scope enterHandler getUserSearchResults++userInfoFromPair :: User -> Text -> UserInfo+userInfoFromPair u status =+ userInfoFromUser u True & uiStatus .~ statusFromText status++-- | Move the selection up in the user list overlay by one user.+userListSelectUp :: MH ()+userListSelectUp = userListMove L.listMoveUp++-- | Move the selection down in the user list overlay by one user.+userListSelectDown :: MH ()+userListSelectDown = userListMove L.listMoveDown++-- | Move the selection up in the user list overlay by a page of users+-- (userListPageSize).+userListPageUp :: MH ()+userListPageUp = userListMove (L.listMoveBy (-1 * userListPageSize))++-- | Move the selection down in the user list overlay by a page of users+-- (userListPageSize).+userListPageDown :: MH ()+userListPageDown = userListMove (L.listMoveBy userListPageSize)++-- | Transform the user list results in some way, e.g. by moving the+-- cursor, and then check to see whether the modification warrants a+-- prefetch of more search results.+userListMove :: (L.List Name UserInfo -> L.List Name UserInfo) -> MH ()+userListMove = listOverlayMove csUserListOverlay++-- | The number of users in a "page" for cursor movement purposes.+userListPageSize :: Int+userListPageSize = 10++getUserSearchResults :: UserSearchScope+ -- ^ The scope to search+ -> Session+ -- ^ The connection session+ -> Text+ -- ^ The search string+ -> IO (Vec.Vector UserInfo)+getUserSearchResults scope s searchString = do+ -- Unfortunately, we don't get pagination control when there is a+ -- search string in effect. We'll get at most 100 results from a+ -- search.+ let query = UserSearch { userSearchTerm = if T.null searchString then " " else searchString+ -- Hack alert: Searching with the string " "+ -- above is a hack to use the search+ -- endpoint to get "all users" instead of+ -- those matching a particular non-empty+ -- non-whitespace string. This is because+ -- only the search endpoint provides a+ -- control to eliminate deleted users from+ -- the results. If we don't do this, and+ -- use the /users endpoint instead, we'll+ -- get deleted users in those results and+ -- then those deleted users will disappear+ -- from the results once the user enters a+ -- non-empty string string.+ , userSearchAllowInactive = False+ , userSearchWithoutTeam = False+ , userSearchInChannelId = case scope of+ ChannelMembers cId _ -> Just cId+ _ -> Nothing+ , userSearchNotInTeamId = Nothing+ , userSearchNotInChannelId = case scope of+ ChannelNonMembers cId _ -> Just cId+ _ -> Nothing+ , userSearchTeamId = case scope of+ AllUsers tId -> tId+ ChannelMembers _ tId -> Just tId+ ChannelNonMembers _ tId -> Just tId+ }+ users <- MM.mmSearchUsers query s++ let uList = toList users+ uIds = userId <$> uList++ -- Now fetch status info for the users we got.+ case null uList of+ False -> do+ statuses <- MM.mmGetUserStatusByIds (Seq.fromList uIds) s+ let statusMap = HM.fromList [ (statusUserId e, statusStatus e) | e <- toList statuses ]+ usersWithStatus = [ userInfoFromPair u (fromMaybe "" $ HM.lookup (userId u) statusMap)+ | u <- uList+ ]++ return $ Vec.fromList usersWithStatus+ True -> return mempty
+ src/Matterhorn/State/Users.hs view
@@ -0,0 +1,99 @@+{-# LANGUAGE OverloadedStrings #-}+module Matterhorn.State.Users+ ( handleNewUsers+ , handleTypingUser+ , withFetchedUser+ , withFetchedUserMaybe+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Text as T+import qualified Data.Foldable as F+import qualified Data.Sequence as Seq+import Data.Time ( getCurrentTime )+import Lens.Micro.Platform++import qualified Network.Mattermost.Endpoints as MM+import Network.Mattermost.Types++import Matterhorn.Config+import Matterhorn.Types+import Matterhorn.State.Common+++handleNewUsers :: Seq UserId -> MH () -> MH ()+handleNewUsers newUserIds after = do+ doAsyncMM Preempt getUserInfo addNewUsers+ where getUserInfo session =+ do nUsers <- MM.mmGetUsersByIds newUserIds session+ let usrInfo u = userInfoFromUser u True+ usrList = toList nUsers+ return $ usrInfo <$> usrList++ addNewUsers :: [UserInfo] -> Maybe (MH ())+ addNewUsers is = Just $ mapM_ addNewUser is >> after++-- | Handle the typing events from the websocket to show the currently+-- typing users on UI+handleTypingUser :: UserId -> ChannelId -> MH ()+handleTypingUser uId cId = do+ config <- use (csResources.crConfiguration)+ when (configShowTypingIndicator config) $ do+ withFetchedUser (UserFetchById uId) $ const $ do+ ts <- liftIO getCurrentTime+ csChannels %= modifyChannelById cId (addChannelTypingUser uId ts)++-- | Given a user fetching strategy, locate the user in the state or+-- fetch it from the server, and pass the result to the specified+-- action. Assumes a single match is the only expected/valid result.+withFetchedUser :: UserFetch -> (UserInfo -> MH ()) -> MH ()+withFetchedUser fetch handle =+ withFetchedUserMaybe fetch $ \u -> do+ case u of+ Nothing -> postErrorMessage' "No such user"+ Just user -> handle user++withFetchedUserMaybe :: UserFetch -> (Maybe UserInfo -> MH ()) -> MH ()+withFetchedUserMaybe fetch handle = do+ st <- use id+ session <- getSession++ let localMatch = case fetch of+ UserFetchById uId -> userById uId st+ UserFetchByUsername uname -> userByUsername uname st+ UserFetchByNickname nick -> userByNickname nick st++ case localMatch of+ Just user -> handle $ Just user+ Nothing -> do+ mhLog LogGeneral $ T.pack $ "withFetchedUserMaybe: getting " <> show fetch+ doAsyncWith Normal $ do+ results <- case fetch of+ UserFetchById uId ->+ MM.mmGetUsersByIds (Seq.singleton uId) session+ UserFetchByUsername uname ->+ MM.mmGetUsersByUsernames (Seq.singleton $ trimUserSigil uname) session+ UserFetchByNickname nick -> do+ let req = UserSearch { userSearchTerm = trimUserSigil nick+ , userSearchAllowInactive = True+ , userSearchWithoutTeam = True+ , userSearchInChannelId = Nothing+ , userSearchNotInTeamId = Nothing+ , userSearchNotInChannelId = Nothing+ , userSearchTeamId = Nothing+ }+ MM.mmSearchUsers req session++ return $ Just $ do+ infos <- forM (F.toList results) $ \u -> do+ let info = userInfoFromUser u True+ addNewUser info+ return info++ case infos of+ [match] -> handle $ Just match+ [] -> handle Nothing+ _ -> postErrorMessage' "Error: ambiguous user information"
+ src/Matterhorn/TeamSelect.hs view
@@ -0,0 +1,90 @@+module Matterhorn.TeamSelect+ ( interactiveTeamSelection+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border+import Brick.Widgets.Center+import Brick.Widgets.List+import qualified Data.Function as F+import Data.List ( sortBy )+import qualified Data.Vector as V+import Graphics.Vty hiding (mkVty)+import qualified Data.Text as T++import Network.Mattermost.Types++import Matterhorn.Draw.RichText+import Matterhorn.Types.Common+++data State =+ State { appList :: List () Team+ , appCancelled :: Bool+ }++interactiveTeamSelection :: Vty -> IO Vty -> [Team] -> IO (Maybe Team, Vty)+interactiveTeamSelection vty mkVty teams = do+ let state = State { appList = list () (V.fromList sortedTeams) 1+ , appCancelled = False+ }+ sortedTeams = sortBy (compare `F.on` teamName) teams++ (finalSt, finalVty) <- customMainWithVty vty mkVty Nothing app state++ let result = if appCancelled finalSt+ then Nothing+ else snd <$> listSelectedElement (appList finalSt)++ return (result, finalVty)++app :: App State e ()+app = App+ { appDraw = teamSelectDraw+ , appChooseCursor = neverShowCursor+ , appHandleEvent = onEvent+ , appStartEvent = return+ , appAttrMap = const colorTheme+ }++colorTheme :: AttrMap+colorTheme = attrMap defAttr+ [ (listSelectedFocusedAttr, black `on` yellow)+ ]++teamSelectDraw :: State -> [Widget ()]+teamSelectDraw st =+ [ teamSelect st+ ]++teamSelect :: State -> Widget ()+teamSelect st =+ center $ hLimit 50 $ vLimit 15 $+ vBox [ hCenter $ txt "Welcome to Mattermost. Please select a team:"+ , txt " "+ , border theList+ , txt " "+ , renderText "Press Enter to select a team and connect or Esc to exit."+ ]+ where+ theList = renderList renderTeamItem True (appList st)++renderTeamItem :: Bool -> Team -> Widget ()+renderTeamItem _ t =+ padRight Max $ txt $ (sanitizeUserText $ teamName t) <>+ if not $ T.null (sanitizeUserText $ teamDisplayName t)+ then " (" <> (sanitizeUserText $ teamDisplayName t) <> ")"+ else ""++onEvent :: State -> BrickEvent () e -> EventM () (Next State)+onEvent st (VtyEvent (EvKey KEsc [])) = do+ halt $ st { appCancelled = True }+onEvent st (VtyEvent (EvKey KEnter [])) = halt st+onEvent st (VtyEvent e) = do+ list' <- handleListEvent e (appList st)+ continue $ st { appList = list' }+onEvent st _ = continue st
+ src/Matterhorn/Themes.hs view
@@ -0,0 +1,808 @@+{-# LANGUAGE OverloadedStrings #-}+module Matterhorn.Themes+ ( InternalTheme(..)++ , defaultTheme+ , internalThemes+ , lookupTheme+ , themeDocs++ -- * Attribute names+ , currentUserAttr+ , timeAttr+ , channelHeaderAttr+ , channelListHeaderAttr+ , currentChannelNameAttr+ , unreadChannelAttr+ , unreadGroupMarkerAttr+ , mentionsChannelAttr+ , urlAttr+ , codeAttr+ , emailAttr+ , emojiAttr+ , channelNameAttr+ , clientMessageAttr+ , clientHeaderAttr+ , strikeThroughAttr+ , clientEmphAttr+ , clientStrongAttr+ , dateTransitionAttr+ , pinnedMessageIndicatorAttr+ , newMessageTransitionAttr+ , gapMessageAttr+ , errorMessageAttr+ , helpAttr+ , helpEmphAttr+ , channelSelectPromptAttr+ , channelSelectMatchAttr+ , completionAlternativeListAttr+ , completionAlternativeCurrentAttr+ , permalinkAttr+ , dialogAttr+ , dialogEmphAttr+ , recentMarkerAttr+ , replyParentAttr+ , loadMoreAttr+ , urlListSelectedAttr+ , messageSelectAttr+ , messageSelectStatusAttr+ , misspellingAttr+ , editedMarkingAttr+ , editedRecentlyMarkingAttr+ , tabSelectedAttr+ , tabUnselectedAttr+ , buttonAttr+ , buttonFocusedAttr++ -- * Username formatting+ , colorUsername+ , attrForUsername+ , usernameColorHashBuckets+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Themes+import Brick.Widgets.List+import qualified Brick.Widgets.FileBrowser as FB+import Brick.Widgets.Skylighting ( attrNameForTokenType+ , attrMappingsForStyle+ , highlightedCodeBlockAttr+ )+import Brick.Forms ( focusedFormInputAttr )+import Data.Hashable ( hash )+import qualified Data.Map as M+import qualified Data.Text as T+import Graphics.Vty+import qualified Skylighting.Styles as Sky+import Skylighting.Types ( TokenType(..) )++import Matterhorn.Types ( InternalTheme(..), specialUserMentions )+++helpAttr :: AttrName+helpAttr = "help"++helpEmphAttr :: AttrName+helpEmphAttr = "helpEmphasis"++recentMarkerAttr :: AttrName+recentMarkerAttr = "recentChannelMarker"++replyParentAttr :: AttrName+replyParentAttr = "replyParentPreview"++pinnedMessageIndicatorAttr :: AttrName+pinnedMessageIndicatorAttr = "pinnedMessageIndicator"++loadMoreAttr :: AttrName+loadMoreAttr = "loadMoreMessages"++urlListSelectedAttr :: AttrName+urlListSelectedAttr = "urlListCursor"++messageSelectAttr :: AttrName+messageSelectAttr = "messageSelectCursor"++editedMarkingAttr :: AttrName+editedMarkingAttr = "editedMarking"++editedRecentlyMarkingAttr :: AttrName+editedRecentlyMarkingAttr = "editedRecentlyMarking"++permalinkAttr :: AttrName+permalinkAttr = "permalink"++dialogAttr :: AttrName+dialogAttr = "dialog"++dialogEmphAttr :: AttrName+dialogEmphAttr = "dialogEmphasis"++channelSelectMatchAttr :: AttrName+channelSelectMatchAttr = "channelSelectMatch"++channelSelectPromptAttr :: AttrName+channelSelectPromptAttr = "channelSelectPrompt"++completionAlternativeListAttr :: AttrName+completionAlternativeListAttr = "tabCompletionAlternative"++completionAlternativeCurrentAttr :: AttrName+completionAlternativeCurrentAttr = "tabCompletionCursor"++timeAttr :: AttrName+timeAttr = "time"++currentUserAttr :: AttrName+currentUserAttr = "currentUser"++channelHeaderAttr :: AttrName+channelHeaderAttr = "channelHeader"++channelListHeaderAttr :: AttrName+channelListHeaderAttr = "channelListSectionHeader"++currentChannelNameAttr :: AttrName+currentChannelNameAttr = "currentChannelName"++channelNameAttr :: AttrName+channelNameAttr = "channelName"++unreadChannelAttr :: AttrName+unreadChannelAttr = "unreadChannel"++unreadGroupMarkerAttr :: AttrName+unreadGroupMarkerAttr = "unreadChannelGroupMarker"++mentionsChannelAttr :: AttrName+mentionsChannelAttr = "channelWithMentions"++tabSelectedAttr :: AttrName+tabSelectedAttr = "tabSelected"++tabUnselectedAttr :: AttrName+tabUnselectedAttr = "tabUnselected"++dateTransitionAttr :: AttrName+dateTransitionAttr = "dateTransition"++newMessageTransitionAttr :: AttrName+newMessageTransitionAttr = "newMessageTransition"++urlAttr :: AttrName+urlAttr = "url"++codeAttr :: AttrName+codeAttr = "codeBlock"++emailAttr :: AttrName+emailAttr = "email"++emojiAttr :: AttrName+emojiAttr = "emoji"++clientMessageAttr :: AttrName+clientMessageAttr = "clientMessage"++clientHeaderAttr :: AttrName+clientHeaderAttr = "markdownHeader"++strikeThroughAttr :: AttrName+strikeThroughAttr = "markdownStrikethrough"++clientEmphAttr :: AttrName+clientEmphAttr = "markdownEmph"++clientStrongAttr :: AttrName+clientStrongAttr = "markdownStrong"++errorMessageAttr :: AttrName+errorMessageAttr = "errorMessage"++gapMessageAttr :: AttrName+gapMessageAttr = "gapMessage"++misspellingAttr :: AttrName+misspellingAttr = "misspelling"++messageSelectStatusAttr :: AttrName+messageSelectStatusAttr = "messageSelectStatus"++buttonAttr :: AttrName+buttonAttr = "button"++buttonFocusedAttr :: AttrName+buttonFocusedAttr = buttonAttr <> "focused"++lookupTheme :: Text -> Maybe InternalTheme+lookupTheme n = find ((== n) . internalThemeName) internalThemes++internalThemes :: [InternalTheme]+internalThemes = validateInternalTheme <$>+ [ darkColorTheme+ , darkColor256Theme+ , lightColorTheme+ , lightColor256Theme+ ]++validateInternalTheme :: InternalTheme -> InternalTheme+validateInternalTheme it =+ let un = undocumentedAttrNames (internalTheme it)+ in if not $ null un+ then error $ "Internal theme " <> show (T.unpack (internalThemeName it)) <>+ " references undocumented attribute names: " <> show un+ else it++undocumentedAttrNames :: Theme -> [AttrName]+undocumentedAttrNames t =+ let noDocs k = isNothing $ attrNameDescription themeDocs k+ in filter noDocs (M.keys $ themeDefaultMapping t)++defaultTheme :: InternalTheme+defaultTheme = darkColorTheme++lightColorTheme :: InternalTheme+lightColorTheme = InternalTheme name theme desc+ where+ theme = newTheme def $ lightAttrs usernameColors16+ name = "builtin:light"+ def = black `on` white+ desc = "A color theme for terminal windows with light background colors"++lightColor256Theme :: InternalTheme+lightColor256Theme = InternalTheme name theme desc+ where+ theme = newTheme def $ lightAttrs usernameColors256+ name = "builtin:light256"+ def = black `on` white+ desc = "Like builtin:light, but with 256-color username colors"++lightAttrs :: [Attr] -> [(AttrName, Attr)]+lightAttrs usernameColors =+ let sty = Sky.kate+ in [ (timeAttr, fg black)+ , (buttonAttr, black `on` cyan)+ , (buttonFocusedAttr, black `on` yellow)+ , (currentUserAttr, defAttr `withStyle` bold)+ , (channelHeaderAttr, fg black)+ , (channelListHeaderAttr, fg cyan)+ , (currentChannelNameAttr, black `on` yellow `withStyle` bold)+ , (unreadChannelAttr, black `on` cyan `withStyle` bold)+ , (unreadGroupMarkerAttr, fg black `withStyle` bold)+ , (mentionsChannelAttr, black `on` red `withStyle` bold)+ , (urlAttr, fg brightYellow)+ , (emailAttr, fg yellow)+ , (codeAttr, fg magenta)+ , (emojiAttr, fg yellow)+ , (channelNameAttr, fg blue)+ , (clientMessageAttr, fg black)+ , (clientEmphAttr, fg black `withStyle` bold)+ , (clientStrongAttr, fg black `withStyle` bold `withStyle` underline)+ , (clientHeaderAttr, fg red `withStyle` bold)+ , (strikeThroughAttr, defAttr `withStyle` strikethrough)+ , (dateTransitionAttr, fg green)+ , (newMessageTransitionAttr, black `on` yellow)+ , (errorMessageAttr, fg red)+ , (gapMessageAttr, fg red)+ , (helpAttr, fg black)+ , (pinnedMessageIndicatorAttr, black `on` cyan)+ , (helpEmphAttr, fg blue `withStyle` bold)+ , (channelSelectMatchAttr, black `on` magenta `withStyle` underline)+ , (channelSelectPromptAttr, fg black)+ , (completionAlternativeListAttr, white `on` blue)+ , (completionAlternativeCurrentAttr, black `on` yellow)+ , (dialogAttr, black `on` cyan)+ , (dialogEmphAttr, fg white)+ , (permalinkAttr, fg green)+ , (listSelectedFocusedAttr, black `on` yellow)+ , (recentMarkerAttr, fg black `withStyle` bold)+ , (loadMoreAttr, black `on` cyan)+ , (urlListSelectedAttr, black `on` yellow)+ , (messageSelectAttr, black `on` yellow)+ , (messageSelectStatusAttr, fg black)+ , (misspellingAttr, fg red `withStyle` underline)+ , (editedMarkingAttr, fg yellow)+ , (editedRecentlyMarkingAttr, black `on` yellow)+ , (tabSelectedAttr, black `on` yellow)+ , (focusedFormInputAttr, black `on` yellow)+ , (FB.fileBrowserCurrentDirectoryAttr, white `on` blue)+ , (FB.fileBrowserSelectionInfoAttr, white `on` blue)+ , (FB.fileBrowserDirectoryAttr, fg blue)+ , (FB.fileBrowserBlockDeviceAttr, fg magenta)+ , (FB.fileBrowserCharacterDeviceAttr, fg green)+ , (FB.fileBrowserNamedPipeAttr, fg yellow)+ , (FB.fileBrowserSymbolicLinkAttr, fg cyan)+ , (FB.fileBrowserUnixSocketAttr, fg red)+ ] <>+ ((\(i, a) -> (usernameAttr i, a)) <$> zip [0..usernameColorHashBuckets-1] (cycle usernameColors)) <>+ (filter skipBaseCodeblockAttr $ attrMappingsForStyle sty)++darkAttrs :: [Attr] -> [(AttrName, Attr)]+darkAttrs usernameColors =+ let sty = Sky.espresso+ in [ (timeAttr, fg white)+ , (buttonAttr, black `on` cyan)+ , (buttonFocusedAttr, black `on` yellow)+ , (currentUserAttr, defAttr `withStyle` bold)+ , (channelHeaderAttr, fg white)+ , (channelListHeaderAttr, fg cyan)+ , (currentChannelNameAttr, black `on` yellow `withStyle` bold)+ , (unreadChannelAttr, black `on` cyan `withStyle` bold)+ , (unreadGroupMarkerAttr, fg white `withStyle` bold)+ , (mentionsChannelAttr, black `on` brightMagenta `withStyle` bold)+ , (urlAttr, fg yellow)+ , (emailAttr, fg yellow)+ , (codeAttr, fg magenta)+ , (emojiAttr, fg yellow)+ , (channelNameAttr, fg cyan)+ , (pinnedMessageIndicatorAttr, fg cyan `withStyle` bold)+ , (clientMessageAttr, fg white)+ , (clientEmphAttr, fg white `withStyle` bold)+ , (clientStrongAttr, fg white `withStyle` bold `withStyle` underline)+ , (clientHeaderAttr, fg red `withStyle` bold)+ , (strikeThroughAttr, defAttr `withStyle` strikethrough)+ , (dateTransitionAttr, fg green)+ , (newMessageTransitionAttr, fg yellow `withStyle` bold)+ , (errorMessageAttr, fg red)+ , (gapMessageAttr, black `on` yellow)+ , (helpAttr, fg white)+ , (helpEmphAttr, fg cyan `withStyle` bold)+ , (channelSelectMatchAttr, black `on` magenta `withStyle` underline)+ , (channelSelectPromptAttr, fg white)+ , (completionAlternativeListAttr, white `on` blue)+ , (completionAlternativeCurrentAttr, black `on` yellow)+ , (dialogAttr, black `on` cyan)+ , (dialogEmphAttr, fg white)+ , (permalinkAttr, fg brightCyan)+ , (listSelectedFocusedAttr, black `on` yellow)+ , (recentMarkerAttr, fg yellow `withStyle` bold)+ , (loadMoreAttr, black `on` cyan)+ , (urlListSelectedAttr, black `on` yellow)+ , (messageSelectAttr, black `on` yellow)+ , (messageSelectStatusAttr, fg white)+ , (misspellingAttr, fg red `withStyle` underline)+ , (editedMarkingAttr, fg yellow)+ , (editedRecentlyMarkingAttr, black `on` yellow)+ , (tabSelectedAttr, black `on` yellow)+ , (focusedFormInputAttr, black `on` yellow)+ , (FB.fileBrowserCurrentDirectoryAttr, white `on` blue)+ , (FB.fileBrowserSelectionInfoAttr, white `on` blue)+ , (FB.fileBrowserDirectoryAttr, fg blue)+ , (FB.fileBrowserBlockDeviceAttr, fg magenta)+ , (FB.fileBrowserCharacterDeviceAttr, fg green)+ , (FB.fileBrowserNamedPipeAttr, fg yellow)+ , (FB.fileBrowserSymbolicLinkAttr, fg cyan)+ , (FB.fileBrowserUnixSocketAttr, fg red)+ ] <>+ ((\(i, a) -> (usernameAttr i, a)) <$> zip [0..usernameColorHashBuckets-1] (cycle usernameColors)) <>+ (filter skipBaseCodeblockAttr $ attrMappingsForStyle sty)++skipBaseCodeblockAttr :: (AttrName, Attr) -> Bool+skipBaseCodeblockAttr = ((/= highlightedCodeBlockAttr) . fst)++darkColorTheme :: InternalTheme+darkColorTheme = InternalTheme name theme desc+ where+ theme = newTheme def $ darkAttrs usernameColors16+ name = "builtin:dark"+ def = defAttr+ desc = "A color theme for terminal windows with dark background colors"++darkColor256Theme :: InternalTheme+darkColor256Theme = InternalTheme name theme desc+ where+ theme = newTheme def $ darkAttrs usernameColors256+ name = "builtin:dark256"+ def = defAttr+ desc = "Like builtin:dark, but with 256-color username colors"++usernameAttr :: Int -> AttrName+usernameAttr i = "username" <> (attrName $ show i)++-- | Render a string with a color chosen based on the text of a+-- username.+--+-- This function takes some display text and renders it using an+-- attribute based on the username associated with the text. If the+-- username associated with the text is equal to the username of+-- the user running Matterhorn, the display text is formatted with+-- 'currentAttr'. Otherwise it is formatted with an attribute chosen+-- by hashing the associated username and choosing from amongst the+-- username color hash buckets with 'usernameAttr'.+--+-- Usually the first argument to this function will be @myUsername st@,+-- where @st@ is a 'ChatState'.+--+-- The most common way to call this function is+--+-- @colorUsername (myUsername st) u u+--+-- The third argument is allowed to vary from the second since sometimes+-- we call this with the user's status sigil as the third argument.+colorUsername :: Text+ -- ^ The username for the user currently running+ -- Matterhorn+ -> Text+ -- ^ The username associated with the text to render+ -> Text+ -- ^ The text to render+ -> Widget a+colorUsername current username display =+ let aName = attrForUsername username+ maybeWithCurrentAttr = if current == username+ then withAttr currentUserAttr+ else id+ in withDefAttr aName $+ maybeWithCurrentAttr $+ txt (display)++-- | Return the attribute name to use for the specified username.+-- The input username is expected to be the username only (i.e. no+-- sigil).+--+-- If the input username is a special reserved username such as "all",+-- the @clientEmphAttr@ attribute name will be returned. Otherwise+-- a hash-bucket username attribute name will be returned based on+-- the hash value of the username and the number of hash buckets+-- (@usernameColorHashBuckets@).+attrForUsername :: Text+ -- ^ The username to get an attribute for+ -> AttrName+attrForUsername username =+ let normalizedUsername = T.toLower username+ aName = if normalizedUsername `elem` specialUserMentions+ then clientEmphAttr+ else usernameAttr h+ h = hash normalizedUsername `mod` usernameColorHashBuckets+ in aName++-- | The number of hash buckets to use when hashing usernames to choose+-- their colors.+usernameColorHashBuckets :: Int+usernameColorHashBuckets = 50++usernameColors16 :: [Attr]+usernameColors16 =+ [ fg red+ , fg green+ , fg yellow+ , fg blue+ , fg magenta+ , fg cyan+ , fg brightRed+ , fg brightGreen+ , fg brightYellow+ , fg brightBlue+ , fg brightMagenta+ , fg brightCyan+ ]++usernameColors256 :: [Attr]+usernameColors256 = mkColor <$> username256ColorChoices+ where+ mkColor (r, g, b) = defAttr `withForeColor` rgbColor r g b++username256ColorChoices :: [(Integer, Integer, Integer)]+username256ColorChoices =+ [ (255, 0, 86)+ , (158, 0, 142)+ , (14, 76, 161)+ , (255, 229, 2)+ , (149, 0, 58)+ , (255, 147, 126)+ , (164, 36, 0)+ , (98, 14, 0)+ , (0, 0, 255)+ , (106, 130, 108)+ , (0, 174, 126)+ , (194, 140, 159)+ , (0, 143, 156)+ , (95, 173, 78)+ , (255, 2, 157)+ , (255, 116, 163)+ , (152, 255, 82)+ , (167, 87, 64)+ , (254, 137, 0)+ , (1, 208, 255)+ , (187, 136, 0)+ , (117, 68, 177)+ , (165, 255, 210)+ , (122, 71, 130)+ , (0, 71, 84)+ , (181, 0, 255)+ , (144, 251, 146)+ , (189, 211, 147)+ , (229, 111, 254)+ , (222, 255, 116)+ , (0, 255, 120)+ , (0, 155, 255)+ , (0, 100, 1)+ , (0, 118, 255)+ , (133, 169, 0)+ , (0, 185, 23)+ , (120, 130, 49)+ , (0, 255, 198)+ , (255, 110, 65)+ ]++-- Functions for dealing with Skylighting styles++attrNameDescription :: ThemeDocumentation -> AttrName -> Maybe Text+attrNameDescription td an = M.lookup an (themeDescriptions td)++themeDocs :: ThemeDocumentation+themeDocs = ThemeDocumentation $ M.fromList $+ [ ( timeAttr+ , "Timestamps on chat messages"+ )+ , ( channelHeaderAttr+ , "Channel headers displayed above chat message lists"+ )+ , ( channelListHeaderAttr+ , "The heading of the channel list sections"+ )+ , ( currentChannelNameAttr+ , "The currently selected channel in the channel list"+ )+ , ( unreadChannelAttr+ , "A channel in the channel list with unread messages"+ )+ , ( unreadGroupMarkerAttr+ , "The channel group marker indicating unread messages"+ )+ , ( mentionsChannelAttr+ , "A channel in the channel list with unread mentions"+ )+ , ( urlAttr+ , "A URL in a chat message"+ )+ , ( codeAttr+ , "A code block in a chat message with no language indication"+ )+ , ( emailAttr+ , "An e-mail address in a chat message"+ )+ , ( emojiAttr+ , "A text emoji indication in a chat message"+ )+ , ( channelNameAttr+ , "A channel name in a chat message"+ )+ , ( clientMessageAttr+ , "A Matterhorn diagnostic or informative message"+ )+ , ( clientHeaderAttr+ , "Markdown heading"+ )+ , ( strikeThroughAttr+ , "Markdown strikethrough text"+ )+ , ( clientEmphAttr+ , "Markdown 'emphasized' text"+ )+ , ( clientStrongAttr+ , "Markdown 'strong' text"+ )+ , ( dateTransitionAttr+ , "Date transition lines between chat messages on different days"+ )+ , ( pinnedMessageIndicatorAttr+ , "The indicator for messages that have been pinned"+ )+ , ( newMessageTransitionAttr+ , "The 'New Messages' line that appears above unread messages"+ )+ , ( tabSelectedAttr+ , "Selected tabs in tabbed windows"+ )+ , ( tabUnselectedAttr+ , "Unselected tabs in tabbed windows"+ )+ , ( errorMessageAttr+ , "Matterhorn error messages"+ )+ , ( gapMessageAttr+ , "Matterhorn message gap information"+ )+ , ( helpAttr+ , "The help screen text"+ )+ , ( helpEmphAttr+ , "The help screen's emphasized text"+ )+ , ( channelSelectPromptAttr+ , "Channel selection: prompt"+ )+ , ( channelSelectMatchAttr+ , "Channel selection: the portion of a channel name that matches"+ )+ , ( completionAlternativeListAttr+ , "Tab completion alternatives"+ )+ , ( completionAlternativeCurrentAttr+ , "The currently-selected tab completion alternative"+ )+ , ( permalinkAttr+ , "A post permalink"+ )+ , ( dialogAttr+ , "Dialog box text"+ )+ , ( dialogEmphAttr+ , "Dialog box emphasized text"+ )+ , ( recentMarkerAttr+ , "The marker indicating the channel last visited"+ )+ , ( replyParentAttr+ , "The first line of parent messages appearing above reply messages"+ )+ , ( loadMoreAttr+ , "The 'Load More' line that appears at the top of a chat message list"+ )+ , ( urlListSelectedAttr+ , "URL list: the selected URL"+ )+ , ( messageSelectAttr+ , "Message selection: the currently-selected message"+ )+ , ( messageSelectStatusAttr+ , "Message selection: the message selection actions"+ )+ , ( misspellingAttr+ , "A misspelled word in the chat message editor"+ )+ , ( editedMarkingAttr+ , "The 'edited' marking that appears on edited messages"+ )+ , ( editedRecentlyMarkingAttr+ , "The 'edited' marking that appears on newly-edited messages"+ )+ , ( highlightedCodeBlockAttr+ , "The base attribute for syntax-highlighted code blocks"+ )+ , ( attrNameForTokenType KeywordTok+ , "Syntax highlighting: Keyword"+ )+ , ( attrNameForTokenType DataTypeTok+ , "Syntax highlighting: DataType"+ )+ , ( attrNameForTokenType DecValTok+ , "Syntax highlighting: Declaration"+ )+ , ( attrNameForTokenType BaseNTok+ , "Syntax highlighting: BaseN"+ )+ , ( attrNameForTokenType FloatTok+ , "Syntax highlighting: Float"+ )+ , ( attrNameForTokenType ConstantTok+ , "Syntax highlighting: Constant"+ )+ , ( attrNameForTokenType CharTok+ , "Syntax highlighting: Char"+ )+ , ( attrNameForTokenType SpecialCharTok+ , "Syntax highlighting: Special Char"+ )+ , ( attrNameForTokenType StringTok+ , "Syntax highlighting: String"+ )+ , ( attrNameForTokenType VerbatimStringTok+ , "Syntax highlighting: Verbatim String"+ )+ , ( attrNameForTokenType SpecialStringTok+ , "Syntax highlighting: Special String"+ )+ , ( attrNameForTokenType ImportTok+ , "Syntax highlighting: Import"+ )+ , ( attrNameForTokenType CommentTok+ , "Syntax highlighting: Comment"+ )+ , ( attrNameForTokenType DocumentationTok+ , "Syntax highlighting: Documentation"+ )+ , ( attrNameForTokenType AnnotationTok+ , "Syntax highlighting: Annotation"+ )+ , ( attrNameForTokenType CommentVarTok+ , "Syntax highlighting: Comment"+ )+ , ( attrNameForTokenType OtherTok+ , "Syntax highlighting: Other"+ )+ , ( attrNameForTokenType FunctionTok+ , "Syntax highlighting: Function"+ )+ , ( attrNameForTokenType VariableTok+ , "Syntax highlighting: Variable"+ )+ , ( attrNameForTokenType ControlFlowTok+ , "Syntax highlighting: Control Flow"+ )+ , ( attrNameForTokenType OperatorTok+ , "Syntax highlighting: Operator"+ )+ , ( attrNameForTokenType BuiltInTok+ , "Syntax highlighting: Built-In"+ )+ , ( attrNameForTokenType ExtensionTok+ , "Syntax highlighting: Extension"+ )+ , ( attrNameForTokenType PreprocessorTok+ , "Syntax highlighting: Preprocessor"+ )+ , ( attrNameForTokenType AttributeTok+ , "Syntax highlighting: Attribute"+ )+ , ( attrNameForTokenType RegionMarkerTok+ , "Syntax highlighting: Region Marker"+ )+ , ( attrNameForTokenType InformationTok+ , "Syntax highlighting: Information"+ )+ , ( attrNameForTokenType WarningTok+ , "Syntax highlighting: Warning"+ )+ , ( attrNameForTokenType AlertTok+ , "Syntax highlighting: Alert"+ )+ , ( attrNameForTokenType ErrorTok+ , "Syntax highlighting: Error"+ )+ , ( attrNameForTokenType NormalTok+ , "Syntax highlighting: Normal text"+ )+ , ( listSelectedFocusedAttr+ , "The selected channel"+ )+ , ( focusedFormInputAttr+ , "A form input that has focus"+ )+ , ( FB.fileBrowserAttr+ , "The base file browser attribute"+ )+ , ( FB.fileBrowserCurrentDirectoryAttr+ , "The file browser current directory attribute"+ )+ , ( FB.fileBrowserSelectionInfoAttr+ , "The file browser selection information attribute"+ )+ , ( FB.fileBrowserDirectoryAttr+ , "Attribute for directories in the file browser"+ )+ , ( FB.fileBrowserBlockDeviceAttr+ , "Attribute for block devices in the file browser"+ )+ , ( FB.fileBrowserRegularFileAttr+ , "Attribute for regular files in the file browser"+ )+ , ( FB.fileBrowserCharacterDeviceAttr+ , "Attribute for character devices in the file browser"+ )+ , ( FB.fileBrowserNamedPipeAttr+ , "Attribute for named pipes in the file browser"+ )+ , ( FB.fileBrowserSymbolicLinkAttr+ , "Attribute for symbolic links in the file browser"+ )+ , ( FB.fileBrowserUnixSocketAttr+ , "Attribute for Unix sockets in the file browser"+ )+ , ( buttonAttr+ , "Attribute for input form buttons"+ )+ , ( buttonFocusedAttr+ , "Attribute for focused input form buttons"+ )+ , ( currentUserAttr+ , "Attribute for the username of the user running Matterhorn"+ )+ ] <> [ (usernameAttr i, T.pack $ "Username color " <> show i)+ | i <- [0..usernameColorHashBuckets-1]+ ]
+ src/Matterhorn/TimeUtils.hs view
@@ -0,0 +1,70 @@+module Matterhorn.TimeUtils+ ( lookupLocalTimeZone+ , startOfDay+ , justAfter, justBefore+ , asLocalTime+ , localTimeText+ , originTime+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Text as T+import Data.Time.Clock ( UTCTime(..) )+import Data.Time.Format ( formatTime, defaultTimeLocale )+import Data.Time.LocalTime ( LocalTime(..), TimeOfDay(..) )+import Data.Time.LocalTime.TimeZone.Olson ( getTimeZoneSeriesFromOlsonFile )+import Data.Time.LocalTime.TimeZone.Series ( localTimeToUTC'+ , utcToLocalTime')++import Network.Mattermost.Types ( ServerTime(..) )+++-- | Get the timezone series that should be used for converting UTC+-- times into local times with appropriate DST adjustments.+lookupLocalTimeZone :: IO TimeZoneSeries+lookupLocalTimeZone = getTimeZoneSeriesFromOlsonFile "/etc/localtime"+++-- | Sometimes it is convenient to render a divider between messages;+-- the 'justAfter' function can be used to get a time that is after+-- the input time but by such a small increment that there is unlikely+-- to be anything between (or at) the result. Adding the divider+-- using this timestamp value allows the general sorting based on+-- timestamps to operate normally (whereas a type-match for a+-- non-timestamp-entry in the sort operation would be considerably+-- more complex).+justAfter :: ServerTime -> ServerTime+justAfter = ServerTime . justAfterUTC . withServerTime+ where justAfterUTC time = let UTCTime d t = time in UTCTime d (succ t)++-- | Obtain a time value that is just moments before the input time;+-- see the comment for the 'justAfter' function for more details.+justBefore :: ServerTime -> ServerTime+justBefore = ServerTime . justBeforeUTC . withServerTime+ where justBeforeUTC time = let UTCTime d t = time in UTCTime d (pred t)++-- | The timestamp for the start of the day associated with the input+-- timestamp. If timezone information is supplied, then the returned+-- value will correspond to when the day started in that timezone;+-- otherwise it is the start of the day in a timezone aligned with+-- UTC.+startOfDay :: Maybe TimeZoneSeries -> UTCTime -> UTCTime+startOfDay Nothing time = let UTCTime d _ = time in UTCTime d 0+startOfDay (Just tz) time = let lt = utcToLocalTime' tz time+ ls = LocalTime (localDay lt) (TimeOfDay 0 0 0)+ in localTimeToUTC' tz ls++-- | Convert a UTC time value to a local time.+asLocalTime :: TimeZoneSeries -> UTCTime -> LocalTime+asLocalTime = utcToLocalTime'++-- | Local time in displayable format+localTimeText :: Text -> LocalTime -> Text+localTimeText fmt time = T.pack $ formatTime defaultTimeLocale (T.unpack fmt) time++-- | Provides a time value that can be used when there are no other times available+originTime :: UTCTime+originTime = UTCTime (toEnum 0) 0
+ src/Matterhorn/Types.hs view
@@ -0,0 +1,2165 @@+{-# LANGUAGE GADTs #-}+{-# LANGUAGE KindSignatures #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE TupleSections #-}+module Matterhorn.Types+ ( ConnectionStatus(..)+ , HelpTopic(..)+ , MessageSelectState(..)+ , ProgramOutput(..)+ , MHEvent(..)+ , InternalEvent(..)+ , Name(..)+ , ChannelSelectMatch(..)+ , StartupStateInfo(..)+ , MHError(..)+ , AttachmentData(..)+ , CPUUsagePolicy(..)+ , tabbedWindow+ , getCurrentTabbedWindowEntry+ , tabbedWindowNextTab+ , tabbedWindowPreviousTab+ , runTabShowHandlerFor+ , getServerBaseUrl+ , serverBaseUrl+ , TabbedWindow(..)+ , TabbedWindowEntry(..)+ , TabbedWindowTemplate(..)+ , ConnectionInfo(..)+ , SidebarUpdate(..)+ , PendingChannelChange(..)+ , ViewMessageWindowTab(..)+ , clearChannelUnreadStatus+ , ChannelListEntry(..)+ , ChannelListOrientation(..)+ , channelListEntryChannelId+ , channelListEntryUserId+ , userIdsFromZipper+ , entryIsDMEntry+ , ciHostname+ , ciPort+ , ciUrlPath+ , ciUsername+ , ciPassword+ , ciType+ , ciAccessToken+ , newChannelTopicDialog+ , ChannelTopicDialogState(..)+ , channelTopicDialogEditor+ , channelTopicDialogFocus+ , Config(..)+ , HelpScreen(..)+ , PasswordSource(..)+ , TokenSource(..)+ , MatchType(..)+ , Mode(..)+ , ChannelSelectPattern(..)+ , PostListContents(..)+ , AuthenticationException(..)+ , BackgroundInfo(..)+ , RequestChan+ , UserFetch(..)+ , writeBChan+ , InternalTheme(..)++ , attrNameToConfig++ , mkChannelZipperList+ , ChannelListGroup(..)+ , channelListGroupUnread++ , trimChannelSigil++ , ChannelSelectState(..)+ , channelSelectMatches+ , channelSelectInput+ , emptyChannelSelectState++ , ChatState+ , newState+ , csChannelTopicDialog+ , csChannelListOrientation+ , csResources+ , csFocus+ , csCurrentChannel+ , csCurrentChannelId+ , csUrlList+ , csShowMessagePreview+ , csShowChannelList+ , csShowExpandedChannelTopics+ , csPostMap+ , csRecentChannel+ , csReturnChannel+ , csThemeListOverlay+ , csPostListOverlay+ , csUserListOverlay+ , csChannelListOverlay+ , csReactionEmojiListOverlay+ , csMyTeam+ , csMessageSelect+ , csConnectionStatus+ , csWorkerIsBusy+ , csChannel+ , csChannels+ , csChannelSelectState+ , csEditState+ , csClientConfig+ , csPendingChannelChange+ , csViewedMessage+ , csNotifyPrefs+ , csMe+ , timeZone+ , whenMode+ , setMode+ , setMode'+ , appMode++ , ChatEditState+ , emptyEditState+ , cedAttachmentList+ , cedFileBrowser+ , cedYankBuffer+ , cedSpellChecker+ , cedMisspellings+ , cedEditMode+ , cedEphemeral+ , cedEditor+ , cedInputHistory+ , cedAutocomplete+ , cedAutocompletePending+ , cedJustCompleted++ , AutocompleteState(..)+ , acPreviousSearchString+ , acCompletionList+ , acCachedResponses+ , acType++ , AutocompletionType(..)++ , CompletionSource(..)+ , AutocompleteAlternative(..)+ , autocompleteAlternativeReplacement+ , SpecialMention(..)+ , specialMentionName+ , isSpecialMention++ , PostListOverlayState+ , postListSelected+ , postListPosts++ , UserSearchScope(..)+ , ChannelSearchScope(..)++ , ListOverlayState+ , listOverlaySearchResults+ , listOverlaySearchInput+ , listOverlaySearchScope+ , listOverlaySearching+ , listOverlayEnterHandler+ , listOverlayNewList+ , listOverlayFetchResults+ , listOverlayRecordCount+ , listOverlayReturnMode++ , getUsers++ , ChatResources(..)+ , crUserPreferences+ , crEventQueue+ , crTheme+ , crStatusUpdateChan+ , crSubprocessLog+ , crWebsocketActionChan+ , crWebsocketThreadId+ , crRequestQueue+ , crFlaggedPosts+ , crConn+ , crConfiguration+ , crSyntaxMap+ , crLogManager+ , crEmoji+ , getSession+ , getResourceSession++ , specialUserMentions++ , UserPreferences(UserPreferences)+ , userPrefShowJoinLeave+ , userPrefFlaggedPostList+ , userPrefGroupChannelPrefs+ , userPrefDirectChannelPrefs+ , userPrefTeammateNameDisplayMode+ , dmChannelShowPreference+ , groupChannelShowPreference++ , defaultUserPreferences+ , setUserPreferences++ , WebsocketAction(..)++ , Cmd(..)+ , commandName+ , CmdArgs(..)++ , MH+ , runMHEvent+ , scheduleUserFetches+ , scheduleUserStatusFetches+ , getScheduledUserFetches+ , getScheduledUserStatusFetches+ , mh+ , generateUUID+ , generateUUID_IO+ , mhSuspendAndResume+ , mhHandleEventLensed+ , St.gets+ , mhError++ , mhLog+ , mhGetIOLogger+ , ioLogWithManager+ , LogContext(..)+ , withLogContext+ , withLogContextChannelId+ , getLogContext+ , LogMessage(..)+ , LogCommand(..)+ , LogCategory(..)++ , LogManager(..)+ , startLoggingToFile+ , stopLoggingToFile+ , requestLogSnapshot+ , requestLogDestination+ , sendLogMessage++ , requestQuit+ , getMessageForPostId+ , getParentMessage+ , getReplyRootMessage+ , resetSpellCheckTimer+ , withChannel+ , withChannelOrDefault+ , userList+ , resetAutocomplete+ , hasUnread+ , hasUnread'+ , isMine+ , setUserStatus+ , myUser+ , myUsername+ , myUserId+ , myTeamId+ , usernameForUserId+ , userByUsername+ , userByNickname+ , channelIdByChannelName+ , channelIdByUsername+ , channelByName+ , userById+ , allUserIds+ , addNewUser+ , useNickname+ , useNickname'+ , displayNameForUserId+ , displayNameForUser+ , raiseInternalEvent+ , getNewMessageCutoff+ , getEditedMessageCutoff++ , HighlightSet(..)+ , UserSet+ , ChannelSet+ , getHighlightSet++ , module Matterhorn.Types.Channels+ , module Matterhorn.Types.Messages+ , module Matterhorn.Types.Posts+ , module Matterhorn.Types.Users+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Graphics.Vty as Vty+import qualified Brick+import Brick ( EventM, Next, Widget )+import Brick.Focus ( FocusRing, focusRing )+import Brick.Themes ( Theme )+import Brick.Main ( invalidateCache, invalidateCacheEntry )+import Brick.AttrMap ( AttrMap )+import qualified Brick.BChan as BCH+import Brick.Forms (Form)+import Brick.Widgets.Edit ( Editor, editor )+import Brick.Widgets.List ( List, list )+import qualified Brick.Widgets.FileBrowser as FB+import Control.Concurrent ( ThreadId )+import Control.Concurrent.Async ( Async )+import qualified Control.Concurrent.STM as STM+import Control.Exception ( SomeException )+import qualified Control.Monad.Fail as MHF+import qualified Control.Monad.State as St+import qualified Control.Monad.Reader as R+import qualified Data.Set as Set+import qualified Data.ByteString as BS+import qualified Data.Foldable as F+import qualified Data.Kind as K+import Data.Ord ( comparing )+import qualified Data.HashMap.Strict as HM+import Data.List ( sortBy, nub, elemIndex )+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import Data.Time.Clock ( getCurrentTime, addUTCTime )+import Data.UUID ( UUID )+import qualified Data.Vector as Vec+import Lens.Micro.Platform ( at, makeLenses, lens, (%~), (^?!), (.=)+ , (%=), (^?), (.~)+ , _Just, Traversal', preuse, to+ , SimpleGetter+ )+import Network.Connection ( HostNotResolved, HostCannotConnect )+import Skylighting.Types ( SyntaxMap )+import System.Exit ( ExitCode )+import System.Random ( randomIO )+import Text.Aspell ( Aspell )++import Network.Mattermost ( ConnectionData )+import Network.Mattermost.Exceptions+import Network.Mattermost.Lenses+import Network.Mattermost.Types+import Network.Mattermost.Types.Config+import Network.Mattermost.WebSocket ( WebsocketEvent, WebsocketActionResponse )++import Matterhorn.Constants ( userSigil, normalChannelSigil )+import Matterhorn.InputHistory+import Matterhorn.Emoji+import Matterhorn.Types.Common+import Matterhorn.Types.Channels+import Matterhorn.Types.DirectionalSeq ( emptyDirSeq )+import Matterhorn.Types.KeyEvents+import Matterhorn.Types.Messages+import Matterhorn.Types.Posts+import Matterhorn.Types.RichText ( TeamBaseURL(..), TeamURLName(..) )+import Matterhorn.Types.Users+import qualified Matterhorn.Zipper as Z+++-- * Configuration++-- | A user password is either given to us directly, or a command+-- which we execute to find the password.+data PasswordSource =+ PasswordString Text+ | PasswordCommand Text+ deriving (Eq, Read, Show)++-- | An access token source.+data TokenSource =+ TokenString Text+ | TokenCommand Text+ deriving (Eq, Read, Show)++-- | The type of channel list group headings. Integer arguments indicate+-- total number of channels in the group that have unread activity.+data ChannelListGroup =+ ChannelGroupPublicChannels Int+ | ChannelGroupPrivateChannels Int+ | ChannelGroupDirectMessages Int+ deriving (Eq)++channelListGroupUnread :: ChannelListGroup -> Int+channelListGroupUnread (ChannelGroupPublicChannels n) = n+channelListGroupUnread (ChannelGroupPrivateChannels n) = n+channelListGroupUnread (ChannelGroupDirectMessages n) = n++-- | The type of channel list entries.+data ChannelListEntry =+ CLChannel ChannelId+ -- ^ A non-DM entry+ | CLUserDM ChannelId UserId+ -- ^ A single-user DM entry+ | CLGroupDM ChannelId+ -- ^ A multi-user DM entry+ deriving (Eq, Show)++-- | This is how we represent the user's configuration. Most fields+-- correspond to configuration file settings (see Config.hs) but some+-- are for internal book-keeping purposes only.+data Config =+ Config { configUser :: Maybe Text+ -- ^ The username to use when connecting.+ , configHost :: Maybe Text+ -- ^ The hostname to use when connecting.+ , configTeam :: Maybe Text+ -- ^ The team name to use when connecting.+ , configPort :: Int+ -- ^ The port to use when connecting.+ , configUrlPath :: Maybe Text+ -- ^ The server path to use when connecting.+ , configPass :: Maybe PasswordSource+ -- ^ The password source to use when connecting.+ , configToken :: Maybe TokenSource+ -- ^ The token source to use when connecting.+ , configTimeFormat :: Maybe Text+ -- ^ The format string for timestamps.+ , configDateFormat :: Maybe Text+ -- ^ The format string for dates.+ , configTheme :: Maybe Text+ -- ^ The name of the theme to use.+ , configThemeCustomizationFile :: Maybe Text+ -- ^ The path to the theme customization file, if any.+ , configSmartBacktick :: Bool+ -- ^ Whether to enable smart quoting characters.+ , configSmartEditing :: Bool+ -- ^ Whether to enable smart editing behaviors.+ , configURLOpenCommand :: Maybe Text+ -- ^ The command to use to open URLs.+ , configURLOpenCommandInteractive :: Bool+ -- ^ Whether the URL-opening command is interactive (i.e.+ -- whether it should be given control of the terminal).+ , configActivityNotifyCommand :: Maybe T.Text+ -- ^ The command to run for activity notifications.+ , configActivityBell :: Bool+ -- ^ Whether to ring the terminal bell on activity.+ , configShowMessageTimestamps :: Bool+ -- ^ Whether to show timestamps on messages.+ , configShowBackground :: BackgroundInfo+ -- ^ Whether to show async background worker thread info.+ , configShowMessagePreview :: Bool+ -- ^ Whether to show the message preview area.+ , configShowChannelList :: Bool+ -- ^ Whether to show the channel list.+ , configShowExpandedChannelTopics :: Bool+ -- ^ Whether to show expanded channel topics.+ , configEnableAspell :: Bool+ -- ^ Whether to enable Aspell spell checking.+ , configAspellDictionary :: Maybe Text+ -- ^ A specific Aspell dictionary name to use.+ , configUnsafeUseHTTP :: Bool+ -- ^ Whether to permit an insecure HTTP connection.+ , configValidateServerCertificate :: Bool+ -- ^ Whether to validate TLS certificates.+ , configChannelListWidth :: Int+ -- ^ The width, in columns, of the channel list sidebar.+ , configLogMaxBufferSize :: Int+ -- ^ The maximum size, in log entries, of the internal log+ -- message buffer.+ , configShowOlderEdits :: Bool+ -- ^ Whether to highlight the edit indicator on edits made+ -- prior to the beginning of the current session.+ , configShowTypingIndicator :: Bool+ -- ^ Whether to show the typing indicator for other users,+ -- and whether to send typing notifications to other users.+ , configAbsPath :: Maybe FilePath+ -- ^ A book-keeping field for the absolute path to the+ -- configuration. (Not a user setting.)+ , configUserKeys :: KeyConfig+ -- ^ The user's keybinding configuration.+ , configHyperlinkingMode :: Bool+ -- ^ Whether to enable terminal hyperlinking mode.+ , configSyntaxDirs :: [FilePath]+ -- ^ The search path for syntax description XML files.+ , configDirectChannelExpirationDays :: Int+ -- ^ The number of days to show a user in the channel menu after a direct+ -- message with them.+ , configCpuUsagePolicy :: CPUUsagePolicy+ -- ^ The CPU usage policy for the application.+ , configDefaultAttachmentPath :: Maybe FilePath+ -- ^ The default path for browsing attachments+ , configChannelListOrientation :: ChannelListOrientation+ -- ^ The orientation of the channel list.+ } deriving (Eq, Show)++-- | The policy for CPU usage.+--+-- The idea is that Matterhorn can benefit from using multiple CPUs,+-- but the exact number is application-determined. We expose this policy+-- setting to the user in the configuration.+data CPUUsagePolicy =+ SingleCPU+ -- ^ Constrain the application to use one CPU.+ | MultipleCPUs+ -- ^ Permit the usage of multiple CPUs (the exact number is+ -- determined by the application).+ deriving (Eq, Show)++-- | The state of the UI diagnostic indicator for the async worker+-- thread.+data BackgroundInfo =+ Disabled+ -- ^ Disable (do not show) the indicator.+ | Active+ -- ^ Show the indicator when the thread is working.+ | ActiveCount+ -- ^ Show the indicator when the thread is working, but include the+ -- thread's work queue length.+ deriving (Eq, Show)++data UserPreferences =+ UserPreferences { _userPrefShowJoinLeave :: Bool+ , _userPrefFlaggedPostList :: Seq FlaggedPost+ , _userPrefGroupChannelPrefs :: HashMap ChannelId Bool+ , _userPrefDirectChannelPrefs :: HashMap UserId Bool+ , _userPrefTeammateNameDisplayMode :: Maybe TeammateNameDisplayMode+ }++hasUnread :: ChatState -> ChannelId -> Bool+hasUnread st cId = fromMaybe False $+ hasUnread' <$> findChannelById cId (_csChannels st)++hasUnread' :: ClientChannel -> Bool+hasUnread' chan = fromMaybe False $ do+ let info = _ccInfo chan+ lastViewTime <- _cdViewed info+ return $ _cdMentionCount info > 0 ||+ (not (isMuted chan) &&+ (((_cdUpdated info) > lastViewTime) ||+ (isJust $ _cdEditedMessageThreshold info)))++mkChannelZipperList :: UTCTime+ -> Config+ -> Maybe ClientConfig+ -> UserPreferences+ -> ClientChannels+ -> Users+ -> [(ChannelListGroup, [ChannelListEntry])]+mkChannelZipperList now config cconfig prefs cs us =+ [ let (unread, entries) = getChannelEntriesInOrder cs Ordinary+ in (ChannelGroupPublicChannels unread, entries)+ , let (unread, entries) = getChannelEntriesInOrder cs Private+ in (ChannelGroupPrivateChannels unread, entries)+ , let (unread, entries) = getDMChannelEntriesInOrder now config cconfig prefs us cs+ in (ChannelGroupDirectMessages unread, entries)+ ]++getChannelEntriesInOrder :: ClientChannels -> Type -> (Int, [ChannelListEntry])+getChannelEntriesInOrder cs ty =+ let matches (_, info) = info^.ccInfo.cdType == ty+ pairs = filteredChannels matches cs+ unread = length $ filter (== True) $ (hasUnread' . snd) <$> pairs+ entries = fmap (CLChannel . fst) $+ sortBy (comparing ((^.ccInfo.cdDisplayName.to T.toLower) . snd)) pairs+ in (unread, entries)++getDMChannelEntriesInOrder :: UTCTime+ -> Config+ -> Maybe ClientConfig+ -> UserPreferences+ -> Users+ -> ClientChannels+ -> (Int, [ChannelListEntry])+getDMChannelEntriesInOrder now config cconfig prefs us cs =+ let oneOnOneDmChans = getDMChannelEntries now config cconfig prefs us cs+ groupChans = getGroupDMChannelEntries now config prefs cs+ allDmChans = groupChans <> oneOnOneDmChans+ sorter (u1, n1, _) (u2, n2, _) =+ if u1 == u2+ then compare n1 n2+ else if u1 && not u2+ then LT+ else GT+ sorted = sortBy sorter allDmChans+ third (_, _, c) = c+ fst3 (a, _, _) = a+ unread = length $ filter id $ fst3 <$> sorted+ in (unread, third <$> sorted)++useNickname' :: Maybe ClientConfig -> UserPreferences -> Bool+useNickname' clientConfig prefs =+ let serverSetting = case clientConfig^?_Just.to clientConfigTeammateNameDisplay of+ Just TMNicknameOrFullname -> Just True+ _ -> Nothing+ accountSetting = (== TMNicknameOrFullname) <$> (_userPrefTeammateNameDisplayMode prefs)+ fallback = False+ in fromMaybe fallback $ accountSetting <|> serverSetting++displayNameForUser :: UserInfo -> Maybe ClientConfig -> UserPreferences -> Text+displayNameForUser u clientConfig prefs+ | useNickname' clientConfig prefs =+ fromMaybe (u^.uiName) (u^.uiNickName)+ | otherwise =+ u^.uiName++getGroupDMChannelEntries :: UTCTime+ -> Config+ -> UserPreferences+ -> ClientChannels+ -> [(Bool, T.Text, ChannelListEntry)]+getGroupDMChannelEntries now config prefs cs =+ let matches (_, info) = info^.ccInfo.cdType == Group &&+ groupChannelShouldAppear now config prefs info+ in fmap (\(cId, ch) -> (hasUnread' ch, ch^.ccInfo.cdDisplayName, CLGroupDM cId)) $+ filteredChannels matches cs++getDMChannelEntries :: UTCTime+ -> Config+ -> Maybe ClientConfig+ -> UserPreferences+ -> Users+ -> ClientChannels+ -> [(Bool, T.Text, ChannelListEntry)]+getDMChannelEntries now config cconfig prefs us cs =+ let mapping = allDmChannelMappings cs+ mappingWithUserInfo = catMaybes $ getInfo <$> mapping+ getInfo (uId, cId) = do+ c <- findChannelById cId cs+ u <- findUserById uId us+ case u^.uiDeleted of+ True -> Nothing+ False ->+ if dmChannelShouldAppear now config prefs c+ then return (hasUnread' c, displayNameForUser u cconfig prefs, CLUserDM cId uId)+ else Nothing+ in mappingWithUserInfo++-- Always show a DM channel if it has unread activity.+--+-- If it has no unread activity and if the preferences explicitly say to+-- hide it, hide it.+--+-- Otherwise, only show it if at least one of the other conditions are+-- met (see 'or' below).+dmChannelShouldAppear :: UTCTime -> Config -> UserPreferences -> ClientChannel -> Bool+dmChannelShouldAppear now config prefs c =+ let ndays = configDirectChannelExpirationDays config+ localCutoff = addUTCTime (nominalDay * (-(fromIntegral ndays))) now+ cutoff = ServerTime localCutoff+ updated = c^.ccInfo.cdUpdated+ Just uId = c^.ccInfo.cdDMUserId+ in if hasUnread' c || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)+ then True+ else case dmChannelShowPreference prefs uId of+ Just False -> False+ _ -> or [+ -- The channel was updated recently enough+ updated >= cutoff+ ]++groupChannelShouldAppear :: UTCTime -> Config -> UserPreferences -> ClientChannel -> Bool+groupChannelShouldAppear now config prefs c =+ let ndays = configDirectChannelExpirationDays config+ localCutoff = addUTCTime (nominalDay * (-(fromIntegral ndays))) now+ cutoff = ServerTime localCutoff+ updated = c^.ccInfo.cdUpdated+ in if hasUnread' c || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)+ then True+ else case groupChannelShowPreference prefs (c^.ccInfo.cdChannelId) of+ Just False -> False+ _ -> or [+ -- The channel was updated recently enough+ updated >= cutoff+ ]++dmChannelShowPreference :: UserPreferences -> UserId -> Maybe Bool+dmChannelShowPreference ps uId = HM.lookup uId (_userPrefDirectChannelPrefs ps)++groupChannelShowPreference :: UserPreferences -> ChannelId -> Maybe Bool+groupChannelShowPreference ps cId = HM.lookup cId (_userPrefGroupChannelPrefs ps)++-- * Internal Names and References++-- | This 'Name' type is the type used in 'brick' to identify various+-- parts of the interface.+data Name =+ ChannelMessages ChannelId+ | MessageInput+ | ChannelList+ | HelpViewport+ | HelpText+ | ScriptHelpText+ | ThemeHelpText+ | SyntaxHighlightHelpText+ | KeybindingHelpText+ | ChannelSelectString+ | CompletionAlternatives+ | CompletionList+ | JoinChannelList+ | UrlList+ | MessagePreviewViewport+ | ThemeListSearchInput+ | UserListSearchInput+ | JoinChannelListSearchInput+ | UserListSearchResults+ | ThemeListSearchResults+ | ViewMessageArea+ | ViewMessageReactionsArea+ | ChannelSidebar+ | ChannelSelectInput+ | AttachmentList+ | AttachmentFileBrowser+ | MessageReactionsArea+ | ReactionEmojiList+ | ReactionEmojiListInput+ | TabbedWindowTabBar+ | MuteToggleField+ | ChannelMentionsField+ | DesktopNotificationsField (WithDefault NotifyOption)+ | PushNotificationsField (WithDefault NotifyOption)+ | ChannelTopicEditor+ | ChannelTopicSaveButton+ | ChannelTopicCancelButton+ | ChannelTopicEditorPreview+ deriving (Eq, Show, Ord)++-- | The sum type of exceptions we expect to encounter on authentication+-- failure. We encode them explicitly here so that we can print them in+-- a more user-friendly manner than just 'show'.+data AuthenticationException =+ ConnectError HostCannotConnect+ | ResolveError HostNotResolved+ | AuthIOError IOError+ | LoginError LoginFailureException+ | OtherAuthError SomeException+ deriving (Show)++-- | Our 'ConnectionInfo' contains exactly as much information as is+-- necessary to start a connection with a Mattermost server. This is+-- built up during interactive authentication and then is used to log+-- in.+--+-- If the access token field is non-empty, that value is used and the+-- username and password values are ignored.+data ConnectionInfo =+ ConnectionInfo { _ciHostname :: Text+ , _ciPort :: Int+ , _ciUrlPath :: Text+ , _ciUsername :: Text+ , _ciPassword :: Text+ , _ciAccessToken :: Text+ , _ciType :: ConnectionType+ }++-- | We want to continue referring to posts by their IDs, but we don't+-- want to have to synthesize new valid IDs for messages from the client+-- itself (like error messages or informative client responses). To that+-- end, a PostRef can be either a PostId or a newly-generated client ID.+data PostRef+ = MMId PostId+ | CLId Int+ deriving (Eq, Show)++-- ** Channel-matching types++-- | A match in channel selection mode.+data ChannelSelectMatch =+ ChannelSelectMatch { nameBefore :: Text+ -- ^ The content of the match before the user's+ -- matching input.+ , nameMatched :: Text+ -- ^ The potion of the name that matched the+ -- user's input.+ , nameAfter :: Text+ -- ^ The portion of the name that came after the+ -- user's matching input.+ , matchFull :: Text+ -- ^ The full string for this entry so it doesn't+ -- have to be reassembled from the parts above.+ , matchEntry :: ChannelListEntry+ -- ^ The original entry data corresponding to the+ -- text match.+ }+ deriving (Eq, Show)++data ChannelSelectPattern = CSP MatchType Text+ | CSPAny+ deriving (Eq, Show)++data MatchType =+ Prefix+ | Suffix+ | Infix+ | Equal+ | PrefixDMOnly+ | PrefixNonDMOnly+ deriving (Eq, Show)++-- * Application State Values++data ProgramOutput =+ ProgramOutput { program :: FilePath+ , programArgs :: [String]+ , programStdout :: String+ , programStderr :: String+ , programExitCode :: ExitCode+ }++defaultUserPreferences :: UserPreferences+defaultUserPreferences =+ UserPreferences { _userPrefShowJoinLeave = True+ , _userPrefFlaggedPostList = mempty+ , _userPrefGroupChannelPrefs = mempty+ , _userPrefDirectChannelPrefs = mempty+ , _userPrefTeammateNameDisplayMode = Nothing+ }++setUserPreferences :: Seq Preference -> UserPreferences -> UserPreferences+setUserPreferences = flip (F.foldr go)+ where go p u+ | Just fp <- preferenceToFlaggedPost p =+ u { _userPrefFlaggedPostList =+ _userPrefFlaggedPostList u Seq.|> fp+ }+ | Just gp <- preferenceToDirectChannelShowStatus p =+ u { _userPrefDirectChannelPrefs =+ HM.insert+ (directChannelShowUserId gp)+ (directChannelShowValue gp)+ (_userPrefDirectChannelPrefs u)+ }+ | Just gp <- preferenceToGroupChannelPreference p =+ u { _userPrefGroupChannelPrefs =+ HM.insert+ (groupChannelId gp)+ (groupChannelShow gp)+ (_userPrefGroupChannelPrefs u)+ }+ | preferenceName p == PreferenceName "join_leave" =+ u { _userPrefShowJoinLeave =+ preferenceValue p /= PreferenceValue "false" }+ | preferenceCategory p == PreferenceCategoryDisplaySettings &&+ preferenceName p == PreferenceName "name_format" =+ let PreferenceValue txt = preferenceValue p+ in u { _userPrefTeammateNameDisplayMode = Just $ teammateDisplayModeFromText txt }+ | otherwise = u++-- | Log message tags.+data LogCategory =+ LogGeneral+ | LogAPI+ | LogWebsocket+ | LogError+ | LogUserMark+ deriving (Eq, Show)++-- | A log message.+data LogMessage =+ LogMessage { logMessageText :: !Text+ -- ^ The text of the log message.+ , logMessageContext :: !(Maybe LogContext)+ -- ^ The optional context information relevant to the log+ -- message.+ , logMessageCategory :: !LogCategory+ -- ^ The category of the log message.+ , logMessageTimestamp :: !UTCTime+ -- ^ The timestamp of the log message.+ }+ deriving (Eq, Show)++-- | A logging thread command.+data LogCommand =+ LogToFile FilePath+ -- ^ Start logging to the specified path.+ | LogAMessage !LogMessage+ -- ^ Log the specified message.+ | StopLogging+ -- ^ Stop any active logging.+ | ShutdownLogging+ -- ^ Shut down.+ | GetLogDestination+ -- ^ Ask the logging thread about its active logging destination.+ | LogSnapshot FilePath+ -- ^ Ask the logging thread to dump the current buffer to the+ -- specified destination.+ deriving (Show)++-- | A handle to the log manager thread.+data LogManager =+ LogManager { logManagerCommandChannel :: STM.TChan LogCommand+ , logManagerHandle :: Async ()+ }++startLoggingToFile :: LogManager -> FilePath -> IO ()+startLoggingToFile mgr loc = sendLogCommand mgr $ LogToFile loc++stopLoggingToFile :: LogManager -> IO ()+stopLoggingToFile mgr = sendLogCommand mgr StopLogging++requestLogSnapshot :: LogManager -> FilePath -> IO ()+requestLogSnapshot mgr path = sendLogCommand mgr $ LogSnapshot path++requestLogDestination :: LogManager -> IO ()+requestLogDestination mgr = sendLogCommand mgr GetLogDestination++sendLogMessage :: LogManager -> LogMessage -> IO ()+sendLogMessage mgr lm = sendLogCommand mgr $ LogAMessage lm++sendLogCommand :: LogManager -> LogCommand -> IO ()+sendLogCommand mgr c =+ STM.atomically $ STM.writeTChan (logManagerCommandChannel mgr) c++-- | 'ChatResources' represents configuration and connection-related+-- information, as opposed to current model or view information.+-- Information that goes in the 'ChatResources' value should be limited+-- to information that we read or set up prior to setting up the bulk of+-- the application state.+data ChatResources =+ ChatResources { _crSession :: Session+ , _crWebsocketThreadId :: Maybe ThreadId+ , _crConn :: ConnectionData+ , _crRequestQueue :: RequestChan+ , _crEventQueue :: BCH.BChan MHEvent+ , _crSubprocessLog :: STM.TChan ProgramOutput+ , _crWebsocketActionChan :: STM.TChan WebsocketAction+ , _crTheme :: AttrMap+ , _crStatusUpdateChan :: STM.TChan [UserId]+ , _crConfiguration :: Config+ , _crFlaggedPosts :: Set PostId+ , _crUserPreferences :: UserPreferences+ , _crSyntaxMap :: SyntaxMap+ , _crLogManager :: LogManager+ , _crEmoji :: EmojiCollection+ }++-- | A "special" mention that does not map to a specific user, but is an+-- alias that the server uses to notify users.+data SpecialMention =+ MentionAll+ -- ^ @all: notify everyone in the channel.+ | MentionChannel+ -- ^ @channel: notify everyone in the channel.++data AutocompleteAlternative =+ UserCompletion User Bool+ -- ^ User, plus whether the user is in the channel that triggered+ -- the autocomplete+ | SpecialMention SpecialMention+ -- ^ A special mention.+ | ChannelCompletion Bool Channel+ -- ^ Channel, plus whether the user is a member of the channel+ | SyntaxCompletion Text+ -- ^ Name of a skylighting syntax definition+ | CommandCompletion CompletionSource Text Text Text+ -- ^ Source, name of a slash command, argspec, and description+ | EmojiCompletion Text+ -- ^ The text of an emoji completion++-- | The source of an autocompletion alternative.+data CompletionSource = Server | Client+ deriving (Eq, Show)++specialMentionName :: SpecialMention -> Text+specialMentionName MentionChannel = "channel"+specialMentionName MentionAll = "all"++isSpecialMention :: T.Text -> Bool+isSpecialMention n = isJust $ lookup (T.toLower $ trimUserSigil n) pairs+ where+ pairs = mkPair <$> mentions+ mentions = [ MentionChannel+ , MentionAll+ ]+ mkPair v = (specialMentionName v, v)++autocompleteAlternativeReplacement :: AutocompleteAlternative -> Text+autocompleteAlternativeReplacement (EmojiCompletion e) =+ ":" <> e <> ":"+autocompleteAlternativeReplacement (SpecialMention m) =+ userSigil <> specialMentionName m+autocompleteAlternativeReplacement (UserCompletion u _) =+ userSigil <> userUsername u+autocompleteAlternativeReplacement (ChannelCompletion _ c) =+ normalChannelSigil <> (sanitizeUserText $ channelName c)+autocompleteAlternativeReplacement (SyntaxCompletion t) =+ "```" <> t+autocompleteAlternativeReplacement (CommandCompletion _ t _ _) =+ "/" <> t++-- | The type of data that the autocompletion logic supports. We use+-- this to track the kind of completion underway in case the type of+-- completion needs to change.+data AutocompletionType =+ ACUsers+ | ACChannels+ | ACCodeBlockLanguage+ | ACEmoji+ | ACCommands+ deriving (Eq, Show)++data AutocompleteState =+ AutocompleteState { _acPreviousSearchString :: Text+ -- ^ The search string used for the+ -- currently-displayed autocomplete results, for+ -- use in deciding whether to issue another server+ -- query+ , _acCompletionList :: List Name AutocompleteAlternative+ -- ^ The list of alternatives that the user+ -- selects from+ , _acType :: AutocompletionType+ -- ^ The type of data that we're completing+ , _acCachedResponses :: HM.HashMap Text [AutocompleteAlternative]+ -- ^ A cache of alternative lists, keyed on search+ -- string, for use in avoiding server requests.+ -- The idea here is that users type quickly enough+ -- (and edit their input) that would normally lead+ -- to rapid consecutive requests, some for the+ -- same strings during editing, that we can avoid+ -- that by caching them here. Note that this cache+ -- gets destroyed whenever autocompletion is not+ -- on, so this cache does not live very long.+ }++-- | The 'ChatEditState' value contains the editor widget itself as well+-- as history and metadata we need for editing-related operations.+data ChatEditState =+ ChatEditState { _cedEditor :: Editor Text Name+ , _cedEditMode :: EditMode+ , _cedEphemeral :: EphemeralEditState+ , _cedInputHistory :: InputHistory+ -- ^ The map of per-channel input history for the+ -- application. We don't distribute the per-channel+ -- history into the per-channel states (like we do+ -- for other per-channel state) since keeping it+ -- under the InputHistory banner lets us use a nicer+ -- startup/shutdown disk file management API.+ , _cedYankBuffer :: Text+ , _cedSpellChecker :: Maybe (Aspell, IO ())+ , _cedMisspellings :: Set Text+ , _cedAutocomplete :: Maybe AutocompleteState+ -- ^ The autocomplete state. The autocompletion UI is+ -- showing only when this state is present.+ , _cedAutocompletePending :: Maybe Text+ -- ^ The search string associated with the latest+ -- in-flight autocompletion request. This is used to+ -- determine whether any (potentially late-arriving)+ -- API responses are for stale queries since the user+ -- can type more quickly than the server can get us+ -- the results, and we wouldn't want to show results+ -- associated with old editor states.+ , _cedAttachmentList :: List Name AttachmentData+ -- ^ The list of attachments to be uploaded with the+ -- post being edited.+ , _cedFileBrowser :: Maybe (FB.FileBrowser Name)+ -- ^ The browser for selecting attachment files.+ -- This is a Maybe because the instantiation of the+ -- FileBrowser causes it to read and ingest the+ -- target directory, so this action is deferred+ -- until the browser is needed.+ , _cedJustCompleted :: Bool+ -- A flag that indicates whether the most recent+ -- editing event was a tab-completion. This is used by+ -- the smart trailing space handling.+ }++-- | An attachment.+data AttachmentData =+ AttachmentData { attachmentDataFileInfo :: FB.FileInfo+ , attachmentDataBytes :: BS.ByteString+ }+ deriving (Eq, Show)++-- | We can initialize a new 'ChatEditState' value with just an edit+-- history, which we save locally.+emptyEditState :: InputHistory -> Maybe (Aspell, IO ()) -> IO ChatEditState+emptyEditState hist sp =+ return ChatEditState { _cedEditor = editor MessageInput Nothing ""+ , _cedEphemeral = defaultEphemeralEditState+ , _cedInputHistory = hist+ , _cedEditMode = NewPost+ , _cedYankBuffer = ""+ , _cedSpellChecker = sp+ , _cedMisspellings = mempty+ , _cedAutocomplete = Nothing+ , _cedAutocompletePending = Nothing+ , _cedAttachmentList = list AttachmentList mempty 1+ , _cedFileBrowser = Nothing+ , _cedJustCompleted = False+ }++-- | A 'RequestChan' is a queue of operations we have to perform in the+-- background to avoid blocking on the main loop+type RequestChan = STM.TChan (IO (Maybe (MH ())))++-- | The 'HelpScreen' type represents the set of possible 'Help'+-- dialogues we have to choose from.+data HelpScreen =+ MainHelp+ | ScriptHelp+ | ThemeHelp+ | SyntaxHighlightHelp+ | KeybindingHelp+ deriving (Eq)++-- | Help topics+data HelpTopic =+ HelpTopic { helpTopicName :: Text+ , helpTopicDescription :: Text+ , helpTopicScreen :: HelpScreen+ , helpTopicViewportName :: Name+ }+ deriving (Eq)++-- | Mode type for the current contents of the post list overlay+data PostListContents =+ PostListFlagged+ | PostListPinned ChannelId+ | PostListSearch Text Bool -- for the query and search status+ deriving (Eq)++-- | The 'Mode' represents the current dominant UI activity+data Mode =+ Main+ | ShowHelp HelpTopic Mode+ | ChannelSelect+ | UrlSelect+ | LeaveChannelConfirm+ | DeleteChannelConfirm+ | MessageSelect+ | MessageSelectDeleteConfirm+ | PostListOverlay PostListContents+ | UserListOverlay+ | ReactionEmojiListOverlay+ | ChannelListOverlay+ | ThemeListOverlay+ | ViewMessage+ | ManageAttachments+ | ManageAttachmentsBrowseFiles+ | EditNotifyPrefs+ | ChannelTopicWindow+ deriving (Eq)++-- | We're either connected or we're not.+data ConnectionStatus = Connected | Disconnected deriving (Eq)++-- | An entry in a tabbed window corresponding to a tab and its content.+-- Parameterized over an abstract handle type ('a') for the tabs so we+-- can give each a unique handle.+data TabbedWindowEntry a =+ TabbedWindowEntry { tweValue :: a+ -- ^ The handle for this tab.+ , tweRender :: a -> ChatState -> Widget Name+ -- ^ The rendering function to use when this tab+ -- is selected.+ , tweHandleEvent :: a -> Vty.Event -> MH ()+ -- ^ The event-handling function to use when this+ -- tab is selected.+ , tweTitle :: a -> Bool -> T.Text+ -- ^ Title function for this tab, with a boolean+ -- indicating whether this is the current tab.+ , tweShowHandler :: a -> MH ()+ -- ^ A handler to be invoked when this tab is+ -- shown.+ }++-- | The definition of a tabbed window. Note that this does not track+-- the *state* of the window; it merely provides a collection of tab+-- window entries (see above). To track the state of a tabbed window,+-- use a TabbedWindow.+--+-- Parameterized over an abstract handle type ('a') for the tabs so we+-- can give each a unique handle.+data TabbedWindowTemplate a =+ TabbedWindowTemplate { twtEntries :: [TabbedWindowEntry a]+ -- ^ The entries in tabbed windows with this+ -- structure.+ , twtTitle :: a -> Widget Name+ -- ^ The title-rendering function for this kind+ -- of tabbed window.+ }++-- | An instantiated tab window. This is based on a template and tracks+-- the state of the tabbed window (current tab).+--+-- Parameterized over an abstract handle type ('a') for the tabs so we+-- can give each a unique handle.+data TabbedWindow a =+ TabbedWindow { twValue :: a+ -- ^ The handle of the currently-selected tab.+ , twReturnMode :: Mode+ -- ^ The mode to return to when the tab is closed.+ , twTemplate :: TabbedWindowTemplate a+ -- ^ The template to use as a basis for rendering the+ -- window and handling user input.+ , twWindowWidth :: Int+ , twWindowHeight :: Int+ -- ^ Window dimensions+ }++-- | Construct a new tabbed window from a template. This will raise an+-- exception if the initially-selected tab does not exist in the window+-- template, or if the window template has any duplicated tab handles.+--+-- Note that the caller is responsible for determining whether to call+-- the initially-selected tab's on-show handler.+tabbedWindow :: (Show a, Eq a)+ => a+ -- ^ The handle corresponding to the tab that should be+ -- selected initially.+ -> TabbedWindowTemplate a+ -- ^ The template for the window to construct.+ -> Mode+ -- ^ When the window is closed, return to this application+ -- mode.+ -> (Int, Int)+ -- ^ The window dimensions (width, height).+ -> TabbedWindow a+tabbedWindow initialVal t retMode (width, height) =+ let handles = tweValue <$> twtEntries t+ in if | null handles ->+ error "BUG: tabbed window template must provide at least one entry"+ | length handles /= length (nub handles) ->+ error "BUG: tabbed window should have one entry per handle"+ | not (initialVal `elem` handles) ->+ error $ "BUG: tabbed window handle " <>+ show initialVal <> " not present in template"+ | otherwise ->+ TabbedWindow { twTemplate = t+ , twValue = initialVal+ , twReturnMode = retMode+ , twWindowWidth = width+ , twWindowHeight = height+ }++-- | Get the currently-selected tab entry for a tabbed window. Raise+-- an exception if the window's selected tab handle is not found in its+-- template (which is a bug in the tabbed window infrastructure).+getCurrentTabbedWindowEntry :: (Show a, Eq a)+ => TabbedWindow a+ -> TabbedWindowEntry a+getCurrentTabbedWindowEntry w =+ lookupTabbedWindowEntry (twValue w) w++-- | Run the on-show handler for the window tab entry with the specified+-- handle.+runTabShowHandlerFor :: (Eq a, Show a) => a -> TabbedWindow a -> MH ()+runTabShowHandlerFor handle w = do+ let entry = lookupTabbedWindowEntry handle w+ tweShowHandler entry handle++-- | Look up a tabbed window entry by handle. Raises an exception if no+-- such entry exists.+lookupTabbedWindowEntry :: (Eq a, Show a)+ => a+ -> TabbedWindow a+ -> TabbedWindowEntry a+lookupTabbedWindowEntry handle w =+ let matchesVal e = tweValue e == handle+ in case filter matchesVal (twtEntries $ twTemplate w) of+ [e] -> e+ _ -> error $ "BUG: tabbed window entry for " <> show (twValue w) <>+ " should have matched a single entry"++-- | Switch a tabbed window's selected tab to its next tab, cycling back+-- to the first tab if the last tab is the selected tab. This also+-- invokes the on-show handler for the newly-selected tab.+--+-- Note that this does nothing if the window has only one tab.+tabbedWindowNextTab :: (Show a, Eq a)+ => TabbedWindow a+ -> MH (TabbedWindow a)+tabbedWindowNextTab w | length (twtEntries $ twTemplate w) == 1 = return w+tabbedWindowNextTab w = do+ let curIdx = case elemIndex (tweValue curEntry) allHandles of+ Nothing ->+ error $ "BUG: tabbedWindowNextTab: could not find " <>+ "current handle in handle list"+ Just i -> i+ nextIdx = if curIdx == length allHandles - 1+ then 0+ else curIdx + 1+ newHandle = allHandles !! nextIdx+ allHandles = tweValue <$> twtEntries (twTemplate w)+ curEntry = getCurrentTabbedWindowEntry w+ newWin = w { twValue = newHandle }++ runTabShowHandlerFor newHandle newWin+ return newWin++-- | Switch a tabbed window's selected tab to its previous tab, cycling+-- to the last tab if the first tab is the selected tab. This also+-- invokes the on-show handler for the newly-selected tab.+--+-- Note that this does nothing if the window has only one tab.+tabbedWindowPreviousTab :: (Show a, Eq a)+ => TabbedWindow a+ -> MH (TabbedWindow a)+tabbedWindowPreviousTab w | length (twtEntries $ twTemplate w) == 1 = return w+tabbedWindowPreviousTab w = do+ let curIdx = case elemIndex (tweValue curEntry) allHandles of+ Nothing ->+ error $ "BUG: tabbedWindowPreviousTab: could not find " <>+ "current handle in handle list"+ Just i -> i+ nextIdx = if curIdx == 0+ then length allHandles - 1+ else curIdx - 1+ newHandle = allHandles !! nextIdx+ allHandles = tweValue <$> twtEntries (twTemplate w)+ curEntry = getCurrentTabbedWindowEntry w+ newWin = w { twValue = newHandle }++ runTabShowHandlerFor newHandle newWin+ return newWin++data ChannelListOrientation =+ ChannelListLeft+ -- ^ Show the channel list to the left of the message area.+ | ChannelListRight+ -- ^ Show the channel list to the right of the message area.+ deriving (Eq, Show)++-- | This is the giant bundle of fields that represents the current+-- state of our application at any given time.+data ChatState =+ ChatState { _csResources :: ChatResources+ -- ^ Global application-wide resources that don't change+ -- much.+ , _csFocus :: Z.Zipper ChannelListGroup ChannelListEntry+ -- ^ The channel sidebar zipper that tracks which channel+ -- is selected.+ , _csChannelListOrientation :: ChannelListOrientation+ -- ^ The orientation of the channel list.+ , _csMe :: User+ -- ^ The authenticated user.+ , _csMyTeam :: Team+ -- ^ The active team of the authenticated user.+ , _csChannels :: ClientChannels+ -- ^ The channels that we are showing, including their+ -- message lists.+ , _csPostMap :: HashMap PostId Message+ -- ^ The map of post IDs to messages. This allows us to+ -- access messages by ID without having to linearly scan+ -- channel message lists.+ , _csUsers :: Users+ -- ^ All of the users we know about.+ , _timeZone :: TimeZoneSeries+ -- ^ The client time zone.+ , _csEditState :: ChatEditState+ -- ^ The state of the input box used for composing and+ -- editing messages and commands.+ , _csMode :: Mode+ -- ^ The current application mode. This is used to+ -- dispatch to different rendering and event handling+ -- routines.+ , _csShowMessagePreview :: Bool+ -- ^ Whether to show the message preview area.+ , _csShowChannelList :: Bool+ -- ^ Whether to show the channel list.+ , _csShowExpandedChannelTopics :: Bool+ -- ^ Whether to show expanded channel topics.+ , _csChannelSelectState :: ChannelSelectState+ -- ^ The state of the user's input and selection for+ -- channel selection mode.+ , _csRecentChannel :: Maybe ChannelId+ -- ^ The most recently-selected channel, if any.+ , _csReturnChannel :: Maybe ChannelId+ -- ^ The channel to return to after visiting one or more+ -- unread channels.+ , _csUrlList :: List Name LinkChoice+ -- ^ The URL list used to show URLs drawn from messages in+ -- a channel.+ , _csConnectionStatus :: ConnectionStatus+ -- ^ Our view of the connection status.+ , _csWorkerIsBusy :: Maybe (Maybe Int)+ -- ^ Whether the async worker thread is busy, and its+ -- queue length if so.+ , _csMessageSelect :: MessageSelectState+ -- ^ The state of message selection mode.+ , _csThemeListOverlay :: ListOverlayState InternalTheme ()+ -- ^ The state of the theme list overlay.+ , _csPostListOverlay :: PostListOverlayState+ -- ^ The state of the post list overlay.+ , _csUserListOverlay :: ListOverlayState UserInfo UserSearchScope+ -- ^ The state of the user list overlay.+ , _csChannelListOverlay :: ListOverlayState Channel ChannelSearchScope+ -- ^ The state of the user list overlay.+ , _csReactionEmojiListOverlay :: ListOverlayState (Bool, T.Text) ()+ -- ^ The state of the reaction emoji list overlay.+ , _csClientConfig :: Maybe ClientConfig+ -- ^ The Mattermost client configuration, as we understand it.+ , _csPendingChannelChange :: Maybe PendingChannelChange+ -- ^ A pending channel change that we need to apply once+ -- the channel in question is available. We set this up+ -- when we need to change to a channel in the sidebar, but+ -- it isn't even there yet because we haven't loaded its+ -- metadata.+ , _csViewedMessage :: Maybe (Message, TabbedWindow ViewMessageWindowTab)+ -- ^ Set when the ViewMessage mode is active. The message+ -- being viewed. Note that this stores a message, not+ -- a message ID. That's because not all messages have+ -- message IDs (e.g. client messages) and we still+ -- want to support viewing of those messages. It's the+ -- responsibility of code that uses this message to always+ -- consult the chat state for the latest *version* of any+ -- message with an ID here, to be sure that the latest+ -- version is used (e.g. if it gets edited, etc.).+ , _csNotifyPrefs :: Maybe (Form ChannelNotifyProps MHEvent Name)+ -- ^ A form for editing the notification preferences for+ -- the current channel. This is set when entering+ -- EditNotifyPrefs mode and updated when the user+ -- changes the form state.+ , _csChannelTopicDialog :: ChannelTopicDialogState+ -- ^ The state for the interactive channel topic editor+ -- window.+ }++-- | Handles for the View Message window's tabs.+data ViewMessageWindowTab =+ VMTabMessage+ -- ^ The message tab.+ | VMTabReactions+ -- ^ The reactions tab.+ deriving (Eq, Show)++data PendingChannelChange =+ ChangeByChannelId ChannelId (Maybe (MH ()))+ | ChangeByUserId UserId++-- | Startup state information that is constructed prior to building a+-- ChatState.+data StartupStateInfo =+ StartupStateInfo { startupStateResources :: ChatResources+ , startupStateChannelZipper :: Z.Zipper ChannelListGroup ChannelListEntry+ , startupStateConnectedUser :: User+ , startupStateTeam :: Team+ , startupStateTimeZone :: TimeZoneSeries+ , startupStateInitialHistory :: InputHistory+ , startupStateSpellChecker :: Maybe (Aspell, IO ())+ }++-- | The state of the channel topic editor window.+data ChannelTopicDialogState =+ ChannelTopicDialogState { _channelTopicDialogEditor :: Editor T.Text Name+ -- ^ The topic string editor state.+ , _channelTopicDialogFocus :: FocusRing Name+ -- ^ The window focus state (editor/buttons)+ }++newState :: StartupStateInfo -> IO ChatState+newState (StartupStateInfo {..}) = do+ editState <- emptyEditState startupStateInitialHistory startupStateSpellChecker+ let config = _crConfiguration startupStateResources+ return ChatState { _csResources = startupStateResources+ , _csFocus = startupStateChannelZipper+ , _csChannelListOrientation = configChannelListOrientation config+ , _csMe = startupStateConnectedUser+ , _csMyTeam = startupStateTeam+ , _csChannels = noChannels+ , _csPostMap = HM.empty+ , _csUsers = noUsers+ , _timeZone = startupStateTimeZone+ , _csEditState = editState+ , _csMode = Main+ , _csShowMessagePreview = configShowMessagePreview config+ , _csShowChannelList = configShowChannelList config+ , _csShowExpandedChannelTopics = configShowExpandedChannelTopics config+ , _csChannelSelectState = emptyChannelSelectState+ , _csRecentChannel = Nothing+ , _csReturnChannel = Nothing+ , _csUrlList = list UrlList mempty 2+ , _csConnectionStatus = Connected+ , _csWorkerIsBusy = Nothing+ , _csMessageSelect = MessageSelectState Nothing+ , _csThemeListOverlay = nullThemeListOverlayState+ , _csPostListOverlay = PostListOverlayState emptyDirSeq Nothing+ , _csUserListOverlay = nullUserListOverlayState+ , _csChannelListOverlay = nullChannelListOverlayState+ , _csReactionEmojiListOverlay = nullEmojiListOverlayState+ , _csClientConfig = Nothing+ , _csPendingChannelChange = Nothing+ , _csViewedMessage = Nothing+ , _csNotifyPrefs = Nothing+ , _csChannelTopicDialog = newChannelTopicDialog ""+ }++-- | Make a new channel topic editor window state.+newChannelTopicDialog :: T.Text -> ChannelTopicDialogState+newChannelTopicDialog t =+ ChannelTopicDialogState { _channelTopicDialogEditor = editor ChannelTopicEditor Nothing t+ , _channelTopicDialogFocus = focusRing [ ChannelTopicEditor+ , ChannelTopicSaveButton+ , ChannelTopicCancelButton+ ]+ }++nullChannelListOverlayState :: ListOverlayState Channel ChannelSearchScope+nullChannelListOverlayState =+ let newList rs = list JoinChannelList rs 2+ in ListOverlayState { _listOverlaySearchResults = newList mempty+ , _listOverlaySearchInput = editor JoinChannelListSearchInput (Just 1) ""+ , _listOverlaySearchScope = AllChannels+ , _listOverlaySearching = False+ , _listOverlayEnterHandler = const $ return False+ , _listOverlayNewList = newList+ , _listOverlayFetchResults = const $ const $ const $ return mempty+ , _listOverlayRecordCount = Nothing+ , _listOverlayReturnMode = Main+ }++nullThemeListOverlayState :: ListOverlayState InternalTheme ()+nullThemeListOverlayState =+ let newList rs = list ThemeListSearchResults rs 3+ in ListOverlayState { _listOverlaySearchResults = newList mempty+ , _listOverlaySearchInput = editor ThemeListSearchInput (Just 1) ""+ , _listOverlaySearchScope = ()+ , _listOverlaySearching = False+ , _listOverlayEnterHandler = const $ return False+ , _listOverlayNewList = newList+ , _listOverlayFetchResults = const $ const $ const $ return mempty+ , _listOverlayRecordCount = Nothing+ , _listOverlayReturnMode = Main+ }++nullUserListOverlayState :: ListOverlayState UserInfo UserSearchScope+nullUserListOverlayState =+ let newList rs = list UserListSearchResults rs 1+ in ListOverlayState { _listOverlaySearchResults = newList mempty+ , _listOverlaySearchInput = editor UserListSearchInput (Just 1) ""+ , _listOverlaySearchScope = AllUsers Nothing+ , _listOverlaySearching = False+ , _listOverlayEnterHandler = const $ return False+ , _listOverlayNewList = newList+ , _listOverlayFetchResults = const $ const $ const $ return mempty+ , _listOverlayRecordCount = Nothing+ , _listOverlayReturnMode = Main+ }++nullEmojiListOverlayState :: ListOverlayState (Bool, T.Text) ()+nullEmojiListOverlayState =+ let newList rs = list ReactionEmojiList rs 1+ in ListOverlayState { _listOverlaySearchResults = newList mempty+ , _listOverlaySearchInput = editor ReactionEmojiListInput (Just 1) ""+ , _listOverlaySearchScope = ()+ , _listOverlaySearching = False+ , _listOverlayEnterHandler = const $ return False+ , _listOverlayNewList = newList+ , _listOverlayFetchResults = const $ const $ const $ return mempty+ , _listOverlayRecordCount = Nothing+ , _listOverlayReturnMode = MessageSelect+ }++getServerBaseUrl :: MH TeamBaseURL+getServerBaseUrl = do+ st <- use id+ return $ serverBaseUrl st++serverBaseUrl :: ChatState -> TeamBaseURL+serverBaseUrl st =+ let baseUrl = connectionDataURL $ _crConn $ _csResources st+ tName = teamName $ _csMyTeam st+ in TeamBaseURL (TeamURLName $ sanitizeUserText tName) baseUrl++-- | The state of channel selection mode.+data ChannelSelectState =+ ChannelSelectState { _channelSelectInput :: Editor Text Name+ , _channelSelectMatches :: Z.Zipper ChannelListGroup ChannelSelectMatch+ }++emptyChannelSelectState :: ChannelSelectState+emptyChannelSelectState =+ ChannelSelectState { _channelSelectInput = editor ChannelSelectInput (Just 1) ""+ , _channelSelectMatches = Z.fromList []+ }++-- | The state of message selection mode.+data MessageSelectState =+ MessageSelectState { selectMessageId :: Maybe MessageId+ }++-- | The state of the post list overlay.+data PostListOverlayState =+ PostListOverlayState { _postListPosts :: Messages+ , _postListSelected :: Maybe PostId+ }++data InternalTheme =+ InternalTheme { internalThemeName :: Text+ , internalTheme :: Theme+ , internalThemeDesc :: Text+ }++-- | The state of the search result list overlay. Type 'a' is the type+-- of data in the list. Type 'b' is the search scope type.+data ListOverlayState a b =+ ListOverlayState { _listOverlaySearchResults :: List Name a+ -- ^ The list of search results currently shown in+ -- the overlay.+ , _listOverlaySearchInput :: Editor Text Name+ -- ^ The editor for the overlay's search input.+ , _listOverlaySearchScope :: b+ -- ^ The overlay's current search scope.+ , _listOverlaySearching :: Bool+ -- ^ Whether a search is in progress (i.e. whether+ -- we are currently awaiting a response from a+ -- search query to the server).+ , _listOverlayEnterHandler :: a -> MH Bool+ -- ^ The handler to invoke on the selected element+ -- when the user presses Enter.+ , _listOverlayNewList :: Vec.Vector a -> List Name a+ -- ^ The function to build a new brick List from a+ -- vector of search results.+ , _listOverlayFetchResults :: b -> Session -> Text -> IO (Vec.Vector a)+ -- ^ The function to call to issue a search query+ -- to the server.+ , _listOverlayRecordCount :: Maybe Int+ -- ^ The total number of available records, if known.+ , _listOverlayReturnMode :: Mode+ -- ^ The mode to return to when the window closes.+ }++-- | The scope for searching for users in a user list overlay.+data UserSearchScope =+ ChannelMembers ChannelId TeamId+ | ChannelNonMembers ChannelId TeamId+ | AllUsers (Maybe TeamId)++-- | The scope for searching for channels to join.+data ChannelSearchScope =+ AllChannels++-- | Actions that can be sent on the websocket to the server.+data WebsocketAction =+ UserTyping UTCTime ChannelId (Maybe PostId) -- ^ user typing in the input box+ deriving (Read, Show, Eq, Ord)++-- * MH Monad++-- | Logging context information, in the event that metadata should+-- accompany a log message.+data LogContext =+ LogContext { logContextChannelId :: Maybe ChannelId+ }+ deriving (Eq, Show)++-- | A user fetching strategy.+data UserFetch =+ UserFetchById UserId+ -- ^ Fetch the user with the specified ID.+ | UserFetchByUsername Text+ -- ^ Fetch the user with the specified username.+ | UserFetchByNickname Text+ -- ^ Fetch the user with the specified nickname.+ deriving (Eq, Show)++data MHState =+ MHState { mhCurrentState :: ChatState+ , mhNextAction :: ChatState -> EventM Name (Next ChatState)+ , mhUsersToFetch :: [UserFetch]+ , mhPendingStatusList :: Maybe [UserId]+ }++-- | A value of type 'MH' @a@ represents a computation that can+-- manipulate the application state and also request that the+-- application quit+newtype MH a =+ MH { fromMH :: R.ReaderT (Maybe LogContext) (St.StateT MHState (EventM Name)) a }++-- | Use a modified logging context for the duration of the specified MH+-- action.+withLogContext :: (Maybe LogContext -> Maybe LogContext) -> MH a -> MH a+withLogContext modifyContext act =+ MH $ R.withReaderT modifyContext (fromMH act)++withLogContextChannelId :: ChannelId -> MH a -> MH a+withLogContextChannelId cId act =+ let f Nothing = Just $ LogContext (Just cId)+ f (Just c) = Just $ c { logContextChannelId = Just cId }+ in withLogContext f act++-- | Get the current logging context.+getLogContext :: MH (Maybe LogContext)+getLogContext = MH R.ask++-- | Log a message.+mhLog :: LogCategory -> Text -> MH ()+mhLog cat msg = do+ logger <- mhGetIOLogger+ liftIO $ logger cat msg++-- | Get a logger suitable for use in IO. The logger always logs using+-- the MH monad log context at the time of the call to mhGetIOLogger.+mhGetIOLogger :: MH (LogCategory -> Text -> IO ())+mhGetIOLogger = do+ ctx <- getLogContext+ mgr <- use (to (_crLogManager . _csResources))+ return $ ioLogWithManager mgr ctx++ioLogWithManager :: LogManager -> Maybe LogContext -> LogCategory -> Text -> IO ()+ioLogWithManager mgr ctx cat msg = do+ now <- getCurrentTime+ let lm = LogMessage { logMessageText = msg+ , logMessageContext = ctx+ , logMessageCategory = cat+ , logMessageTimestamp = now+ }+ sendLogMessage mgr lm++-- | Run an 'MM' computation, choosing whether to continue or halt based+-- on the resulting+runMHEvent :: ChatState -> MH () -> EventM Name (Next ChatState)+runMHEvent st (MH mote) = do+ let mhSt = MHState { mhCurrentState = st+ , mhNextAction = Brick.continue+ , mhUsersToFetch = []+ , mhPendingStatusList = Nothing+ }+ ((), st') <- St.runStateT (R.runReaderT mote Nothing) mhSt+ (mhNextAction st') (mhCurrentState st')++scheduleUserFetches :: [UserFetch] -> MH ()+scheduleUserFetches fs = MH $ do+ St.modify $ \s -> s { mhUsersToFetch = fs <> mhUsersToFetch s }++scheduleUserStatusFetches :: [UserId] -> MH ()+scheduleUserStatusFetches is = MH $ do+ St.modify $ \s -> s { mhPendingStatusList = Just is }++getScheduledUserFetches :: MH [UserFetch]+getScheduledUserFetches = MH $ St.gets mhUsersToFetch++getScheduledUserStatusFetches :: MH (Maybe [UserId])+getScheduledUserStatusFetches = MH $ St.gets mhPendingStatusList++-- | lift a computation in 'EventM' into 'MH'+mh :: EventM Name a -> MH a+mh = MH . R.lift . St.lift++generateUUID :: MH UUID+generateUUID = liftIO generateUUID_IO++generateUUID_IO :: IO UUID+generateUUID_IO = randomIO++mhHandleEventLensed :: Lens' ChatState b -> (e -> b -> EventM Name b) -> e -> MH ()+mhHandleEventLensed ln f event = MH $ do+ s <- St.get+ let st = mhCurrentState s+ n <- R.lift $ St.lift $ f event (st ^. ln)+ St.put (s { mhCurrentState = st & ln .~ n })++mhSuspendAndResume :: (ChatState -> IO ChatState) -> MH ()+mhSuspendAndResume mote = MH $ do+ s <- St.get+ St.put $ s { mhNextAction = \ _ -> Brick.suspendAndResume (mote $ mhCurrentState s) }++-- | This will request that after this computation finishes the+-- application should exit+requestQuit :: MH ()+requestQuit = MH $ do+ s <- St.get+ St.put $ s { mhNextAction = Brick.halt }++instance Functor MH where+ fmap f (MH x) = MH (fmap f x)++instance Applicative MH where+ pure x = MH (pure x)+ MH f <*> MH x = MH (f <*> x)++instance MHF.MonadFail MH where+ fail = MH . MHF.fail++instance Monad MH where+ return x = MH (return x)+ MH x >>= f = MH (x >>= \ x' -> fromMH (f x'))++-- We want to pretend that the state is only the ChatState, rather+-- than the ChatState and the Brick continuation+instance St.MonadState ChatState MH where+ get = mhCurrentState `fmap` MH St.get+ put st = MH $ do+ s <- St.get+ St.put $ s { mhCurrentState = st }++instance St.MonadIO MH where+ liftIO = MH . St.liftIO++-- | This represents events that we handle in the main application loop.+data MHEvent =+ WSEvent WebsocketEvent+ -- ^ For events that arise from the websocket+ | WSActionResponse WebsocketActionResponse+ -- ^ For responses to websocket actions+ | RespEvent (MH ())+ -- ^ For the result values of async IO operations+ | RefreshWebsocketEvent+ -- ^ Tell our main loop to refresh the websocket connection+ | WebsocketParseError String+ -- ^ We failed to parse an incoming websocket event+ | WebsocketDisconnect+ -- ^ The websocket connection went down.+ | WebsocketConnect+ -- ^ The websocket connection came up.+ | BGIdle+ -- ^ background worker is idle+ | BGBusy (Maybe Int)+ -- ^ background worker is busy (with n requests)+ | RateLimitExceeded Int+ -- ^ A request initially failed due to a rate limit but will be+ -- retried if possible. The argument is the number of seconds in+ -- which the retry will be attempted.+ | RateLimitSettingsMissing+ -- ^ A request denied by a rate limit could not be retried because+ -- the response contained no rate limit metadata+ | RequestDropped+ -- ^ A request was reattempted due to a rate limit and was rate+ -- limited again+ | IEvent InternalEvent+ -- ^ MH-internal events++-- | Internal application events.+data InternalEvent =+ DisplayError MHError+ -- ^ Some kind of application error occurred+ | LoggingStarted FilePath+ | LoggingStopped FilePath+ | LogStartFailed FilePath String+ | LogDestination (Maybe FilePath)+ | LogSnapshotSucceeded FilePath+ | LogSnapshotFailed FilePath String+ -- ^ Logging events from the logging thread++-- | Application errors.+data MHError =+ GenericError T.Text+ -- ^ A generic error message constructor+ | NoSuchChannel T.Text+ -- ^ The specified channel does not exist+ | NoSuchUser T.Text+ -- ^ The specified user does not exist+ | AmbiguousName T.Text+ -- ^ The specified name matches both a user and a channel+ | ServerError MattermostError+ -- ^ A Mattermost server error occurred+ | ClipboardError T.Text+ -- ^ A problem occurred trying to deal with yanking or the system+ -- clipboard+ | ConfigOptionMissing T.Text+ -- ^ A missing config option is required to perform an operation+ | ProgramExecutionFailed T.Text T.Text+ -- ^ Args: program name, path to log file. A problem occurred when+ -- running the program.+ | NoSuchScript T.Text+ -- ^ The specified script was not found+ | NoSuchHelpTopic T.Text+ -- ^ The specified help topic was not found+ | AsyncErrEvent SomeException+ -- ^ For errors that arise in the course of async IO operations+ deriving (Show)++-- ** Application State Lenses++makeLenses ''ChatResources+makeLenses ''ChatState+makeLenses ''ChatEditState+makeLenses ''AutocompleteState+makeLenses ''PostListOverlayState+makeLenses ''ListOverlayState+makeLenses ''ChannelSelectState+makeLenses ''UserPreferences+makeLenses ''ConnectionInfo+makeLenses ''ChannelTopicDialogState++getSession :: MH Session+getSession = use (csResources.crSession)++getResourceSession :: ChatResources -> Session+getResourceSession = _crSession++whenMode :: Mode -> MH () -> MH ()+whenMode m act = do+ curMode <- use csMode+ when (curMode == m) act++setMode :: Mode -> MH ()+setMode m = do+ csMode .= m+ mh invalidateCache++setMode' :: Mode -> ChatState -> ChatState+setMode' m = csMode .~ m++appMode :: ChatState -> Mode+appMode = _csMode++resetSpellCheckTimer :: ChatEditState -> IO ()+resetSpellCheckTimer s =+ case s^.cedSpellChecker of+ Nothing -> return ()+ Just (_, reset) -> reset++-- ** Utility Lenses+csCurrentChannelId :: SimpleGetter ChatState ChannelId+csCurrentChannelId = csFocus.to Z.unsafeFocus.to channelListEntryChannelId++channelListEntryChannelId :: ChannelListEntry -> ChannelId+channelListEntryChannelId (CLChannel cId) = cId+channelListEntryChannelId (CLUserDM cId _) = cId+channelListEntryChannelId (CLGroupDM cId) = cId++channelListEntryUserId :: ChannelListEntry -> Maybe UserId+channelListEntryUserId (CLUserDM _ uId) = Just uId+channelListEntryUserId _ = Nothing++userIdsFromZipper :: Z.Zipper ChannelListGroup ChannelListEntry -> [UserId]+userIdsFromZipper z =+ concat $ (catMaybes . fmap channelListEntryUserId . snd) <$> Z.toList z++entryIsDMEntry :: ChannelListEntry -> Bool+entryIsDMEntry (CLUserDM {}) = True+entryIsDMEntry (CLGroupDM {}) = True+entryIsDMEntry (CLChannel {}) = False++csCurrentChannel :: Lens' ChatState ClientChannel+csCurrentChannel =+ lens (\ st -> findChannelById (st^.csCurrentChannelId) (st^.csChannels) ^?! _Just)+ (\ st n -> st & csChannels %~ addChannel (st^.csCurrentChannelId) n)++csChannel :: ChannelId -> Traversal' ChatState ClientChannel+csChannel cId =+ csChannels . channelByIdL cId++withChannel :: ChannelId -> (ClientChannel -> MH ()) -> MH ()+withChannel cId = withChannelOrDefault cId ()++withChannelOrDefault :: ChannelId -> a -> (ClientChannel -> MH a) -> MH a+withChannelOrDefault cId deflt mote = do+ chan <- preuse (csChannel(cId))+ case chan of+ Nothing -> return deflt+ Just c -> mote c++-- ** 'ChatState' Helper Functions++raiseInternalEvent :: InternalEvent -> MH ()+raiseInternalEvent ev = do+ queue <- use (csResources.crEventQueue)+ writeBChan queue (IEvent ev)++writeBChan :: (MonadIO m) => BCH.BChan MHEvent -> MHEvent -> m ()+writeBChan chan e = do+ written <- liftIO $ BCH.writeBChanNonBlocking chan e+ when (not written) $+ error $ "mhSendEvent: BChan full, please report this as a bug!"++-- | Log and raise an error.+mhError :: MHError -> MH ()+mhError err = do+ mhLog LogError $ T.pack $ show err+ raiseInternalEvent (DisplayError err)++isMine :: ChatState -> Message -> Bool+isMine st msg =+ case msg^.mUser of+ UserI _ uid -> uid == myUserId st+ _ -> False++getMessageForPostId :: ChatState -> PostId -> Maybe Message+getMessageForPostId st pId = st^.csPostMap.at(pId)++getParentMessage :: ChatState -> Message -> Maybe Message+getParentMessage st msg+ | InReplyTo pId <- msg^.mInReplyToMsg+ = st^.csPostMap.at(pId)+ | otherwise = Nothing++getReplyRootMessage :: Message -> MH Message+getReplyRootMessage msg = do+ case postRootId =<< (msg^.mOriginalPost) of+ Nothing -> return msg+ Just rootId -> do+ st <- use id+ case getMessageForPostId st rootId of+ -- NOTE: this case should never happen. This is the+ -- case where a message has a root post ID but we+ -- don't have a copy of the root post in storage. This+ -- shouldn't happen because whenever we add a message+ -- to a channel, we always fetch the parent post and+ -- store it if it is in a thread. That should mean that+ -- whenever we reply to a post, if that post is itself+ -- a reply, we should have its root post in storage+ -- and this case should never match. Even though it+ -- shouldn't happen, rather than raising a BUG exception+ -- here we'll just fall back to the input message.+ Nothing -> return msg+ Just m -> return m++setUserStatus :: UserId -> Text -> MH ()+setUserStatus uId t = do+ csUsers %= modifyUserById uId (uiStatus .~ statusFromText t)+ mh $ invalidateCacheEntry ChannelSidebar++usernameForUserId :: UserId -> ChatState -> Maybe Text+usernameForUserId uId st = _uiName <$> findUserById uId (st^.csUsers)++displayNameForUserId :: UserId -> ChatState -> Maybe Text+displayNameForUserId uId st = do+ u <- findUserById uId (st^.csUsers)+ return $ displayNameForUser u (st^.csClientConfig) (st^.csResources.crUserPreferences)++-- | Note: this only searches users we have already loaded. Be+-- aware that if you think you need a user we haven't fetched, use+-- withFetchedUser!+userIdForUsername :: Text -> ChatState -> Maybe UserId+userIdForUsername name st =+ fst <$> (findUserByUsername name $ st^.csUsers)++channelIdByChannelName :: Text -> ChatState -> Maybe ChannelId+channelIdByChannelName name st =+ let matches (_, cc) = cc^.ccInfo.cdName == (trimChannelSigil name)+ in listToMaybe $ fst <$> filteredChannels matches (st^.csChannels)++channelIdByUsername :: Text -> ChatState -> Maybe ChannelId+channelIdByUsername name st = do+ uId <- userIdForUsername name st+ getDmChannelFor uId (st^.csChannels)++useNickname :: ChatState -> Bool+useNickname st =+ useNickname' (st^.csClientConfig) (st^.csResources.crUserPreferences)++channelByName :: Text -> ChatState -> Maybe ClientChannel+channelByName n st = do+ cId <- channelIdByChannelName n st+ findChannelById cId (st^.csChannels)++trimChannelSigil :: Text -> Text+trimChannelSigil n+ | normalChannelSigil `T.isPrefixOf` n = T.tail n+ | otherwise = n++addNewUser :: UserInfo -> MH ()+addNewUser u = do+ csUsers %= addUser u+ -- Invalidate the cache because channel message rendering may need+ -- to get updated if this user authored posts in any channels.+ mh invalidateCache++data SidebarUpdate =+ SidebarUpdateImmediate+ | SidebarUpdateDeferred+ deriving (Eq, Show)+++resetAutocomplete :: MH ()+resetAutocomplete = do+ csEditState.cedAutocomplete .= Nothing+ csEditState.cedAutocompletePending .= Nothing+++-- * Slash Commands++-- | The 'CmdArgs' type represents the arguments to a slash-command; the+-- type parameter represents the argument structure.+data CmdArgs :: K.Type -> K.Type where+ NoArg :: CmdArgs ()+ LineArg :: Text -> CmdArgs Text+ UserArg :: CmdArgs rest -> CmdArgs (Text, rest)+ ChannelArg :: CmdArgs rest -> CmdArgs (Text, rest)+ TokenArg :: Text -> CmdArgs rest -> CmdArgs (Text, rest)++-- | A 'CmdExec' value represents the implementation of a command when+-- provided with its arguments+type CmdExec a = a -> MH ()++-- | A 'Cmd' packages up a 'CmdArgs' specifier and the 'CmdExec'+-- implementation with a name and a description.+data Cmd =+ forall a. Cmd { cmdName :: Text+ , cmdDescr :: Text+ , cmdArgSpec :: CmdArgs a+ , cmdAction :: CmdExec a+ }++-- | Helper function to extract the name out of a 'Cmd' value+commandName :: Cmd -> Text+commandName (Cmd name _ _ _ ) = name++-- * Channel Updates and Notifications++userList :: ChatState -> [UserInfo]+userList st = filter showUser $ allUsers (st^.csUsers)+ where showUser u = not (isSelf u) && (u^.uiInTeam)+ isSelf u = (myUserId st) == (u^.uiId)++allUserIds :: ChatState -> [UserId]+allUserIds st = getAllUserIds $ st^.csUsers++-- BEWARE: you probably don't want this, but instead+-- State.Users.withFetchedUser, since this only looks up users in the+-- collection we have already loaded rather than all valid users on the+-- server.+userById :: UserId -> ChatState -> Maybe UserInfo+userById uId st = findUserById uId (st^.csUsers)++myUserId :: ChatState -> UserId+myUserId st = myUser st ^. userIdL++myTeamId :: ChatState -> TeamId+myTeamId st = st ^. csMyTeam . teamIdL++myUser :: ChatState -> User+myUser st = st^.csMe++myUsername :: ChatState -> Text+myUsername st = userUsername $ st^.csMe++-- BEWARE: you probably don't want this, but instead+-- State.Users.withFetchedUser, since this only looks up users in the+-- collection we have already loaded rather than all valid users on the+-- server.+userByUsername :: Text -> ChatState -> Maybe UserInfo+userByUsername name st = do+ snd <$> (findUserByUsername name $ st^.csUsers)++-- BEWARE: you probably don't want this, but instead+-- State.Users.withFetchedUser, since this only looks up users in the+-- collection we have already loaded rather than all valid users on the+-- server.+userByNickname :: Text -> ChatState -> Maybe UserInfo+userByNickname name st =+ snd <$> (findUserByNickname name $ st^.csUsers)++getUsers :: MH Users+getUsers = use csUsers++-- * HighlightSet++type UserSet = Set Text+type ChannelSet = Set Text++-- | The set of usernames, channel names, and language names used for+-- highlighting when rendering messages.+data HighlightSet =+ HighlightSet { hUserSet :: Set Text+ , hChannelSet :: Set Text+ , hSyntaxMap :: SyntaxMap+ }++getHighlightSet :: ChatState -> HighlightSet+getHighlightSet st =+ HighlightSet { hUserSet = addSpecialUserMentions $ getUsernameSet $ st^.csUsers+ , hChannelSet = getChannelNameSet $ st^.csChannels+ , hSyntaxMap = st^.csResources.crSyntaxMap+ }++attrNameToConfig :: Brick.AttrName -> Text+attrNameToConfig = T.pack . intercalate "." . Brick.attrNameComponents++-- From: https://docs.mattermost.com/help/messaging/mentioning-teammates.html+specialUserMentions :: [T.Text]+specialUserMentions = ["all", "channel", "here"]++addSpecialUserMentions :: Set Text -> Set Text+addSpecialUserMentions s = foldr Set.insert s specialUserMentions++getNewMessageCutoff :: ChannelId -> ChatState -> Maybe NewMessageIndicator+getNewMessageCutoff cId st = do+ cc <- st^?csChannel(cId)+ return $ cc^.ccInfo.cdNewMessageIndicator++getEditedMessageCutoff :: ChannelId -> ChatState -> Maybe ServerTime+getEditedMessageCutoff cId st = do+ cc <- st^?csChannel(cId)+ cc^.ccInfo.cdEditedMessageThreshold++clearChannelUnreadStatus :: ChannelId -> MH ()+clearChannelUnreadStatus cId = do+ mh $ invalidateCacheEntry (ChannelMessages cId)+ csChannel(cId) %= (clearNewMessageIndicator .+ clearEditedThreshold)
+ src/Matterhorn/Types/Channels.hs view
@@ -0,0 +1,420 @@+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE DeriveFoldable #-}+{-# LANGUAGE DeriveTraversable #-}+{-# LANGUAGE RankNTypes #-}++module Matterhorn.Types.Channels+ ( ClientChannel(..)+ , ChannelContents(..)+ , ChannelInfo(..)+ , ClientChannels -- constructor remains internal+ , NewMessageIndicator(..)+ , EphemeralEditState(..)+ , EditMode(..)+ , eesMultiline, eesInputHistoryPosition, eesLastInput+ , defaultEphemeralEditState+ -- * Lenses created for accessing ClientChannel fields+ , ccContents, ccInfo, ccEditState+ -- * Lenses created for accessing ChannelInfo fields+ , cdViewed, cdNewMessageIndicator, cdEditedMessageThreshold, cdUpdated+ , cdName, cdDisplayName, cdHeader, cdPurpose, cdType+ , cdMentionCount, cdTypingUsers, cdDMUserId, cdChannelId+ , cdSidebarShowOverride, cdNotifyProps+ -- * Lenses created for accessing ChannelContents fields+ , cdMessages, cdFetchPending+ -- * Creating ClientChannel objects+ , makeClientChannel+ -- * Managing ClientChannel collections+ , noChannels, addChannel, removeChannel, findChannelById, modifyChannelById+ , channelByIdL, maybeChannelByIdL+ , filteredChannelIds+ , filteredChannels+ -- * Creating ChannelInfo objects+ , channelInfoFromChannelWithData+ -- * Channel State management+ , clearNewMessageIndicator+ , clearEditedThreshold+ , adjustUpdated+ , adjustEditedThreshold+ , updateNewMessageIndicator+ , addChannelTypingUser+ -- * Notification settings+ , notifyPreference+ , isMuted+ , channelNotifyPropsMarkUnreadL+ , channelNotifyPropsIgnoreChannelMentionsL+ , channelNotifyPropsDesktopL+ , channelNotifyPropsPushL+ -- * Miscellaneous channel-related operations+ , canLeaveChannel+ , preferredChannelName+ , isTownSquare+ , channelDeleted+ , getDmChannelFor+ , allDmChannelMappings+ , getChannelNameSet+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.HashMap.Strict as HM+import qualified Data.Set as S+import qualified Data.Text as T+import Lens.Micro.Platform ( (%~), (.~), Traversal', Lens'+ , makeLenses, ix, at+ , to, non )++import Network.Mattermost.Lenses hiding ( Lens' )+import Network.Mattermost.Types ( Channel(..), UserId, ChannelId+ , ChannelMember(..)+ , Type(..)+ , Post+ , User(userNotifyProps)+ , ChannelNotifyProps+ , NotifyOption(..)+ , WithDefault(..)+ , ServerTime+ , emptyChannelNotifyProps+ )++import Matterhorn.Types.Messages ( Messages, noMessages, addMessage+ , clientMessageToMessage, Message, MessageType )+import Matterhorn.Types.Posts ( ClientMessageType(UnknownGapBefore)+ , newClientMessage )+import Matterhorn.Types.Users ( TypingUsers, noTypingUsers, addTypingUser )+import Matterhorn.Types.Common+++-- * Channel representations++-- | A 'ClientChannel' contains both the message+-- listing and the metadata about a channel+data ClientChannel = ClientChannel+ { _ccContents :: ChannelContents+ -- ^ A list of 'Message's in the channel+ , _ccInfo :: ChannelInfo+ -- ^ The 'ChannelInfo' for the channel+ , _ccEditState :: EphemeralEditState+ -- ^ Editor state that we swap in and out as the current channel is+ -- changed.+ }++-- | The input state associated with the message editor.+data EditMode =+ NewPost+ -- ^ The input is for a new post.+ | Editing Post MessageType+ -- ^ The input is ultimately to replace the body of an existing post+ -- of the specified type.+ | Replying Message Post+ -- ^ The input is to be used as a new post in reply to the specified+ -- post.+ deriving (Show)++data EphemeralEditState =+ EphemeralEditState { _eesMultiline :: Bool+ -- ^ Whether the editor is in multiline mode+ , _eesInputHistoryPosition :: Maybe Int+ -- ^ The input history position, if any+ , _eesLastInput :: (T.Text, EditMode)+ -- ^ The input entered into the text editor last+ -- time the user was focused on the channel+ -- associated with this state.+ }++-- Get a channel's name, depending on its type+preferredChannelName :: Channel -> Text+preferredChannelName ch+ | channelType ch == Group = sanitizeUserText $ channelDisplayName ch+ | otherwise = sanitizeUserText $ channelName ch++data NewMessageIndicator =+ Hide+ | NewPostsAfterServerTime ServerTime+ | NewPostsStartingAt ServerTime+ deriving (Eq, Show)++initialChannelInfo :: UserId -> Channel -> ChannelInfo+initialChannelInfo myId chan =+ let updated = chan ^. channelLastPostAtL+ in ChannelInfo { _cdChannelId = chan^.channelIdL+ , _cdViewed = Nothing+ , _cdNewMessageIndicator = Hide+ , _cdEditedMessageThreshold = Nothing+ , _cdMentionCount = 0+ , _cdUpdated = updated+ , _cdName = preferredChannelName chan+ , _cdDisplayName = sanitizeUserText $ channelDisplayName chan+ , _cdHeader = sanitizeUserText $ chan^.channelHeaderL+ , _cdPurpose = sanitizeUserText $ chan^.channelPurposeL+ , _cdType = chan^.channelTypeL+ , _cdNotifyProps = emptyChannelNotifyProps+ , _cdTypingUsers = noTypingUsers+ , _cdDMUserId = if chan^.channelTypeL == Direct+ then userIdForDMChannel myId $+ sanitizeUserText $ channelName chan+ else Nothing+ , _cdSidebarShowOverride = Nothing+ }++channelInfoFromChannelWithData :: Channel -> ChannelMember -> ChannelInfo -> ChannelInfo+channelInfoFromChannelWithData chan chanMember ci =+ let viewed = chanMember ^. to channelMemberLastViewedAt+ updated = chan ^. channelLastPostAtL+ in ci { _cdViewed = Just viewed+ , _cdNewMessageIndicator = case _cdNewMessageIndicator ci of+ Hide -> if updated > viewed then NewPostsAfterServerTime viewed else Hide+ v -> v+ , _cdUpdated = updated+ , _cdName = preferredChannelName chan+ , _cdDisplayName = sanitizeUserText $ channelDisplayName chan+ , _cdHeader = (sanitizeUserText $ chan^.channelHeaderL)+ , _cdPurpose = (sanitizeUserText $ chan^.channelPurposeL)+ , _cdType = (chan^.channelTypeL)+ , _cdMentionCount = chanMember^.to channelMemberMentionCount+ , _cdNotifyProps = chanMember^.to channelMemberNotifyProps+ }++-- | The 'ChannelContents' is a wrapper for a list of+-- 'Message' values+data ChannelContents = ChannelContents+ { _cdMessages :: Messages+ , _cdFetchPending :: Bool+ }++-- | An initial empty 'ChannelContents' value. This also contains an+-- UnknownGapBefore, which is a signal that causes actual content fetching.+-- The initial Gap's timestamp is the local client time, but+-- subsequent fetches will synchronize with the server (and eventually+-- eliminate this Gap as well).+emptyChannelContents :: MonadIO m => m ChannelContents+emptyChannelContents = do+ gapMsg <- clientMessageToMessage <$> newClientMessage UnknownGapBefore "--Fetching messages--"+ return $ ChannelContents { _cdMessages = addMessage gapMsg noMessages+ , _cdFetchPending = False+ }+++------------------------------------------------------------------------++-- | The 'ChannelInfo' record represents metadata+-- about a channel+data ChannelInfo = ChannelInfo+ { _cdChannelId :: ChannelId+ -- ^ The channel's ID+ , _cdViewed :: Maybe ServerTime+ -- ^ The last time we looked at a channel+ , _cdNewMessageIndicator :: NewMessageIndicator+ -- ^ The state of the channel's new message indicator.+ , _cdEditedMessageThreshold :: Maybe ServerTime+ -- ^ The channel's edited message threshold.+ , _cdMentionCount :: Int+ -- ^ The current number of unread mentions+ , _cdUpdated :: ServerTime+ -- ^ The last time a message showed up in the channel+ , _cdName :: Text+ -- ^ The name of the channel+ , _cdDisplayName :: Text+ -- ^ The display name of the channel+ , _cdHeader :: Text+ -- ^ The header text of a channel+ , _cdPurpose :: Text+ -- ^ The stated purpose of the channel+ , _cdType :: Type+ -- ^ The type of a channel: public, private, or DM+ , _cdNotifyProps :: ChannelNotifyProps+ -- ^ The user's notification settings for this channel+ , _cdTypingUsers :: TypingUsers+ -- ^ The users who are currently typing in this channel+ , _cdDMUserId :: Maybe UserId+ -- ^ The user associated with this channel, if it is a DM channel+ , _cdSidebarShowOverride :: Maybe UTCTime+ -- ^ If set, show this channel in the sidebar regardless of other+ -- considerations as long as the specified timestamp meets a cutoff.+ -- Otherwise fall back to other application policy to determine+ -- whether to show the channel.+ }++-- ** Channel-related Lenses++makeLenses ''ChannelContents+makeLenses ''ChannelInfo+makeLenses ''ClientChannel+makeLenses ''EphemeralEditState++isMuted :: ClientChannel -> Bool+isMuted cc = cc^.ccInfo.cdNotifyProps.channelNotifyPropsMarkUnreadL ==+ IsValue NotifyOptionMention++notifyPreference :: User -> ClientChannel -> NotifyOption+notifyPreference u cc =+ if isMuted cc then NotifyOptionNone+ else case cc^.ccInfo.cdNotifyProps.channelNotifyPropsDesktopL of+ IsValue v -> v+ Default -> (userNotifyProps u)^.userNotifyPropsDesktopL++-- ** Miscellaneous channel operations++makeClientChannel :: (MonadIO m) => UserId -> Channel -> m ClientChannel+makeClientChannel myId nc = emptyChannelContents >>= \contents ->+ return ClientChannel+ { _ccContents = contents+ , _ccInfo = initialChannelInfo myId nc+ , _ccEditState = defaultEphemeralEditState+ }++defaultEphemeralEditState :: EphemeralEditState+defaultEphemeralEditState =+ EphemeralEditState { _eesMultiline = False+ , _eesInputHistoryPosition = Nothing+ , _eesLastInput = ("", NewPost)+ }++canLeaveChannel :: ChannelInfo -> Bool+canLeaveChannel cInfo = not $ cInfo^.cdType `elem` [Direct]++-- ** Manage the collection of all Channels++-- | Define a binary kinded type to allow derivation of functor.+data AllMyChannels a =+ AllChannels { _chanMap :: HashMap ChannelId a+ , _userChannelMap :: HashMap UserId ChannelId+ , _channelNameSet :: S.Set Text+ }+ deriving (Functor, Foldable, Traversable)++-- | Define the exported typename which universally binds the+-- collection to the ChannelInfo type.+type ClientChannels = AllMyChannels ClientChannel++makeLenses ''AllMyChannels++getChannelNameSet :: ClientChannels -> S.Set Text+getChannelNameSet = _channelNameSet++-- | Initial collection of Channels with no members+noChannels :: ClientChannels+noChannels = AllChannels HM.empty HM.empty mempty++-- | Add a channel to the existing collection.+addChannel :: ChannelId -> ClientChannel -> ClientChannels -> ClientChannels+addChannel cId cinfo =+ (chanMap %~ HM.insert cId cinfo) .+ (if cinfo^.ccInfo.cdType `notElem` [Direct, Group]+ then channelNameSet %~ S.insert (cinfo^.ccInfo.cdName)+ else id) .+ (case cinfo^.ccInfo.cdDMUserId of+ Nothing -> id+ Just uId -> userChannelMap %~ HM.insert uId cId+ )++-- | Remove a channel from the collection.+removeChannel :: ChannelId -> ClientChannels -> ClientChannels+removeChannel cId cs =+ let mChan = findChannelById cId cs+ removeChannelName = case mChan of+ Nothing -> id+ Just ch -> channelNameSet %~ S.delete (ch^.ccInfo.cdName)+ in cs & chanMap %~ HM.delete cId+ & removeChannelName+ & userChannelMap %~ HM.filter (/= cId)++getDmChannelFor :: UserId -> ClientChannels -> Maybe ChannelId+getDmChannelFor uId cs = cs^.userChannelMap.at uId++allDmChannelMappings :: ClientChannels -> [(UserId, ChannelId)]+allDmChannelMappings = HM.toList . _userChannelMap++-- | Get the ChannelInfo information given the ChannelId+findChannelById :: ChannelId -> ClientChannels -> Maybe ClientChannel+findChannelById cId = HM.lookup cId . _chanMap++-- | Transform the specified channel in place with provided function.+modifyChannelById :: ChannelId -> (ClientChannel -> ClientChannel)+ -> ClientChannels -> ClientChannels+modifyChannelById cId f = chanMap.ix(cId) %~ f++-- | A 'Traversal' that will give us the 'ClientChannel' in a+-- 'ClientChannels' structure if it exists+channelByIdL :: ChannelId -> Traversal' ClientChannels ClientChannel+channelByIdL cId = chanMap . ix cId++-- | A 'Lens' that will give us the 'ClientChannel' in a+-- 'ClientChannels' wrapped in a 'Maybe'+maybeChannelByIdL :: ChannelId -> Lens' ClientChannels (Maybe ClientChannel)+maybeChannelByIdL cId = chanMap . at cId++-- | Apply a filter to each ClientChannel and return a list of the+-- ChannelId values for which the filter matched.+filteredChannelIds :: (ClientChannel -> Bool) -> ClientChannels -> [ChannelId]+filteredChannelIds f cc = fst <$> filter (f . snd) (HM.toList (cc^.chanMap))++-- | Filter the ClientChannel collection, keeping only those for which+-- the provided filter test function returns True.+filteredChannels :: ((ChannelId, ClientChannel) -> Bool)+ -> ClientChannels -> [(ChannelId, ClientChannel)]+filteredChannels f cc = filter f $ cc^.chanMap.to HM.toList++------------------------------------------------------------------------++-- * Channel State management+++-- | Add user to the list of users in this channel who are currently typing.+addChannelTypingUser :: UserId -> UTCTime -> ClientChannel -> ClientChannel+addChannelTypingUser uId ts = ccInfo.cdTypingUsers %~ (addTypingUser uId ts)++-- | Clear the new message indicator for the specified channel+clearNewMessageIndicator :: ClientChannel -> ClientChannel+clearNewMessageIndicator c = c & ccInfo.cdNewMessageIndicator .~ Hide++-- | Clear the edit threshold for the specified channel+clearEditedThreshold :: ClientChannel -> ClientChannel+clearEditedThreshold c = c & ccInfo.cdEditedMessageThreshold .~ Nothing++-- | Adjust updated time based on a message, ensuring that the updated+-- time does not move backward.+adjustUpdated :: Post -> ClientChannel -> ClientChannel+adjustUpdated m =+ ccInfo.cdUpdated %~ max (maxPostTimestamp m)++adjustEditedThreshold :: Post -> ClientChannel -> ClientChannel+adjustEditedThreshold m c =+ if m^.postUpdateAtL <= m^.postCreateAtL+ then c+ else c & ccInfo.cdEditedMessageThreshold %~ (\mt -> case mt of+ Just t -> Just $ min (m^.postUpdateAtL) t+ Nothing -> Just $ m^.postUpdateAtL+ )++maxPostTimestamp :: Post -> ServerTime+maxPostTimestamp m = max (m^.postDeleteAtL . non (m^.postUpdateAtL)) (m^.postCreateAtL)++updateNewMessageIndicator :: Post -> ClientChannel -> ClientChannel+updateNewMessageIndicator m =+ ccInfo.cdNewMessageIndicator %~+ (\old ->+ case old of+ Hide ->+ NewPostsStartingAt $ m^.postCreateAtL+ NewPostsStartingAt ts ->+ NewPostsStartingAt $ min (m^.postCreateAtL) ts+ NewPostsAfterServerTime ts ->+ if m^.postCreateAtL <= ts+ then NewPostsStartingAt $ m^.postCreateAtL+ else NewPostsAfterServerTime ts+ )++-- | Town Square is special in that its non-display name cannot be+-- changed and is a hard-coded constant server-side according to the+-- developers (as of 8/2/17). So this is a reliable way to check for+-- whether a channel is in fact that channel, even if the user has+-- changed its display name.+isTownSquare :: Channel -> Bool+isTownSquare c = (sanitizeUserText $ c^.channelNameL) == "town-square"++channelDeleted :: Channel -> Bool+channelDeleted c = c^.channelDeleteAtL > c^.channelCreateAtL
+ src/Matterhorn/Types/Common.hs view
@@ -0,0 +1,41 @@+{-# LANGUAGE MultiWayIf #-}+module Matterhorn.Types.Common+ ( sanitizeUserText+ , sanitizeUserText'+ , userIdForDMChannel+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Text as T++import Network.Mattermost.Types ( UserText, unsafeUserText, UserId(..), Id(..) )++sanitizeUserText :: UserText -> T.Text+sanitizeUserText = sanitizeUserText' . unsafeUserText++sanitizeUserText' :: T.Text -> T.Text+sanitizeUserText' =+ T.replace "\ESC" "<ESC>" .+ T.replace "\t" " " .+ T.filter (\c -> c >= ' ' || c == '\n') -- remove non-printable++-- | Extract the corresponding other user from a direct channel name.+-- Returns Nothing if the string is not a direct channel name or if it+-- is but neither user ID in the name matches the current user's ID.+userIdForDMChannel :: UserId+ -- ^ My user ID+ -> Text+ -- ^ The channel name+ -> Maybe UserId+userIdForDMChannel me chanName =+ -- Direct channel names are of the form "UID__UID" where one of the+ -- UIDs is mine and the other is the other channel participant.+ let vals = T.splitOn "__" chanName+ in case vals of+ [u1, u2] -> if | (UI $ Id u1) == me -> Just $ UI $ Id u2+ | (UI $ Id u2) == me -> Just $ UI $ Id u1+ | otherwise -> Nothing+ _ -> Nothing
+ src/Matterhorn/Types/DirectionalSeq.hs view
@@ -0,0 +1,97 @@+{-# LANGUAGE DeriveFoldable #-}+{-# LANGUAGE DeriveFunctor #-}+{-# LANGUAGE DeriveTraversable #-}+{-# LANGUAGE TypeFamilies #-}++{- | These declarations allow the use of a DirectionalSeq, which is a+ Seq that uses a phantom type to identify the ordering of the+ elements in the sequence (Forward or Reverse). The constructors+ are not exported from this module so that a DirectionalSeq can only+ be constructed by the functions in this module.+-}++module Matterhorn.Types.DirectionalSeq where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Sequence as Seq+++data Chronological+data Retrograde+class SeqDirection a where+ type ReverseDirection a+instance SeqDirection Chronological+ where type ReverseDirection Chronological = Retrograde+instance SeqDirection Retrograde+ where type ReverseDirection Retrograde = Chronological++data SeqDirection dir => DirectionalSeq dir a =+ DSeq { dseq :: Seq a }+ deriving (Show, Functor, Foldable, Traversable)++emptyDirSeq :: DirectionalSeq dir a+emptyDirSeq = DSeq mempty++appendDirSeq :: DirectionalSeq dir a -> DirectionalSeq dir a -> DirectionalSeq dir a+appendDirSeq a b = DSeq $ mappend (dseq a) (dseq b)++onDirectedSeq :: SeqDirection dir => (Seq a -> Seq b)+ -> DirectionalSeq dir a -> DirectionalSeq dir b+onDirectedSeq f = DSeq . f . dseq++-- | Uses a start-predicate and and end-predicate to+-- identify (the first matching) subset that is delineated by+-- start-predicate and end-predicate (inclusive). It will then call+-- the passed operation function on the subset messages to get back a+-- (possibly modified) set of messages, along with an extracted value.+-- The 'onDirSeqSubset' function will replace the original subset of+-- messages with the set returned by the operation function and return+-- the resulting message list along with the extracted value.++onDirSeqSubset :: SeqDirection dir =>+ (e -> Bool) -> (e -> Bool)+ -> (DirectionalSeq dir e -> (DirectionalSeq dir e, a))+ -> DirectionalSeq dir e+ -> (DirectionalSeq dir e, a)+onDirSeqSubset startPred endPred op entries =+ let ml = dseq entries+ (bl, ml1) = Seq.breakl startPred ml+ (ml2, el) = Seq.breakl endPred ml1+ -- move match from start of el to end of ml2+ (ml2', el') = if not (Seq.null el)+ then (ml2 <> Seq.take 1 el, Seq.drop 1 el)+ else (ml2, el)+ (ml3, rval) = op $ DSeq ml2'+ in (DSeq bl `appendDirSeq` ml3 `appendDirSeq` DSeq el', rval)++-- | dirSeqBreakl splits the DirectionalSeq into a tuple where the+-- first element is the (possibly empty) DirectionalSeq of all+-- elements from the start for which the predicate returns false; the+-- second tuple element is the remainder of the list, starting with+-- the first element for which the predicate matched.+dirSeqBreakl :: SeqDirection dir =>+ (e -> Bool) -> DirectionalSeq dir e+ -> (DirectionalSeq dir e, DirectionalSeq dir e)+dirSeqBreakl isMatch entries =+ let (removed, remaining) = Seq.breakl isMatch $ dseq entries+ in (DSeq removed, DSeq remaining)++-- | dirSeqPartition splits the DirectionalSeq into a tuple of two+-- DirectionalSeq elements: the first contains all elements for which+-- the predicate is true and the second contains all elements for+-- which the predicate is false.+dirSeqPartition :: SeqDirection dir =>+ (e -> Bool) -> DirectionalSeq dir e+ -> (DirectionalSeq dir e, DirectionalSeq dir e)+dirSeqPartition isMatch entries =+ let (match, nomatch) = Seq.partition isMatch $ dseq entries+ in (DSeq match, DSeq nomatch)+++withDirSeqHead :: SeqDirection dir => (e -> r) -> DirectionalSeq dir e -> Maybe r+withDirSeqHead op entries =+ case Seq.viewl (dseq entries) of+ Seq.EmptyL -> Nothing+ e Seq.:< _ -> Just $ op e
+ src/Matterhorn/Types/KeyEvents.hs view
@@ -0,0 +1,437 @@+module Matterhorn.Types.KeyEvents+ (+ -- * Types+ KeyEvent(..)+ , KeyConfig+ , Binding(..)+ , BindingState(..)++ -- * Data+ , allEvents++ -- * Parsing and pretty-printing+ , parseBinding+ , parseBindingList+ , ppBinding+ , nonCharKeys+ , eventToBinding++ -- * Key event name resolution+ , keyEventFromName+ , keyEventName+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Map.Strict as M+import qualified Data.Text as T+import qualified Graphics.Vty as Vty+++-- | This enum represents all the possible key events a user might+-- want to use.+data KeyEvent+ = VtyRefreshEvent+ | ShowHelpEvent+ | EnterSelectModeEvent+ | ReplyRecentEvent+ | ToggleMessagePreviewEvent+ | InvokeEditorEvent+ | EnterFastSelectModeEvent+ | QuitEvent+ | NextChannelEvent+ | PrevChannelEvent+ | NextChannelEventAlternate+ | PrevChannelEventAlternate+ | NextUnreadChannelEvent+ | NextUnreadUserOrChannelEvent+ | LastChannelEvent+ | EnterOpenURLModeEvent+ | ClearUnreadEvent+ | ToggleMultiLineEvent+ | EnterFlaggedPostsEvent+ | ToggleChannelListVisibleEvent+ | ToggleExpandedChannelTopicsEvent+ | ShowAttachmentListEvent++ | EditorKillToBolEvent+ | EditorKillToEolEvent+ | EditorBolEvent+ | EditorEolEvent+ | EditorTransposeCharsEvent+ | EditorDeleteCharacter+ | EditorPrevCharEvent+ | EditorNextCharEvent+ | EditorPrevWordEvent+ | EditorNextWordEvent+ | EditorDeleteNextWordEvent+ | EditorDeletePrevWordEvent+ | EditorHomeEvent+ | EditorEndEvent+ | EditorYankEvent++ | SelectNextTabEvent+ | SelectPreviousTabEvent++ -- generic cancel+ | CancelEvent++ -- channel-scroll-specific+ | LoadMoreEvent+ | OpenMessageURLEvent++ -- scrolling events---maybe rebindable?+ | ScrollUpEvent+ | ScrollDownEvent+ | ScrollLeftEvent+ | ScrollRightEvent+ | PageUpEvent+ | PageDownEvent+ | PageRightEvent+ | PageLeftEvent+ | ScrollTopEvent+ | ScrollBottomEvent+ | SelectOldestMessageEvent++ -- select events---not the same as scrolling sometimes!+ | SelectUpEvent+ | SelectDownEvent++ -- search select events---these need to not be valid editor inputs+ -- (such as 'j' and 'k')+ | SearchSelectUpEvent+ | SearchSelectDownEvent++ -- E.g. Pressing enter on an item in a list to do something with it+ | ActivateListItemEvent++ | ViewMessageEvent+ | FillGapEvent+ | FlagMessageEvent+ | PinMessageEvent+ | YankMessageEvent+ | YankWholeMessageEvent+ | DeleteMessageEvent+ | EditMessageEvent+ | ReplyMessageEvent+ | ReactToMessageEvent++ -- Attachments+ | AttachmentListAddEvent+ | AttachmentListDeleteEvent+ | AttachmentOpenEvent++ -- Form submission+ | FormSubmitEvent+ deriving (Eq, Show, Ord, Enum)++allEvents :: [KeyEvent]+allEvents =+ [ QuitEvent+ , VtyRefreshEvent+ , ClearUnreadEvent++ , ToggleMessagePreviewEvent+ , InvokeEditorEvent+ , ToggleMultiLineEvent+ , CancelEvent+ , ReplyRecentEvent+ , SelectNextTabEvent+ , SelectPreviousTabEvent++ , EnterFastSelectModeEvent+ , NextChannelEvent+ , PrevChannelEvent+ , NextChannelEventAlternate+ , PrevChannelEventAlternate+ , NextUnreadChannelEvent+ , NextUnreadUserOrChannelEvent+ , LastChannelEvent++ , ShowAttachmentListEvent++ , EditorKillToBolEvent+ , EditorKillToEolEvent+ , EditorBolEvent+ , EditorEolEvent+ , EditorTransposeCharsEvent+ , EditorDeleteCharacter+ , EditorPrevCharEvent+ , EditorNextCharEvent+ , EditorPrevWordEvent+ , EditorNextWordEvent+ , EditorDeleteNextWordEvent+ , EditorDeletePrevWordEvent+ , EditorHomeEvent+ , EditorEndEvent+ , EditorYankEvent++ , EnterFlaggedPostsEvent+ , ToggleChannelListVisibleEvent+ , ToggleExpandedChannelTopicsEvent+ , ShowHelpEvent+ , EnterSelectModeEvent+ , EnterOpenURLModeEvent++ , LoadMoreEvent+ , OpenMessageURLEvent++ , ScrollUpEvent+ , ScrollDownEvent+ , ScrollLeftEvent+ , ScrollRightEvent+ , PageUpEvent+ , PageDownEvent+ , PageLeftEvent+ , PageRightEvent+ , ScrollTopEvent+ , ScrollBottomEvent+ , SelectOldestMessageEvent++ , SelectUpEvent+ , SelectDownEvent++ , ActivateListItemEvent++ , SearchSelectUpEvent+ , SearchSelectDownEvent++ , FlagMessageEvent+ , PinMessageEvent+ , ViewMessageEvent+ , FillGapEvent+ , YankMessageEvent+ , YankWholeMessageEvent+ , DeleteMessageEvent+ , EditMessageEvent+ , ReplyMessageEvent+ , ReactToMessageEvent+ , AttachmentListAddEvent+ , AttachmentListDeleteEvent+ , AttachmentOpenEvent+ , FormSubmitEvent+ ]++eventToBinding :: Vty.Event -> Binding+eventToBinding (Vty.EvKey k mods) = Binding mods k+eventToBinding k = error $ "BUG: invalid keybinding " <> show k++data Binding = Binding+ { kbMods :: [Vty.Modifier]+ , kbKey :: Vty.Key+ } deriving (Eq, Show, Ord)++data BindingState =+ BindingList [Binding]+ | Unbound+ deriving (Show, Eq, Ord)++type KeyConfig = M.Map KeyEvent BindingState++parseBinding :: Text -> Either String Binding+parseBinding kb = go (T.splitOn "-" $ T.toLower kb) []+ where go [k] mods = do+ key <- pKey k+ return Binding { kbMods = mods, kbKey = key }+ go (k:ks) mods = do+ m <- case k of+ "s" -> return Vty.MShift+ "shift" -> return Vty.MShift+ "m" -> return Vty.MMeta+ "meta" -> return Vty.MMeta+ "a" -> return Vty.MAlt+ "alt" -> return Vty.MAlt+ "c" -> return Vty.MCtrl+ "ctrl" -> return Vty.MCtrl+ "control" -> return Vty.MCtrl+ _ -> Left ("Unknown modifier prefix: " ++ show k)+ go ks (m:mods)+ go [] _ = Left "Empty keybinding not allowed"+ pKey "esc" = return Vty.KEsc+ pKey "backspace" = return Vty.KBS+ pKey "enter" = return Vty.KEnter+ pKey "left" = return Vty.KLeft+ pKey "right" = return Vty.KRight+ pKey "up" = return Vty.KUp+ pKey "down" = return Vty.KDown+ pKey "upleft" = return Vty.KUpLeft+ pKey "upright" = return Vty.KUpRight+ pKey "downleft" = return Vty.KDownLeft+ pKey "downright" = return Vty.KDownRight+ pKey "center" = return Vty.KCenter+ pKey "backtab" = return Vty.KBackTab+ pKey "printscreen" = return Vty.KPrtScr+ pKey "pause" = return Vty.KPause+ pKey "insert" = return Vty.KIns+ pKey "home" = return Vty.KHome+ pKey "pgup" = return Vty.KPageUp+ pKey "del" = return Vty.KDel+ pKey "end" = return Vty.KEnd+ pKey "pgdown" = return Vty.KPageDown+ pKey "begin" = return Vty.KBegin+ pKey "menu" = return Vty.KMenu+ pKey "space" = return (Vty.KChar ' ')+ pKey "tab" = return (Vty.KChar '\t')+ pKey t+ | Just (c, "") <- T.uncons t =+ return (Vty.KChar c)+ | Just n <- T.stripPrefix "f" t =+ case readMaybe (T.unpack n) of+ Nothing -> Left ("Unknown keybinding: " ++ show t)+ Just i -> return (Vty.KFun i)+ | otherwise = Left ("Unknown keybinding: " ++ show t)++ppBinding :: Binding -> Text+ppBinding (Binding mods k) =+ T.intercalate "-" $ (ppMod <$> mods) <> [ppKey k]++ppKey :: Vty.Key -> Text+ppKey (Vty.KChar c) = ppChar c+ppKey (Vty.KFun n) = "F" <> (T.pack $ show n)+ppKey Vty.KBackTab = "BackTab"+ppKey Vty.KEsc = "Esc"+ppKey Vty.KBS = "Backspace"+ppKey Vty.KEnter = "Enter"+ppKey Vty.KUp = "Up"+ppKey Vty.KDown = "Down"+ppKey Vty.KLeft = "Left"+ppKey Vty.KRight = "Right"+ppKey Vty.KHome = "Home"+ppKey Vty.KEnd = "End"+ppKey Vty.KPageUp = "PgUp"+ppKey Vty.KPageDown = "PgDown"+ppKey Vty.KDel = "Del"+ppKey Vty.KUpLeft = "UpLeft"+ppKey Vty.KUpRight = "UpRight"+ppKey Vty.KDownLeft = "DownLeft"+ppKey Vty.KDownRight = "DownRight"+ppKey Vty.KCenter = "Center"+ppKey Vty.KPrtScr = "PrintScreen"+ppKey Vty.KPause = "Pause"+ppKey Vty.KIns = "Insert"+ppKey Vty.KBegin = "Begin"+ppKey Vty.KMenu = "Menu"++nonCharKeys :: [Text]+nonCharKeys = map ppKey+ [ Vty.KBackTab, Vty.KEsc, Vty.KBS, Vty.KEnter, Vty.KUp, Vty.KDown+ , Vty.KLeft, Vty.KRight, Vty.KHome, Vty.KEnd, Vty.KPageDown+ , Vty.KPageUp, Vty.KDel, Vty.KUpLeft, Vty.KUpRight, Vty.KDownLeft+ , Vty.KDownRight, Vty.KCenter, Vty.KPrtScr, Vty.KPause, Vty.KIns+ , Vty.KBegin, Vty.KMenu+ ]++ppChar :: Char -> Text+ppChar '\t' = "Tab"+ppChar ' ' = "Space"+ppChar c = T.singleton c++ppMod :: Vty.Modifier -> Text+ppMod Vty.MMeta = "M"+ppMod Vty.MAlt = "A"+ppMod Vty.MCtrl = "C"+ppMod Vty.MShift = "S"++parseBindingList :: Text -> Either String BindingState+parseBindingList t =+ if T.toLower t == "unbound"+ then return Unbound+ else BindingList <$> mapM (parseBinding . T.strip) (T.splitOn "," t)++keyEventFromName :: Text -> Either String KeyEvent+keyEventFromName t =+ let mapping = M.fromList [ (keyEventName e, e) | e <- allEvents ]+ in case M.lookup t mapping of+ Just e -> return e+ Nothing -> Left ("Unknown event: " ++ show t)++keyEventName :: KeyEvent -> Text+keyEventName ev = case ev of+ QuitEvent -> "quit"+ VtyRefreshEvent -> "vty-refresh"+ ClearUnreadEvent -> "clear-unread"+ CancelEvent -> "cancel"++ ToggleMessagePreviewEvent -> "toggle-message-preview"+ InvokeEditorEvent -> "invoke-editor"+ ToggleMultiLineEvent -> "toggle-multiline"+ ReplyRecentEvent -> "reply-recent"++ EnterFastSelectModeEvent -> "enter-fast-select"+ NextChannelEvent -> "focus-next-channel"+ PrevChannelEvent -> "focus-prev-channel"+ NextChannelEventAlternate -> "focus-next-channel-alternate"+ PrevChannelEventAlternate -> "focus-prev-channel-alternate"+ NextUnreadChannelEvent -> "focus-next-unread"+ NextUnreadUserOrChannelEvent -> "focus-next-unread-user-or-channel"+ LastChannelEvent -> "focus-last-channel"++ SelectNextTabEvent -> "select-next-tab"+ SelectPreviousTabEvent -> "select-previous-tab"++ ShowAttachmentListEvent -> "show-attachment-list"++ EditorKillToBolEvent -> "editor-kill-to-beginning-of-line"+ EditorKillToEolEvent -> "editor-kill-to-end-of-line"+ EditorBolEvent -> "editor-beginning-of-line"+ EditorEolEvent -> "editor-end-of-line"+ EditorTransposeCharsEvent -> "editor-transpose-chars"+ EditorDeleteCharacter -> "editor-delete-char"+ EditorPrevCharEvent -> "editor-prev-char"+ EditorNextCharEvent -> "editor-next-char"+ EditorPrevWordEvent -> "editor-prev-word"+ EditorNextWordEvent -> "editor-next-word"+ EditorDeleteNextWordEvent -> "editor-delete-next-word"+ EditorDeletePrevWordEvent -> "editor-delete-prev-word"+ EditorHomeEvent -> "editor-home"+ EditorEndEvent -> "editor-end"+ EditorYankEvent -> "editor-yank"++ EnterFlaggedPostsEvent -> "show-flagged-posts"+ ToggleChannelListVisibleEvent -> "toggle-channel-list-visibility"+ ToggleExpandedChannelTopicsEvent -> "toggle-expanded-channel-topics"+ ShowHelpEvent -> "show-help"+ EnterSelectModeEvent -> "select-mode"+ EnterOpenURLModeEvent -> "enter-url-open"++ LoadMoreEvent -> "load-more"+ OpenMessageURLEvent -> "open-message-url"++ ScrollUpEvent -> "scroll-up"+ ScrollDownEvent -> "scroll-down"+ ScrollLeftEvent -> "scroll-left"+ ScrollRightEvent -> "scroll-right"+ PageUpEvent -> "page-up"+ PageDownEvent -> "page-down"+ PageLeftEvent -> "page-left"+ PageRightEvent -> "page-right"+ ScrollTopEvent -> "scroll-top"+ ScrollBottomEvent -> "scroll-bottom"+ SelectOldestMessageEvent -> "select-oldest-message"++ SelectUpEvent -> "select-up"+ SelectDownEvent -> "select-down"++ SearchSelectUpEvent -> "search-select-up"+ SearchSelectDownEvent -> "search-select-down"++ ActivateListItemEvent -> "activate-list-item"++ FlagMessageEvent -> "flag-message"+ PinMessageEvent -> "pin-message"+ ViewMessageEvent -> "view-message"+ FillGapEvent -> "fetch-for-gap"+ YankMessageEvent -> "yank-message"+ YankWholeMessageEvent -> "yank-whole-message"+ DeleteMessageEvent -> "delete-message"+ EditMessageEvent -> "edit-message"+ ReplyMessageEvent -> "reply-message"+ ReactToMessageEvent -> "react-to-message"++ AttachmentListAddEvent -> "add-to-attachment-list"+ AttachmentListDeleteEvent -> "delete-from-attachment-list"+ AttachmentOpenEvent -> "open-attachment"++ FormSubmitEvent -> "submit-form"
+ src/Matterhorn/Types/Messages.hs view
@@ -0,0 +1,750 @@+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE DeriveAnyClass #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE MultiParamTypeClasses #-}+{-# LANGUAGE TemplateHaskell #-}++{-|++The 'Message' is a single displayed event in a Channel. All Messages+have a date/time, and messages that represent posts to the channel+have a (hash) ID, and displayable text, along with other attributes.++All Messages are sorted chronologically. There is no assumption that+the server date/time is synchronized with the local date/time, so all+of the Message ordering uses the server's date/time.++The mattermost-api retrieves a 'Post' from the server, briefly encodes+the useful portions of that as a 'ClientPost' object and then converts+it to a 'Message' inserting this result it into the collection of+Messages associated with a Channel. The PostID of the message+uniquely identifies that message and can be used to interact with the+server for subsequent operations relative to that message's 'Post'.+The date/time associated with these messages is generated by the+server.++There are also "messages" generated directly by the Matterhorn client+which can be used to display additional, client-related information to+the user. Examples of these client messages are: date boundaries, the+"new messages" marker, errors from invoking the browser, etc. These+client-generated messages will have a date/time although it is locally+generated (usually by relation to an associated Post).++Most other Matterhorn operations primarily are concerned with+user-posted messages (@case mMessageId of Just _@ or @case mType of CP+_@), but others will include client-generated messages (@case mMessageId+of Nothing@ or @case mType of C _@).++--}++module Matterhorn.Types.Messages+ ( -- * Message and operations on a single Message+ Message(..)+ , isDeletable, isReplyable, isReactable, isEditable, isReplyTo, isGap, isFlaggable+ , isPinnable, isEmote, isJoinLeave, isTransition, isNewMessagesTransition+ , mText, mUser, mDate, mType, mPending, mDeleted, mPinned+ , mAttachments, mInReplyToMsg, mMessageId, mReactions, mFlagged+ , mOriginalPost, mChannelId, mMarkdownSource+ , isBotMessage+ , MessageType(..)+ , MessageId(..)+ , ThreadState(..)+ , MentionedUser(..)+ , isPostMessage+ , messagePostId+ , messageIdPostId+ , UserRef(..)+ , ReplyState(..)+ , clientMessageToMessage+ , clientPostToMessage+ , clientPostReactionUserIds+ , newMessageOfType+ -- * Message Collections+ , Messages+ , ChronologicalMessages+ , RetrogradeMessages+ , MessageOps (..)+ , noMessages+ , messagesLength+ , filterMessages+ , reverseMessages+ , unreverseMessages+ , splitMessages+ , splitDirSeqOn+ , chronologicalMsgsWithThreadStates+ , retrogradeMsgsWithThreadStates+ , findMessage+ , getRelMessageId+ , messagesHead+ , messagesDrop+ , getNextMessage+ , getPrevMessage+ , getNextMessageId+ , getPrevMessageId+ , getNextPostId+ , getPrevPostId+ , getEarliestPostMsg+ , getLatestPostMsg+ , getEarliestSelectableMessage+ , getLatestSelectableMessage+ , findLatestUserMessage+ -- * Operations on any Message type+ , messagesAfter+ , removeMatchesFromSubset+ , withFirstMessage+ , msgURLs++ , LinkTarget(..)+ , LinkChoice(LinkChoice, _linkTarget)+ , linkUser+ , linkTarget+ , linkTime+ , linkLabel+ )+where++import Prelude ()+import Matterhorn.Prelude++import Control.Monad+import qualified Data.Foldable as F+import Data.Hashable ( Hashable )+import qualified Data.Map.Strict as Map+import Data.Sequence as Seq+import qualified Data.Set as S+import Data.Tuple+import Data.UUID ( UUID )+import GHC.Generics ( Generic )+import Lens.Micro.Platform ( makeLenses )++import Network.Mattermost.Types ( ChannelId, PostId, Post+ , ServerTime, UserId, FileId+ )++import Matterhorn.Types.DirectionalSeq+import Matterhorn.Types.Posts+import Matterhorn.Types.RichText ( RichTextBlock(..), Element(..)+ , ElementData(..), findUsernames, blockGetURLs+ , ElementStyle(..), URL(..), parseMarkdown+ , TeamURLName+ )+++-- | The state of a message's thread context.+data ThreadState =+ NoThread+ -- ^ The message is not in a thread at all.+ | InThreadShowParent+ -- ^ The message is in a thread, and the thread's root message+ -- (parent) should be displayed above this message.+ | InThread+ -- ^ The message is in a thread but the thread's root message should+ -- not be displayed above this message.+ deriving (Show, Eq)++-- ----------------------------------------------------------------------+-- * Messages++data MessageId = MessagePostId PostId+ | MessageUUID UUID+ deriving (Eq, Read, Show, Generic, Hashable)++messageIdPostId :: MessageId -> Maybe PostId+messageIdPostId (MessagePostId p) = Just p+messageIdPostId _ = Nothing++-- | A 'Message' is any message we might want to render, either from+-- Mattermost itself or from a client-internal source.+data Message = Message+ { _mText :: Seq RichTextBlock+ , _mMarkdownSource :: Text+ , _mUser :: UserRef+ , _mDate :: ServerTime+ , _mType :: MessageType+ , _mPending :: Bool+ , _mDeleted :: Bool+ , _mAttachments :: Seq Attachment+ , _mInReplyToMsg :: ReplyState+ , _mMessageId :: Maybe MessageId+ , _mReactions :: Map.Map Text (S.Set UserId)+ , _mOriginalPost :: Maybe Post+ , _mFlagged :: Bool+ , _mPinned :: Bool+ , _mChannelId :: Maybe ChannelId+ } deriving (Show)++isPostMessage :: Message -> Bool+isPostMessage m =+ isJust (_mMessageId m >>= messageIdPostId)++messagePostId :: Message -> Maybe PostId+messagePostId m = do+ mId <- _mMessageId m+ messageIdPostId mId++isDeletable :: Message -> Bool+isDeletable m =+ isJust (messagePostId m) &&+ case _mType m of+ CP NormalPost -> True+ CP Emote -> True+ _ -> False++isFlaggable :: Message -> Bool+isFlaggable = isJust . messagePostId++isPinnable :: Message -> Bool+isPinnable = isJust . messagePostId++isReplyable :: Message -> Bool+isReplyable m =+ isJust (messagePostId m) &&+ case _mType m of+ CP NormalPost -> True+ CP Emote -> True+ _ -> False++isReactable :: Message -> Bool+isReactable m =+ isJust (messagePostId m) &&+ case _mType m of+ CP NormalPost -> True+ CP Emote -> True+ _ -> False++isEditable :: Message -> Bool+isEditable m =+ isJust (messagePostId m) &&+ case _mType m of+ CP NormalPost -> True+ CP Emote -> True+ _ -> False++isReplyTo :: PostId -> Message -> Bool+isReplyTo expectedParentId m =+ case _mInReplyToMsg m of+ NotAReply -> False+ InReplyTo actualParentId -> actualParentId == expectedParentId++isGap :: Message -> Bool+isGap m = case _mType m of+ C UnknownGapBefore -> True+ C UnknownGapAfter -> True+ _ -> False++isTransition :: Message -> Bool+isTransition m = case _mType m of+ C DateTransition -> True+ C NewMessagesTransition -> True+ _ -> False++isNewMessagesTransition :: Message -> Bool+isNewMessagesTransition m = case _mType m of+ C NewMessagesTransition -> True+ _ -> False++isEmote :: Message -> Bool+isEmote m = case _mType m of+ CP Emote -> True+ _ -> False++isJoinLeave :: Message -> Bool+isJoinLeave m = case _mType m of+ CP Join -> True+ CP Leave -> True+ _ -> False++-- | A 'Message' is the representation we use for storage and+-- rendering, so it must be able to represent either a+-- post from Mattermost or an internal message. This represents+-- the union of both kinds of post types.+data MessageType = C ClientMessageType+ | CP ClientPostType+ deriving (Show)++-- | There may be no user (usually an internal message), a reference to+-- a user (by Id), or the server may have supplied a specific username+-- (often associated with bots). The boolean flag indicates whether the+-- user reference is for a message from a bot.+data UserRef = NoUser | UserI Bool UserId | UserOverride Bool Text+ deriving (Eq, Show, Ord)++isBotMessage :: Message -> Bool+isBotMessage m =+ case _mUser m of+ UserI bot _ -> bot+ UserOverride bot _ -> bot+ NoUser -> False++-- | The 'ReplyState' of a message represents whether a message+-- is a reply, and if so, to what message+data ReplyState =+ NotAReply+ | InReplyTo PostId+ deriving (Show, Eq)++data LinkTarget =+ LinkURL URL+ | LinkFileId FileId+ | LinkPermalink TeamURLName PostId+ deriving (Eq, Show, Ord)++-- | This type represents links to things in the 'open links' view.+data LinkChoice =+ LinkChoice { _linkTime :: ServerTime+ , _linkUser :: UserRef+ , _linkLabel :: Maybe (Seq Element)+ , _linkTarget :: LinkTarget+ } deriving (Eq, Show)++makeLenses ''LinkChoice++-- | Convert a 'ClientMessage' to a 'Message'. A 'ClientMessage' is+-- one that was generated by the Matterhorn client and which the+-- server knows nothing about. For example, an error message+-- associated with passing a link to the local browser.+clientMessageToMessage :: ClientMessage -> Message+clientMessageToMessage cm = Message+ { _mText = parseMarkdown Nothing (cm^.cmText)+ , _mMarkdownSource = cm^.cmText+ , _mUser = NoUser+ , _mDate = cm^.cmDate+ , _mType = C $ cm^.cmType+ , _mPending = False+ , _mDeleted = False+ , _mAttachments = Seq.empty+ , _mInReplyToMsg = NotAReply+ , _mMessageId = Nothing+ , _mReactions = Map.empty+ , _mOriginalPost = Nothing+ , _mFlagged = False+ , _mPinned = False+ , _mChannelId = Nothing+ }+++data MentionedUser =+ UsernameMention Text+ | UserIdMention UserId+ deriving (Eq, Show, Ord)++clientPostReactionUserIds :: ClientPost -> S.Set UserId+clientPostReactionUserIds cp =+ S.unions $ F.toList $ cp^.cpReactions++-- | Builds a message from a ClientPost and also returns the set of+-- usernames mentioned in the text of the message.+clientPostToMessage :: ClientPost -> (Message, S.Set MentionedUser)+clientPostToMessage cp = (m, mentions)+ where+ mentions =+ S.fromList $+ (UsernameMention <$> (F.toList $ findUsernames $ cp^.cpText)) <>+ (UserIdMention <$> (F.toList $ clientPostReactionUserIds cp))+ m = Message { _mText = cp^.cpText+ , _mMarkdownSource = cp^.cpMarkdownSource+ , _mUser =+ case cp^.cpUserOverride of+ Just n | cp^.cpType == NormalPost -> UserOverride (cp^.cpFromWebhook) n+ _ -> maybe NoUser (UserI (cp^.cpFromWebhook)) $ cp^.cpUser+ , _mDate = cp^.cpDate+ , _mType = CP $ cp^.cpType+ , _mPending = cp^.cpPending+ , _mDeleted = cp^.cpDeleted+ , _mAttachments = cp^.cpAttachments+ , _mInReplyToMsg =+ case cp^.cpInReplyToPost of+ Nothing -> NotAReply+ Just pId -> InReplyTo pId+ , _mMessageId = Just $ MessagePostId $ cp^.cpPostId+ , _mReactions = cp^.cpReactions+ , _mOriginalPost = Just $ cp^.cpOriginalPost+ , _mFlagged = False+ , _mPinned = cp^.cpPinned+ , _mChannelId = Just $ cp^.cpChannelId+ }+++newMessageOfType :: Text -> MessageType -> ServerTime -> Message+newMessageOfType text typ d = Message+ { _mText = parseMarkdown Nothing text+ , _mMarkdownSource = text+ , _mUser = NoUser+ , _mDate = d+ , _mType = typ+ , _mPending = False+ , _mDeleted = False+ , _mAttachments = Seq.empty+ , _mInReplyToMsg = NotAReply+ , _mMessageId = Nothing+ , _mReactions = Map.empty+ , _mOriginalPost = Nothing+ , _mFlagged = False+ , _mPinned = False+ , _mChannelId = Nothing+ }++-- ** 'Message' Lenses++makeLenses ''Message++-- ----------------------------------------------------------------------++-- * Message Collections++-- | A wrapper for an ordered, unique list of 'Message' values.+--+-- This type has (and promises) the following instances: Show,+-- Functor, Monoid, Foldable, Traversable+type ChronologicalMessages = DirectionalSeq Chronological Message+type Messages = ChronologicalMessages++-- | There are also cases where the list of 'Message' values are kept+-- in reverse order (most recent -> oldest); these cases are+-- represented by the `RetrogradeMessages` type.+type RetrogradeMessages = DirectionalSeq Retrograde Message++-- ** Common operations on Messages++filterMessages :: SeqDirection seq+ => (a -> Bool)+ -> DirectionalSeq seq a+ -> DirectionalSeq seq a+filterMessages f = onDirectedSeq (Seq.filter f)++class MessageOps a where+ -- | addMessage inserts a date in proper chronological order, with+ -- the following extra functionality:+ -- * no duplication (by PostId)+ -- * no duplication (adjacent UnknownGap entries)+ addMessage :: Message -> a -> a++instance MessageOps ChronologicalMessages where+ addMessage m ml =+ case viewr (dseq ml) of+ EmptyR -> DSeq $ singleton m+ _ :> l ->+ case compare (m^.mDate) (l^.mDate) of+ GT -> DSeq $ dseq ml |> m+ EQ -> if m^.mMessageId == l^.mMessageId && isJust (m^.mMessageId)+ then ml+ else dirDateInsert m ml+ LT -> dirDateInsert m ml++dirDateInsert :: Message -> ChronologicalMessages -> ChronologicalMessages+dirDateInsert m = onDirectedSeq $ finalize . foldr insAfter initial+ where initial = (Just m, mempty)+ insAfter c (Nothing, l) = (Nothing, c <| l)+ insAfter c (Just n, l) =+ case compare (n^.mDate) (c^.mDate) of+ GT -> (Nothing, c <| (n <| l))+ EQ -> if n^.mMessageId == c^.mMessageId && isJust (c^.mMessageId)+ then (Nothing, c <| l)+ else (Just n, c <| l)+ LT -> (Just n, c <| l)+ finalize (Just n, l) = n <| l+ finalize (_, l) = l++noMessages :: Messages+noMessages = DSeq mempty++messagesLength :: DirectionalSeq seq a -> Int+messagesLength (DSeq ms) = Seq.length ms++-- | Reverse the order of the messages+reverseMessages :: Messages -> RetrogradeMessages+reverseMessages = DSeq . Seq.reverse . dseq++-- | Unreverse the order of the messages+unreverseMessages :: RetrogradeMessages -> Messages+unreverseMessages = DSeq . Seq.reverse . dseq++splitDirSeqOn :: SeqDirection d+ => (a -> Bool)+ -> DirectionalSeq d a+ -> (Maybe a, (DirectionalSeq (ReverseDirection d) a,+ DirectionalSeq d a))+splitDirSeqOn f msgs =+ let (removed, remaining) = dirSeqBreakl f msgs+ devomer = DSeq $ Seq.reverse $ dseq removed+ in (withDirSeqHead id remaining, (devomer, onDirectedSeq (Seq.drop 1) remaining))++-- ----------------------------------------------------------------------+-- * Operations on Posted Messages++-- | Searches for the specified MessageId and returns a tuple where the+-- first element is the Message associated with the MessageId (if it+-- exists), and the second element is another tuple: the first element+-- of the second is all the messages from the beginning of the list to+-- the message just before the MessageId message (or all messages if not+-- found) *in reverse order*, and the second element of the second are+-- all the messages that follow the found message (none if the message+-- was never found) in *forward* order.+splitMessages :: Maybe MessageId+ -> DirectionalSeq Chronological (Message, ThreadState)+ -> (Maybe (Message, ThreadState),+ ( DirectionalSeq Retrograde (Message, ThreadState),+ DirectionalSeq Chronological (Message, ThreadState)))+splitMessages mid msgs = splitDirSeqOn (\(m, _) -> isJust mid && m^.mMessageId == mid) msgs++-- | Given a message and its chronological predecessor, return+-- the thread state of the specified message with respect to its+-- predecessor.+threadStateFor :: Message+ -- ^ The message whose state is to be obtained.+ -> Message+ -- ^ The message's predecessor.+ -> ThreadState+threadStateFor msg prev = case msg^.mInReplyToMsg of+ InReplyTo rootId ->+ if | (prev^.mMessageId) == Just (MessagePostId rootId) ->+ InThread+ | prev^.mInReplyToMsg == msg^.mInReplyToMsg ->+ InThread+ | otherwise ->+ InThreadShowParent+ _ -> NoThread++retrogradeMsgsWithThreadStates :: RetrogradeMessages -> DirectionalSeq Retrograde (Message, ThreadState)+retrogradeMsgsWithThreadStates msgs = DSeq $ checkAdjacentMessages (dseq msgs)+ where+ getMessagePredecessor ms =+ let visiblePredMsg m = not (isTransition m || m^.mDeleted) in+ case Seq.viewl ms of+ prev Seq.:< rest ->+ if visiblePredMsg prev+ then Just prev+ else getMessagePredecessor rest+ Seq.EmptyL -> Nothing++ checkAdjacentMessages s = case Seq.viewl s of+ Seq.EmptyL -> mempty+ m Seq.:< t ->+ let new_m = case getMessagePredecessor t of+ Just prev -> (m, threadStateFor m prev)+ Nothing -> case m^.mInReplyToMsg of+ InReplyTo _ -> (m, InThreadShowParent)+ _ -> (m, NoThread)+ in new_m Seq.<| checkAdjacentMessages t++chronologicalMsgsWithThreadStates :: Messages -> DirectionalSeq Chronological (Message, ThreadState)+chronologicalMsgsWithThreadStates msgs = DSeq $ checkAdjacentMessages (dseq msgs)+ where+ getMessagePredecessor ms =+ let visiblePredMsg m = not (isTransition m || m^.mDeleted) in+ case Seq.viewr ms of+ rest Seq.:> prev ->+ if visiblePredMsg prev+ then Just prev+ else getMessagePredecessor rest+ Seq.EmptyR -> Nothing++ checkAdjacentMessages s = case Seq.viewr s of+ Seq.EmptyR -> mempty+ t Seq.:> m ->+ let new_m = case getMessagePredecessor t of+ Just prev -> (m, threadStateFor m prev)+ Nothing -> case m^.mInReplyToMsg of+ InReplyTo _ -> (m, InThreadShowParent)+ _ -> (m, NoThread)+ in checkAdjacentMessages t Seq.|> new_m++-- | findMessage searches for a specific message as identified by the+-- PostId. The search starts from the most recent messages because+-- that is the most likely place the message will occur.+findMessage :: MessageId -> Messages -> Maybe Message+findMessage mid msgs =+ findIndexR (\m -> m^.mMessageId == Just mid) (dseq msgs)+ >>= Just . Seq.index (dseq msgs)++-- | Look forward for the first Message with an ID that follows the+-- specified Id and return it. If no input Id supplied, get the+-- latest (most recent chronologically) Message in the input set.+getNextMessage :: Maybe MessageId -> Messages -> Maybe Message+getNextMessage = getRelMessageId++-- | Look backward for the first Message with an ID that follows the+-- specified MessageId and return it. If no input MessageId supplied,+-- get the latest (most recent chronologically) Message in the input+-- set.+getPrevMessage :: Maybe MessageId -> Messages -> Maybe Message+getPrevMessage mId = getRelMessageId mId . reverseMessages++messagesHead :: (SeqDirection seq) => DirectionalSeq seq a -> Maybe a+messagesHead = withDirSeqHead id++messagesDrop :: (SeqDirection seq) => Int -> DirectionalSeq seq a -> DirectionalSeq seq a+messagesDrop i = onDirectedSeq (Seq.drop i)++-- | Look forward for the first Message with an ID that follows the+-- specified MessageId and return that found Message's ID; if no input+-- MessageId is specified, return the latest (most recent+-- chronologically) MessageId (if any) in the input set.+getNextMessageId :: Maybe MessageId -> Messages -> Maybe MessageId+getNextMessageId mId = _mMessageId <=< getNextMessage mId++-- | Look backwards for the first Message with an ID that comes before+-- the specified MessageId and return that found Message's ID; if no+-- input MessageId is specified, return the latest (most recent+-- chronologically) MessageId (if any) in the input set.+getPrevMessageId :: Maybe MessageId -> Messages -> Maybe MessageId+getPrevMessageId mId = _mMessageId <=< getPrevMessage mId++-- | Look forward for the first Message with an ID that follows the+-- specified PostId and return that found Message's PostID; if no+-- input PostId is specified, return the latest (most recent+-- chronologically) PostId (if any) in the input set.+getNextPostId :: Maybe PostId -> Messages -> Maybe PostId+getNextPostId pid = messagePostId <=< getNextMessage (MessagePostId <$> pid)++-- | Look backwards for the first Post with an ID that comes before+-- the specified PostId.+getPrevPostId :: Maybe PostId -> Messages -> Maybe PostId+getPrevPostId pid = messagePostId <=< getPrevMessage (MessagePostId <$> pid)+++getRelMessageId :: SeqDirection dir =>+ Maybe MessageId+ -> DirectionalSeq dir Message+ -> Maybe Message+getRelMessageId mId =+ let isMId = const ((==) mId . _mMessageId) <$> mId+ in getRelMessage isMId++-- | Internal worker function to return a different user message in+-- relation to either the latest point or a specific message.+getRelMessage :: SeqDirection dir =>+ Maybe (Message -> Bool)+ -> DirectionalSeq dir Message+ -> Maybe Message+getRelMessage matcher msgs =+ let after = case matcher of+ Just matchFun -> case splitDirSeqOn matchFun msgs of+ (_, (_, ms)) -> ms+ Nothing -> msgs+ in withDirSeqHead id $ filterMessages validSelectableMessage after++-- | Find the most recent message that is a Post (as opposed to a+-- local message) (if any).+getLatestPostMsg :: Messages -> Maybe Message+getLatestPostMsg msgs =+ case viewr $ dropWhileR (not . validUserMessage) (dseq msgs) of+ EmptyR -> Nothing+ _ :> m -> Just m++-- | Find the oldest message that is a message with an ID.+getEarliestSelectableMessage :: Messages -> Maybe Message+getEarliestSelectableMessage msgs =+ case viewl $ dropWhileL (not . validSelectableMessage) (dseq msgs) of+ EmptyL -> Nothing+ m :< _ -> Just m++-- | Find the most recent message that is a message with an ID.+getLatestSelectableMessage :: Messages -> Maybe Message+getLatestSelectableMessage msgs =+ case viewr $ dropWhileR (not . validSelectableMessage) (dseq msgs) of+ EmptyR -> Nothing+ _ :> m -> Just m++-- | Find the earliest message that is a Post (as opposed to a+-- local message) (if any).+getEarliestPostMsg :: Messages -> Maybe Message+getEarliestPostMsg msgs =+ case viewl $ dropWhileL (not . validUserMessage) (dseq msgs) of+ EmptyL -> Nothing+ m :< _ -> Just m++-- | Find the most recent message that is a message posted by a user+-- that matches the test (if any), skipping local client messages and+-- any user event that is not a message (i.e. find a normal message or+-- an emote).+findLatestUserMessage :: (Message -> Bool) -> Messages -> Maybe Message+findLatestUserMessage f ml =+ case viewr $ dropWhileR (\m -> not (validUserMessage m && f m)) $ dseq ml of+ EmptyR -> Nothing+ _ :> m -> Just m++validUserMessage :: Message -> Bool+validUserMessage m =+ not (m^.mDeleted) && case m^.mMessageId of+ Just (MessagePostId _) -> True+ _ -> False++validSelectableMessage :: Message -> Bool+validSelectableMessage m = (not $ m^.mDeleted) && (isJust $ m^.mMessageId)++-- ----------------------------------------------------------------------+-- * Operations on any Message type++-- | Return all messages that were posted after the specified date/time.+messagesAfter :: ServerTime -> Messages -> Messages+messagesAfter viewTime = onDirectedSeq $ takeWhileR (\m -> m^.mDate > viewTime)++-- | Removes any Messages (all types) for which the predicate is true+-- from the specified subset of messages (identified by a starting and+-- ending MessageId, inclusive) and returns the resulting list (from+-- start to finish, irrespective of 'firstId' and 'lastId') and the+-- list of removed items.+--+-- start | end | operates-on | (test) case+-- --------------------------------------------------------|-------------+-- Nothing | Nothing | entire list | C1+-- Nothing | Just found | start --> found] | C2+-- Nothing | Just missing | nothing [suggest invalid] | C3+-- Just found | Nothing | [found --> end | C4+-- Just found | Just found | [found --> found] | C5+-- Just found | Just missing | [found --> end | C6+-- Just missing | Nothing | nothing [suggest invalid] | C7+-- Just missing | Just found | start --> found] | C8+-- Just missing | Just missing | nothing [suggest invalid] | C9+--+-- @removeMatchesFromSubset matchPred fromId toId msgs = (remaining, removed)@+--+removeMatchesFromSubset :: (Message -> Bool) -> Maybe MessageId -> Maybe MessageId+ -> Messages -> (Messages, Messages)+removeMatchesFromSubset matching firstId lastId msgs =+ let knownIds = fmap (^.mMessageId) msgs+ in if isNothing firstId && isNothing lastId+ then swap $ dirSeqPartition matching msgs+ else if isJust firstId && firstId `elem` knownIds+ then onDirSeqSubset+ (\m -> m^.mMessageId == firstId)+ (if isJust lastId then \m -> m^.mMessageId == lastId else const False)+ (swap . dirSeqPartition matching) msgs+ else if isJust lastId && lastId `elem` knownIds+ then onDirSeqSubset+ (const True)+ (\m -> m^.mMessageId == lastId)+ (swap . dirSeqPartition matching) msgs+ else (msgs, noMessages)++-- | Performs an operation on the first Message, returning just the+-- result of that operation, or Nothing if there were no messages.+-- Note that the message is not necessarily a posted user message.+withFirstMessage :: SeqDirection dir+ => (Message -> r)+ -> DirectionalSeq dir Message+ -> Maybe r+withFirstMessage = withDirSeqHead++msgURLs :: Message -> Seq LinkChoice+msgURLs msg =+ let uRef = msg^.mUser+ mkTarget (Right url) = LinkURL url+ mkTarget (Left (tName, pId)) = LinkPermalink tName pId+ mkEntry (val, text) = LinkChoice (msg^.mDate) uRef text (mkTarget val)+ msgUrls = mkEntry <$> (Seq.fromList $ mconcat $ blockGetURLs <$> (toList $ msg^.mText))+ attachmentURLs = (\ a ->+ LinkChoice+ (msg^.mDate)+ uRef+ (Just $ attachmentLabel a)+ (LinkFileId $ a^.attachmentFileId))+ <$> (msg^.mAttachments)+ attachmentLabel a =+ Seq.fromList [ Element Normal $ EText "attachment"+ , Element Normal ESpace+ , Element Code $ EText $ a^.attachmentName+ ]+ in msgUrls <> attachmentURLs
+ src/Matterhorn/Types/Posts.hs view
@@ -0,0 +1,231 @@+{-# LANGUAGE MultiWayIf #-}+{-# LANGUAGE TemplateHaskell #-}+module Matterhorn.Types.Posts+ ( ClientMessage+ , newClientMessage+ , cmDate+ , cmType+ , cmText++ , ClientMessageType(..)++ , Attachment+ , mkAttachment+ , attachmentName+ , attachmentFileId+ , attachmentURL++ , ClientPostType(..)++ , ClientPost+ , toClientPost+ , cpUserOverride+ , cpMarkdownSource+ , cpUser+ , cpText+ , cpType+ , cpReactions+ , cpPending+ , cpOriginalPost+ , cpFromWebhook+ , cpInReplyToPost+ , cpDate+ , cpChannelId+ , cpAttachments+ , cpDeleted+ , cpPostId+ , cpPinned++ , unEmote++ , postIsLeave+ , postIsJoin+ , postIsTopicChange+ , postIsEmote+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Map.Strict as Map+import qualified Data.Sequence as Seq+import qualified Data.Text as T+import qualified Data.Set as S+import Data.Time.Clock ( getCurrentTime )+import Lens.Micro.Platform ( makeLenses )++import Network.Mattermost.Lenses+import Network.Mattermost.Types++import Matterhorn.Types.Common+import Matterhorn.Types.RichText ( RichTextBlock(Blockquote), parseMarkdown+ , TeamBaseURL+ )+++-- * Client Messages++-- | A 'ClientMessage' is a message given to us by our client,+-- like help text or an error message.+data ClientMessage = ClientMessage+ { _cmText :: Text+ , _cmDate :: ServerTime+ , _cmType :: ClientMessageType+ } deriving (Show)++-- | Create a new 'ClientMessage' value. This is a message generated+-- by this Matterhorn client and not by (or visible to) the Server.+-- These should be visible, but not necessarily integrated into any+-- special position in the output stream (i.e., they should generally+-- appear at the bottom of the messages display, but subsequent+-- messages should follow them), so this is a special place where+-- there is an assumed approximation of equality between local time+-- and server time.+newClientMessage :: (MonadIO m) => ClientMessageType -> Text -> m ClientMessage+newClientMessage ty msg = do+ now <- liftIO getCurrentTime+ return (ClientMessage msg (ServerTime now) ty)++-- | We format 'ClientMessage' values differently depending on+-- their 'ClientMessageType'+data ClientMessageType =+ Informative+ | Error+ | DateTransition+ | NewMessagesTransition+ | UnknownGapBefore -- ^ a region where the server may have+ -- messages before the given timestamp that are+ -- not known locally by this client+ | UnknownGapAfter -- ^ a region where server may have messages+ -- after the given timestamp that are not known+ -- locally by this client+ deriving (Show)++-- ** 'ClientMessage' Lenses++makeLenses ''ClientMessage++-- * Mattermost Posts++-- | A 'ClientPost' is a temporary internal representation of+-- the Mattermost 'Post' type, with unnecessary information+-- removed and some preprocessing done.+data ClientPost = ClientPost+ { _cpText :: Seq RichTextBlock+ , _cpMarkdownSource :: Text+ , _cpUser :: Maybe UserId+ , _cpUserOverride :: Maybe Text+ , _cpDate :: ServerTime+ , _cpType :: ClientPostType+ , _cpPending :: Bool+ , _cpDeleted :: Bool+ , _cpAttachments :: Seq Attachment+ , _cpInReplyToPost :: Maybe PostId+ , _cpPostId :: PostId+ , _cpChannelId :: ChannelId+ , _cpReactions :: Map.Map Text (S.Set UserId)+ , _cpOriginalPost :: Post+ , _cpFromWebhook :: Bool+ , _cpPinned :: Bool+ } deriving (Show)++-- | An attachment has a very long URL associated, as well as+-- an actual file URL+data Attachment = Attachment+ { _attachmentName :: Text+ , _attachmentURL :: Text+ , _attachmentFileId :: FileId+ } deriving (Eq, Show)++mkAttachment :: Text -> Text -> FileId -> Attachment+mkAttachment = Attachment++-- | A Mattermost 'Post' value can represent either a normal+-- chat message or one of several special events.+data ClientPostType =+ NormalPost+ | Emote+ | Join+ | Leave+ | TopicChange+ deriving (Eq, Show)++-- ** Creating 'ClientPost' Values++-- | Determine the internal 'PostType' based on a 'Post'+postClientPostType :: Post -> ClientPostType+postClientPostType cp =+ if | postIsEmote cp -> Emote+ | postIsJoin cp -> Join+ | postIsLeave cp -> Leave+ | postIsTopicChange cp -> TopicChange+ | otherwise -> NormalPost++-- | Find out whether a 'Post' represents a topic change+postIsTopicChange :: Post -> Bool+postIsTopicChange p = postType p == PostTypeHeaderChange++-- | Find out whether a 'Post' is from a @/me@ command+postIsEmote :: Post -> Bool+postIsEmote p =+ and [ p^.postPropsL.postPropsOverrideIconUrlL == Just (""::Text)+ , ("*" `T.isPrefixOf` (sanitizeUserText $ postMessage p))+ , ("*" `T.isSuffixOf` (sanitizeUserText $ postMessage p))+ ]++-- | Find out whether a 'Post' is a user joining a channel+postIsJoin :: Post -> Bool+postIsJoin p =+ p^.postTypeL == PostTypeJoinChannel++-- | Find out whether a 'Post' is a user leaving a channel+postIsLeave :: Post -> Bool+postIsLeave p =+ p^.postTypeL == PostTypeLeaveChannel++-- | Undo the automatic formatting of posts generated by @/me@-commands+unEmote :: ClientPostType -> Text -> Text+unEmote Emote t = if "*" `T.isPrefixOf` t && "*" `T.isSuffixOf` t+ then T.init $ T.tail t+ else t+unEmote _ t = t++-- | Convert a Mattermost 'Post' to a 'ClientPost', passing in a+-- 'ParentId' if it has a known one.+toClientPost :: TeamBaseURL -> Post -> Maybe PostId -> ClientPost+toClientPost baseUrl p parentId =+ let src = unEmote (postClientPostType p) $ sanitizeUserText $ postMessage p+ in ClientPost { _cpText = parseMarkdown (Just baseUrl) src <> getAttachmentText p+ , _cpMarkdownSource = src+ , _cpUser = postUserId p+ , _cpUserOverride = p^.postPropsL.postPropsOverrideUsernameL+ , _cpDate = postCreateAt p+ , _cpType = postClientPostType p+ , _cpPending = False+ , _cpDeleted = False+ , _cpPinned = fromMaybe False $ postPinned p+ , _cpAttachments = Seq.empty+ , _cpInReplyToPost = parentId+ , _cpPostId = p^.postIdL+ , _cpChannelId = p^.postChannelIdL+ , _cpReactions = Map.empty+ , _cpOriginalPost = p+ , _cpFromWebhook = fromMaybe False $ p^.postPropsL.postPropsFromWebhookL+ }++-- | Right now, instead of treating 'attachment' properties specially, we're+-- just going to roll them directly into the message text+getAttachmentText :: Post -> Seq RichTextBlock+getAttachmentText p =+ case p^.postPropsL.postPropsAttachmentsL of+ Nothing -> Seq.empty+ Just attachments ->+ fmap (Blockquote . render) attachments+ where render att = parseMarkdown Nothing (att^.ppaTextL) <>+ parseMarkdown Nothing (att^.ppaFallbackL)++-- ** 'ClientPost' Lenses++makeLenses ''Attachment+makeLenses ''ClientPost
+ src/Matterhorn/Types/RichText.hs view
@@ -0,0 +1,486 @@+-- | This module provides a set of data types to represent message text.+-- The types here are based loosely on the @cheapskate@ package's types+-- but provide higher-level support for the kinds of things we find in+-- Mattermost messages such as user and channel references.+--+-- To parse a Markdown document, use 'parseMarkdown'. To actually render+-- text in this representation, see the module 'Draw.RichText'.+module Matterhorn.Types.RichText+ ( RichTextBlock(..)+ , ListType(..)+ , CodeBlockInfo(..)+ , NumDecoration(..)+ , Element(..)+ , ElementData(..)+ , ElementStyle(..)+ , TeamBaseURL(..)+ , TeamURLName(..)++ , URL(..)+ , unURL++ , parseMarkdown+ , setElementStyle++ , findUsernames+ , blockGetURLs+ , findVerbatimChunk++ -- Exposed for testing only:+ , fromMarkdownBlocks+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Cheapskate as C+import Data.Char ( isAlphaNum, isAlpha )+import qualified Data.Foldable as F+import Data.Monoid (First(..))+import qualified Data.Set as S+import qualified Data.Sequence as Seq+import Data.Sequence ( (<|), ViewL((:<)) )+import qualified Data.Text as T++import Network.Mattermost.Types ( PostId(..), Id(..), ServerBaseURL(..) )++import Matterhorn.Constants ( userSigil, normalChannelSigil )++-- | A team name found in a Mattermost post URL+data TeamURLName = TeamURLName Text+ deriving (Eq, Show, Ord)++-- | A server base URL with a team name.+data TeamBaseURL = TeamBaseURL TeamURLName ServerBaseURL+ deriving (Eq, Show)++-- | A block in a rich text document.+data RichTextBlock =+ Para (Seq Element)+ -- ^ A paragraph.+ | Header Int (Seq Element)+ -- ^ A section header with specified depth and contents.+ | Blockquote (Seq RichTextBlock)+ -- ^ A blockquote.+ | List Bool ListType (Seq (Seq RichTextBlock))+ -- ^ An itemized list.+ | CodeBlock CodeBlockInfo Text+ -- ^ A code block.+ | HTMLBlock Text+ -- ^ A fragment of raw HTML.+ | HRule+ -- ^ A horizontal rule.+ deriving (Show)++-- | The type of itemized list items.+data ListType =+ Bullet Char+ -- ^ Decorate the items with bullet using the specified character.+ | Numbered NumDecoration Int+ -- ^ Number the items starting at the specified number; use the+ -- indicated decoration following the number.+ deriving (Eq, Show, Ord)++-- | Information about a code block.+data CodeBlockInfo =+ CodeBlockInfo { codeBlockLanguage :: Maybe Text+ -- ^ The language of the source code in the code+ -- block, if any. This is encoded in Markdown as a+ -- sequence of non-whitespace characters following the+ -- fenced code block opening backticks.+ , codeBlockInfo :: Maybe Text+ -- ^ Any text that comes after the language token.+ -- This text is separated from the language token by+ -- whitespace.+ }+ deriving (Eq, Show, Ord)++-- | Ways to decorate numbered itemized list items. The decoration+-- follows the list item number.+data NumDecoration =+ Paren+ | Period+ deriving (Eq, Show, Ord)++-- | A single logical inline element in a rich text block.+data Element =+ Element { eStyle :: ElementStyle+ -- ^ The element's visual style.+ , eData :: ElementData+ -- ^ The element's data.+ }+ deriving (Show, Eq, Ord)++setElementStyle :: ElementStyle -> Element -> Element+setElementStyle s e = e { eStyle = s }++-- | A URL.+newtype URL = URL Text+ deriving (Eq, Show, Ord)++unURL :: URL -> Text+unURL (URL url) = url++-- | The kinds of data that can appear in rich text elements.+data ElementData =+ EText Text+ -- ^ A sequence of non-whitespace characters.+ | ESpace+ -- ^ A single space.+ | ESoftBreak+ -- ^ A soft line break.+ | ELineBreak+ -- ^ A hard line break.+ | ERawHtml Text+ -- ^ Raw HTML.+ | EEditSentinel Bool+ -- ^ A sentinel indicating that some text has been edited (used+ -- to indicate that mattermost messages have been edited by their+ -- authors). This has no parsable representation; it is only used+ -- to annotate a message prior to rendering to add a visual editing+ -- indicator. The boolean indicates whether the edit was "recent"+ -- (True) or not (False).+ | EUser Text+ -- ^ A user reference. The text here includes only the username, not+ -- the sigil.+ | EChannel Text+ -- ^ A channel reference. The text here includes only the channel+ -- name, not the sigil.+ | EHyperlink URL (Maybe (Seq Element))+ -- ^ A hyperlink to the specified URL. Optionally provides an+ -- element sequence indicating the URL's text label; if absent, the+ -- label is understood to be the URL itself.+ | EImage URL (Maybe (Seq Element))+ -- ^ An image at the specified URL. Optionally provides an element+ -- sequence indicating the image's "alt" text label; if absent, the+ -- label is understood to be the URL itself.+ | EEmoji Text+ -- ^ An emoji reference. The text here includes only the text+ -- portion, not the colons, e.g. "foo" instead of ":foo:".+ | ENonBreaking (Seq Element)+ -- ^ A sequence of elements that must never be separated during line+ -- wrapping.+ | EPermalink TeamURLName PostId (Maybe (Seq Element))+ -- ^ A permalink to the specified team (name) and post ID with an+ -- optional label.+ deriving (Show, Eq, Ord)++-- | Element visual styles.+data ElementStyle =+ Normal+ -- ^ Normal text+ | Emph+ -- ^ Emphasized text+ | Strikethrough+ -- ^ Strikethrough text+ | Strong+ -- ^ Bold text+ | Code+ -- ^ Inline code segment or code block+ | Hyperlink URL ElementStyle+ -- ^ A terminal hyperlink to the specified URL, composed with+ -- another element style+ | Permalink+ -- ^ A post permalink+ deriving (Eq, Show, Ord)++parseMarkdown :: Maybe TeamBaseURL -> T.Text -> Seq RichTextBlock+parseMarkdown baseUrl t =+ fromMarkdownBlocks baseUrl bs where C.Doc _ bs = C.markdown C.def t++-- | Convert a sequence of markdown (Cheapskate) blocks into rich text+-- blocks.+fromMarkdownBlocks :: Maybe TeamBaseURL -> C.Blocks -> Seq RichTextBlock+fromMarkdownBlocks baseUrl = fmap (fromMarkdownBlock baseUrl)++-- | Convert a single markdown block into a single rich text block.+fromMarkdownBlock :: Maybe TeamBaseURL -> C.Block -> RichTextBlock+fromMarkdownBlock baseUrl (C.Para is) =+ Para $ fromMarkdownInlines baseUrl is+fromMarkdownBlock baseUrl (C.Header level is) =+ Header level $ fromMarkdownInlines baseUrl is+fromMarkdownBlock baseUrl (C.Blockquote bs) =+ Blockquote $ fromMarkdownBlock baseUrl <$> bs+fromMarkdownBlock baseUrl (C.List f ty bss) =+ List f (fromMarkdownListType ty) $ fmap (fromMarkdownBlock baseUrl) <$> Seq.fromList bss+fromMarkdownBlock _ (C.CodeBlock attr body) =+ CodeBlock (fromMarkdownCodeAttr attr) body+fromMarkdownBlock _ (C.HtmlBlock body) =+ HTMLBlock body+fromMarkdownBlock _ C.HRule =+ HRule++fromMarkdownCodeAttr :: C.CodeAttr -> CodeBlockInfo+fromMarkdownCodeAttr (C.CodeAttr lang info) =+ let strippedLang = T.strip lang+ strippedInfo = T.strip info+ maybeText t = if T.null t then Nothing else Just t+ in CodeBlockInfo (maybeText strippedLang)+ (maybeText strippedInfo)++fromMarkdownListType :: C.ListType -> ListType+fromMarkdownListType (C.Bullet c) =+ Bullet c+fromMarkdownListType (C.Numbered wrap i) =+ let dec = case wrap of+ C.PeriodFollowing -> Period+ C.ParenFollowing -> Paren+ in Numbered dec i++-- | Remove hyperlinks from an inline sequence. This should only be used+-- for sequences that are themselves used as labels for hyperlinks; this+-- prevents them from embedding their own hyperlinks, which is nonsense.+--+-- Any hyperlinks found in the sequence will be replaced with a text+-- represent of their URL (if they have no label) or the label itself+-- otherwise.+removeHyperlinks :: Seq C.Inline -> Seq C.Inline+removeHyperlinks is =+ case Seq.viewl is of+ h :< t ->+ case h of+ C.Link label theUrl _ ->+ if Seq.null label+ then C.Str theUrl <| removeHyperlinks t+ else removeHyperlinks label <> removeHyperlinks t+ _ -> h <| removeHyperlinks t+ Seq.EmptyL -> mempty++-- | Convert a sequence of Markdown inline values to a sequence of+-- Elements.+--+-- This conversion converts sequences of Markdown AST fragments into+-- RichText elements. In particular, this function may determine that+-- some sequences of Markdown text fragments belong together, such as+-- the sequence @["@", "joe", "-", "user"]@, which should be treated as+-- a single "@joe-user" token due to username character validation. When+-- appropriate, this function does such consolidation when converting to+-- RichText elements.+--+-- This function is also partially responsible for paving the way for+-- line-wrapping later on when RichText is rendered. This means that,+-- when possible, the elements produced by this function need to be as+-- small as possible, without losing structure information. An example+-- of this is Markdown inline code fragments, such as "`this is code`".+-- This function will convert that one Markdown inline code fragment+-- into a sequence of five RichText elements, each with a "Code" style+-- assigned: @[EText "this", ESpace, EText "is", ESpace, EText "code"]@.+-- This "flattening" of the original Markdown structure makes it much+-- easier to do line-wrapping without losing style information because+-- it is possible to gather up tokens that do not exceed line widths+-- without losing style information. This is key to rendering the text+-- with the right terminal attributes.+--+-- However, there are a couple of cases where such flattening does *not*+-- happen: hyperlinks and images. In these cases we do not flatten the+-- (arbitrary) text label structure of links and images because we need+-- to be able to recover those labels to gather up URLs to show to the+-- user in the URL list. So we leave the complete text structure of+-- those labels behind in the 'EHyperlink' and 'EImage' constructors as+-- sequences of Elements. This means that logic in 'Draw.RichText' that+-- does line-wrapping will have to explicitly break up link and image+-- labels across line breaks.+fromMarkdownInlines :: Maybe TeamBaseURL -> Seq C.Inline -> Seq Element+fromMarkdownInlines baseUrl inlines =+ let go sty is = case Seq.viewl is of+ C.Str "~" :< xs ->+ case Seq.viewl xs of+ C.Str "~" :< xs2 ->+ case takeUntilStrikethroughEnd xs2 of+ Nothing -> Element sty (EText "~") <|+ go sty xs+ Just (strikethroughInlines, rest) ->+ go Strikethrough strikethroughInlines <>+ go sty rest+ _ ->+ let (cFrags, rest) = Seq.spanl isNameFragment xs+ cn = T.concat (unsafeGetStr <$> F.toList cFrags)+ in if not (T.null cn)+ then Element sty (EChannel cn) <| go sty rest+ else Element sty (EText normalChannelSigil) <| go sty xs+ C.Str ":" :< xs ->+ let validEmojiFragment (C.Str f) =+ f `elem` ["_", "-"] || T.all isAlphaNum f+ validEmojiFragment _ = False+ (emojiFrags, rest) = Seq.spanl validEmojiFragment xs+ em = T.concat $ unsafeGetStr <$> F.toList emojiFrags+ in case Seq.viewl rest of+ C.Str ":" :< rest2 ->+ Element Normal (EEmoji em) <| go sty rest2+ _ ->+ Element sty (EText ":") <| go sty xs+ C.Str t :< xs | userSigil `T.isPrefixOf` t ->+ let (uFrags, rest) = Seq.spanl isNameFragment xs+ t' = T.concat $ t : (unsafeGetStr <$> F.toList uFrags)+ u = T.drop 1 t'+ in Element sty (EUser u) <| go sty rest+ C.Str t :< xs ->+ -- When we encounter a string node, we go ahead and+ -- process the rest of the nodes in the sequence. If the+ -- new sequence starts with *another* string node with+ -- the same style, we merge them. We do this because+ -- Cheapskate will parse things like punctuation as+ -- individual string nodes, but we want to keep those+ -- together with any text is adjacent to them to avoid+ -- e.g. breaking up quotes from quoted text when doing+ -- line-wrapping. It's important to make sure we only do+ -- this for adjacent string (i.e. not whitespace) nodes+ -- and that we make sure we only merge when they have the+ -- same style.+ let rest = go sty xs+ e = Element sty (EText t)+ in case Seq.viewl rest of+ Element sty2 (EText t2) :< tail_ | sty2 == sty ->+ (Element sty (EText (t <> t2))) <| tail_+ _ ->+ e <| rest+ C.Space :< xs ->+ Element sty ESpace <| go sty xs+ C.SoftBreak :< xs ->+ Element sty ESoftBreak <| go sty xs+ C.LineBreak :< xs ->+ Element sty ELineBreak <| go sty xs+ C.Link label theUrl _ :< xs ->+ let mLabel = if Seq.null label+ then Nothing+ else case F.toList label of+ [C.Str u] | u == theUrl -> Nothing+ _ -> Just $ fromMarkdownInlines baseUrl $ removeHyperlinks label+ rest = go sty xs+ this = case flip getPermalink theUrl =<< baseUrl of+ Nothing ->+ let url = URL theUrl+ in Element (Hyperlink url sty) $ EHyperlink url mLabel+ Just (tName, pId) ->+ Element Permalink $ EPermalink tName pId mLabel+ in this <| rest+ C.Image altIs theUrl _ :< xs ->+ let mLabel = if Seq.null altIs+ then Nothing+ else Just $ fromMarkdownInlines baseUrl altIs+ url = URL theUrl+ in (Element (Hyperlink url sty) $ EImage url mLabel) <| go sty xs+ C.RawHtml t :< xs ->+ Element sty (ERawHtml t) <| go sty xs+ C.Code t :< xs ->+ -- We turn a single code string into individual Elements+ -- so we can perform line-wrapping on the inline code+ -- text.+ let ts = [ Element Code frag+ | wd <- T.split (== ' ') t+ , frag <- case wd of+ "" -> [ESpace]+ _ -> [ESpace, EText wd]+ ]+ ts' = case ts of+ (Element _ ESpace:rs) -> rs+ _ -> ts+ in Seq.fromList ts' <> go sty xs+ C.Emph as :< xs ->+ go Emph as <> go sty xs+ C.Strong as :< xs ->+ go Strong as <> go sty xs+ C.Entity t :< xs ->+ Element sty (EText t) <| go sty xs+ Seq.EmptyL -> mempty++ in go Normal inlines++takeUntilStrikethroughEnd :: Seq C.Inline -> Maybe (Seq C.Inline, Seq C.Inline)+takeUntilStrikethroughEnd is =+ let go pos s = case Seq.viewl s of+ C.Str "~" :< rest ->+ case Seq.viewl rest of+ C.Str "~" :< _ ->+ Just pos+ _ -> go (pos + 1) rest+ _ :< rest -> go (pos + 1) rest+ Seq.EmptyL -> Nothing+ in do+ pos <- go 0 is+ let (h, t) = Seq.splitAt pos is+ return (h, Seq.drop 2 t)++-- | If the specified URL matches the active server base URL and team+-- and refers to a post, extract the team name and post ID values and+-- return them.+getPermalink :: TeamBaseURL -> Text -> Maybe (TeamURLName, PostId)+getPermalink (TeamBaseURL tName (ServerBaseURL baseUrl)) url =+ let newBaseUrl = if "/" `T.isSuffixOf` baseUrl+ then baseUrl+ else baseUrl <> "/"+ in if not $ newBaseUrl `T.isPrefixOf` url+ then Nothing+ else let rest = T.drop (T.length newBaseUrl) url+ (tName', rawPIdStr) = T.breakOn "/pl/" rest+ pIdStr = T.drop 4 rawPIdStr+ in if tName == TeamURLName tName' && not (T.null pIdStr)+ then Just (tName, PI $ Id pIdStr)+ else Nothing+++unsafeGetStr :: C.Inline -> Text+unsafeGetStr (C.Str t) = t+unsafeGetStr _ = error "BUG: unsafeGetStr called on non-Str Inline"++-- | Obtain all username references in a sequence of rich text blocks.+findUsernames :: Seq RichTextBlock -> S.Set T.Text+findUsernames = S.unions . F.toList . fmap blockFindUsernames++blockFindUsernames :: RichTextBlock -> S.Set T.Text+blockFindUsernames (Para is) =+ elementFindUsernames $ F.toList is+blockFindUsernames (Header _ is) =+ elementFindUsernames $ F.toList is+blockFindUsernames (Blockquote bs) =+ findUsernames bs+blockFindUsernames (List _ _ bs) =+ S.unions $ F.toList $ findUsernames <$> bs+blockFindUsernames _ =+ mempty++elementFindUsernames :: [Element] -> S.Set T.Text+elementFindUsernames [] = mempty+elementFindUsernames (e : es) =+ case eData e of+ EUser u -> S.insert u $ elementFindUsernames es+ _ -> elementFindUsernames es++-- | Obtain all URLs (and optional labels) in a rich text block.+blockGetURLs :: RichTextBlock -> [(Either (TeamURLName, PostId) URL, Maybe (Seq Element))]+blockGetURLs (Para is) =+ catMaybes $ elementGetURL <$> toList is+blockGetURLs (Header _ is) =+ catMaybes $ elementGetURL <$> toList is+blockGetURLs (Blockquote bs) =+ mconcat $ blockGetURLs <$> toList bs+blockGetURLs (List _ _ bss) =+ mconcat $ mconcat $+ (fmap blockGetURLs . F.toList) <$> F.toList bss+blockGetURLs _ =+ mempty++elementGetURL :: Element -> Maybe (Either (TeamURLName, PostId) URL, Maybe (Seq Element))+elementGetURL (Element _ (EHyperlink url label)) =+ Just (Right url, label)+elementGetURL (Element _ (EImage url label)) =+ Just (Right url, label)+elementGetURL (Element _ (EPermalink tName pId label)) =+ Just (Left (tName, pId), label)+elementGetURL _ =+ Nothing++-- | Find the first code block in a sequence of rich text blocks.+findVerbatimChunk :: Seq RichTextBlock -> Maybe Text+findVerbatimChunk = getFirst . F.foldMap go+ where go (CodeBlock _ t) = First (Just t)+ go _ = First Nothing++isValidNameChar :: Char -> Bool+isValidNameChar c = isAlpha c || c == '_' || c == '.' || c == '-'++isNameFragment :: C.Inline -> Bool+isNameFragment (C.Str t) =+ not (T.null t) && isValidNameChar (T.head t)+isNameFragment _ = False
+ src/Matterhorn/Types/Users.hs view
@@ -0,0 +1,196 @@+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE DeriveFunctor #-}++module Matterhorn.Types.Users+ ( UserInfo(..)+ , UserStatus(..)+ , Users -- constructor remains internal+ -- * Lenses created for accessing UserInfo fields+ , uiName, uiId, uiStatus, uiInTeam, uiNickName, uiFirstName, uiLastName, uiEmail+ , uiDeleted+ -- * Various operations on UserInfo+ -- * Creating UserInfo objects+ , userInfoFromUser+ -- * Miscellaneous+ , getUsernameSet+ , trimUserSigil+ , statusFromText+ , findUserById+ , findUserByUsername+ , findUserByNickname+ , noUsers, addUser, allUsers+ , modifyUserById+ , userDeleted+ , TypingUsers+ , noTypingUsers+ , addTypingUser+ , allTypingUsers+ , expireTypingUsers+ , getAllUserIds+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.HashMap.Strict as HM+import qualified Data.Set as S+import Data.Semigroup ( Max(..) )+import qualified Data.Text as T+import Lens.Micro.Platform ( (%~), makeLenses, ix )++import Network.Mattermost.Types ( UserId(..), User(..) )++import Matterhorn.Types.Common+import Matterhorn.Constants ( userSigil )++-- * 'UserInfo' Values++-- | A 'UserInfo' value represents everything we need to know at+-- runtime about a user+data UserInfo = UserInfo+ { _uiName :: Text+ , _uiId :: UserId+ , _uiStatus :: UserStatus+ , _uiInTeam :: Bool+ , _uiNickName :: Maybe Text+ , _uiFirstName :: Text+ , _uiLastName :: Text+ , _uiEmail :: Text+ , _uiDeleted :: Bool+ } deriving (Eq, Show)++-- | Is this user deleted?+userDeleted :: User -> Bool+userDeleted u =+ case userCreateAt u of+ Nothing -> False+ Just c -> userDeleteAt u > c++-- | Create a 'UserInfo' value from a Mattermost 'User' value+userInfoFromUser :: User -> Bool -> UserInfo+userInfoFromUser up inTeam = UserInfo+ { _uiName = userUsername up+ , _uiId = userId up+ , _uiStatus = Offline+ , _uiInTeam = inTeam+ , _uiNickName =+ let nick = sanitizeUserText $ userNickname up+ in if T.null nick then Nothing else Just nick+ , _uiFirstName = sanitizeUserText $ userFirstName up+ , _uiLastName = sanitizeUserText $ userLastName up+ , _uiEmail = sanitizeUserText $ userEmail up+ , _uiDeleted = userDeleted up+ }++-- | The 'UserStatus' value represents possible current status for+-- a user+data UserStatus+ = Online+ | Away+ | Offline+ | DoNotDisturb+ | Other Text+ deriving (Eq, Show)++statusFromText :: Text -> UserStatus+statusFromText t = case t of+ "online" -> Online+ "offline" -> Offline+ "away" -> Away+ "dnd" -> DoNotDisturb+ _ -> Other t++-- ** 'UserInfo' lenses++makeLenses ''UserInfo++-- ** Manage the collection of all Users++-- | Define a binary kinded type to allow derivation of functor.+data AllMyUsers a =+ AllUsers { _ofUsers :: HashMap UserId a+ , _usernameSet :: S.Set Text+ }+ deriving Functor++makeLenses ''AllMyUsers++-- | Define the exported typename which universally binds the+-- collection to the UserInfo type.+type Users = AllMyUsers UserInfo++getUsernameSet :: Users -> S.Set Text+getUsernameSet = _usernameSet++-- | Initial collection of Users with no members+noUsers :: Users+noUsers = AllUsers HM.empty mempty++getAllUserIds :: Users -> [UserId]+getAllUserIds = HM.keys . _ofUsers++-- | Add a member to the existing collection of Users+addUser :: UserInfo -> Users -> Users+addUser userinfo u =+ u & ofUsers %~ HM.insert (userinfo^.uiId) userinfo+ & usernameSet %~ S.insert (userinfo^.uiName)++-- | Get a list of all known users+allUsers :: Users -> [UserInfo]+allUsers = HM.elems . _ofUsers++-- | Define the exported typename to represent the collection of users+-- | who are currently typing. The values kept against the user id keys are the+-- | latest timestamps of typing events from the server.+type TypingUsers = AllMyUsers (Max UTCTime)++-- | Initial collection of TypingUsers with no members+noTypingUsers :: TypingUsers+noTypingUsers = AllUsers HM.empty mempty++-- | Add a member to the existing collection of TypingUsers+addTypingUser :: UserId -> UTCTime -> TypingUsers -> TypingUsers+addTypingUser uId ts = ofUsers %~ HM.insertWith (<>) uId (Max ts)++-- | Get a list of all typing users+allTypingUsers :: TypingUsers -> [UserId]+allTypingUsers = HM.keys . _ofUsers++-- | Remove all the expired users from the collection of TypingUsers.+-- | Expiry is decided by the given timestamp.+expireTypingUsers :: UTCTime -> TypingUsers -> TypingUsers+expireTypingUsers expiryTimestamp =+ ofUsers %~ HM.filter (\(Max ts') -> ts' >= expiryTimestamp)++-- | Get the User information given the UserId+findUserById :: UserId -> Users -> Maybe UserInfo+findUserById uId = HM.lookup uId . _ofUsers++-- | Get the User information given the user's name. This is an exact+-- match on the username field. It will automatically trim a user sigil+-- from the input.+findUserByUsername :: Text -> Users -> Maybe (UserId, UserInfo)+findUserByUsername name allusers =+ case filter ((== trimUserSigil name) . _uiName . snd) $ HM.toList $ _ofUsers allusers of+ (usr : []) -> Just usr+ _ -> Nothing++-- | Get the User information given the user's name. This is an exact+-- match on the nickname field, not necessarily the presented name. It+-- will automatically trim a user sigil from the input.+findUserByNickname:: Text -> Users -> Maybe (UserId, UserInfo)+findUserByNickname nick us =+ case filter ((== (Just $ trimUserSigil nick)) . _uiNickName . snd) $ HM.toList $ _ofUsers us of+ (pair : []) -> Just pair+ _ -> Nothing++trimUserSigil :: Text -> Text+trimUserSigil n+ | userSigil `T.isPrefixOf` n = T.tail n+ | otherwise = n++-- | Extract a specific user from the collection and perform an+-- endomorphism operation on it, then put it back into the collection.+modifyUserById :: UserId -> (UserInfo -> UserInfo) -> Users -> Users+modifyUserById uId f = ofUsers.ix(uId) %~ f
+ src/Matterhorn/Util.hs view
@@ -0,0 +1,25 @@+module Matterhorn.Util+ ( nubOn+ )+where++import Prelude ()+import Matterhorn.Prelude++import qualified Data.Set as Set+++-- | The 'nubOn' function removes duplicate elements from a list. In+-- particular, it keeps only the /last/ occurrence of each+-- element. The equality of two elements in a call to @nub f@ is+-- determined using @f x == f y@, and the resulting elements must have+-- an 'Ord' instance in order to make this function more efficient.+nubOn :: (Ord b) => (a -> b) -> [a] -> [a]+nubOn f = snd . go Set.empty+ where go before [] = (before, [])+ go before (x:xs) =+ let (before', xs') = go before xs+ key = f x in+ if key `Set.member` before'+ then (before', xs')+ else (Set.insert key before', x : xs')
+ src/Matterhorn/Windows/ViewMessage.hs view
@@ -0,0 +1,218 @@+module Matterhorn.Windows.ViewMessage+ ( viewMessageWindowTemplate+ , viewMessageKeybindings+ , viewMessageKeyHandlers+ , viewMessageReactionsKeybindings+ , viewMessageReactionsKeyHandlers+ )+where++import Prelude ()+import Matterhorn.Prelude++import Brick+import Brick.Widgets.Border++import qualified Data.Set as S+import qualified Data.Map as M+import Data.Maybe ( fromJust )+import qualified Data.Text as T+import qualified Data.Foldable as F+import qualified Graphics.Vty as Vty+import Lens.Micro.Platform ( to )++import Matterhorn.Constants+import Matterhorn.Events.Keybindings+import Matterhorn.Themes+import Matterhorn.Types+import Matterhorn.Draw.RichText+import Matterhorn.Draw.Messages ( nameForUserRef )++-- | The template for "View Message" windows triggered by message+-- selection mode.+viewMessageWindowTemplate :: TabbedWindowTemplate ViewMessageWindowTab+viewMessageWindowTemplate =+ TabbedWindowTemplate { twtEntries = [ messageEntry+ , reactionsEntry+ ]+ , twtTitle = const $ txt "View Message"+ }++messageEntry :: TabbedWindowEntry ViewMessageWindowTab+messageEntry =+ TabbedWindowEntry { tweValue = VMTabMessage+ , tweRender = renderTab+ , tweHandleEvent = handleEvent+ , tweTitle = tabTitle+ , tweShowHandler = onShow+ }++reactionsEntry :: TabbedWindowEntry ViewMessageWindowTab+reactionsEntry =+ TabbedWindowEntry { tweValue = VMTabReactions+ , tweRender = renderTab+ , tweHandleEvent = handleEvent+ , tweTitle = tabTitle+ , tweShowHandler = onShow+ }++tabTitle :: ViewMessageWindowTab -> Bool -> T.Text+tabTitle VMTabMessage _ = "Message"+tabTitle VMTabReactions _ = "Reactions"++-- When we show the tabs, we need to reset the viewport scroll position+-- for viewports in that tab. This is because an older View Message+-- window used the same handle for the viewport and we don't want that+-- old state affecting this window. This also means that switching tabs+-- in an existing window resets this state, too.+onShow :: ViewMessageWindowTab -> MH ()+onShow VMTabMessage = resetVp ViewMessageArea+onShow VMTabReactions = resetVp ViewMessageArea++resetVp :: Name -> MH ()+resetVp n = do+ let vs = viewportScroll n+ mh $ do+ vScrollToBeginning vs+ hScrollToBeginning vs++renderTab :: ViewMessageWindowTab -> ChatState -> Widget Name+renderTab tab cs =+ let latestMessage = case cs^.csViewedMessage of+ Nothing -> error "BUG: no message to show, please report!"+ Just (m, _) -> getLatestMessage cs m+ in case tab of+ VMTabMessage -> viewMessageBox cs latestMessage+ VMTabReactions -> reactionsText cs latestMessage++getLatestMessage :: ChatState -> Message -> Message+getLatestMessage cs m =+ case m^.mMessageId of+ Nothing -> m+ Just mId -> fromJust $ findMessage mId $ cs^.csCurrentChannel.ccContents.cdMessages++handleEvent :: ViewMessageWindowTab -> Vty.Event -> MH ()+handleEvent VMTabMessage =+ void . handleKeyboardEvent viewMessageKeybindings (const $ return ())+handleEvent VMTabReactions =+ void . handleKeyboardEvent viewMessageReactionsKeybindings (const $ return ())++reactionsText :: ChatState -> Message -> Widget Name+reactionsText st m = viewport ViewMessageReactionsArea Vertical body+ where+ body = case null reacList of+ True -> txt "This message has no reactions."+ False -> vBox $ mkEntry <$> reacList+ reacList = M.toList (m^.mReactions)+ mkEntry (reactionName, userIdSet) =+ let count = str $ "(" <> show (S.size userIdSet) <> ")"+ name = withDefAttr emojiAttr $ txt $ ":" <> reactionName <> ":"+ usernameList = usernameText userIdSet+ in (name <+> (padLeft (Pad 1) count)) <=>+ (padLeft (Pad 2) usernameList)++ hs = getHighlightSet st++ usernameText uids =+ renderText' Nothing (myUsername st) hs $+ T.intercalate ", " $+ fmap (userSigil <>) $+ catMaybes (lookupUsername <$> F.toList uids)++ lookupUsername uid = usernameForUserId uid st++viewMessageBox :: ChatState -> Message -> Widget Name+viewMessageBox st msg =+ let maybeWarn = if not (msg^.mDeleted) then id else warn+ warn w = vBox [w, hBorder, deleteWarning]+ deleteWarning = withDefAttr errorMessageAttr $+ txtWrap $ "Alert: this message has been deleted and " <>+ "will no longer be accessible once this window " <>+ "is closed."+ mkBody vpWidth =+ let hs = getHighlightSet st+ parent = case msg^.mInReplyToMsg of+ NotAReply -> Nothing+ InReplyTo pId -> getMessageForPostId st pId+ md = MessageData { mdEditThreshold = Nothing+ , mdShowOlderEdits = False+ , mdMessage = msg+ , mdUserName = msg^.mUser.to (nameForUserRef st)+ , mdParentMessage = parent+ , mdParentUserName = parent >>= (^.mUser.to (nameForUserRef st))+ , mdRenderReplyParent = True+ , mdHighlightSet = hs+ , mdIndentBlocks = True+ , mdThreadState = NoThread+ , mdShowReactions = False+ , mdMessageWidthLimit = Just vpWidth+ , mdMyUsername = myUsername st+ , mdWrapNonhighlightedCodeBlocks = False+ }+ in renderMessage md++ in Widget Greedy Greedy $ do+ ctx <- getContext+ render $ maybeWarn $ viewport ViewMessageArea Both $ mkBody (ctx^.availWidthL)++viewMessageKeybindings :: KeyConfig -> KeyHandlerMap+viewMessageKeybindings = mkKeybindings viewMessageKeyHandlers++viewMessageKeyHandlers :: [KeyEventHandler]+viewMessageKeyHandlers =+ let vs = viewportScroll ViewMessageArea+ in [ mkKb PageUpEvent "Page up" $+ mh $ vScrollBy vs (-1 * pageAmount)++ , mkKb PageDownEvent "Page down" $+ mh $ vScrollBy vs pageAmount++ , mkKb PageLeftEvent "Page left" $+ mh $ hScrollBy vs (-2 * pageAmount)++ , mkKb PageRightEvent "Page right" $+ mh $ hScrollBy vs (2 * pageAmount)++ , mkKb ScrollUpEvent "Scroll up" $+ mh $ vScrollBy vs (-1)++ , mkKb ScrollDownEvent "Scroll down" $+ mh $ vScrollBy vs 1++ , mkKb ScrollLeftEvent "Scroll left" $+ mh $ hScrollBy vs (-1)++ , mkKb ScrollRightEvent "Scroll right" $+ mh $ hScrollBy vs 1++ , mkKb ScrollBottomEvent "Scroll to the end of the message" $+ mh $ vScrollToEnd vs++ , mkKb ScrollTopEvent "Scroll to the beginning of the message" $+ mh $ vScrollToBeginning vs+ ]++viewMessageReactionsKeybindings :: KeyConfig -> KeyHandlerMap+viewMessageReactionsKeybindings = mkKeybindings viewMessageReactionsKeyHandlers++viewMessageReactionsKeyHandlers :: [KeyEventHandler]+viewMessageReactionsKeyHandlers =+ let vs = viewportScroll ViewMessageReactionsArea+ in [ mkKb PageUpEvent "Page up" $+ mh $ vScrollBy vs (-1 * pageAmount)++ , mkKb PageDownEvent "Page down" $+ mh $ vScrollBy vs pageAmount++ , mkKb ScrollUpEvent "Scroll up" $+ mh $ vScrollBy vs (-1)++ , mkKb ScrollDownEvent "Scroll down" $+ mh $ vScrollBy vs 1++ , mkKb ScrollBottomEvent "Scroll to the end of the reactions list" $+ mh $ vScrollToEnd vs++ , mkKb ScrollTopEvent "Scroll to the beginning of the reactions list" $+ mh $ vScrollToBeginning vs+ ]
+ src/Matterhorn/Zipper.hs view
@@ -0,0 +1,119 @@+module Matterhorn.Zipper+ ( Zipper+ , fromList+ , toList+ , focus+ , unsafeFocus+ , left+ , leftL+ , right+ , rightL+ , findRight+ , maybeFindRight+ , updateList+ , updateListBy+ , filterZipper+ , maybeMapZipper+ , isEmpty+ )+where++import Prelude ()+import Matterhorn.Prelude hiding (toList)++import Data.Maybe ( fromJust )+import qualified Data.Foldable as F+import qualified Data.Sequence as Seq+import qualified Data.CircularList as C+import Lens.Micro.Platform++data Zipper a b =+ Zipper { zRing :: C.CList b+ , zTrees :: Seq.Seq (a, Seq.Seq b)+ }++instance F.Foldable (Zipper a) where+ foldMap f = foldMap f .+ F.toList .+ mconcat .+ F.toList .+ fmap snd .+ zTrees++instance Functor (Zipper a) where+ fmap f z =+ Zipper { zRing = f <$> zRing z+ , zTrees = zTrees z & mapped._2.mapped %~ f+ }++isEmpty :: Zipper a b -> Bool+isEmpty = C.isEmpty . zRing++-- Move the focus one element to the left+left :: Zipper a b -> Zipper a b+left z = z { zRing = C.rotL (zRing z) }++-- A lens on the zipper moved to the left+leftL :: Lens (Zipper a b) (Zipper a b) (Zipper a b) (Zipper a b)+leftL = lens left (\ _ b -> right b)++-- Move the focus one element to the right+right :: Zipper a b -> Zipper a b+right z = z { zRing = C.rotR (zRing z) }++-- A lens on the zipper moved to the right+rightL :: Lens (Zipper a b) (Zipper a b) (Zipper a b) (Zipper a b)+rightL = lens right (\ _ b -> left b)++-- Return the focus element+focus :: Zipper a b -> Maybe b+focus = C.focus . zRing++unsafeFocus :: Zipper a b -> b+unsafeFocus = fromJust . focus++-- Turn a list into a wraparound zipper, focusing on the head+fromList :: (Eq b) => [(a, [b])] -> Zipper a b+fromList xs =+ let ts = Seq.fromList $ xs & mapped._2 %~ Seq.fromList+ tsList = F.toList $ mconcat $ F.toList $ snd <$> ts+ maybeFocus = if null tsList+ then id+ else fromJust . C.rotateTo (tsList !! 0)+ in Zipper { zRing = maybeFocus $ C.fromList tsList+ , zTrees = ts+ }++toList :: Zipper a b -> [(a, [b])]+toList z = F.toList $ zTrees z & mapped._2 %~ F.toList++-- Shift the focus until a given element is found, or return the+-- same zipper if none applies+findRight :: (b -> Bool) -> Zipper a b -> Zipper a b+findRight f z = fromMaybe z $ maybeFindRight f z++-- Shift the focus until a given element is found, or return+-- Nothing if none applies+maybeFindRight :: (b -> Bool) -> Zipper a b -> Maybe (Zipper a b)+maybeFindRight f z = do+ newRing <- C.findRotateTo f (zRing z)+ return z { zRing = newRing }++updateList :: (Eq b) => [(a, [b])] -> Zipper a b -> Zipper a b+updateList newList oldZip = updateListBy (\old b -> old == Just b) newList oldZip++updateListBy :: (Eq b) => (Maybe b -> b -> Bool) -> [(a, [b])] -> Zipper a b -> Zipper a b+updateListBy f newList oldZip = findRight (f (focus oldZip)) $ fromList newList++maybeMapZipper :: (Eq c) => (b -> Maybe c) -> Zipper a b -> Zipper a c+maybeMapZipper f z =+ let oldTrees = zTrees z+ newTrees = F.toList $ oldTrees & mapped._2 %~ (catMaybes . F.toList . fmap f)+ in fromList newTrees++filterZipper :: (Eq b) => (b -> Bool) -> Zipper a b -> Zipper a b+filterZipper f oldZip = maintainFocus newZip+ where maintainFocus = findRight ((== focus oldZip) . Just)+ newZip = Zipper { zTrees = zTrees oldZip & mapped._2 %~ Seq.filter f+ , zRing = C.filterR f (zRing oldZip)+ }
− src/Options.hs
@@ -1,156 +0,0 @@-{-# LANGUAGE TemplateHaskell #-}--module Options where--import Prelude ()-import Prelude.MH--import Config-import Data.Char ( toLower )-import Data.Foldable (traverse_)-import Data.Tuple ( swap )-import Data.Version ( showVersion )-import Development.GitRev-import Network.Mattermost.Version ( mmApiVersion )-import Paths_matterhorn ( version )-import System.Console.GetOpt-import System.Environment ( getArgs )-import System.Exit ( exitFailure, exitSuccess )-import System.IO ( hPutStrLn, stderr )---data Behaviour- = Normal- | ShowVersion- | ShowHelp- | CheckConfig- deriving (Eq, Show)--data PrintFormat =- Markdown | Plain deriving (Eq, Show)--data Options = Options- { optConfLocation :: Maybe FilePath- , optLogLocation :: Maybe FilePath- , optBehaviour :: Behaviour- , optIgnoreConfig :: Bool- , optPrintKeybindings :: Bool- , optPrintCommands :: Bool- , optPrintFormat :: PrintFormat- } deriving (Eq, Show)--defaultOptions :: Options-defaultOptions = Options- { optConfLocation = Nothing- , optLogLocation = Nothing- , optBehaviour = Normal- , optIgnoreConfig = False- , optPrintKeybindings = False- , optPrintCommands = False- , optPrintFormat = Plain- }--optDescrs :: [OptDescr (Options -> Options)]-optDescrs =- [ Option ['c'] ["config"]- (ReqArg (\ path c -> c { optConfLocation = Just path }) "PATH")- "Path to the configuration file"- , Option ['l'] ["logs"]- (ReqArg (\ path c -> c { optLogLocation = Just path }) "PATH")- "Debug log output path"- , Option ['v'] ["version"]- (NoArg (\ c -> c { optBehaviour = ShowVersion }))- "Print version information and exit"- , Option ['h'] ["help"]- (NoArg (\ c -> c { optBehaviour = ShowHelp }))- "Print help for command-line flags and exit"- , Option ['i'] ["ignore-config"]- (NoArg (\ c -> c { optIgnoreConfig = True }))- "Start with no configuration"- , Option ['k'] ["keybindings"]- (NoArg (\ c -> c { optPrintKeybindings = True }))- "Print keybindings effective for the current configuration"- , Option ['m'] ["commands"]- (NoArg (\ c -> c { optPrintCommands = True }))- "Print available commands"- , Option ['f'] ["format"]- (ReqArg handleFormat "FORMAT")- ("Print keybinding or command output in the specified format " <>- "(options: " <> formatChoicesStr <> ", default: " <>- formatStringFor (optPrintFormat defaultOptions) <> ")")- , Option [] ["check-config"]- (NoArg (\ c -> c { optBehaviour = CheckConfig }))- "Validate configuration file"- ]--formatChoices :: [(String, PrintFormat)]-formatChoices =- [ ("plain", Plain)- , ("markdown", Markdown)- ]--formatStringFor :: PrintFormat -> String-formatStringFor fmt =- case lookup fmt (swap <$> formatChoices) of- Nothing -> error $ "BUG: no format string for " <> show fmt- Just s -> s--formatChoicesStr :: String-formatChoicesStr = intercalate ", " $ fst <$> formatChoices--handleFormat :: String -> Options -> Options-handleFormat fmtStr c =- let fmt = case lookup (toLower <$> fmtStr) formatChoices of- Just f -> f- Nothing ->- error $ "Invalid format: " <> show fmtStr <> ", choices: " <>- formatChoicesStr- in c { optPrintFormat = fmt }--mhVersion :: String-mhVersion- | $(gitHash) == ("UNKNOWN" :: String) = "matterhorn " ++ showVersion version- | otherwise = "matterhorn " ++ showVersion version ++ " (" ++- $(gitBranch) ++ "@" ++ take 7 $(gitHash) ++ ")"--fullVersionString :: String-fullVersionString = mhVersion ++ "\n using " ++ mmApiVersion--usage :: IO ()-usage = putStr (usageInfo "matterhorn" optDescrs)--grabOptions :: IO Options-grabOptions = do- args <- getArgs- case getOpt Permute optDescrs args of- (aps, [], []) -> do- let rs = foldr (.) id aps defaultOptions- case optBehaviour rs of- Normal -> return rs- ShowHelp -> usage >> exitSuccess- ShowVersion -> putStrLn fullVersionString >> exitSuccess- CheckConfig -> checkConfiguration (optConfLocation rs)- (_, _, errs) -> do- mapM_ putStr errs- usage- exitFailure--checkConfiguration :: Maybe FilePath -> IO a-checkConfiguration mb =- do res <- findConfig mb- let writeLn = hPutStrLn stderr- printLocation Nothing = "No configuration file"- printLocation (Just fp) = "Location: " ++ fp- case res of- Left e ->- do writeLn e- exitFailure- Right ([], config) ->- do writeLn "Configuration file valid"- writeLn (printLocation (configAbsPath config))- exitSuccess- Right (ws, config) ->- do writeLn "Configuration file generated warnings"- writeLn (printLocation (configAbsPath config))- traverse_ writeLn ws- exitFailure
− src/Prelude/MH.hs
@@ -1,115 +0,0 @@-{-# LANGUAGE NoImplicitPrelude #-}-{-# LANGUAGE CPP #-}--{-| This module is for internal re-exports of commonly-used- functions. This also lets us avoid churn between versions of GHC by- putting changed functions behind CPP in a single place.--}--{-- GHC version <--> base version (https://wiki.haskell.org/Base_package) includes:- 8.0.1 4.9.0.0- 8.2.1 4.10.0.0- 8.4.1 4.11.0.0-- GHC distributions include a set of core packages; overriding these- with new versions is unwise. Core packages include (see- https://downloads.haskell.org/~ghc/X.Y.Z/docs/html/users_guide/X.Y.Z-notes.html#included-libraries- ):-- base, Cabal, array, bytestring, containers, deepseq, directory,- filepath, mtl, parsec, process, text, time, unix--}--module Prelude.MH- ( module P-#if !MIN_VERSION_base(4,9,0)- , (<>)-#endif- , (<|>)- -- commonly-used functions from Maybe- , Maybe.isJust- , Maybe.isNothing- , Maybe.listToMaybe- , Maybe.maybeToList- , Maybe.fromMaybe- , Maybe.catMaybes-- -- a non-partial Read function- , Read.readMaybe-- -- commonly-used functions from Monad- , Monad.forM- , Monad.forM_- , Monad.filterM- , Monad.when- , Monad.unless- , Monad.void- , Monad.join- , Monad.forever- , Monad.foldM- , Monad.MonadIO(..)-- -- commonly-used functions from List- , Foldable.toList- , List.find- , List.sort- , List.intercalate- , Exts.sortWith- , Exts.groupWith-- -- common read-only lens operators- , (Lens.&)- , (Lens.^.)- , Lens.use-- -- not available in all versions of GHC currently in use-#if MIN_VERSION_base(4,10,0)- , Clock.nominalDay-#else- , nominalDay-#endif-- -- various type aliases- , Text- , HashMap- , Seq- , Set- , Time.UTCTime- , Time.TimeZoneSeries- , Time.NominalDiffTime- )-where---#if !MIN_VERSION_base(4,11,0)-import Data.Semigroup ( (<>) )-#endif--import Control.Applicative ( (<|>) )-import qualified Control.Monad as Monad-import qualified Control.Monad.IO.Class as Monad-import qualified Data.Foldable as Foldable-import qualified Data.List as List-import qualified Data.Maybe as Maybe-import qualified Data.Time as Time-#if MIN_VERSION_base(4,10,0)-import qualified Data.Time.Clock as Clock-#endif-import qualified Data.Time.LocalTime.TimeZone.Series as Time-import qualified GHC.Exts as Exts-import qualified Lens.Micro.Platform as Lens-import Prelude.Compat-import qualified Prelude.Compat as P-import qualified Text.Read as Read---- these below we import only for type aliases-import Data.HashMap.Strict ( HashMap )-import Data.Sequence ( Seq )-import Data.Set ( Set )-import Data.Text ( Text )--#if !MIN_VERSION_base(4,10,0)-nominalDay :: Time.NominalDiffTime-nominalDay = 86400-#endif
− src/Scripts.hs
@@ -1,81 +0,0 @@-module Scripts- ( findAndRunScript- , listScripts- )-where--import Prelude ()-import Prelude.MH--import Control.Concurrent ( takeMVar, newEmptyMVar )-import qualified Control.Concurrent.STM as STM-import qualified Data.Text as T-import System.Exit ( ExitCode(..) )--import Network.Mattermost.Types ( ChannelId )--import FilePaths ( Script(..), getAllScripts, locateScriptPath )-import State.Common-import State.Messages ( sendMessage )-import Types---findAndRunScript :: ChannelId -> Text -> Text -> MH ()-findAndRunScript cId scriptName input = do- fpMb <- liftIO $ locateScriptPath (T.unpack scriptName)- outputChan <- use (csResources.crSubprocessLog)- case fpMb of- ScriptPath scriptPath -> do- doAsyncWith Preempt $ runScript cId outputChan scriptPath input- NonexecScriptPath scriptPath -> do- let msg = ("The script `" <> T.pack scriptPath <> "` cannot be " <>- "executed. Try running\n" <>- "```\n" <>- "$ chmod u+x " <> T.pack scriptPath <> "\n" <>- "```\n" <>- "to correct this error. " <> scriptHelpAddendum)- mhError $ GenericError msg- ScriptNotFound -> do- mhError $ NoSuchScript scriptName--runScript :: ChannelId -> STM.TChan ProgramOutput -> FilePath -> Text -> IO (Maybe (MH ()))-runScript cId outputChan fp text = do- outputVar <- newEmptyMVar- runLoggedCommand True outputChan fp [] (Just $ T.unpack text) (Just outputVar)- po <- takeMVar outputVar- return $ case programExitCode po of- ExitSuccess -> do- case null $ programStderr po of- True -> Just $ do- mode <- use (csEditState.cedEditMode)- sendMessage cId mode (T.pack $ programStdout po) []- False -> Nothing- ExitFailure _ -> Nothing--listScripts :: MH ()-listScripts = do- (execs, nonexecs) <- liftIO getAllScripts- let scripts = ("Available scripts are:\n" <>- mconcat [ " - " <> T.pack cmd <> "\n"- | cmd <- execs- ])- postInfoMessage scripts- case nonexecs of- [] -> return ()- _ -> do- let errMsg = ("Some non-executable script files are also " <>- "present. If you want to run these as scripts " <>- "in Matterhorn, mark them executable with \n" <>- "```\n" <>- "$ chmod u+x [script path]\n" <>- "```\n" <>- "\n" <>- mconcat [ " - " <> T.pack cmd <> "\n"- | cmd <- nonexecs- ] <> "\n" <> scriptHelpAddendum)- mhError $ GenericError errMsg--scriptHelpAddendum :: Text-scriptHelpAddendum =- "For more help with scripts, run the command\n" <>- "```\n/help scripts\n```\n"
− src/State/Async.hs
@@ -1,161 +0,0 @@-module State.Async- ( AsyncPriority(..)- , doAsync- , doAsyncIO- , doAsyncWith- , doAsyncChannelMM- , doAsyncWithIO- , doAsyncMM- , tryMM- , endAsyncNOP- )-where--import Prelude ()-import Prelude.MH--import qualified Control.Concurrent.STM as STM-import Control.Exception ( try )--import Network.Mattermost.Types--import Types----- | Try to run a computation, posting an informative error--- message if it fails with a 'MattermostServerError'.-tryMM :: IO a- -- ^ The action to try (usually a MM API call)- -> (a -> IO (Maybe (MH ())))- -- ^ What to do on success- -> IO (Maybe (MH ()))-tryMM act onSuccess = do- result <- liftIO $ try act- case result of- Left e -> return $ Just $ mhError $ ServerError e- Right value -> liftIO $ onSuccess value---- * Background Computation---- $background_computation------ The main context for Matterhorn is the EventM context provided by--- the 'Brick' library. This context is normally waiting for user--- input (or terminal resizing, etc.) which gets turned into an--- MHEvent and the 'onEvent' event handler is called to process that--- event, after which the display is redrawn as necessary and brick--- awaits the next input.------ However, it is often convenient to communicate with the Mattermost--- server in the background, so that large numbers of--- synchronously-blocking events (e.g. on startup) or refreshes can--- occur whenever needed and without negatively impacting the UI--- updates or responsiveness. This is handled by a 'forkIO' context--- that waits on an STM channel for work to do, performs the work, and--- then sends brick an MHEvent containing the completion or failure--- information for that work.------ The /doAsyncWith/ family of functions here facilitates that--- asynchronous functionality. This is typically used in the--- following fashion:------ > doSomething :: MH ()--- > doSomething = do--- > got <- something--- > doAsyncWith Normal $ do--- > r <- mmFetchR ....--- > return $ do--- > csSomething.here %= processed r------ The second argument is an IO monad operation (because 'forkIO' runs--- in the IO Monad context), but it returns an MH monad operation.--- The IO monad has access to the closure of 'doSomething' (e.g. the--- 'got' value), but it should be aware that the state of the MH monad--- may have been changed by the time the IO monad runs in the--- background, so the closure is a snapshot of information at the time--- the 'doAsyncWith' was called.------ Similarly, the returned MH monad operation is *not* run in the--- context of the 'forkIO' background, but it is instead passed via an--- MHEvent back to the main brick thread, where it is executed in an--- EventM handler's MH monad context. This operation therefore has--- access to the combined closure of the pre- 'doAsyncWith' code and--- the closure of the IO operation. It is important that the final MH--- monad operation should *re-obtain* state information from the MH--- monad instead of using or setting the state obtained prior to the--- 'doAsyncWith' call.---- | Priority setting for asynchronous work items. Preempt means that--- the queued item will be the next work item begun (i.e. it goes to the--- front of the queue); normal means it will go last in the queue.-data AsyncPriority = Preempt | Normal---- | Run a computation in the background, ignoring any results from it.-doAsync :: AsyncPriority -> IO () -> MH ()-doAsync prio act = doAsyncWith prio (act >> return Nothing)---- | Run a computation in the background, returning a computation to be--- called on the 'ChatState' value.-doAsyncWith :: AsyncPriority -> IO (Maybe (MH ())) -> MH ()-doAsyncWith prio act = do- let putChan = case prio of- Preempt -> STM.unGetTChan- Normal -> STM.writeTChan- queue <- use (csResources.crRequestQueue)- liftIO $ STM.atomically $ putChan queue act--doAsyncIO :: AsyncPriority -> ChatState -> IO () -> IO ()-doAsyncIO prio st act =- doAsyncWithIO prio st (act >> return Nothing)---- | Run a computation in the background, returning a computation to be--- called on the 'ChatState' value.-doAsyncWithIO :: AsyncPriority -> ChatState -> IO (Maybe (MH ())) -> IO ()-doAsyncWithIO prio st act = do- let putChan = case prio of- Preempt -> STM.unGetTChan- Normal -> STM.writeTChan- let queue = st^.csResources.crRequestQueue- STM.atomically $ putChan queue act---- | Performs an asynchronous IO operation. On completion, the final--- argument a completion function is executed in an MH () context in the--- main (brick) thread.-doAsyncMM :: AsyncPriority- -- ^ the priority for this async operation- -> (Session -> IO a)- -- ^ the async MM channel-based IO operation- -> (a -> Maybe (MH ()))- -- ^ function to process the results in brick event handling- -- context- -> MH ()-doAsyncMM prio mmOp eventHandler = do- session <- getSession- doAsyncWith prio $ do- r <- mmOp session- return $ eventHandler r---- | Helper type for a function to perform an asynchronous MM operation--- on a channel and then invoke an MH completion event.-type DoAsyncChannelMM a =- AsyncPriority- -- ^ the priority for this async operation- -> ChannelId- -- ^ The channel- -> (Session -> ChannelId -> IO a)- -- ^ the asynchronous Mattermost channel-based IO operation- -> (ChannelId -> a -> Maybe (MH ()))- -- ^ function to process the results in brick event handling context- -> MH ()---- | Performs an asynchronous IO operation on a specific channel. On--- completion, the final argument a completion function is executed in--- an MH () context in the main (brick) thread.-doAsyncChannelMM :: DoAsyncChannelMM a-doAsyncChannelMM prio cId mmOp eventHandler =- doAsyncMM prio (\s -> mmOp s cId) (eventHandler cId)---- | Use this convenience function if no operation needs to be--- performed in the MH state after an async operation completes.-endAsyncNOP :: ChannelId -> a -> Maybe (MH ())-endAsyncNOP _ _ = Nothing
− src/State/Attachments.hs
@@ -1,53 +0,0 @@-module State.Attachments- ( showAttachmentList- , resetAttachmentList- , showAttachmentFileBrowser- )-where--import Prelude ()-import Prelude.MH-import qualified Control.Exception as E-import Data.Either ( isRight )-import System.Directory ( doesDirectoryExist, getDirectoryContents )-import Data.Bool ( bool )--import Brick ( vScrollToBeginning, viewportScroll )-import qualified Brick.Widgets.List as L-import qualified Brick.Widgets.FileBrowser as FB-import Lens.Micro.Platform ( (.=) )--import Types--validateAttachmentPath :: FilePath -> IO (Maybe FilePath)-validateAttachmentPath path = bool Nothing (Just path) <$> do- ex <- doesDirectoryExist path- case ex of- False -> return False- True -> do- result :: Either E.SomeException [FilePath]- <- E.try $ getDirectoryContents path- return $ isRight result--defaultAttachmentsPath :: Config -> IO (Maybe FilePath)-defaultAttachmentsPath = maybe (return Nothing) validateAttachmentPath . configDefaultAttachmentPath--showAttachmentList :: MH ()-showAttachmentList = do- lst <- use (csEditState.cedAttachmentList)- case length (L.listElements lst) of- 0 -> showAttachmentFileBrowser- _ -> setMode ManageAttachments--resetAttachmentList :: MH ()-resetAttachmentList = do- csEditState.cedAttachmentList .= L.list AttachmentList mempty 1- mh $ vScrollToBeginning $ viewportScroll AttachmentList--showAttachmentFileBrowser :: MH ()-showAttachmentFileBrowser = do- config <- use (csResources.crConfiguration)- filePath <- liftIO $ defaultAttachmentsPath config- browser <- liftIO $ Just <$> FB.newFileBrowser FB.selectNonDirectories AttachmentFileBrowser filePath- csEditState.cedFileBrowser .= browser- setMode ManageAttachmentsBrowseFiles
− src/State/Autocomplete.hs
@@ -1,263 +0,0 @@-module State.Autocomplete- ( AutocompleteContext(..)- , checkForAutocompletion- )-where--import Prelude ()-import Prelude.MH--import Brick.Main ( viewportScroll, vScrollToBeginning )-import Brick.Widgets.Edit ( editContentsL )-import qualified Brick.Widgets.List as L-import Data.Char ( isSpace )-import qualified Data.Foldable as F-import qualified Data.HashMap.Strict as HM-import Data.List ( sortBy, partition )-import qualified Data.Map as M-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import qualified Data.Text.Zipper as Z-import qualified Data.Vector as V-import Lens.Micro.Platform ( (%=), (.=), (.~), _Just, preuse )-import qualified Skylighting.Types as Sky--import Network.Mattermost.Types (userId, channelId)-import qualified Network.Mattermost.Endpoints as MM--import Constants ( userSigil, normalChannelSigil )-import {-# SOURCE #-} Command ( commandList, printArgSpec )-import State.Common-import {-# SOURCE #-} State.Editing ( Direction(..), tabComplete )-import Types hiding ( newState )-import Emoji---data AutocompleteContext =- AutocompleteContext { autocompleteManual :: Bool- -- ^ Whether the autocompletion was manual- -- (True) or automatic (False). The automatic- -- case is the case where the autocomplete- -- lookups and UI are triggered merely by- -- entering some initial text (such as "@").- -- The manual case is the case where the- -- autocomplete lookups and UI are triggered- -- explicitly by a user's TAB keypress.- , autocompleteFirstMatch :: Bool- -- ^ Once the results of the autocomplete lookup- -- are available, this flag determines whether- -- the user's input is replaced immediately- -- with the first available match (True) or not- -- (False).- }---- | Check for whether the currently-edited word in the message editor--- should cause an autocompletion UI to appear. If so, initiate a server--- query or local cache lookup to present the completion alternatives--- for the word at the cursor.-checkForAutocompletion :: AutocompleteContext -> MH ()-checkForAutocompletion ctx = do- result <- getCompleterForInput ctx- case result of- Nothing -> resetAutocomplete- Just (ty, runUpdater, searchString) -> do- prevResult <- use (csEditState.cedAutocomplete)- -- We should update the completion state if EITHER:- --- -- 1) The type changed- --- -- or- --- -- 2) The search string changed but the type did NOT change- let shouldUpdate = ((maybe True ((/= searchString) . _acPreviousSearchString)- prevResult) &&- (maybe True ((== ty) . _acType) prevResult)) ||- (maybe False ((/= ty) . _acType) prevResult)- when shouldUpdate $ do- csEditState.cedAutocompletePending .= Just searchString- runUpdater ty ctx searchString--getCompleterForInput :: AutocompleteContext- -> MH (Maybe (AutocompletionType, AutocompletionType -> AutocompleteContext -> Text -> MH (), Text))-getCompleterForInput ctx = do- z <- use (csEditState.cedEditor.editContentsL)-- let col = snd $ Z.cursorPosition z- curLine = Z.currentLine z-- return $ case wordAtColumn col curLine of- Just (startCol, w)- | userSigil `T.isPrefixOf` w ->- Just (ACUsers, doUserAutoCompletion, T.tail w)- | normalChannelSigil `T.isPrefixOf` w ->- Just (ACChannels, doChannelAutoCompletion, T.tail w)- | ":" `T.isPrefixOf` w && autocompleteManual ctx ->- Just (ACEmoji, doEmojiAutoCompletion, T.tail w)- | "```" `T.isPrefixOf` w ->- Just (ACCodeBlockLanguage, doSyntaxAutoCompletion, T.drop 3 w)- | "/" `T.isPrefixOf` w && startCol == 0 ->- Just (ACCommands, doCommandAutoCompletion, T.tail w)- _ -> Nothing---- Completion implementations--doEmojiAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()-doEmojiAutoCompletion ty ctx searchString = do- session <- getSession- em <- use (csResources.crEmoji)- withCachedAutocompleteResults ctx ty searchString $- doAsyncWith Preempt $ do- results <- getMatchingEmoji session em searchString- let alts = EmojiCompletion <$> results- return $ Just $ setCompletionAlternatives ctx searchString alts ty--doSyntaxAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()-doSyntaxAutoCompletion ty ctx searchString = do- mapping <- use (csResources.crSyntaxMap)- let allNames = Sky.sShortname <$> M.elems mapping- (prefixed, notPrefixed) = partition isPrefixed $ filter match allNames- match = (((T.toLower searchString) `T.isInfixOf`) . T.toLower)- isPrefixed = (((T.toLower searchString) `T.isPrefixOf`) . T.toLower)- alts = SyntaxCompletion <$> (sort prefixed <> sort notPrefixed)- setCompletionAlternatives ctx searchString alts ty--doCommandAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()-doCommandAutoCompletion ty ctx searchString = do- let alts = mkAlt <$> sortBy compareCommands (filter matches commandList)- compareCommands a b =- let isAPrefix = searchString `T.isPrefixOf` cmdName a- isBPrefix = searchString `T.isPrefixOf` cmdName b- in if isAPrefix && isBPrefix- then compare (cmdName a) (cmdName b)- else if isAPrefix- then LT- else GT- lowerSearch = T.toLower searchString- matches c = lowerSearch `T.isInfixOf` (cmdName c) ||- lowerSearch `T.isInfixOf` (T.toLower $ cmdDescr c)- mkAlt (Cmd name desc args _) =- CommandCompletion name (printArgSpec args) desc- setCompletionAlternatives ctx searchString alts ty--doUserAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()-doUserAutoCompletion ty ctx searchString = do- session <- getSession- myTid <- gets myTeamId- myUid <- gets myUserId- cId <- use csCurrentChannelId-- withCachedAutocompleteResults ctx ty searchString $- doAsyncWith Preempt $ do- ac <- MM.mmAutocompleteUsers (Just myTid) (Just cId) searchString session-- let active = Seq.filter (\u -> userId u /= myUid && (not $ userDeleted u))- alts = F.toList $- ((\u -> UserCompletion u True) <$> (active $ MM.userAutocompleteUsers ac)) <>- (maybe mempty (fmap (\u -> UserCompletion u False) . active) $- MM.userAutocompleteOutOfChannel ac)-- specials = [ MentionAll- , MentionChannel- ]- extras = [ SpecialMention m | m <- specials- , (T.toLower searchString) `T.isPrefixOf` specialMentionName m- ]-- return $ Just $ setCompletionAlternatives ctx searchString (alts <> extras) ty--doChannelAutoCompletion :: AutocompletionType -> AutocompleteContext -> Text -> MH ()-doChannelAutoCompletion ty ctx searchString = do- session <- getSession- tId <- gets myTeamId- cs <- use csChannels-- withCachedAutocompleteResults ctx ty searchString $ do- doAsyncWith Preempt $ do- results <- MM.mmAutocompleteChannels tId searchString session- let alts = F.toList $ (ChannelCompletion True <$> inChannels) <>- (ChannelCompletion False <$> notInChannels)- (inChannels, notInChannels) = Seq.partition isMember results- isMember c = isJust $ findChannelById (channelId c) cs- return $ Just $ setCompletionAlternatives ctx searchString alts ty---- Utility functions---- | Attempt to re-use a cached autocomplete alternative list for--- a given search string. If the cache contains no such entry (keyed--- on search string), run the specified action, which is assumed to be--- responsible for fetching the completion results from the server.-withCachedAutocompleteResults :: AutocompleteContext- -- ^ The autocomplete context- -> AutocompletionType- -- ^ The type of autocompletion we're- -- doing- -> Text- -- ^ The search string to look for in the- -- cache- -> MH ()- -- ^ The action to execute on a cache miss- -> MH ()-withCachedAutocompleteResults ctx ty searchString act = do- mCache <- preuse (csEditState.cedAutocomplete._Just.acCachedResponses)- mActiveTy <- preuse (csEditState.cedAutocomplete._Just.acType)-- case Just ty == mActiveTy of- True ->- -- Does the cache have results for this search string? If- -- so, use them; otherwise invoke the specified action.- case HM.lookup searchString =<< mCache of- Just alts -> setCompletionAlternatives ctx searchString alts ty- Nothing -> act- False -> act--setCompletionAlternatives :: AutocompleteContext- -> Text- -> [AutocompleteAlternative]- -> AutocompletionType- -> MH ()-setCompletionAlternatives ctx searchString alts ty = do- let list = L.list CompletionList (V.fromList $ F.toList alts) 1- state = AutocompleteState { _acPreviousSearchString = searchString- , _acCompletionList =- list & L.listSelectedL .~ Nothing- , _acCachedResponses = HM.fromList [(searchString, alts)]- , _acType = ty- }-- pending <- use (csEditState.cedAutocompletePending)- case pending of- Just val | val == searchString -> do-- -- If there is already state, update it, but also cache the- -- search results.- csEditState.cedAutocomplete %= \prev ->- let newState = case prev of- Nothing ->- state- Just oldState ->- state & acCachedResponses .~- HM.insert searchString alts (oldState^.acCachedResponses)- in Just newState-- mh $ vScrollToBeginning $ viewportScroll CompletionList-- when (autocompleteFirstMatch ctx) $- tabComplete Forwards- _ ->- -- Do not update the state if this result does not- -- correspond to the search string we used most recently.- -- This happens when the editor changes faster than the- -- async completion responses arrive from the server. If we- -- don't check this, we show completion results that are- -- wrong for the editor state.- return ()--wordAtColumn :: Int -> Text -> Maybe (Int, Text)-wordAtColumn i t =- let tokens = T.groupBy (\a b -> isSpace a == isSpace b) t- go _ j _ | j < 0 = Nothing- go col j ts = case ts of- [] -> Nothing- (w:rest) | j <= T.length w && not (isSpace $ T.head w) -> Just (col, w)- | otherwise -> go (col + T.length w) (j - T.length w) rest- in go 0 i tokens
− src/State/ChannelListOverlay.hs
@@ -1,82 +0,0 @@-module State.ChannelListOverlay- ( enterChannelListOverlayMode-- , channelListSelectDown- , channelListSelectUp- , channelListPageDown- , channelListPageUp- )-where--import Prelude ()-import Prelude.MH--import qualified Brick.Widgets.List as L-import qualified Data.Vector as Vec-import qualified Data.Sequence as Seq-import Data.Function ( on )-import Lens.Micro.Platform ( to )--import Network.Mattermost.Types-import qualified Network.Mattermost.Endpoints as MM--import State.ListOverlay-import State.Channels-import Types---enterChannelListOverlayMode :: MH ()-enterChannelListOverlayMode = do- myTId <- gets myTeamId- myChannels <- use (csChannels.to (filteredChannelIds (const True)))- enterListOverlayMode csChannelListOverlay ChannelListOverlay- AllChannels enterHandler (fetchResults myTId myChannels)--enterHandler :: Channel -> MH Bool-enterHandler chan = do- joinChannel (getId chan)- return True--fetchResults :: TeamId- -> [ChannelId]- -- ^ The channels to exclude from the results- -> ChannelSearchScope- -- ^ The scope to search- -> Session- -- ^ The connection session- -> Text- -- ^ The search string- -> IO (Vec.Vector Channel)-fetchResults myTId exclude AllChannels session searchString = do- resultChans <- MM.mmSearchChannels myTId searchString session- let filteredChans = Seq.filter (\ c -> not (channelId c `elem` exclude)) resultChans- sortedChans = Vec.fromList $ toList $ Seq.sortBy (compare `on` channelName) filteredChans- return sortedChans---- | Move the selection up in the channel list overlay by one channel.-channelListSelectUp :: MH ()-channelListSelectUp = channelListMove L.listMoveUp---- | Move the selection down in the channel list overlay by one channel.-channelListSelectDown :: MH ()-channelListSelectDown = channelListMove L.listMoveDown---- | Move the selection up in the channel list overlay by a page of channels--- (channelListPageSize).-channelListPageUp :: MH ()-channelListPageUp = channelListMove (L.listMoveBy (-1 * channelListPageSize))---- | Move the selection down in the channel list overlay by a page of channels--- (channelListPageSize).-channelListPageDown :: MH ()-channelListPageDown = channelListMove (L.listMoveBy channelListPageSize)---- | Transform the channel list results in some way, e.g. by moving the--- cursor, and then check to see whether the modification warrants a--- prefetch of more search results.-channelListMove :: (L.List Name Channel -> L.List Name Channel) -> MH ()-channelListMove = listOverlayMove csChannelListOverlay---- | The number of channels in a "page" for cursor movement purposes.-channelListPageSize :: Int-channelListPageSize = 10
− src/State/ChannelSelect.hs
@@ -1,155 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-module State.ChannelSelect- ( beginChannelSelect- , updateChannelSelectMatches- , channelSelectNext- , channelSelectPrevious- )-where--import Prelude ()-import Prelude.MH--import Brick.Widgets.Edit ( getEditContents )-import Data.Char ( isUpper )-import qualified Data.Text as T-import Lens.Micro.Platform--import qualified Network.Mattermost.Types as MM--import Constants ( userSigil, normalChannelSigil )-import Types-import qualified Zipper as Z--beginChannelSelect :: MH ()-beginChannelSelect = do- setMode ChannelSelect- csChannelSelectState .= emptyChannelSelectState- updateChannelSelectMatches-- -- Preserve the current channel selection when initializing channel- -- selection mode- zipper <- use csFocus- let isCurrentFocus m = Just (matchEntry m) == Z.focus zipper- csChannelSelectState.channelSelectMatches %= Z.findRight isCurrentFocus---- Select the next match in channel selection mode.-channelSelectNext :: MH ()-channelSelectNext = updateSelectedMatch Z.right---- Select the previous match in channel selection mode.-channelSelectPrevious :: MH ()-channelSelectPrevious = updateSelectedMatch Z.left--updateChannelSelectMatches :: MH ()-updateChannelSelectMatches = do- st <- use id-- input <- use (csChannelSelectState.channelSelectInput)- cconfig <- use csClientConfig- prefs <- use (csResources.crUserPreferences)-- let pat = parseChannelSelectPattern $ T.concat $ getEditContents input- chanNameMatches e = case pat of- Nothing -> const Nothing- Just p -> applySelectPattern p e- patTy = case pat of- Nothing -> Nothing- Just CSPAny -> Nothing- Just (CSP ty _) -> Just ty-- let chanMatches e chan =- if patTy == Just PrefixDMOnly- then Nothing- else if chan^.ccInfo.cdType /= MM.Group- then chanNameMatches e $ chan^.ccInfo.cdDisplayName- else Nothing- groupChanMatches e chan =- if patTy == Just PrefixNonDMOnly- then Nothing- else if chan^.ccInfo.cdType == MM.Group- then chanNameMatches e $ chan^.ccInfo.cdDisplayName- else Nothing- displayName uInfo = displayNameForUser uInfo cconfig prefs- userMatches e uInfo =- if patTy == Just PrefixNonDMOnly- then Nothing- else (chanNameMatches e . displayName) uInfo- matches e@(CLChannel cId) = findChannelById cId (st^.csChannels) >>= chanMatches e- matches e@(CLUserDM _ uId) = userById uId st >>= userMatches e- matches e@(CLGroupDM cId) = findChannelById cId (st^.csChannels) >>= groupChanMatches e-- preserveFocus Nothing _ = False- preserveFocus (Just m) m2 = matchEntry m == matchEntry m2-- csChannelSelectState.channelSelectMatches %= (Z.updateListBy preserveFocus $ Z.toList $ Z.maybeMapZipper matches (st^.csFocus))--applySelectPattern :: ChannelSelectPattern -> ChannelListEntry -> Text -> Maybe ChannelSelectMatch-applySelectPattern CSPAny entry chanName = do- return $ ChannelSelectMatch "" "" chanName chanName entry-applySelectPattern (CSP ty pat) entry chanName = do- let applyType Infix | pat `T.isInfixOf` normalizedChanName =- case T.breakOn pat normalizedChanName of- (pre, _) ->- return ( T.take (T.length pre) chanName- , T.take (T.length pat) $ T.drop (T.length pre) chanName- , T.drop (T.length pat + T.length pre) chanName- )-- applyType Prefix | pat `T.isPrefixOf` normalizedChanName = do- let (b, a) = T.splitAt (T.length pat) chanName- return ("", b, a)-- applyType PrefixDMOnly | pat `T.isPrefixOf` normalizedChanName = do- let (b, a) = T.splitAt (T.length pat) chanName- return ("", b, a)-- applyType PrefixNonDMOnly | pat `T.isPrefixOf` normalizedChanName = do- let (b, a) = T.splitAt (T.length pat) chanName- return ("", b, a)-- applyType Suffix | pat `T.isSuffixOf` normalizedChanName = do- let (b, a) = T.splitAt (T.length chanName - T.length pat) chanName- return (b, a, "")-- applyType Equal | pat == normalizedChanName =- return ("", chanName, "")-- applyType _ = Nothing-- caseSensitive = T.any isUpper pat- normalizedChanName = if caseSensitive- then chanName- else T.toLower chanName-- (pre, m, post) <- applyType ty- return $ ChannelSelectMatch pre m post chanName entry--parseChannelSelectPattern :: Text -> Maybe ChannelSelectPattern-parseChannelSelectPattern "" = return CSPAny-parseChannelSelectPattern pat = do- let only = if | userSigil `T.isPrefixOf` pat -> Just $ CSP PrefixDMOnly $ T.tail pat- | normalChannelSigil `T.isPrefixOf` pat -> Just $ CSP PrefixNonDMOnly $ T.tail pat- | otherwise -> Nothing-- (pat1, pfx) <- case "^" `T.isPrefixOf` pat of- True -> return (T.tail pat, Just Prefix)- False -> return (pat, Nothing)-- (pat2, sfx) <- case "$" `T.isSuffixOf` pat1 of- True -> return (T.init pat1, Just Suffix)- False -> return (pat1, Nothing)-- only <|> case (pfx, sfx) of- (Nothing, Nothing) -> return $ CSP Infix pat2- (Just Prefix, Nothing) -> return $ CSP Prefix pat2- (Nothing, Just Suffix) -> return $ CSP Suffix pat2- (Just Prefix, Just Suffix) -> return $ CSP Equal pat2- tys -> error $ "BUG: invalid channel select case: " <> show tys---- Update the channel selection mode match cursor. The argument function--- determines how to navigate to the next item.-updateSelectedMatch :: (Z.Zipper ChannelListGroup ChannelSelectMatch -> Z.Zipper ChannelListGroup ChannelSelectMatch)- -> MH ()-updateSelectedMatch nextItem =- csChannelSelectState.channelSelectMatches %= nextItem
− src/State/Channels.hs
@@ -1,1082 +0,0 @@-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE MultiWayIf #-}-module State.Channels- ( updateSidebar- , updateViewed- , updateViewedChan- , refreshChannel- , refreshChannelsAndUsers- , setFocus- , refreshChannelById- , applyPreferenceChange- , leaveChannel- , leaveCurrentChannel- , getNextUnreadChannel- , getNextUnreadUserOrChannel- , nextUnreadChannel- , nextUnreadUserOrChannel- , createOrFocusDMChannel- , clearChannelUnreadStatus- , prevChannel- , nextChannel- , recentChannel- , setReturnChannel- , resetReturnChannel- , hideDMChannel- , createGroupChannel- , showGroupChannelPref- , channelHistoryForward- , channelHistoryBackward- , handleNewChannel- , createOrdinaryChannel- , handleChannelInvite- , addUserByNameToCurrentChannel- , addUserToCurrentChannel- , removeUserFromCurrentChannel- , removeChannelFromState- , isRecentChannel- , isReturnChannel- , isCurrentChannel- , deleteCurrentChannel- , startLeaveCurrentChannel- , joinChannel- , joinChannelByName- , changeChannelByName- , setChannelTopic- , setChannelPurpose- , beginCurrentChannelDeleteConfirm- , toggleChannelListVisibility- , showChannelInSidebar- , updateChannelNotifyProps- )-where--import Prelude ()-import Prelude.MH--import Brick.Main ( getVtyHandle, viewportScroll, vScrollToBeginning- , invalidateCache, invalidateCacheEntry )-import Brick.Widgets.Edit ( applyEdit, getEditContents, editContentsL )-import Control.Concurrent.Async ( runConcurrently, Concurrently(..) )-import Control.Exception ( SomeException, try )-import Data.Char ( isAlphaNum )-import qualified Data.HashMap.Strict as HM-import qualified Data.Foldable as F-import Data.List ( nub )-import Data.Maybe ( fromJust )-import qualified Data.Set as S-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import Data.Text.Zipper ( textZipper, clearZipper, insertMany, gotoEOL )-import Data.Time.Clock ( getCurrentTime )-import qualified Graphics.Vty as Vty-import Lens.Micro.Platform--import qualified Network.Mattermost.Endpoints as MM-import Network.Mattermost.Lenses-import Network.Mattermost.Types--import Constants ( normalChannelSigil )-import InputHistory-import State.Common-import {-# SOURCE #-} State.Messages ( fetchVisibleIfNeeded )-import State.Users-import State.Flagging-import Types-import Types.Common-import Zipper ( Zipper )-import qualified Zipper as Z---updateSidebar :: MH ()-updateSidebar = do- -- Invalidate the cached sidebar rendering since we are about to- -- change the underlying state- mh $ invalidateCacheEntry ChannelSidebar-- -- Get the currently-focused channel ID so we can compare after the- -- zipper is rebuilt- cconfig <- use csClientConfig- oldCid <- use csCurrentChannelId-- -- Update the zipper- cs <- use csChannels- us <- getUsers- prefs <- use (csResources.crUserPreferences)- now <- liftIO getCurrentTime- config <- use (csResources.crConfiguration)-- let zl = mkChannelZipperList now config cconfig prefs cs us- csFocus %= Z.updateList zl-- -- Schedule the current sidebar for user status updates at the end- -- of this MH action.- newZ <- use csFocus- myId <- gets myUserId- scheduleUserStatusFetches $ myId : userIdsFromZipper newZ-- -- Update the window title based on the unread status of the- -- channels.- let anyUnread = any channelListGroupHasUnread $ fst <$> zl- title = "matterhorn" <> if anyUnread then "(*)" else ""-- vty <- mh getVtyHandle- liftIO $ Vty.setWindowTitle vty title-- -- If the zipper rebuild caused the current channel to change, such- -- as when the previously-focused channel was removed, we need to- -- call fetchVisibleIfNeeded on the newly-focused channel to ensure- -- that it gets loaded.- newCid <- use csCurrentChannelId- when (newCid /= oldCid) $- fetchVisibleIfNeeded--updateViewed :: Bool -> MH ()-updateViewed updatePrev = do- csCurrentChannel.ccInfo.cdMentionCount .= 0- updateViewedChan updatePrev =<< use csCurrentChannelId---- | When a new channel has been selected for viewing, this will--- notify the server of the change, and also update the local channel--- state to set the last-viewed time for the previous channel and--- update the viewed time to now for the newly selected channel.------ The boolean argument indicates whether the view time of the previous--- channel (if any) should be updated, too. We typically want to do that--- only on channel switching; when we just want to update the view time--- of the specified channel, False should be provided.-updateViewedChan :: Bool -> ChannelId -> MH ()-updateViewedChan updatePrev cId = use csConnectionStatus >>= \case- Connected -> do- -- Only do this if we're connected to avoid triggering noisy- -- exceptions.- pId <- if updatePrev- then use csRecentChannel- else return Nothing- doAsyncChannelMM Preempt cId- (\s c -> MM.mmViewChannel UserMe c pId s)- (\c () -> Just $ setLastViewedFor pId c)- Disconnected ->- -- Cannot update server; make no local updates to avoid getting- -- out of sync with the server. Assumes that this is a temporary- -- break in connectivity and that after the connection is- -- restored, the user's normal activities will update state as- -- appropriate. If connectivity is permanently lost, managing- -- this state is irrelevant.- return ()--toggleChannelListVisibility :: MH ()-toggleChannelListVisibility = do- mh invalidateCache- csShowChannelList %= not---- | If the current channel is a DM channel with a single user or a--- group of users, hide it from the sidebar and adjust the server-side--- preference to hide it persistently.------ If the current channel is any other kind of channel, complain with a--- usage error.-hideDMChannel :: ChannelId -> MH ()-hideDMChannel cId = do- me <- gets myUser- session <- getSession- withChannel cId $ \chan -> do- case chan^.ccInfo.cdType of- Direct -> do- let pref = showDirectChannelPref (me^.userIdL) uId False- Just uId = chan^.ccInfo.cdDMUserId- csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing- doAsyncWith Preempt $ do- MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session- return Nothing- Group -> do- let pref = hideGroupChannelPref cId (me^.userIdL)- csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing- doAsyncWith Preempt $ do- MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session- return Nothing- _ -> do- mhError $ GenericError "Cannot hide this channel. Consider using /leave instead."---- | Called on async completion when the currently viewed channel has--- been updated (i.e., just switched to this channel) to update local--- state.-setLastViewedFor :: Maybe ChannelId -> ChannelId -> MH ()-setLastViewedFor prevId cId = do- chan <- use (csChannels.to (findChannelById cId))- -- Update new channel's viewed time, creating the channel if needed- case chan of- Nothing ->- -- It's possible for us to get spurious WMChannelViewed- -- events from the server, e.g. for channels that have been- -- deleted. So here we ignore the request since it's hard to- -- detect it before this point.- return ()- Just _ ->- -- The server has been sent a viewed POST update, but there is- -- no local information on what timestamp the server actually- -- recorded. There are a couple of options for setting the- -- local value of the viewed time:- --- -- 1. Attempting to locally construct a value, which would- -- involve scanning all (User) messages in the channel- -- to find the maximum of the created date, the modified- -- date, or the deleted date, and assuming that maximum- -- mostly matched the server's viewed time.- --- -- 2. Issuing a channel metadata request to get the server's- -- new concept of the viewed time.- --- -- 3. Having the "chan/viewed" POST that was just issued- -- return a value from the server. See- -- https://github.com/mattermost/platform/issues/6803.- --- -- Method 3 would be the best and most lightweight. Until that- -- is available, Method 2 will be used. The downside to Method- -- 2 is additional client-server messaging, and a delay in- -- updating the client data, but it's also immune to any new- -- or removed Message date fields, or anything else that would- -- contribute to the viewed/updated times on the server.- doAsyncChannelMM Preempt cId (\ s _ ->- (,) <$> MM.mmGetChannel cId s- <*> MM.mmGetChannelMember cId UserMe s)- (\pcid (cwd, member) -> Just $ csChannel(pcid).ccInfo %= channelInfoFromChannelWithData cwd member)-- -- Update the old channel's previous viewed time (allows tracking of- -- new messages)- case prevId of- Nothing -> return ()- Just p -> clearChannelUnreadStatus p---- | Refresh information about all channels and users. This is usually--- triggered when a reconnect event for the WebSocket to the server--- occurs.-refreshChannelsAndUsers :: MH ()-refreshChannelsAndUsers = do- session <- getSession- myTId <- gets myTeamId- me <- gets myUser- knownUsers <- gets allUserIds- doAsyncWith Preempt $ do- (chans, datas) <- runConcurrently $ (,)- <$> Concurrently (MM.mmGetChannelsForUser UserMe myTId session)- <*> Concurrently (MM.mmGetChannelMembersForUser UserMe myTId session)-- -- Collect all user IDs associated with DM channels so we can- -- bulk-fetch their user records.- let dmUsers = catMaybes $ flip map (F.toList chans) $ \chan ->- case chan^.channelTypeL of- Direct -> case userIdForDMChannel (userId me) (sanitizeUserText $ channelName chan) of- Nothing -> Nothing- Just otherUserId -> Just otherUserId- _ -> Nothing- uIdsToFetch = nub $ userId me : knownUsers <> dmUsers-- dataMap = HM.fromList $ toList $ (\d -> (channelMemberChannelId d, d)) <$> datas- mkPair chan = (chan, fromJust $ HM.lookup (channelId chan) dataMap)- chansWithData = mkPair <$> chans-- return $ Just $- -- Fetch user data associated with DM channels- handleNewUsers (Seq.fromList uIdsToFetch) $ do- -- Then refresh all loaded channels- forM_ chansWithData $ uncurry (refreshChannel SidebarUpdateDeferred)- updateSidebar---- | Refresh information about a specific channel. The channel--- metadata is refreshed, and if this is a loaded channel, the--- scrollback is updated as well.------ The sidebar update argument indicates whether this refresh should--- also update the sidebar. Ordinarily you want this, so pass--- SidebarUpdateImmediate unless you are very sure you know what you are--- doing, i.e., you are very sure that a call to refreshChannel will--- be followed immediately by a call to updateSidebar. We provide this--- control so that channel refreshes can be batched and then a single--- updateSidebar call can be used instead of the default behavior of--- calling it once per refreshChannel call, which is the behavior if the--- immediate setting is passed here.-refreshChannel :: SidebarUpdate -> Channel -> ChannelMember -> MH ()-refreshChannel upd chan member = do- let cId = getId chan- myTId <- gets myTeamId- let ourTeam = channelTeamId chan == Nothing ||- Just myTId == channelTeamId chan-- case not ourTeam of- True -> return ()- False -> do- -- If this channel is unknown, register it first.- mChan <- preuse (csChannel(cId))- when (isNothing mChan) $- handleNewChannel False upd chan member-- updateChannelInfo cId chan member--handleNewChannel :: Bool -> SidebarUpdate -> Channel -> ChannelMember -> MH ()-handleNewChannel = handleNewChannel_ True--handleNewChannel_ :: Bool- -- ^ Whether to permit this call to recursively- -- schedule itself for later if it can't locate- -- a DM channel user record. This is to prevent- -- uncontrolled recursion.- -> Bool- -- ^ Whether to switch to the new channel once it has- -- been installed.- -> SidebarUpdate- -- ^ Whether to update the sidebar, in case the caller- -- wants to batch these before updating it. Pass- -- SidebarUpdateImmediate unless you know what- -- you are doing, i.e., unless you intend to call- -- updateSidebar yourself after calling this.- -> Channel- -- ^ The channel to install.- -> ChannelMember- -> MH ()-handleNewChannel_ permitPostpone switch sbUpdate nc member = do- -- Only add the channel to the state if it isn't already known.- me <- gets myUser- mChan <- preuse (csChannel(getId nc))- case mChan of- Just _ -> when switch $ setFocus (getId nc)- Nothing -> do- -- Create a new ClientChannel structure- cChannel <- (ccInfo %~ channelInfoFromChannelWithData nc member) <$>- makeClientChannel (me^.userIdL) nc-- st <- use id-- -- Add it to the message map, and to the name map so we- -- can look it up by name. The name we use for the channel- -- depends on its type:- let chType = nc^.channelTypeL-- -- Get the channel name. If we couldn't, that means we have- -- async work to do before we can register this channel (in- -- which case abort because we got rescheduled).- register <- case chType of- Direct -> case userIdForDMChannel (myUserId st) (sanitizeUserText $ channelName nc) of- Nothing -> return True- Just otherUserId ->- case userById otherUserId st of- -- If we found a user ID in the channel- -- name string but don't have that user's- -- metadata, postpone adding this channel- -- until we have fetched the metadata. This- -- can happen when we have a channel record- -- for a user that is no longer in the- -- current team. To avoid recursion due to a- -- problem, ensure that the rescheduled new- -- channel handler is not permitted to try- -- this again.- --- -- If we're already in a recursive attempt- -- to register this channel and still- -- couldn't find a username, just bail and- -- use the synthetic name (this has the same- -- problems as above).- Nothing -> do- case permitPostpone of- False -> return True- True -> do- mhLog LogAPI $ T.pack $ "handleNewChannel_: about to call handleNewUsers for " <> show otherUserId- handleNewUsers (Seq.singleton otherUserId) (return ())- doAsyncWith Normal $- return $ Just $ handleNewChannel_ False switch sbUpdate nc member- return False- Just _ -> return True- _ -> return True-- when register $ do- csChannels %= addChannel (getId nc) cChannel- when (sbUpdate == SidebarUpdateImmediate) $ do- -- Note that we only check for whether we should- -- switch to this channel when doing a sidebar- -- update, since that's the only case where it's- -- possible to do so.- updateSidebar-- -- Finally, set our focus to the newly created- -- channel if the caller requested a change of- -- channel. Also consider the last join request- -- state field in case this is an asynchronous- -- channel addition triggered by a /join.- pending1 <- checkPendingChannelChange (ChangeByChannelId $ getId nc)- pending2 <- case cChannel^.ccInfo.cdDMUserId of- Nothing -> return False- Just uId -> checkPendingChannelChange (ChangeByUserId uId)-- when (switch || pending1 || pending2) $ setFocus (getId nc)---- | Check to see whether the specified channel has been queued up to--- be switched to. Note that this condition is only cleared by the--- actual setFocus switch to the channel because there may be multiple--- operations that must complete before the channel is fully ready for--- display/use.-checkPendingChannelChange :: PendingChannelChange -> MH Bool-checkPendingChannelChange change = (==) (Just change) <$> use csPendingChannelChange---- | Update the indicated Channel entry with the new data retrieved from--- the Mattermost server. Also update the channel name if it changed.-updateChannelInfo :: ChannelId -> Channel -> ChannelMember -> MH ()-updateChannelInfo cid new member = do- mh $ invalidateCacheEntry $ ChannelMessages cid- csChannel(cid).ccInfo %= channelInfoFromChannelWithData new member- updateSidebar--setFocus :: ChannelId -> MH ()-setFocus cId = do- showChannelInSidebar cId True- setFocusWith True (Z.findRight ((== cId) . channelListEntryChannelId)) (return ())--showChannelInSidebar :: ChannelId -> Bool -> MH ()-showChannelInSidebar cId setPending = do- mChan <- preuse $ csChannel cId- me <- gets myUser- prefs <- use (csResources.crUserPreferences)- session <- getSession-- case mChan of- Nothing ->- -- The requested channel doesn't actually exist yet, so no- -- action can be taken. It's likely that this is a- -- pendingChannel situation and not all of the operations to- -- locally define the channel have completed, in which case- -- this code will be re-entered later and the mChan will be- -- known.- return ()- Just ch -> do-- -- Able to successfully switch to a known channel. This- -- should clear any pending channel intention. If the- -- intention was for this channel, then: done. If the- -- intention was for a different channel, reaching this- -- point means that the pending is still outstanding but- -- that the user identified a new channel which *was*- -- displayable, and the UI should always prefer to SATISFY- -- the user's latest request over any pending/background- -- task.- csPendingChannelChange .= Nothing-- now <- liftIO getCurrentTime- csChannel(cId).ccInfo.cdSidebarShowOverride .= Just now- updateSidebar-- case ch^.ccInfo.cdType of- Direct -> do- let Just uId = ch^.ccInfo.cdDMUserId- case dmChannelShowPreference prefs uId of- Just False -> do- let pref = showDirectChannelPref (me^.userIdL) uId True- when setPending $- csPendingChannelChange .= Just (ChangeByChannelId $ ch^.ccInfo.cdChannelId)- doAsyncWith Preempt $ do- MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session- return Nothing- _ -> return ()-- Group ->- case groupChannelShowPreference prefs cId of- Just False -> do- let pref = showGroupChannelPref cId (me^.userIdL)- when setPending $- csPendingChannelChange .= Just (ChangeByChannelId $ ch^.ccInfo.cdChannelId)- doAsyncWith Preempt $ do- MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session- return Nothing- _ -> return ()-- _ -> return ()--setFocusWith :: Bool- -> (Zipper ChannelListGroup ChannelListEntry- -> Zipper ChannelListGroup ChannelListEntry)- -> MH ()- -> MH ()-setFocusWith updatePrev f onNoChange = do- oldZipper <- use csFocus- let newZipper = f oldZipper- newFocus = Z.focus newZipper- oldFocus = Z.focus oldZipper-- -- If we aren't changing anything, skip all the book-keeping because- -- we'll end up clobbering things like csRecentChannel.- if newFocus /= oldFocus- then do- mh $ invalidateCacheEntry ChannelSidebar- resetAutocomplete- preChangeChannelCommon- csFocus .= newZipper-- now <- liftIO getCurrentTime- newCid <- use csCurrentChannelId- csChannel(newCid).ccInfo.cdSidebarShowOverride .= Just now-- updateViewed updatePrev- postChangeChannelCommon- else onNoChange--postChangeChannelCommon :: MH ()-postChangeChannelCommon = do- resetEditorState- updateChannelListScroll- loadLastEdit- fetchVisibleIfNeeded--loadLastEdit :: MH ()-loadLastEdit = do- cId <- use csCurrentChannelId-- oldEphemeral <- preuse (csChannel(cId).ccEditState)- case oldEphemeral of- Nothing -> return ()- Just e -> csEditState.cedEphemeral .= e-- loadLastChannelInput--loadLastChannelInput :: MH ()-loadLastChannelInput = do- cId <- use csCurrentChannelId- inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)- case inputHistoryPos of- Just i -> loadHistoryEntryToEditor cId i- Nothing -> do- (lastEdit, lastEditMode) <- use (csEditState.cedEphemeral.eesLastInput)- csEditState.cedEditor %= (applyEdit $ insertMany lastEdit . clearZipper)- csEditState.cedEditMode .= lastEditMode--updateChannelListScroll :: MH ()-updateChannelListScroll = do- mh $ vScrollToBeginning (viewportScroll ChannelList)--preChangeChannelCommon :: MH ()-preChangeChannelCommon = do- cId <- use csCurrentChannelId- csRecentChannel .= Just cId- saveCurrentEdit--resetEditorState :: MH ()-resetEditorState = do- csEditState.cedEditMode .= NewPost- clearEditor--clearEditor :: MH ()-clearEditor = csEditState.cedEditor %= applyEdit clearZipper--saveCurrentEdit :: MH ()-saveCurrentEdit = do- saveCurrentChannelInput-- oldEphemeral <- use (csEditState.cedEphemeral)- cId <- use csCurrentChannelId- csChannel(cId).ccEditState .= oldEphemeral--saveCurrentChannelInput :: MH ()-saveCurrentChannelInput = do- cmdLine <- use (csEditState.cedEditor)- mode <- use (csEditState.cedEditMode)-- -- Only save the editor contents if the user is not navigating the- -- history.- inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)-- when (isNothing inputHistoryPos) $- csEditState.cedEphemeral.eesLastInput .=- (T.intercalate "\n" $ getEditContents $ cmdLine, mode)--hideGroupChannelPref :: ChannelId -> UserId -> Preference-hideGroupChannelPref cId uId =- Preference { preferenceCategory = PreferenceCategoryGroupChannelShow- , preferenceValue = PreferenceValue "false"- , preferenceName = PreferenceName $ idString cId- , preferenceUserId = uId- }--showGroupChannelPref :: ChannelId -> UserId -> Preference-showGroupChannelPref cId uId =- Preference { preferenceCategory = PreferenceCategoryGroupChannelShow- , preferenceValue = PreferenceValue "true"- , preferenceName = PreferenceName $ idString cId- , preferenceUserId = uId- }--showDirectChannelPref :: UserId -> UserId -> Bool -> Preference-showDirectChannelPref myId otherId s =- Preference { preferenceCategory = PreferenceCategoryDirectChannelShow- , preferenceValue = if s then PreferenceValue "true"- else PreferenceValue "false"- , preferenceName = PreferenceName $ idString otherId- , preferenceUserId = myId- }--applyPreferenceChange :: Preference -> MH ()-applyPreferenceChange pref = do- -- always update our user preferences accordingly- csResources.crUserPreferences %= setUserPreferences (Seq.singleton pref)-- -- Invalidate the entire rendering cache since many things depend on- -- user preferences- mh invalidateCache-- if- | Just f <- preferenceToFlaggedPost pref -> do- updateMessageFlag (flaggedPostId f) (flaggedPostStatus f)-- | Just d <- preferenceToDirectChannelShowStatus pref -> do- updateSidebar-- cs <- use csChannels-- -- We need to check on whether this preference was to show a- -- channel and, if so, whether it was the one we attempted to- -- switch to (thus triggering the preference change). If so,- -- we need to switch to it now.- let Just cId = getDmChannelFor (directChannelShowUserId d) cs- case directChannelShowValue d of- True -> do- pending <- checkPendingChannelChange $ ChangeByChannelId cId- when pending $ setFocus cId- False -> do- csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing-- | Just g <- preferenceToGroupChannelPreference pref -> do- updateSidebar-- -- We need to check on whether this preference was to show a- -- channel and, if so, whether it was the one we attempted to- -- switch to (thus triggering the preference change). If so,- -- we need to switch to it now.- let cId = groupChannelId g- case groupChannelShow g of- True -> do- pending <- checkPendingChannelChange $ ChangeByChannelId cId- when pending $ setFocus cId- False -> do- csChannel(cId).ccInfo.cdSidebarShowOverride .= Nothing-- | otherwise -> return ()--refreshChannelById :: ChannelId -> MH ()-refreshChannelById cId = do- session <- getSession- doAsyncWith Preempt $ do- cwd <- MM.mmGetChannel cId session- member <- MM.mmGetChannelMember cId UserMe session- return $ Just $ do- refreshChannel SidebarUpdateImmediate cwd member--removeChannelFromState :: ChannelId -> MH ()-removeChannelFromState cId = do- withChannel cId $ \ chan -> do- when (chan^.ccInfo.cdType /= Direct) $ do- origFocus <- use csCurrentChannelId- when (origFocus == cId) nextChannelSkipPrevView- -- Update input history- csEditState.cedInputHistory %= removeChannelHistory cId- -- Update msgMap- csChannels %= removeChannel cId- -- Remove from focus zipper- csFocus %= Z.filterZipper ((/= cId) . channelListEntryChannelId)- updateSidebar--nextChannel :: MH ()-nextChannel = do- resetReturnChannel- setFocusWith True Z.right (return ())---- | This is almost never what you want; we use this when we delete a--- channel and we don't want to update the deleted channel's view time.-nextChannelSkipPrevView :: MH ()-nextChannelSkipPrevView = setFocusWith False Z.right (return ())--prevChannel :: MH ()-prevChannel = do- resetReturnChannel- setFocusWith True Z.left (return ())--recentChannel :: MH ()-recentChannel = do- recent <- use csRecentChannel- case recent of- Nothing -> return ()- Just cId -> do- ret <- use csReturnChannel- when (ret == Just cId) resetReturnChannel- setFocus cId--resetReturnChannel :: MH ()-resetReturnChannel = do- val <- use csReturnChannel- case val of- Nothing -> return ()- Just _ -> do- mh $ invalidateCacheEntry ChannelSidebar- csReturnChannel .= Nothing--gotoReturnChannel :: MH ()-gotoReturnChannel = do- ret <- use csReturnChannel- case ret of- Nothing -> return ()- Just cId -> do- resetReturnChannel- setFocus cId--setReturnChannel :: MH ()-setReturnChannel = do- ret <- use csReturnChannel- case ret of- Nothing -> do- cId <- use csCurrentChannelId- csReturnChannel .= Just cId- mh $ invalidateCacheEntry ChannelSidebar- Just _ -> return ()--nextUnreadChannel :: MH ()-nextUnreadChannel = do- st <- use id- setReturnChannel- setFocusWith True (getNextUnreadChannel st) gotoReturnChannel--nextUnreadUserOrChannel :: MH ()-nextUnreadUserOrChannel = do- st <- use id- setReturnChannel- setFocusWith True (getNextUnreadUserOrChannel st) gotoReturnChannel--leaveChannel :: ChannelId -> MH ()-leaveChannel cId = leaveChannelIfPossible cId False--leaveChannelIfPossible :: ChannelId -> Bool -> MH ()-leaveChannelIfPossible cId delete = do- st <- use id- me <- gets myUser- let isMe u = u^.userIdL == me^.userIdL-- case st ^? csChannel(cId).ccInfo of- Nothing -> return ()- Just cInfo -> case canLeaveChannel cInfo of- False -> return ()- True ->- -- The server will reject an attempt to leave a private- -- channel if we're the only member. To check this, we- -- just ask for the first two members of the channel.- -- If there is only one, it must be us: hence the "all- -- isMe" check below. If there are two members, it- -- doesn't matter who they are, because we just know- -- that we aren't the only remaining member, so we can't- -- delete the channel.- doAsyncChannelMM Preempt cId- (\s _ ->- let query = MM.defaultUserQuery- { MM.userQueryPage = Just 0- , MM.userQueryPerPage = Just 2- , MM.userQueryInChannel = Just cId- }- in toList <$> MM.mmGetUsers query s)- (\_ members -> Just $ do- -- If the channel is private:- -- * leave it if we aren't the last member.- -- * delete it if we are.- --- -- Otherwise:- -- * leave (or delete) the channel as specified- -- by the delete argument.- let func = case cInfo^.cdType of- Private -> case all isMe members of- True -> (\ s c -> MM.mmDeleteChannel c s)- False -> (\ s c -> MM.mmRemoveUserFromChannel c UserMe s)- Group ->- \s _ ->- let pref = hideGroupChannelPref cId (me^.userIdL)- in MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) s- _ -> if delete- then (\ s c -> MM.mmDeleteChannel c s)- else (\ s c -> MM.mmRemoveUserFromChannel c UserMe s)-- doAsyncChannelMM Preempt cId func endAsyncNOP- )--getNextUnreadChannel :: ChatState- -> (Zipper a ChannelListEntry -> Zipper a ChannelListEntry)-getNextUnreadChannel st =- -- The next channel with unread messages must also be a channel- -- other than the current one, since the zipper may be on a channel- -- that has unread messages and will stay that way until we leave- -- it- so we need to skip that channel when doing the zipper search- -- for the next candidate channel.- Z.findRight (\e ->- let cId = channelListEntryChannelId e- in hasUnread st cId && (cId /= st^.csCurrentChannelId))--getNextUnreadUserOrChannel :: ChatState- -> Zipper a ChannelListEntry- -> Zipper a ChannelListEntry-getNextUnreadUserOrChannel st z =- -- Find the next unread channel, prefering direct messages- let cur = st^.csCurrentChannelId- matches e = entryIsDMEntry e && isFresh (channelListEntryChannelId e)- isFresh c = hasUnread st c && (c /= cur)- in fromMaybe (Z.findRight (isFresh . channelListEntryChannelId) z)- (Z.maybeFindRight matches z)--leaveCurrentChannel :: MH ()-leaveCurrentChannel = use csCurrentChannelId >>= leaveChannel--createGroupChannel :: Text -> MH ()-createGroupChannel usernameList = do- me <- gets myUser- session <- getSession- cs <- use csChannels-- doAsyncWith Preempt $ do- let usernames = Seq.fromList $ fmap trimUserSigil $ T.words usernameList- results <- MM.mmGetUsersByUsernames usernames session-- -- If we found all of the users mentioned, then create the group- -- channel.- case length results == length usernames of- True -> do- chan <- MM.mmCreateGroupMessageChannel (userId <$> results) session- return $ Just $ do- case findChannelById (channelId chan) cs of- Just _ ->- -- If we already know about the channel ID,- -- that means the channel already exists so- -- we can just switch to it.- setFocus (channelId chan)- Nothing -> do- csPendingChannelChange .= (Just $ ChangeByChannelId $ channelId chan)- let pref = showGroupChannelPref (channelId chan) (me^.userIdL)- doAsyncWith Normal $ do- MM.mmSaveUsersPreferences UserMe (Seq.singleton pref) session- return $ Just $ applyPreferenceChange pref- False -> do- let foundUsernames = userUsername <$> results- missingUsernames = S.toList $- S.difference (S.fromList $ F.toList usernames)- (S.fromList $ F.toList foundUsernames)- return $ Just $ do- forM_ missingUsernames (mhError . NoSuchUser)--channelHistoryForward :: MH ()-channelHistoryForward = do- cId <- use csCurrentChannelId- inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)- case inputHistoryPos of- Just i- | i == 0 -> do- -- Transition out of history navigation- csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing- loadLastChannelInput- | otherwise -> do- let newI = i - 1- loadHistoryEntryToEditor cId newI- csEditState.cedEphemeral.eesInputHistoryPosition .= (Just newI)- _ -> return ()--loadHistoryEntryToEditor :: ChannelId -> Int -> MH ()-loadHistoryEntryToEditor cId idx = do- inputHistory <- use (csEditState.cedInputHistory)- case getHistoryEntry cId idx inputHistory of- Nothing -> return ()- Just entry -> do- let eLines = T.lines entry- mv = if length eLines == 1 then gotoEOL else id- csEditState.cedEditor.editContentsL .= (mv $ textZipper eLines Nothing)--channelHistoryBackward :: MH ()-channelHistoryBackward = do- cId <- use csCurrentChannelId- inputHistoryPos <- use (csEditState.cedEphemeral.eesInputHistoryPosition)- saveCurrentChannelInput-- let newI = maybe 0 (+ 1) inputHistoryPos- loadHistoryEntryToEditor cId newI- csEditState.cedEphemeral.eesInputHistoryPosition .= (Just newI)--createOrdinaryChannel :: Bool -> Text -> MH ()-createOrdinaryChannel public name = do- session <- getSession- myTId <- gets myTeamId- doAsyncWith Preempt $ do- -- create a new chat channel- let slug = T.map (\ c -> if isAlphaNum c then c else '-') (T.toLower name)- minChannel = MinChannel- { minChannelName = slug- , minChannelDisplayName = name- , minChannelPurpose = Nothing- , minChannelHeader = Nothing- , minChannelType = if public then Ordinary else Private- , minChannelTeamId = myTId- }- tryMM (do c <- MM.mmCreateChannel minChannel session- chan <- MM.mmGetChannel (getId c) session- member <- MM.mmGetChannelMember (getId c) UserMe session- return (chan, member)- )- (return . Just . uncurry (handleNewChannel True SidebarUpdateImmediate))---- | When we are added to a channel not locally known about, we need--- to fetch the channel info for that channel.-handleChannelInvite :: ChannelId -> MH ()-handleChannelInvite cId = do- session <- getSession- doAsyncWith Normal $ do- member <- MM.mmGetChannelMember cId UserMe session- tryMM (MM.mmGetChannel cId session)- (\cwd -> return $ Just $ do- pending <- checkPendingChannelChange $ ChangeByChannelId cId- handleNewChannel pending SidebarUpdateImmediate cwd member)--addUserByNameToCurrentChannel :: Text -> MH ()-addUserByNameToCurrentChannel uname =- withFetchedUser (UserFetchByUsername uname) addUserToCurrentChannel--addUserToCurrentChannel :: UserInfo -> MH ()-addUserToCurrentChannel u = do- cId <- use csCurrentChannelId- session <- getSession- let channelMember = MinChannelMember (u^.uiId) cId- doAsyncWith Normal $ do- tryMM (void $ MM.mmAddUser cId channelMember session)- (const $ return Nothing)--removeUserFromCurrentChannel :: Text -> MH ()-removeUserFromCurrentChannel uname =- withFetchedUser (UserFetchByUsername uname) $ \u -> do- cId <- use csCurrentChannelId- session <- getSession- doAsyncWith Normal $ do- tryMM (void $ MM.mmRemoveUserFromChannel cId (UserById $ u^.uiId) session)- (const $ return Nothing)--startLeaveCurrentChannel :: MH ()-startLeaveCurrentChannel = do- cInfo <- use (csCurrentChannel.ccInfo)- case cInfo^.cdType of- Direct -> hideDMChannel (cInfo^.cdChannelId)- Group -> hideDMChannel (cInfo^.cdChannelId)- _ -> setMode LeaveChannelConfirm--deleteCurrentChannel :: MH ()-deleteCurrentChannel = do- setMode Main- cId <- use csCurrentChannelId- leaveChannelIfPossible cId True--isCurrentChannel :: ChatState -> ChannelId -> Bool-isCurrentChannel st cId = st^.csCurrentChannelId == cId--isRecentChannel :: ChatState -> ChannelId -> Bool-isRecentChannel st cId = st^.csRecentChannel == Just cId--isReturnChannel :: ChatState -> ChannelId -> Bool-isReturnChannel st cId = st^.csReturnChannel == Just cId--joinChannelByName :: Text -> MH ()-joinChannelByName rawName = do- session <- getSession- tId <- gets myTeamId- doAsyncWith Preempt $ do- result <- try $ MM.mmGetChannelByName tId (trimChannelSigil rawName) session- return $ Just $ case result of- Left (_::SomeException) -> mhError $ NoSuchChannel rawName- Right chan -> joinChannel $ getId chan---- | If the user is not a member of the specified channel, submit a--- request to join it. Otherwise switch to the channel.-joinChannel :: ChannelId -> MH ()-joinChannel chanId = do- setMode Main- mChan <- preuse (csChannel(chanId))- case mChan of- Just _ -> setFocus chanId- Nothing -> do- myId <- gets myUserId- let member = MinChannelMember myId chanId- csPendingChannelChange .= (Just $ ChangeByChannelId chanId)- doAsyncChannelMM Preempt chanId (\ s c -> MM.mmAddUser c member s) endAsyncNOP--createOrFocusDMChannel :: UserInfo -> Maybe (ChannelId -> MH ()) -> MH ()-createOrFocusDMChannel user successAct = do- cs <- use csChannels- case getDmChannelFor (user^.uiId) cs of- Just cId -> do- setFocus cId- case successAct of- Nothing -> return ()- Just act -> act cId- Nothing -> do- -- We have a user of that name but no channel. Time to make one!- myId <- gets myUserId- session <- getSession- csPendingChannelChange .= (Just $ ChangeByUserId $ user^.uiId)- doAsyncWith Normal $ do- -- create a new channel- chan <- MM.mmCreateDirectMessageChannel (user^.uiId, myId) session- return $ successAct <*> pure (channelId chan)---- | This switches to the named channel or creates it if it is a missing--- but valid user channel.-changeChannelByName :: Text -> MH ()-changeChannelByName name = do- mCId <- gets (channelIdByChannelName name)- mDMCId <- gets (channelIdByUsername name)-- withFetchedUserMaybe (UserFetchByUsername name) $ \foundUser -> do- let err = mhError $ AmbiguousName name- case (mCId, mDMCId) of- (Nothing, Nothing) ->- case foundUser of- -- We know about the user but there isn't already a DM- -- channel, so create one.- Just user -> createOrFocusDMChannel user Nothing- -- There were no matches of any kind.- Nothing -> mhError $ NoSuchChannel name- (Just cId, Nothing)- -- We matched a channel and there was an explicit sigil, so we- -- don't care about the username match.- | normalChannelSigil `T.isPrefixOf` name -> setFocus cId- -- We matched both a channel and a user, even though there is- -- no DM channel.- | Just _ <- foundUser -> err- -- We matched a channel only.- | otherwise -> setFocus cId- (Nothing, Just cId) ->- -- We matched a DM channel only.- setFocus cId- (Just _, Just _) ->- -- We matched both a channel and a DM channel.- err--setChannelTopic :: Text -> MH ()-setChannelTopic msg = do- cId <- use csCurrentChannelId- let patch = defaultChannelPatch { channelPatchHeader = Just msg }- doAsyncChannelMM Preempt cId- (\s _ -> MM.mmPatchChannel cId patch s)- (\_ _ -> Nothing)--setChannelPurpose :: Text -> MH ()-setChannelPurpose msg = do- cId <- use csCurrentChannelId- let patch = defaultChannelPatch { channelPatchPurpose = Just msg }- doAsyncChannelMM Preempt cId- (\s _ -> MM.mmPatchChannel cId patch s)- (\_ _ -> Nothing)--beginCurrentChannelDeleteConfirm :: MH ()-beginCurrentChannelDeleteConfirm = do- cId <- use csCurrentChannelId- withChannel cId $ \chan -> do- let chType = chan^.ccInfo.cdType- if chType /= Direct- then setMode DeleteChannelConfirm- else mhError $ GenericError "Direct message channels cannot be deleted."--updateChannelNotifyProps :: ChannelId -> ChannelNotifyProps -> MH ()-updateChannelNotifyProps cId notifyProps = do- mh $ invalidateCacheEntry ChannelSidebar- csChannel(cId).ccInfo.cdNotifyProps .= notifyProps
− src/State/Common.hs
@@ -1,383 +0,0 @@-module State.Common- (- -- * System interface- openURL- , runLoggedCommand-- -- * Posts- , installMessagesFromPosts- , updatePostMap-- -- * Utilities- , postInfoMessage- , postErrorMessageIO- , postErrorMessage'- , addEmoteFormatting- , removeEmoteFormatting-- , fetchMentionedUsers- , doPendingUserFetches- , doPendingUserStatusFetches-- , module State.Async- )-where--import Prelude ()-import Prelude.MH--import Brick.Main ( invalidateCacheEntry )-import Control.Concurrent ( MVar, putMVar, forkIO )-import Control.Concurrent.Async ( concurrently )-import qualified Control.Concurrent.STM as STM-import Control.Exception ( SomeException, try )-import qualified Data.ByteString as BS-import qualified Data.HashMap.Strict as HM-import qualified Data.Sequence as Seq-import qualified Data.Set as Set-import qualified Data.Text as T-import Lens.Micro.Platform ( (.=), (%=), (%~), (.~) )-import System.Directory ( createDirectoryIfMissing )-import System.Environment.XDG.BaseDir ( getUserCacheDir )-import System.Exit ( ExitCode(..) )-import System.FilePath-import System.IO ( hGetContents, hFlush, hPutStrLn )-import System.Process ( proc, std_in, std_out, std_err, StdStream(..)- , createProcess, waitForProcess )--import Network.Mattermost.Endpoints-import Network.Mattermost.Lenses-import Network.Mattermost.Types--import FilePaths ( xdgName )-import State.Async-import Types-import Types.Common-import Types.RichText ( unURL )----- * Client Messages---- | Given a collection of posts from the server, save the posts in the--- global post map. Also convert the posts to Matterhorn's Message type--- and return them along with the set of all usernames mentioned in the--- text of the resulting messages.------ This also sets the mFlagged field of each message based on whether--- its post ID is a flagged post according to crFlaggedPosts at the time--- of this call.-installMessagesFromPosts :: Posts -> MH Messages-installMessagesFromPosts postCollection = do- flags <- use (csResources.crFlaggedPosts)-- -- Add all posts in this collection to the global post cache- updatePostMap postCollection-- -- Build the ordered list of posts. Note that postsOrder lists the- -- posts most recent first, but we want most recent last.- let postsInOrder = findPost <$> (Seq.reverse $ postsOrder postCollection)- mkClientPost p = toClientPost p (postId <$> parent p)- clientPosts = mkClientPost <$> postsInOrder-- addNext cp (msgs, us) =- let (msg, mUsernames) = clientPostToMessage cp- in (addMessage (maybeFlag flags msg) msgs, Set.union us mUsernames)- (ms, mentions) = foldr addNext (noMessages, mempty) clientPosts-- fetchMentionedUsers mentions- return ms- where- maybeFlag flagSet msg- | Just (MessagePostId pId) <- msg^.mMessageId, pId `Set.member` flagSet- = msg & mFlagged .~ True- | otherwise = msg- parent x = do- parentId <- x^.postRootIdL- HM.lookup parentId (postCollection^.postsPostsL)- findPost pId = case HM.lookup pId (postsPosts postCollection) of- Nothing -> error $ "BUG: could not find post for post ID " <> show pId- Just post -> post---- Add all posts in this collection to the global post cache-updatePostMap :: Posts -> MH ()-updatePostMap postCollection = do- -- Build a map from post ID to Matterhorn message, then add the new- -- messages to the global post map. We use the "postsPosts" field for- -- this because that might contain more messages than the "postsOrder"- -- list, since the former can contain other messages in threads that- -- the server sent us, even if those messages are not part of the- -- ordered post listing of "postsOrder."- let postMap = HM.fromList- [ ( pId- , fst $ clientPostToMessage (toClientPost x Nothing)- )- | (pId, x) <- HM.toList (postCollection^.postsPostsL)- ]- csPostMap %= HM.union postMap---- | Add a 'ClientMessage' to the current channel's message list-addClientMessage :: ClientMessage -> MH ()-addClientMessage msg = do- cid <- use csCurrentChannelId- uuid <- generateUUID- let addCMsg = ccContents.cdMessages %~- (addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))- csChannels %= modifyChannelById cid addCMsg- mh $ invalidateCacheEntry $ ChannelMessages cid- mh $ invalidateCacheEntry ChannelSidebar-- let msgTy = case msg^.cmType of- Error -> LogError- _ -> LogGeneral-- mhLog msgTy $ T.pack $ show msg---- | Add a new 'ClientMessage' representing an error message to--- the current channel's message list-postInfoMessage :: Text -> MH ()-postInfoMessage info =- addClientMessage =<< newClientMessage Informative (sanitizeUserText' info)---- | Add a new 'ClientMessage' representing an error message to--- the current channel's message list-postErrorMessage' :: Text -> MH ()-postErrorMessage' err =- addClientMessage =<< newClientMessage Error (sanitizeUserText' err)--postErrorMessageIO :: Text -> ChatState -> IO ChatState-postErrorMessageIO err st = do- msg <- newClientMessage Error err- uuid <- generateUUID_IO- let cId = st ^. csCurrentChannelId- addEMsg = ccContents.cdMessages %~- (addMessage $ clientMessageToMessage msg & mMessageId .~ Just (MessageUUID uuid))- return $ st & csChannels %~ modifyChannelById cId addEMsg--openURL :: OpenInBrowser -> MH Bool-openURL thing = do- cfg <- use (csResources.crConfiguration)- case configURLOpenCommand cfg of- Nothing ->- return False- Just urlOpenCommand -> do- session <- getSession-- -- Is the URL referring to an attachment?- let act = case thing of- OpenLinkChoice link ->- case link^.linkFileId of- Nothing -> prepareLink link- Just fId -> prepareAttachment fId session- OpenLocalFile path ->- return [path]-- -- Is the URL-opening command interactive? If so, pause- -- Matterhorn and run the opener interactively. Otherwise- -- run the opener asynchronously and continue running- -- Matterhorn interactively.- case configURLOpenCommandInteractive cfg of- False -> do- outputChan <- use (csResources.crSubprocessLog)- doAsyncWith Preempt $ do- args <- act- runLoggedCommand False outputChan (T.unpack urlOpenCommand)- args Nothing Nothing- return Nothing- True -> do- -- If there isn't a new message cutoff showing in- -- the current channel, set one. This way, while the- -- user is gone using their interactive URL opener,- -- when they return, any messages that arrive in the- -- current channel will be displayed as new.- curChan <- use csCurrentChannel- let msgs = curChan^.ccContents.cdMessages- case findLatestUserMessage isEditable msgs of- Nothing -> return ()- Just m ->- case m^.mOriginalPost of- Nothing -> return ()- Just p ->- case curChan^.ccInfo.cdNewMessageIndicator of- Hide ->- csCurrentChannel.ccInfo.cdNewMessageIndicator .= (NewPostsAfterServerTime (p^.postCreateAtL))- _ -> return ()- -- No need to add a gap here: the websocket- -- disconnect/reconnect events will automatically- -- handle management of messages delivered while- -- suspended.-- mhSuspendAndResume $ \st -> do- args <- act- result <- runInteractiveCommand (T.unpack urlOpenCommand) args-- let waitForKeypress = do- putStrLn "Press any key to return to Matterhorn."- void getChar-- case result of- Right ExitSuccess -> return ()- Left err -> do- putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>- " could not be run: " <> err- waitForKeypress- Right (ExitFailure code) -> do- putStrLn $ "URL opener subprocess " <> (show urlOpenCommand) <>- " exited with non-zero status " <> show code- waitForKeypress-- return $ setMode' Main st-- return True--runInteractiveCommand :: String- -> [String]- -> IO (Either String ExitCode)-runInteractiveCommand cmd args = do- let opener = (proc cmd args) { std_in = Inherit- , std_out = Inherit- , std_err = Inherit- }- result <- try $ createProcess opener- case result of- Left (e::SomeException) -> return $ Left $ show e- Right (_, _, _, ph) -> do- ec <- waitForProcess ph- return $ Right ec--runLoggedCommand :: Bool- -- ^ Whether stdout output is expected for this program- -> STM.TChan ProgramOutput- -- ^ The output channel to send the output to- -> String- -- ^ The program name- -> [String]- -- ^ Arguments- -> Maybe String- -- ^ The stdin to send, if any- -> Maybe (MVar ProgramOutput)- -- ^ Where to put the program output when it is ready- -> IO ()-runLoggedCommand stdoutOkay outputChan cmd args mInput mOutputVar = void $ forkIO $ do- let stdIn = maybe NoStream (const CreatePipe) mInput- opener = (proc cmd args) { std_in = stdIn- , std_out = CreatePipe- , std_err = CreatePipe- }- result <- try $ createProcess opener- case result of- Left (e::SomeException) -> do- let po = ProgramOutput cmd args "" stdoutOkay (show e) (ExitFailure 1)- STM.atomically $ STM.writeTChan outputChan po- maybe (return ()) (flip putMVar po) mOutputVar- Right (stdinResult, Just outh, Just errh, ph) -> do- case stdinResult of- Just inh -> do- let Just input = mInput- hPutStrLn inh input- hFlush inh- Nothing -> return ()-- ec <- waitForProcess ph- outResult <- hGetContents outh- errResult <- hGetContents errh- let po = ProgramOutput cmd args outResult stdoutOkay errResult ec- STM.atomically $ STM.writeTChan outputChan po- maybe (return ()) (flip putMVar po) mOutputVar- Right _ ->- error $ "BUG: createProcess returned unexpected result, report this at " <>- "https://github.com/matterhorn-chat/matterhorn"--prepareLink :: LinkChoice -> IO [String]-prepareLink link = return [T.unpack $ unURL $ link^.linkURL]--prepareAttachment :: FileId -> Session -> IO [String]-prepareAttachment fId sess = do- -- The link is for an attachment, so fetch it and then- -- open the local copy.-- (info, contents) <- concurrently (mmGetMetadataForFile fId sess) (mmGetFile fId sess)- cacheDir <- getUserCacheDir xdgName-- let dir = cacheDir </> "files" </> T.unpack (idString fId)- fname = dir </> T.unpack (fileInfoName info)-- createDirectoryIfMissing True dir- BS.writeFile fname contents- return [fname]--removeEmoteFormatting :: T.Text -> T.Text-removeEmoteFormatting t- | "*" `T.isPrefixOf` t &&- "*" `T.isSuffixOf` t = T.init $ T.drop 1 t- | otherwise = t--addEmoteFormatting :: T.Text -> T.Text-addEmoteFormatting t = "*" <> t <> "*"--fetchMentionedUsers :: Set.Set MentionedUser -> MH ()-fetchMentionedUsers ms- | Set.null ms = return ()- | otherwise = do- let convertMention (UsernameMention u) = UserFetchByUsername u- convertMention (UserIdMention i) = UserFetchById i- scheduleUserFetches $ convertMention <$> Set.toList ms--doPendingUserStatusFetches :: MH ()-doPendingUserStatusFetches = do- mz <- getScheduledUserStatusFetches- case mz of- Nothing -> return ()- Just z -> do- statusChan <- use (csResources.crStatusUpdateChan)- liftIO $ STM.atomically $ STM.writeTChan statusChan z--doPendingUserFetches :: MH ()-doPendingUserFetches = do- fs <- getScheduledUserFetches-- let getUsername (UserFetchByUsername u) = Just u- getUsername _ = Nothing-- getUserId (UserFetchById i) = Just i- getUserId _ = Nothing-- fetchUsers (catMaybes $ getUsername <$> fs) (catMaybes $ getUserId <$> fs)---- | Given a list of usernames, ensure that we have a user record for--- each one in the state, either by confirming that a local record--- exists or by issuing a request for user records.-fetchUsers :: [Text] -> [UserId] -> MH ()-fetchUsers rawUsernames uids = do- st <- use id- session <- getSession- let usernames = trimUserSigil <$> rawUsernames- missingUsernames = filter isMissing usernames- isMissing n = and [ not $ T.null n- , not $ isSpecialMention n- , isNothing $ userByUsername n st- ]- missingIds = filter (\i -> isNothing $ userById i st) uids-- when (not $ null missingUsernames) $ do- mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingUsernames-- when (not $ null missingIds) $ do- mhLog LogGeneral $ T.pack $ "fetchUsers: getting " <> show missingIds-- when ((not $ null missingUsernames) || (not $ null missingIds)) $ do- doAsyncWith Normal $ do- act1 <- case null missingUsernames of- True -> return $ return ()- False -> do- results <- mmGetUsersByUsernames (Seq.fromList missingUsernames) session- return $ do- forM_ results (\u -> addNewUser $ userInfoFromUser u True)-- act2 <- case null missingIds of- True -> return $ return ()- False -> do- results <- mmGetUsersByIds (Seq.fromList missingIds) session- return $ do- forM_ results (\u -> addNewUser $ userInfoFromUser u True)-- return $ Just $ act1 >> act2
− src/State/Editing.hs
@@ -1,524 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE RankNTypes #-}-module State.Editing- ( requestSpellCheck- , editingKeybindings- , editingKeyHandlers- , messageEditingKeybindings- , toggleMultilineEditing- , invokeExternalEditor- , handlePaste- , handleInputSubmission- , getEditorContent- , handleEditingInput- , cancelAutocompleteOrReplyOrEdit- , replyToLatestMessage- , Direction(..)- , tabComplete- )-where--import Prelude ()-import Prelude.MH--import Brick.Main ( invalidateCache, invalidateCacheEntry )-import Brick.Widgets.Edit ( Editor, applyEdit , handleEditorEvent- , getEditContents, editContentsL )-import qualified Brick.Widgets.List as L-import qualified Codec.Binary.UTF8.Generic as UTF8-import Control.Arrow-import qualified Control.Concurrent.STM as STM-import qualified Data.ByteString as BS-import Data.Char ( isSpace )-import qualified Data.Foldable as F-import qualified Data.Map as M-import qualified Data.Set as S-import qualified Data.Text as T-import qualified Data.Text.Encoding as T-import qualified Data.Text.Zipper as Z-import qualified Data.Text.Zipper.Generic.Words as Z-import Data.Time ( getCurrentTime )-import Graphics.Vty ( Event(..), Key(..) )-import Lens.Micro.Platform ( Lens', (%=), (.=), (.~), to, _Just )-import qualified System.Environment as Sys-import qualified System.Exit as Sys-import qualified System.IO as Sys-import qualified System.IO.Temp as Sys-import qualified System.Process as Sys-import Text.Aspell ( AspellResponse(..), mistakeWord, askAspell )--import Network.Mattermost.Types ( Post(..), ChannelId )--import Config-import {-# SOURCE #-} Command ( dispatchCommand )-import InputHistory-import Events.Keybindings-import State.Common-import State.Autocomplete-import State.Attachments-import State.Messages-import Types hiding ( newState )-import Types.Common ( sanitizeUserText' )---startMultilineEditing :: MH ()-startMultilineEditing = do- mh invalidateCache- csEditState.cedEphemeral.eesMultiline .= True--toggleMultilineEditing :: MH ()-toggleMultilineEditing = do- mh invalidateCache- csEditState.cedEphemeral.eesMultiline %= not-- -- If multiline is now disabled and there is more than one line in- -- the editor, that means we're showing the multiline message status- -- (see Draw.Main.renderUserCommandBox.commandBox) so we want to be- -- sure no autocomplete UI is present in case the cursor was left on- -- a word that would otherwise show completion alternatives.- multiline <- use (csEditState.cedEphemeral.eesMultiline)- numLines <- use (csEditState.cedEditor.to getEditContents.to length)- when (not multiline && numLines > 1) resetAutocomplete--invokeExternalEditor :: MH ()-invokeExternalEditor = do- -- If EDITOR is in the environment, write the current message to a- -- temp file, invoke EDITOR on it, read the result, remove the temp- -- file, and update the program state.- --- -- If EDITOR is not present, fall back to 'vi'.- mEnv <- liftIO $ Sys.lookupEnv "EDITOR"- let editorProgram = maybe "vi" id mEnv-- mhSuspendAndResume $ \ st -> do- Sys.withSystemTempFile "matterhorn_editor.md" $ \tmpFileName tmpFileHandle -> do- -- Write the current message to the temp file- Sys.hPutStr tmpFileHandle $ T.unpack $ T.intercalate "\n" $- getEditContents $ st^.csEditState.cedEditor- Sys.hClose tmpFileHandle-- -- Run the editor- status <- Sys.system (editorProgram <> " " <> tmpFileName)-- -- On editor exit, if exited with zero status, read temp file.- -- If non-zero status, skip temp file read.- case status of- Sys.ExitSuccess -> do- tmpBytes <- BS.readFile tmpFileName- case T.decodeUtf8' tmpBytes of- Left _ -> do- postErrorMessageIO "Failed to decode file contents as UTF-8" st- Right t -> do- let tmpLines = T.lines $ sanitizeUserText' t- return $ st & csEditState.cedEditor.editContentsL .~ (Z.textZipper tmpLines Nothing)- Sys.ExitFailure _ -> return st--handlePaste :: BS.ByteString -> MH ()-handlePaste bytes = do- let pasteStr = T.pack (UTF8.toString bytes)- csEditState.cedEditor %= applyEdit (Z.insertMany (sanitizeUserText' pasteStr))- contents <- use (csEditState.cedEditor.to getEditContents)- case length contents > 1 of- True -> startMultilineEditing- False -> return ()--editingPermitted :: ChatState -> Bool-editingPermitted st =- (length (getEditContents $ st^.csEditState.cedEditor) == 1) ||- st^.csEditState.cedEphemeral.eesMultiline--messageEditingKeybindings :: KeyConfig -> KeyHandlerMap-messageEditingKeybindings kc =- let KeyHandlerMap m = editingKeybindings (csEditState.cedEditor) kc- in KeyHandlerMap $ M.map withUserTypingAction m--withUserTypingAction :: KeyHandler -> KeyHandler-withUserTypingAction kh =- kh { khHandler = newH }- where- oldH = khHandler kh- newH = oldH { kehHandler = newKEH }- oldKEH = kehHandler oldH- newKEH = oldKEH { ehAction = ehAction oldKEH >> sendUserTypingAction }--editingKeybindings :: Lens' ChatState (Editor T.Text Name) -> KeyConfig -> KeyHandlerMap-editingKeybindings editor = mkKeybindings $ editingKeyHandlers editor--editingKeyHandlers :: Lens' ChatState (Editor T.Text Name) -> [KeyEventHandler]-editingKeyHandlers editor =- [ mkKb EditorTransposeCharsEvent- "Transpose the final two characters"- (editor %= applyEdit Z.transposeChars)- , mkKb EditorBolEvent- "Go to the start of the current line"- (editor %= applyEdit Z.gotoBOL)- , mkKb EditorEolEvent- "Go to the end of the current line"- (editor %= applyEdit Z.gotoEOL)- , mkKb EditorDeleteCharacter- "Delete the character at the cursor"- (editor %= applyEdit Z.deleteChar)- , mkKb EditorKillToBolEvent- "Delete from the cursor to the start of the current line"- (editor %= applyEdit Z.killToBOL)- , mkKb EditorKillToEolEvent- "Kill the line to the right of the current position and copy it" $ do- z <- use (editor.editContentsL)- let restOfLine = Z.currentLine (Z.killToBOL z)- csEditState.cedYankBuffer .= restOfLine- editor %= applyEdit Z.killToEOL- , mkKb EditorNextCharEvent- "Move one character to the right"- (editor %= applyEdit Z.moveRight)- , mkKb EditorPrevCharEvent- "Move one character to the left"- (editor %= applyEdit Z.moveLeft)- , mkKb EditorNextWordEvent- "Move one word to the right"- (editor %= applyEdit Z.moveWordRight)- , mkKb EditorPrevWordEvent- "Move one word to the left"- (editor %= applyEdit Z.moveWordLeft)- , mkKb EditorDeletePrevWordEvent- "Delete the word to the left of the cursor" $ do- editor %= applyEdit Z.deletePrevWord- , mkKb EditorDeleteNextWordEvent- "Delete the word to the right of the cursor" $ do- editor %= applyEdit Z.deleteWord- , mkKb EditorHomeEvent- "Move the cursor to the beginning of the input" $ do- editor %= applyEdit gotoHome- , mkKb EditorEndEvent- "Move the cursor to the end of the input" $ do- editor %= applyEdit gotoEnd- , mkKb EditorYankEvent- "Paste the current buffer contents at the cursor" $ do- buf <- use (csEditState.cedYankBuffer)- editor %= applyEdit (Z.insertMany buf)- ]--getEditorContent :: MH Text-getEditorContent = do- cmdLine <- use (csEditState.cedEditor)- let (line:rest) = getEditContents cmdLine- return $ T.intercalate "\n" $ line : rest---- | Handle an input submission in the message editor.------ This handles the specified input text as if it were user input for--- the specified channel. This means that if the specified input text--- contains a command ("/...") then it is executed as normal. Otherwise--- the text is sent as a message to the specified channel.------ However, this function assumes that the message editor is the--- *source* of the text, so it also takes care of clearing the editor,--- resetting the edit mode, updating the input history for the specified--- channel, etc.-handleInputSubmission :: ChannelId -> Text -> MH ()-handleInputSubmission cId content = do- -- We clean up before dispatching the command or sending the message- -- since otherwise the command could change the state and then doing- -- cleanup afterwards could clean up the wrong things.- csEditState.cedEditor %= applyEdit Z.clearZipper- csEditState.cedInputHistory %= addHistoryEntry content cId- csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing-- case T.uncons content of- Just ('/', cmd) ->- dispatchCommand cmd- _ -> do- attachments <- use (csEditState.cedAttachmentList.L.listElementsL)- mode <- use (csEditState.cedEditMode)- sendMessage cId mode content $ F.toList attachments-- -- Reset the autocomplete UI- resetAutocomplete-- -- Empty the attachment list- resetAttachmentList-- -- Reset the edit mode *after* handling the input so that the input- -- handler can tell whether we're editing, replying, etc.- csEditState.cedEditMode .= NewPost--closingPunctuationMarks :: String-closingPunctuationMarks = ".,'\";:)]!?"--isSmartClosingPunctuation :: Event -> Bool-isSmartClosingPunctuation (EvKey (KChar c) []) = c `elem` closingPunctuationMarks-isSmartClosingPunctuation _ = False--handleEditingInput :: Event -> MH ()-handleEditingInput e = do- -- Only handle input events to the editor if we permit editing:- -- if multiline mode is off, or if there is only one line of text- -- in the editor. This means we skip input this catch-all handler- -- if we're *not* in multiline mode *and* there are multiple lines,- -- i.e., we are showing the user the status message about the- -- current editor state and editing is not permitted.-- -- Record the input line count before handling the editing event- -- so we can tell whether the editing event changes the line count- -- later.- beforeLineCount <- use (csEditState.cedEditor.to getEditContents.to length)-- smartBacktick <- use (csResources.crConfiguration.to configSmartBacktick)- let smartChars = "*`_"- st <- use id- csEditState.cedEphemeral.eesInputHistoryPosition .= Nothing-- smartEditing <- use (csResources.crConfiguration.to configSmartEditing)- justCompleted <- use (csEditState.cedJustCompleted)-- conf <- use (csResources.crConfiguration)- let keyMap = editingKeybindings (csEditState.cedEditor) (configUserKeys conf)- case lookupKeybinding e keyMap of- Just kb | editingPermitted st -> (ehAction $ kehHandler $ khHandler kb)- _ -> do- case e of- -- Not editing; backspace here means cancel multi-line message- -- composition- EvKey KBS [] | (not $ editingPermitted st) ->- csEditState.cedEditor %= applyEdit Z.clearZipper-- -- Backspace in editing mode with smart pair insertion means- -- smart pair removal when possible- EvKey KBS [] | editingPermitted st && smartBacktick ->- let backspace = csEditState.cedEditor %= applyEdit Z.deletePrevChar- in case cursorAtOneOf smartChars (st^.csEditState.cedEditor) of- Nothing -> backspace- Just ch ->- -- Smart char removal:- if | (cursorAtChar ch $ applyEdit Z.moveLeft $ st^.csEditState.cedEditor) &&- (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.csEditState.cedEditor) ->- csEditState.cedEditor %= applyEdit (Z.deleteChar >>> Z.deletePrevChar)- | otherwise -> backspace-- EvKey (KChar ch) []- | editingPermitted st && smartBacktick && ch `elem` smartChars ->- -- Smart char insertion:- let doInsertChar = do- csEditState.cedEditor %= applyEdit (Z.insertChar ch)- sendUserTypingAction- curLine = Z.currentLine $ st^.csEditState.cedEditor.editContentsL- -- First case: if the cursor is at the end of the current- -- line and it contains "``" and the user entered a third- -- "`", enable multi-line mode since they're likely typing- -- a code block.- in if | (cursorIsAtEnd $ st^.csEditState.cedEditor) &&- curLine == "``" &&- ch == '`' -> do- csEditState.cedEditor %= applyEdit (Z.insertMany (T.singleton ch))- csEditState.cedEphemeral.eesMultiline .= True- -- Second case: user entered some smart character- -- (don't care which) on an empty line or at the end- -- of the line after whitespace, so enter a pair of- -- the smart chars and put the cursor between them.- | (editorEmpty $ st^.csEditState.cedEditor) ||- ((cursorAtChar ' ' (applyEdit Z.moveLeft $ st^.csEditState.cedEditor)) &&- (cursorIsAtEnd $ st^.csEditState.cedEditor)) ->- csEditState.cedEditor %= applyEdit (Z.insertMany (T.pack $ ch:ch:[]) >>> Z.moveLeft)- -- Third case: the cursor is already on a smart- -- character and that character is the last one- -- on the line, so instead of inserting a new- -- character, just move past it.- | (cursorAtChar ch $ st^.csEditState.cedEditor) &&- (cursorIsAtEnd $ applyEdit Z.moveRight $ st^.csEditState.cedEditor) ->- csEditState.cedEditor %= applyEdit Z.moveRight- -- Fall-through case: just insert one of the chars- -- without doing anything smart.- | otherwise -> doInsertChar- | editingPermitted st -> do-- -- If the most recent editing event was a tab completion,- -- there is a trailing space that we want to remove if the- -- next input character is punctuation.- when (smartEditing && justCompleted && isSmartClosingPunctuation e) $- csEditState.cedEditor %= applyEdit Z.deletePrevChar-- csEditState.cedEditor %= applyEdit (Z.insertMany (sanitizeUserText' $ T.singleton ch))- sendUserTypingAction- _ | editingPermitted st -> do- mhHandleEventLensed (csEditState.cedEditor) handleEditorEvent e- sendUserTypingAction- | otherwise -> return ()-- let ctx = AutocompleteContext { autocompleteManual = False- , autocompleteFirstMatch = False- }- checkForAutocompletion ctx- liftIO $ resetSpellCheckTimer $ st^.csEditState-- -- If the preview is enabled and multi-line editing is enabled and- -- the line count changed, we need to invalidate the rendering cache- -- entry for the channel messages because we want to redraw them to- -- fit in the space just changed by the size of the preview area.- afterLineCount <- use (csEditState.cedEditor.to getEditContents.to length)- isMultiline <- use (csEditState.cedEphemeral.eesMultiline)- isPreviewing <- use csShowMessagePreview- when (beforeLineCount /= afterLineCount && isMultiline && isPreviewing) $ do- cId <- use csCurrentChannelId- mh $ invalidateCacheEntry $ ChannelMessages cId-- -- Reset the recent autocompletion flag to stop smart punctuation- -- handling.- when justCompleted $- csEditState.cedJustCompleted .= False---- | Send the user_typing action to the server asynchronously, over the--- connected websocket. If the websocket is not connected, drop the--- action silently.-sendUserTypingAction :: MH ()-sendUserTypingAction = do- st <- use id- when (configShowTypingIndicator (st^.csResources.crConfiguration)) $- case st^.csConnectionStatus of- Connected -> do- let pId = case st^.csEditState.cedEditMode of- Replying _ post -> Just $ postId post- _ -> Nothing- liftIO $ do- now <- getCurrentTime- let action = UserTyping now (st^.csCurrentChannelId) pId- STM.atomically $ STM.writeTChan (st^.csResources.crWebsocketActionChan) action- Disconnected -> return ()---- Kick off an async request to the spell checker for the current editor--- contents.-requestSpellCheck :: MH ()-requestSpellCheck = do- st <- use id- case st^.csEditState.cedSpellChecker of- Nothing -> return ()- Just (checker, _) -> do- -- Get the editor contents.- contents <- getEditContents <$> use (csEditState.cedEditor)- doAsyncWith Preempt $ do- -- For each line in the editor, submit an aspell request.- let query = concat <$> mapM (askAspell checker) contents- postMistakes :: [AspellResponse] -> MH ()- postMistakes responses = do- let getMistakes AllCorrect = []- getMistakes (Mistakes ms) = mistakeWord <$> ms- allMistakes = S.fromList $ concat $ getMistakes <$> responses- csEditState.cedMisspellings .= allMistakes-- tryMM query (return . Just . postMistakes)--editorEmpty :: Editor Text a -> Bool-editorEmpty e = cursorIsAtEnd e &&- cursorIsAtBeginning e--cursorIsAtEnd :: Editor Text a -> Bool-cursorIsAtEnd e =- let col = snd $ Z.cursorPosition z- curLine = Z.currentLine z- z = e^.editContentsL- in col == T.length curLine--cursorIsAtBeginning :: Editor Text a -> Bool-cursorIsAtBeginning e =- let col = snd $ Z.cursorPosition z- z = e^.editContentsL- in col == 0--cursorAtOneOf :: [Char] -> Editor Text a -> Maybe Char-cursorAtOneOf [] _ = Nothing-cursorAtOneOf (c:cs) e =- if cursorAtChar c e- then Just c- else cursorAtOneOf cs e--cursorAtChar :: Char -> Editor Text a -> Bool-cursorAtChar ch e =- let col = snd $ Z.cursorPosition z- curLine = Z.currentLine z- z = e^.editContentsL- in (T.singleton ch) `T.isPrefixOf` T.drop col curLine--gotoHome :: Z.TextZipper Text -> Z.TextZipper Text-gotoHome = Z.moveCursor (0, 0)--gotoEnd :: Z.TextZipper Text -> Z.TextZipper Text-gotoEnd z =- let zLines = Z.getText z- numLines = length zLines- lastLineLength = T.length $ last zLines- in if numLines > 0- then Z.moveCursor (numLines - 1, lastLineLength) z- else z--cancelAutocompleteOrReplyOrEdit :: MH ()-cancelAutocompleteOrReplyOrEdit = do- cId <- use csCurrentChannelId- mh $ invalidateCacheEntry $ ChannelMessages cId- ac <- use (csEditState.cedAutocomplete)- case ac of- Just _ -> do- resetAutocomplete- Nothing -> do- mode <- use (csEditState.cedEditMode)- case mode of- NewPost -> return ()- _ -> do- csEditState.cedEditMode .= NewPost- csEditState.cedEditor %= applyEdit Z.clearZipper- resetAttachmentList--replyToLatestMessage :: MH ()-replyToLatestMessage = do- msgs <- use (csCurrentChannel . ccContents . cdMessages)- case findLatestUserMessage isReplyable msgs of- Just msg | isReplyable msg ->- do let Just p = msg^.mOriginalPost- setMode Main- cId <- use csCurrentChannelId- mh $ invalidateCacheEntry $ ChannelMessages cId- csEditState.cedEditMode .= Replying msg p- _ -> return ()--data Direction = Forwards | Backwards--tabComplete :: Direction -> MH ()-tabComplete dir = do- let transform list =- let len = list^.L.listElementsL.to length- in case dir of- Forwards ->- if (L.listSelected list == Just (len - 1)) ||- (L.listSelected list == Nothing && len > 0)- then L.listMoveTo 0 list- else L.listMoveBy 1 list- Backwards ->- if (L.listSelected list == Just 0) ||- (L.listSelected list == Nothing && len > 0)- then L.listMoveTo (len - 1) list- else L.listMoveBy (-1) list- csEditState.cedAutocomplete._Just.acCompletionList %= transform-- mac <- use (csEditState.cedAutocomplete)- case mac of- Nothing -> do- let ctx = AutocompleteContext { autocompleteManual = True- , autocompleteFirstMatch = True- }- checkForAutocompletion ctx- Just ac -> do- case ac^.acCompletionList.to L.listSelectedElement of- Nothing -> return ()- Just (_, alternative) -> do- let replacement = autocompleteAlternativeReplacement alternative- maybeEndOfWord z =- if maybe True isSpace (Z.currentChar z)- then z- else Z.moveWordRight z- csEditState.cedEditor %=- applyEdit (Z.insertChar ' ' . Z.insertMany replacement . Z.deletePrevWord .- maybeEndOfWord)- csEditState.cedJustCompleted .= True-- -- If there was only one completion alternative,- -- hide the autocomplete listing now that we've- -- completed the only completion.- when (ac^.acCompletionList.to L.listElements.to length == 1) resetAutocomplete
− src/State/Editing.hs-boot
@@ -1,10 +0,0 @@-module State.Editing- ( Direction(..)- , tabComplete- )-where--import Types ( MH )--data Direction = Forwards | Backwards-tabComplete :: Direction -> MH ()
− src/State/Flagging.hs
@@ -1,66 +0,0 @@-module State.Flagging- ( loadFlaggedMessages- , updateMessageFlag- )-where--import Prelude ()-import Prelude.MH--import Data.Function ( on )-import qualified Data.Set as Set-import Lens.Micro.Platform--import Network.Mattermost.Types--import State.Common-import Types---loadFlaggedMessages :: Seq FlaggedPost -> ChatState -> IO ()-loadFlaggedMessages prefs st = doAsyncWithIO Normal st $ do- return $ Just $ do- sequence_ [ updateMessageFlag (flaggedPostId fp) True- | fp <- toList prefs- , flaggedPostStatus fp- ]----- | Update the UI to reflect the flagged/unflagged state of a--- message. This __does not__ talk to the Mattermost server, but--- rather is the function we call when the Mattermost server notifies--- us of flagged or unflagged messages.-updateMessageFlag :: PostId -> Bool -> MH ()-updateMessageFlag pId f = do- if f- then csResources.crFlaggedPosts %= Set.insert pId- else csResources.crFlaggedPosts %= Set.delete pId- msgMb <- use (csPostMap.at(pId))- case msgMb of- Just msg- | Just cId <- msg^.mChannelId -> do- let isTargetMessage m = m^.mMessageId == Just (MessagePostId pId)- csChannel(cId).ccContents.cdMessages.traversed.filtered isTargetMessage.mFlagged .= f- csPostMap.ix(pId).mFlagged .= f- -- We also want to update the post overlay if this happens while- -- we're we're observing it- mode <- gets appMode- case mode of- PostListOverlay PostListFlagged- | f ->- csPostListOverlay.postListPosts %=- addMessage (msg & mFlagged .~ True)- -- deleting here is tricky, because it means that we need to- -- move the focus somewhere: we'll try moving it _up_ unless- -- we can't, in which case we'll try moving it down.- | otherwise -> do- selId <- use (csPostListOverlay.postListSelected)- posts <- use (csPostListOverlay.postListPosts)- let nextId = case getNextPostId selId posts of- Nothing -> getPrevPostId selId posts- Just x -> Just x- csPostListOverlay.postListSelected .= nextId- csPostListOverlay.postListPosts %=- filterMessages (((/=) `on` _mMessageId) msg)- _ -> return ()- _ -> return ()
− src/State/Help.hs
@@ -1,21 +0,0 @@-module State.Help- ( showHelpScreen- )-where--import Prelude ()-import Prelude.MH--import Brick.Main ( viewportScroll, vScrollToBeginning )--import Types---showHelpScreen :: HelpTopic -> MH ()-showHelpScreen topic = do- curMode <- gets appMode- case curMode of- ShowHelp {} -> return ()- _ -> do- mh $ vScrollToBeginning (viewportScroll HelpViewport)- setMode $ ShowHelp topic curMode
− src/State/ListOverlay.hs
@@ -1,144 +0,0 @@-{-# LANGUAGE RankNTypes #-}-module State.ListOverlay- ( listOverlayActivateCurrent- , listOverlaySearchString- , listOverlayMove- , exitListOverlay- , enterListOverlayMode- , resetListOverlaySearch- , onEventListOverlay- )-where--import Prelude ()-import Prelude.MH--import qualified Brick.Widgets.List as L-import qualified Brick.Widgets.Edit as E-import qualified Data.Text.Zipper as Z-import qualified Data.Vector as Vec-import Lens.Micro.Platform ( Lens', (%=), (.=) )-import Network.Mattermost.Types ( Session )-import qualified Graphics.Vty as Vty--import Types-import State.Common-import State.Editing ( editingKeybindings )-import Events.Keybindings ( KeyConfig, KeyHandlerMap, handleKeyboardEvent )----- | Activate the specified list overlay's selected item by invoking the--- overlay's configured enter keypress handler function.-listOverlayActivateCurrent :: Lens' ChatState (ListOverlayState a b) -> MH ()-listOverlayActivateCurrent which = do- mItem <- L.listSelectedElement <$> use (which.listOverlaySearchResults)- case mItem of- Nothing -> return ()- Just (_, val) -> do- handler <- use (which.listOverlayEnterHandler)- activated <- handler val- if activated- then setMode Main- else return ()---- | Get the current search string for the specified overlay.-listOverlaySearchString :: Lens' ChatState (ListOverlayState a b) -> MH Text-listOverlaySearchString which =- (head . E.getEditContents) <$> use (which.listOverlaySearchInput)---- | Move the list cursor in the specified overlay.-listOverlayMove :: Lens' ChatState (ListOverlayState a b)- -- ^ Which overlay- -> (L.List Name a -> L.List Name a)- -- ^ How to transform the list in the overlay- -> MH ()-listOverlayMove which how = which.listOverlaySearchResults %= how---- | Clear the state of the specified list overlay and return to the--- Main mode.-exitListOverlay :: Lens' ChatState (ListOverlayState a b)- -- ^ Which overlay to reset- -> MH ()-exitListOverlay which = do- newList <- use (which.listOverlayNewList)- which.listOverlaySearchResults .= newList mempty- which.listOverlayEnterHandler .= (const $ return False)- setMode Main---- | Initialize a list overlay with the specified arguments and switch--- to the specified mode.-enterListOverlayMode :: (Lens' ChatState (ListOverlayState a b))- -- ^ Which overlay to initialize- -> Mode- -- ^ The mode to change to- -> b- -- ^ The overlay's initial search scope- -> (a -> MH Bool)- -- ^ The overlay's enter keypress handler- -> (b -> Session -> Text -> IO (Vec.Vector a))- -- ^ The overlay's results fetcher function- -> MH ()-enterListOverlayMode which mode scope enterHandler fetcher = do- which.listOverlaySearchScope .= scope- which.listOverlaySearchInput.E.editContentsL %= Z.clearZipper- which.listOverlayEnterHandler .= enterHandler- which.listOverlayFetchResults .= fetcher- which.listOverlaySearching .= False- newList <- use (which.listOverlayNewList)- which.listOverlaySearchResults .= newList mempty- setMode mode- resetListOverlaySearch which---- | Reset the overlay's search by initiating a new search request for--- the string that is currently in the overlay's editor. This does--- nothing if a search for this overlay is already in progress.-resetListOverlaySearch :: Lens' ChatState (ListOverlayState a b) -> MH ()-resetListOverlaySearch which = do- searchPending <- use (which.listOverlaySearching)-- when (not searchPending) $ do- searchString <- listOverlaySearchString which- which.listOverlaySearching .= True- newList <- use (which.listOverlayNewList)- session <- getSession- scope <- use (which.listOverlaySearchScope)- fetcher <- use (which.listOverlayFetchResults)- doAsyncWith Preempt $ do- results <- fetcher scope session searchString- return $ Just $ do- which.listOverlaySearchResults .= newList results- which.listOverlaySearching .= False-- -- Now that the results are available, check to see if the- -- search string changed since this request was submitted.- -- If so, issue another search.- afterSearchString <- listOverlaySearchString which- when (searchString /= afterSearchString) $ resetListOverlaySearch which---- | Generically handle an event for the list overlay state targeted--- by the specified lens. Automatically dispatches new searches in the--- overlay's editor if the editor contents change.-onEventListOverlay :: Lens' ChatState (ListOverlayState a b)- -- ^ Which overlay to dispatch to?- -> (KeyConfig -> KeyHandlerMap)- -- ^ The keybinding builder- -> Vty.Event- -- ^ The event- -> MH Bool-onEventListOverlay which keybindings =- handleKeyboardEvent keybindings $ \e -> do- -- Get the editor content before the event.- before <- listOverlaySearchString which-- -- First find a matching keybinding in the keybinding list.- handled <- handleKeyboardEvent (editingKeybindings (which.listOverlaySearchInput)) (const $ return ()) e-- -- If we didn't find a matching binding, just handle the event- -- as a normal editor input event.- when (not handled) $- mhHandleEventLensed (which.listOverlaySearchInput) E.handleEditorEvent e-- -- Get the editor content after the event. If the string changed,- -- start a new search.- after <- listOverlaySearchString which- when (before /= after) $ resetListOverlaySearch which
− src/State/Logging.hs
@@ -1,33 +0,0 @@-module State.Logging- ( startLogging- , stopLogging- , logSnapshot- , getLogDestination- )-where--import Prelude ()-import Prelude.MH--import Types---startLogging :: FilePath -> MH ()-startLogging path = do- mgr <- use (csResources.crLogManager)- liftIO $ startLoggingToFile mgr path--stopLogging :: MH ()-stopLogging = do- mgr <- use (csResources.crLogManager)- liftIO $ stopLoggingToFile mgr--logSnapshot :: FilePath -> MH ()-logSnapshot path = do- mgr <- use (csResources.crLogManager)- liftIO $ requestLogSnapshot mgr path--getLogDestination :: MH ()-getLogDestination = do- mgr <- use (csResources.crLogManager)- liftIO $ requestLogDestination mgr
− src/State/MessageSelect.hs
@@ -1,294 +0,0 @@-module State.MessageSelect- (- -- * Message selection mode- beginMessageSelect- , flagSelectedMessage- , pinSelectedMessage- , viewSelectedMessage- , fillSelectedGap- , yankSelectedMessageVerbatim- , yankSelectedMessage- , openSelectedMessageURLs- , beginConfirmDeleteSelectedMessage- , messageSelectUp- , messageSelectUpBy- , messageSelectDown- , messageSelectDownBy- , messageSelectFirst- , messageSelectLast- , deleteSelectedMessage- , beginReplyCompose- , beginEditMessage- , flagMessage- , getSelectedMessage- )-where--import Prelude ()-import Prelude.MH--import Brick.Widgets.Edit ( applyEdit )-import Data.Text.Zipper ( clearZipper, insertMany )-import Lens.Micro.Platform--import qualified Network.Mattermost.Endpoints as MM-import Network.Mattermost.Types--import Clipboard ( copyToClipboard )-import State.Common-import State.Messages-import Types-import Types.RichText ( findVerbatimChunk )-import Types.Common-import Windows.ViewMessage----- | In these modes, we allow access to the selected message state.-messageSelectCompatibleModes :: [Mode]-messageSelectCompatibleModes =- [ MessageSelect- , MessageSelectDeleteConfirm- , ReactionEmojiListOverlay- ]--getSelectedMessage :: ChatState -> Maybe Message-getSelectedMessage st- | not (appMode st `elem` messageSelectCompatibleModes) = Nothing- | otherwise = do- selMsgId <- selectMessageId $ st^.csMessageSelect- let chanMsgs = st ^. csCurrentChannel . ccContents . cdMessages- findMessage selMsgId chanMsgs--beginMessageSelect :: MH ()-beginMessageSelect = do- -- Get the number of messages in the current channel and set the- -- currently selected message index to be the most recently received- -- message that corresponds to a Post (i.e. exclude informative- -- messages).- --- -- If we can't find one at all, we ignore the mode switch request- -- and just return.- chanMsgs <- use(csCurrentChannel . ccContents . cdMessages)- let recentMsg = getLatestSelectableMessage chanMsgs-- when (isJust recentMsg) $ do- setMode MessageSelect- csMessageSelect .= MessageSelectState (recentMsg >>= _mMessageId)---- | Tell the server that the message we currently have selected--- should have its flagged state toggled.-flagSelectedMessage :: MH ()-flagSelectedMessage = do- selected <- use (to getSelectedMessage)- case selected of- Just msg- | isFlaggable msg, Just pId <- messagePostId msg ->- flagMessage pId (not (msg^.mFlagged))- _ -> return ()---- | Tell the server that the message we currently have selected--- should have its pinned state toggled.-pinSelectedMessage :: MH ()-pinSelectedMessage = do- selected <- use (to getSelectedMessage)- case selected of- Just msg- | isPinnable msg, Just pId <- messagePostId msg ->- pinMessage pId (not (msg^.mPinned))- _ -> return ()--viewSelectedMessage :: MH ()-viewSelectedMessage = do- selected <- use (to getSelectedMessage)- case selected of- Just msg- | not (isGap msg) -> viewMessage msg- _ -> return ()--fillSelectedGap :: MH ()-fillSelectedGap = do- selected <- use (to getSelectedMessage)- case selected of- Just msg- | isGap msg -> do cId <- use csCurrentChannelId- asyncFetchMessagesForGap cId msg- _ -> return ()--viewMessage :: Message -> MH ()-viewMessage m = do- let w = tabbedWindow VMTabMessage viewMessageWindowTemplate Main (78, 25)- csViewedMessage .= Just (m, w)- runTabShowHandlerFor (twValue w) w- setMode ViewMessage--yankSelectedMessageVerbatim :: MH ()-yankSelectedMessageVerbatim = do- selectedMessage <- use (to getSelectedMessage)- case selectedMessage of- Nothing -> return ()- Just m -> do- setMode Main- case findVerbatimChunk (m^.mText) of- Just txt -> copyToClipboard txt- Nothing -> return ()--yankSelectedMessage :: MH ()-yankSelectedMessage = do- selectedMessage <- use (to getSelectedMessage)- case selectedMessage of- Nothing -> return ()- Just m -> do- setMode Main- copyToClipboard $ m^.mMarkdownSource--openSelectedMessageURLs :: MH ()-openSelectedMessageURLs = whenMode MessageSelect $ do- mCurMsg <- use (to getSelectedMessage)- curMsg <- case mCurMsg of- Nothing -> error "BUG: openSelectedMessageURLs: no selected message available"- Just m -> return m-- remainInSelectMode <- use (csResources.crConfiguration.to configMessageSelectAfterURLOpen)-- let urls = msgURLs curMsg- when (not (null urls)) $ do- openedAll <- and <$> mapM (openURL . OpenLinkChoice) urls- case openedAll of- True -> when (not remainInSelectMode) $ setMode Main- False ->- mhError $ ConfigOptionMissing "urlOpenCommand"--beginConfirmDeleteSelectedMessage :: MH ()-beginConfirmDeleteSelectedMessage = do- st <- use id- selected <- use (to getSelectedMessage)- case selected of- Just msg | isDeletable msg && isMine st msg ->- setMode MessageSelectDeleteConfirm- _ -> return ()--messageSelectUp :: MH ()-messageSelectUp = do- mode <- gets appMode- selected <- use (csMessageSelect.to selectMessageId)- case selected of- Just _ | mode == MessageSelect -> do- chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)- let nextMsgId = getPrevMessageId selected chanMsgs- csMessageSelect .= MessageSelectState (nextMsgId <|> selected)- _ -> return ()--messageSelectDown :: MH ()-messageSelectDown = do- selected <- use (csMessageSelect.to selectMessageId)- case selected of- Just _ -> whenMode MessageSelect $ do- chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)- let nextMsgId = getNextMessageId selected chanMsgs- csMessageSelect .= MessageSelectState (nextMsgId <|> selected)- _ -> return ()--messageSelectDownBy :: Int -> MH ()-messageSelectDownBy amt- | amt <= 0 = return ()- | otherwise =- messageSelectDown >> messageSelectDownBy (amt - 1)--messageSelectUpBy :: Int -> MH ()-messageSelectUpBy amt- | amt <= 0 = return ()- | otherwise =- messageSelectUp >> messageSelectUpBy (amt - 1)--messageSelectFirst :: MH ()-messageSelectFirst = do- selected <- use (csMessageSelect.to selectMessageId)- case selected of- Just _ -> whenMode MessageSelect $ do- chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)- case getEarliestSelectableMessage chanMsgs of- Just firstMsg ->- csMessageSelect .= MessageSelectState (firstMsg^.mMessageId <|> selected)- Nothing -> mhLog LogError "No first message found from current message?!"- _ -> return ()--messageSelectLast :: MH ()-messageSelectLast = do- selected <- use (csMessageSelect.to selectMessageId)- case selected of- Just _ -> whenMode MessageSelect $ do- chanMsgs <- use (csCurrentChannel.ccContents.cdMessages)- case getLatestSelectableMessage chanMsgs of- Just lastSelMsg ->- csMessageSelect .= MessageSelectState (lastSelMsg^.mMessageId <|> selected)- Nothing -> mhLog LogError "No last message found from current message?!"- _ -> return ()--deleteSelectedMessage :: MH ()-deleteSelectedMessage = do- selectedMessage <- use (to getSelectedMessage)- st <- use id- cId <- use csCurrentChannelId- case selectedMessage of- Just msg | isMine st msg && isDeletable msg ->- case msg^.mOriginalPost of- Just p ->- doAsyncChannelMM Preempt cId- (\s _ -> MM.mmDeletePost (postId p) s)- (\_ _ -> Just $ do- csEditState.cedEditMode .= NewPost- setMode Main)- Nothing -> return ()- _ -> return ()--beginReplyCompose :: MH ()-beginReplyCompose = do- selected <- use (to getSelectedMessage)- case selected of- Just msg | isReplyable msg -> do- let Just p = msg^.mOriginalPost- setMode Main- csEditState.cedEditMode .= Replying msg p- _ -> return ()--beginEditMessage :: MH ()-beginEditMessage = do- selected <- use (to getSelectedMessage)- st <- use id- case selected of- Just msg | isMine st msg && isEditable msg -> do- let Just p = msg^.mOriginalPost- setMode Main- csEditState.cedEditMode .= Editing p (msg^.mType)- -- If the post that we're editing is an emote, we need- -- to strip the formatting because that's only there to- -- indicate that the post is an emote. This is annoying and- -- can go away one day when there is an actual post type- -- value of "emote" that we can look at. Note that the- -- removed formatting needs to be reinstated just prior to- -- issuing the API call to update the post.- let sanitized = sanitizeUserText $ postMessage p- let toEdit = if isEmote msg- then removeEmoteFormatting sanitized- else sanitized- csEditState.cedEditor %= applyEdit (insertMany toEdit . clearZipper)- _ -> return ()---- | Tell the server that we have flagged or unflagged a message.-flagMessage :: PostId -> Bool -> MH ()-flagMessage pId f = do- session <- getSession- myId <- gets myUserId- doAsyncWith Normal $ do- let doFlag = if f then MM.mmFlagPost else MM.mmUnflagPost- doFlag myId pId session- return Nothing---- | Tell the server that we have pinned or unpinned a message.-pinMessage :: PostId -> Bool -> MH ()-pinMessage pId f = do- session <- getSession- doAsyncWith Normal $ do- let doPin = if f then MM.mmPinPostToChannel else MM.mmUnpinPostToChannel- void $ doPin pId session- return Nothing
− src/State/Messages.hs
@@ -1,871 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-module State.Messages- ( PostToAdd(..)- , addDisconnectGaps- , lastMsg- , sendMessage- , editMessage- , deleteMessage- , addNewPostedMessage- , addObtainedMessages- , asyncFetchMoreMessages- , asyncFetchMessagesForGap- , asyncFetchMessagesSurrounding- , fetchVisibleIfNeeded- , disconnectChannels- )-where--import Prelude ()-import Prelude.MH--import Brick.Main ( getVtyHandle, invalidateCacheEntry )-import qualified Brick.Widgets.FileBrowser as FB-import qualified Data.Foldable as F-import qualified Data.HashMap.Strict as HM-import qualified Data.Set as Set-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import Graphics.Vty ( outputIface )-import Graphics.Vty.Output.Interface ( ringTerminalBell )-import Lens.Micro.Platform ( Traversal', (.=), (%=), (%~), (.~), (^?)- , to, at, traversed, filtered, ix, _1 )--import Network.Mattermost-import qualified Network.Mattermost.Endpoints as MM-import Network.Mattermost.Lenses-import Network.Mattermost.Types--import Constants-import State.Channels-import State.Common-import State.Reactions-import State.Users-import TimeUtils-import Types-import Types.Common ( sanitizeUserText )-import Types.DirectionalSeq ( DirectionalSeq, SeqDirection )----- ------------------------------------------------------------------------- Message gaps----- | Called to add an UnknownGap to the end of the Messages collection--- for all channels when the client has become disconnected from the--- server. This gaps will later be removed by successful fetching--- overlaps if the connection is re-established. Note that the--- disconnect is re-iterated periodically via a re-connect timer--- attempt, so do not duplicate gaps. Also clear any flags--- representing a pending exchange with the server (which will now--- never complete).-addDisconnectGaps :: MH ()-addDisconnectGaps = mapM_ onEach . filteredChannelIds (const True) =<< use csChannels- where onEach c = do addEndGap c- clearPendingFlags c- mh $ invalidateCacheEntry (ChannelMessages c)---- | Websocket was disconnected, so all channels may now miss some--- messages-disconnectChannels :: MH ()-disconnectChannels = addDisconnectGaps--clearPendingFlags :: ChannelId -> MH ()-clearPendingFlags c = csChannel(c).ccContents.cdFetchPending .= False--addEndGap :: ChannelId -> MH ()-addEndGap cId = withChannel cId $ \chan ->- let lastmsg_ = chan^.ccContents.cdMessages.to reverseMessages.to lastMsg- lastIsGap = maybe False isGap lastmsg_- gapMsg = newGapMessage timeJustAfterLast- timeJustAfterLast = maybe t0 (justAfter . _mDate) lastmsg_- t0 = ServerTime $ originTime -- use any time for a channel with no messages yet- newGapMessage = newMessageOfType- (T.pack "Disconnected. Will refresh when connected.")- (C UnknownGapAfter)- in unless lastIsGap- (csChannels %= modifyChannelById cId (ccContents.cdMessages %~ addMessage gapMsg))--lastMsg :: RetrogradeMessages -> Maybe Message-lastMsg = withFirstMessage id---- | Send a message and attachments to the specified channel.-sendMessage :: ChannelId -> EditMode -> Text -> [AttachmentData] -> MH ()-sendMessage chanId mode msg attachments =- when (not $ shouldSkipMessage msg) $ do- status <- use csConnectionStatus- case status of- Disconnected -> do- let m = T.concat [ "Cannot send messages while disconnected. Enable logging to "- , "get disconnection information. If Matterhorn's reconnection "- , "attempts are failing, use `/reconnect` to attempt to "- , "reconnect manually."- ]- mhError $ GenericError m- Connected -> do- session <- getSession- doAsync Preempt $ do- -- Upload attachments- fileInfos <- forM attachments $ \a -> do- MM.mmUploadFile chanId (FB.fileInfoFilename $ attachmentDataFileInfo a)- (attachmentDataBytes a) session-- let fileIds = Seq.fromList $- fmap fileInfoId $- concat $- (F.toList . MM.uploadResponseFileInfos) <$> fileInfos-- case mode of- NewPost -> do- let pendingPost = (rawPost msg chanId) { rawPostFileIds = fileIds }- void $ MM.mmCreatePost pendingPost session- Replying _ p -> do- let pendingPost = (rawPost msg chanId) { rawPostRootId = postRootId p <|> (Just $ postId p)- , rawPostFileIds = fileIds- }- void $ MM.mmCreatePost pendingPost session- Editing p ty -> do- let body = case ty of- CP Emote -> addEmoteFormatting msg- _ -> msg- update = (postUpdateBody body) { postUpdateFileIds = if null fileIds- then Nothing- else Just fileIds- }- void $ MM.mmPatchPost (postId p) update session--shouldSkipMessage :: Text -> Bool-shouldSkipMessage "" = True-shouldSkipMessage s = T.all (`elem` (" \t"::String)) s--editMessage :: Post -> MH ()-editMessage new = do- myId <- gets myUserId- let isEditedMessage m = m^.mMessageId == Just (MessagePostId $ new^.postIdL)- (msg, mentionedUsers) = clientPostToMessage (toClientPost new (new^.postRootIdL))- chan = csChannel (new^.postChannelIdL)- chan . ccContents . cdMessages . traversed . filtered isEditedMessage .= msg- mh $ invalidateCacheEntry (ChannelMessages $ new^.postChannelIdL)-- fetchMentionedUsers mentionedUsers-- when (postUserId new /= Just myId) $- chan %= adjustEditedThreshold new-- csPostMap.ix(postId new) .= msg- asyncFetchReactionsForPost (postChannelId new) new- asyncFetchAttachments new--deleteMessage :: Post -> MH ()-deleteMessage new = do- let isDeletedMessage m = m^.mMessageId == Just (MessagePostId $ new^.postIdL) ||- isReplyTo (new^.postIdL) m- chan :: Traversal' ChatState ClientChannel- chan = csChannel (new^.postChannelIdL)- chan.ccContents.cdMessages.traversed.filtered isDeletedMessage %= (& mDeleted .~ True)- chan %= adjustUpdated new- mh $ invalidateCacheEntry (ChannelMessages $ new^.postChannelIdL)--addNewPostedMessage :: PostToAdd -> MH ()-addNewPostedMessage p =- addMessageToState True True p >>= postProcessMessageAdd---- | Adds the set of Posts to the indicated channel. The Posts must--- all be for the specified Channel. The reqCnt argument indicates--- how many posts were requested, which will determine whether a gap--- message is added to either end of the posts list or not.------ The addTrailingGap is only True when fetching the very latest messages--- for the channel, and will suppress the generation of a Gap message--- following the added block of messages.-addObtainedMessages :: ChannelId -> Int -> Bool -> Posts -> MH PostProcessMessageAdd-addObtainedMessages cId reqCnt addTrailingGap posts =- if null $ posts^.postsOrderL- then do when addTrailingGap $- -- Fetched at the end of the channel, but nothing was- -- available. This is common if this is a new channel- -- with no messages in it. Need to remove any gaps that- -- exist at the end of the channel.- csChannels %= modifyChannelById cId- (ccContents.cdMessages %~- \msgs -> let startPoint = join $ _mMessageId <$> getLatestPostMsg msgs- in fst $ removeMatchesFromSubset isGap startPoint Nothing msgs)- return NoAction- else- -- Adding a block of server-provided messages, which are known to- -- be contiguous. Locally this may overlap with some UnknownGap- -- messages, which can therefore be removed. Alternatively the- -- new block may be discontiguous with the local blocks, in which- -- case the new block should be surrounded by UnknownGaps.- withChannelOrDefault cId NoAction $ \chan -> do- let pIdList = toList (posts^.postsOrderL)- -- the first and list PostId in the batch to be added- earliestPId = last pIdList- latestPId = head pIdList- earliestDate = postCreateAt $ (posts^.postsPostsL) HM.! earliestPId- latestDate = postCreateAt $ (posts^.postsPostsL) HM.! latestPId-- localMessages = chan^.ccContents . cdMessages-- -- Get a list of the duplicated message PostIds between- -- the messages already in the channel and the new posts- -- to be added.-- match = snd $ removeMatchesFromSubset- (\m -> maybe False (\p -> p `elem` pIdList) (messagePostId m))- (Just (MessagePostId earliestPId))- (Just (MessagePostId latestPId))- localMessages-- accum m l =- case messagePostId m of- Just pId -> pId : l- Nothing -> l- dupPIds = foldr accum [] match-- -- If there were any matches, then there was overlap of- -- the new messages with existing messages.-- -- Don't re-add matching messages (avoid overhead like- -- re-checking/re-fetching related post information, and- -- do not signal action needed for notifications), and- -- remove any gaps in the overlapping region.-- newGapMessage d isOlder =- -- newGapMessage is a helper for generating a gap- -- message- do uuid <- generateUUID- let txt = "Load " <>- (if isOlder then "older" else "newer") <>- " messages" <>- (if isOlder then " ↥↥↥" else " ↧↧↧")- ty = if isOlder- then C UnknownGapBefore- else C UnknownGapAfter- return (newMessageOfType txt ty d- & mMessageId .~ Just (MessageUUID uuid))-- -- If this batch contains the latest known messages, do- -- not add a following gap. A gap at this point is added- -- by a websocket disconnect, and any fetches thereafter- -- are assumed to be the most current information (until- -- another disconnect), so no gap is needed.- -- Additionally, the presence of a gap at the end for a- -- connected client causes a fetch of messages at this- -- location, so adding the gap here would cause an- -- infinite update loop.-- addingAtEnd = maybe True (latestDate >=) $- (^.mDate) <$> getLatestPostMsg localMessages-- addingAtStart = maybe True (earliestDate <=) $- (^.mDate) <$> getEarliestPostMsg localMessages- removeStart = if addingAtStart && noMoreBefore- then Nothing- else Just (MessagePostId earliestPId)- removeEnd = if addTrailingGap || (addingAtEnd && noMoreAfter)- then Nothing- else Just (MessagePostId latestPId)-- noMoreBefore = reqCnt < 0 && length pIdList < (-reqCnt)- noMoreAfter = addTrailingGap || reqCnt > 0 && length pIdList < reqCnt-- reAddGapBefore = earliestPId `elem` dupPIds || noMoreBefore- -- addingAtEnd used to be in reAddGapAfter but does not- -- seem to be needed. I may have missed a specific use- -- case/scenario, so I've left it commented out here for- -- debug assistance.- reAddGapAfter = latestPId `elem` dupPIds || {- addingAtEnd || -} noMoreAfter-- -- The post map returned by the server will *already* have- -- all thread messages for each post that is part of a- -- thread. By calling installMessagesFromPosts here, we go ahead- -- and populate the csPostMap with those posts so that below, in- -- addMessageToState, we notice that we already know about reply- -- parent messages and can avoid fetching them. This converts- -- the posts to Messages and stores those and also returns- -- them, but we don't need them here. We just want the post- -- map update. This also gathers up the set of all mentioned- -- usernames in the text of the messages which we need to use to- -- submit a single batch request for user metadata so we don't- -- submit one request per mention.- void $ installMessagesFromPosts posts-- -- Add all the new *unique* posts into the existing channel- -- corpus, generating needed fetches of data associated with- -- the post, and determining an notification action to be- -- taken (if any).- action <- foldr andProcessWith NoAction <$>- mapM (addMessageToState False False . OldPost)- [ (posts^.postsPostsL) HM.! p- | p <- toList (posts^.postsOrderL)- , not (p `elem` dupPIds)- ]-- -- The channel messages now include all the fetched messages.- -- Things to do at this point are:- --- -- 1. Remove any duplicates just added, as well as any gaps- -- 2. Add new gaps (if needed) at either end of the added- -- messages.- -- 3. Update the "current selection" if it was on a removed message.- --- -- Do this with the updated copy of the channel's messages.-- withChannelOrDefault cId () $ \updchan -> do- let updMsgs = updchan ^. ccContents . cdMessages-- -- Remove any gaps in the added region. If there was an- -- active message selection and it is one of the removed- -- gaps, reset the selection to the beginning or end of the- -- added region (if there are any added selectable messages,- -- otherwise just the end if the message list in it's- -- entirety, or no selection at all).-- let (resultMessages, removedMessages) =- removeMatchesFromSubset isGap removeStart removeEnd updMsgs- csChannels %= modifyChannelById cId- (ccContents.cdMessages .~ resultMessages)-- -- Determine if the current selected message was one of the- -- removed messages.-- selMsgId <- use (csMessageSelect.to selectMessageId)- let rmvdSel = do- i <- selMsgId -- :: Maybe MessageId- findMessage i removedMessages- rmvdSelType = _mType <$> rmvdSel-- case rmvdSel of- Nothing -> return ()- Just rm ->- if isGap rm- then return () -- handled during gap insertion below- else do- -- Replaced a selected message that wasn't a gap.- -- This is unlikely, but may occur if the previously- -- selected message was just deleted by another user- -- and is in the fetched region. The choices here are- -- to move the selection, or cancel the selection.- -- Both will be unpleasant surprises for the user, but- -- cancelling the selection is probably the better- -- choice than allowing the user to perform select- -- actions on a message that isn't the one they just- -- selected.- setMode Main- csMessageSelect .= MessageSelectState Nothing-- -- Add a gap at each end of the newly fetched data, unless:- -- 1. there is an overlap- -- 2. there is no more in the indicated direction- -- a. indicated by adding messages later than any currently- -- held messages (see note above re 'addingAtEnd').- -- b. the amount returned was less than the amount requested-- if reAddGapBefore- then- -- No more gaps. If the selected gap was removed, move- -- select to first (earliest) message)- case rmvdSelType of- Just (C UnknownGapBefore) ->- csMessageSelect .= MessageSelectState (pure $ MessagePostId earliestPId)- _ -> return ()- else do- -- add a gap at the start of the newly fetched block and- -- make that the active selection if this fetch removed- -- the previously selected gap in this direction.- gapMsg <- newGapMessage (justBefore earliestDate) True- csChannels %= modifyChannelById cId- (ccContents.cdMessages %~ addMessage gapMsg)- -- Move selection from old gap to new gap- case rmvdSelType of- Just (C UnknownGapBefore) -> do- csMessageSelect .= MessageSelectState (gapMsg^.mMessageId)- _ -> return ()-- if reAddGapAfter- then- -- No more gaps. If the selected gap was removed, move- -- select to last (latest) message.- case rmvdSelType of- Just (C UnknownGapAfter) ->- csMessageSelect .= MessageSelectState (pure $ MessagePostId latestPId)- _ -> return ()- else do- -- add a gap at the end of the newly fetched block and- -- make that the active selection if this fetch removed- -- the previously selected gap in this direction.- gapMsg <- newGapMessage (justAfter latestDate) False- csChannels %= modifyChannelById cId- (ccContents.cdMessages %~ addMessage gapMsg)- -- Move selection from old gap to new gap- case rmvdSelType of- Just (C UnknownGapAfter) ->- csMessageSelect .= MessageSelectState (gapMsg^.mMessageId)- _ -> return ()-- -- Now initiate fetches for use information for any- -- as-yet-unknown users related to this new set of messages-- let users = foldr (\post s -> maybe s (flip Set.insert s) (postUserId post))- Set.empty (posts^.postsPostsL)- addUnknownUsers inputUserIds = do- knownUserIds <- Set.fromList <$> gets allUserIds- let unknownUsers = Set.difference inputUserIds knownUserIds- if Set.null unknownUsers- then return ()- else handleNewUsers (Seq.fromList $ toList unknownUsers) (return ())-- addUnknownUsers users-- -- Return the aggregated user notification action needed- -- relative to the set of added messages.-- return action---- | Adds a possibly new message to the associated channel contents.--- Returns an indicator of whether the user should be potentially--- notified of a change (a new message not posted by this user, a--- mention of the user, etc.). This operation has no effect on any--- existing UnknownGap entries and should be called when those are--- irrelevant.------ The first boolean argument ('doFetchMentionedUsers') indicates--- whether this function should schedule a fetch for any mentioned--- users in the message. This is provided so that callers can batch--- this operation if a large collection of messages is being added--- together, in which case we don't want this function to schedule a--- single request per message (worst case). If you're calling this as--- part of scrollback processing, you should pass False. Otherwise if--- you're adding only a single message, you should pass True.------ The second boolean argument ('fetchAuthor') is similar to the first--- boolean argument but it refers to the author of the message instead--- of any user mentions within the message body.------ The third argument ('newPostData') indicates whether this message--- is being added as part of a fetch of old messages (e.g. scrollback)--- or if ti is a new message and affects things like whether--- notifications are generated and if the "New Messages" marker gets--- updated.-addMessageToState :: Bool -> Bool -> PostToAdd -> MH PostProcessMessageAdd-addMessageToState doFetchMentionedUsers fetchAuthor newPostData = do- let (new, wasMentioned) = case newPostData of- -- A post from scrollback history has no mention data, and- -- that's okay: we only need to track mentions to tell the- -- user that recent posts contained mentions.- OldPost p -> (p, False)- RecentPost p m -> (p, m)-- st <- use id- case st ^? csChannel(postChannelId new) of- Nothing -> do- session <- getSession- doAsyncWith Preempt $ do- nc <- MM.mmGetChannel (postChannelId new) session- member <- MM.mmGetChannelMember (postChannelId new) UserMe session-- let chType = nc^.channelTypeL- pref = showGroupChannelPref (postChannelId new) (myUserId st)-- -- If the channel has been archived, we don't want to- -- post this message or add the channel to the state.- case channelDeleted nc of- True -> return Nothing- False -> return $ Just $ do- -- If the incoming message is for a group- -- channel we don't know about, that's because- -- it was previously hidden by the user. We need- -- to show it, and to do that we need to update- -- the server-side preference. (That, in turn,- -- triggers a channel refresh.)- if chType == Group- then applyPreferenceChange pref- else refreshChannel SidebarUpdateImmediate nc member-- addMessageToState doFetchMentionedUsers fetchAuthor newPostData >>=- postProcessMessageAdd-- return NoAction- Just _ -> do- let cp = toClientPost new (new^.postRootIdL)- fromMe = (cp^.cpUser == (Just $ myUserId st)) &&- (isNothing $ cp^.cpUserOverride)- userPrefs = st^.csResources.crUserPreferences- isJoinOrLeave = case cp^.cpType of- Join -> True- Leave -> True- _ -> False- ignoredJoinLeaveMessage =- not (userPrefs^.userPrefShowJoinLeave) && isJoinOrLeave- cId = postChannelId new-- doAddMessage = do- -- Do we have the user data for the post author?- case cp^.cpUser of- Nothing -> return ()- Just authorId -> when fetchAuthor $ do- authorResult <- gets (userById authorId)- when (isNothing authorResult) $- handleNewUsers (Seq.singleton authorId) (return ())-- currCId <- use csCurrentChannelId- flags <- use (csResources.crFlaggedPosts)- let (msg', mentionedUsers) = clientPostToMessage cp- & _1.mFlagged .~ ((cp^.cpPostId) `Set.member` flags)-- when doFetchMentionedUsers $- fetchMentionedUsers mentionedUsers-- csPostMap.at(postId new) .= Just msg'- mh $ invalidateCacheEntry (ChannelMessages cId)- csChannels %= modifyChannelById cId- ((ccContents.cdMessages %~ addMessage msg') .- (if not ignoredJoinLeaveMessage then adjustUpdated new else id) .- (\c -> if currCId == cId- then c- else case newPostData of- OldPost _ -> c- RecentPost _ _ ->- updateNewMessageIndicator new c) .- (\c -> if wasMentioned- then c & ccInfo.cdMentionCount %~ succ- else c)- )- asyncFetchReactionsForPost cId new- asyncFetchAttachments new- postedChanMessage-- doHandleAddedMessage = do- -- If the message is in reply to another message,- -- try to find it in the scrollback for the post's- -- channel. If the message isn't there, fetch it. If- -- we have to fetch it, don't post this message to the- -- channel until we have fetched the parent.- case cp^.cpInReplyToPost of- Just parentId ->- case getMessageForPostId st parentId of- Nothing -> do- doAsyncChannelMM Preempt cId- (\s _ -> MM.mmGetThread parentId s)- (\_ p -> Just $ updatePostMap p)- _ -> return ()- _ -> return ()-- doAddMessage-- postedChanMessage =- withChannelOrDefault (postChannelId new) NoAction $ \chan -> do- currCId <- use csCurrentChannelId-- let notifyPref = notifyPreference (myUser st) chan- curChannelAction = if postChannelId new == currCId- then UpdateServerViewed- else NoAction- originUserAction =- if | fromMe -> NoAction- | ignoredJoinLeaveMessage -> NoAction- | notifyPref == NotifyOptionAll -> NotifyUser [newPostData]- | notifyPref == NotifyOptionMention- && wasMentioned -> NotifyUser [newPostData]- | otherwise -> NoAction-- return $ curChannelAction `andProcessWith` originUserAction-- doHandleAddedMessage---- | PostProcessMessageAdd is an internal value that informs the main--- code whether the user should be notified (e.g., ring the bell) or--- the server should be updated (e.g., that the channel has been--- viewed). This is a monoid so that it can be folded over when there--- are multiple inbound posts to be processed.-data PostProcessMessageAdd = NoAction- | NotifyUser [PostToAdd]- | UpdateServerViewed- | NotifyUserAndServer [PostToAdd]--andProcessWith- :: PostProcessMessageAdd -> PostProcessMessageAdd -> PostProcessMessageAdd-andProcessWith NoAction x = x-andProcessWith x NoAction = x-andProcessWith (NotifyUserAndServer p) UpdateServerViewed = NotifyUserAndServer p-andProcessWith (NotifyUserAndServer p1) (NotifyUser p2) = NotifyUserAndServer (p1 <> p2)-andProcessWith (NotifyUserAndServer p1) (NotifyUserAndServer p2) = NotifyUserAndServer (p1 <> p2)-andProcessWith (NotifyUser p1) (NotifyUserAndServer p2) = NotifyUser (p1 <> p2)-andProcessWith (NotifyUser p1) (NotifyUser p2) = NotifyUser (p1 <> p2)-andProcessWith (NotifyUser p) UpdateServerViewed = NotifyUserAndServer p-andProcessWith UpdateServerViewed UpdateServerViewed = UpdateServerViewed-andProcessWith UpdateServerViewed (NotifyUserAndServer p) = NotifyUserAndServer p-andProcessWith UpdateServerViewed (NotifyUser p) = NotifyUserAndServer p---- | postProcessMessageAdd performs the actual actions indicated by--- the corresponding input value.-postProcessMessageAdd :: PostProcessMessageAdd -> MH ()-postProcessMessageAdd ppma = postOp ppma- where- postOp NoAction = return ()- postOp UpdateServerViewed = updateViewed False- postOp (NotifyUser p) = maybeRingBell >> mapM_ maybeNotify p- postOp (NotifyUserAndServer p) = updateViewed False >> maybeRingBell >> mapM_ maybeNotify p--maybeNotify :: PostToAdd -> MH ()-maybeNotify (OldPost _) = do- return ()-maybeNotify (RecentPost post mentioned) = runNotifyCommand post mentioned--maybeRingBell :: MH ()-maybeRingBell = do- doBell <- use (csResources.crConfiguration.to configActivityBell)- when doBell $ do- vty <- mh getVtyHandle- liftIO $ ringTerminalBell $ outputIface vty---- | When we add posts to the application state, we either get them--- from the server during scrollback fetches (here called 'OldPost') or--- we get them from websocket events when they are posted in real time--- (here called 'RecentPost').-data PostToAdd =- OldPost Post- -- ^ A post from the server's history- | RecentPost Post Bool- -- ^ A message posted to the channel since the user connected, along- -- with a flag indicating whether the post triggered any of the- -- user's mentions. We need an extra flag because the server- -- determines whether the post has any mentions, and that data is- -- only available in websocket events (and then provided to this- -- constructor).--runNotifyCommand :: Post -> Bool -> MH ()-runNotifyCommand post mentioned = do- outputChan <- use (csResources.crSubprocessLog)- st <- use id- notifyCommand <- use (csResources.crConfiguration.to configActivityNotifyCommand)- case notifyCommand of- Nothing -> return ()- Just cmd ->- doAsyncWith Preempt $ do- let messageString = T.unpack $ sanitizeUserText $ postMessage post- notified = if mentioned then "1" else "2"- sender = T.unpack $ maybePostUsername st post- runLoggedCommand False outputChan (T.unpack cmd)- [notified, sender, messageString] Nothing Nothing- return Nothing--maybePostUsername :: ChatState -> Post -> T.Text-maybePostUsername st p =- fromMaybe T.empty $ do- uId <- postUserId p- usernameForUserId uId st---- | Fetches additional message history for the current channel. This--- is generally called when in ChannelScroll mode, in which state the--- output is cached and seen via a scrolling viewport; new messages--- received in this mode are not normally shown, but this explicit--- user-driven fetch should be displayed, so this also invalidates the--- cache.------ This function assumes it is being called to add "older" messages to--- the message history (i.e. near the beginning of the known--- messages). It will normally try to overlap the fetch with the--- known existing messages so that when the fetch results are--- processed (which should be a contiguous set of messages as provided--- by the server) there will be an overlap with existing messages; if--- there is no overlap, then a special "gap" must be inserted in the--- area between the existing messages and the newly fetched messages--- to indicate that this client does not know if there are missing--- messages there or not.------ In order to achieve an overlap, this code attempts to get the--- second oldest messages as the message ID to pass to the server as--- the "older than" marker ('postQueryBefore'), so that the oldest--- message here overlaps with the fetched results to ensure no gap--- needs to be inserted. However, there may already be a gap between--- the oldest and second-oldest messages, so this code must actually--- search for the first set of two *contiguous* messages it is aware--- of to avoid adding additional gaps. (It's OK if gaps are added, but--- the user must explicitly request a check for messages in order to--- eliminate them, so it's better to avoid adding them in the first--- place). This code is nearly always used to extend the older--- history of a channel that information has already been retrieved--- from, so it's almost certain that there are at least two contiguous--- messages to use as a starting point, but exceptions are new--- channels and empty channels.-asyncFetchMoreMessages :: MH ()-asyncFetchMoreMessages = do- cId <- use csCurrentChannelId- withChannel cId $ \chan ->- let offset = max 0 $ length (chan^.ccContents.cdMessages) - 2- page = offset `div` pageAmount- usefulMsgs = getTwoContiguousPosts Nothing (chan^.ccContents.cdMessages.to reverseMessages)- sndOldestId = (messagePostId . snd) =<< usefulMsgs- query = MM.defaultPostQuery- { MM.postQueryPage = maybe (Just page) (const Nothing) sndOldestId- , MM.postQueryPerPage = Just pageAmount- , MM.postQueryBefore = sndOldestId- }- addTrailingGap = MM.postQueryBefore query == Nothing &&- MM.postQueryPage query == Just 0- in doAsyncChannelMM Preempt cId- (\s c -> MM.mmGetPostsForChannel c query s)- (\c p -> Just $ do- pp <- addObtainedMessages c (-pageAmount) addTrailingGap p- postProcessMessageAdd pp- mh $ invalidateCacheEntry (ChannelMessages cId))----- | Given a starting point and a direction to move from that point,--- returns the closest two adjacent messages on that direction (as a--- tuple of closest and next-closest), or Nothing if there are no--- adjacent messages in the indicated direction.-getTwoContiguousPosts :: SeqDirection dir =>- Maybe Message- -> DirectionalSeq dir Message- -> Maybe (Message, Message)-getTwoContiguousPosts startMsg msgs =- let go start =- do anchor <- getRelMessageId (_mMessageId =<< start) msgs- hinge <- getRelMessageId (anchor^.mMessageId) msgs- if isGap anchor || isGap hinge- then go $ Just anchor- else Just (anchor, hinge)- in go startMsg---asyncFetchMessagesForGap :: ChannelId -> Message -> MH ()-asyncFetchMessagesForGap cId gapMessage =- when (isGap gapMessage) $- withChannel cId $ \chan ->- let offset = max 0 $ length (chan^.ccContents.cdMessages) - 2- page = offset `div` pageAmount- chanMsgs = chan^.ccContents.cdMessages- fromMsg = Just gapMessage- fetchNewer = case gapMessage^.mType of- C UnknownGapAfter -> True- C UnknownGapBefore -> False- _ -> error "fetch gap messages: unknown gap message type"- baseId = messagePostId . snd =<<- case gapMessage^.mType of- C UnknownGapAfter -> getTwoContiguousPosts fromMsg $- reverseMessages chanMsgs- C UnknownGapBefore -> getTwoContiguousPosts fromMsg chanMsgs- _ -> error "fetch gap messages: unknown gap message type"- query = MM.defaultPostQuery- { MM.postQueryPage = maybe (Just page) (const Nothing) baseId- , MM.postQueryPerPage = Just pageAmount- , MM.postQueryBefore = if fetchNewer then Nothing else baseId- , MM.postQueryAfter = if fetchNewer then baseId else Nothing- }- addTrailingGap = MM.postQueryBefore query == Nothing &&- MM.postQueryPage query == Just 0- in doAsyncChannelMM Preempt cId- (\s c -> MM.mmGetPostsForChannel c query s)- (\c p -> Just $ do- void $ addObtainedMessages c (-pageAmount) addTrailingGap p- mh $ invalidateCacheEntry (ChannelMessages cId))---- | Given a particular message ID, this fetches n messages before and--- after immediately before and after the specified message in order--- to establish some context for that message. This is frequently--- used as a background operation when looking at search or flag--- results so that jumping to message select mode for one of those--- messages will show a bit of context (and it also prevents showing--- gap messages for adjacent targets).------ The result will be adding at most 2n messages to the channel, with--- the input post ID being somewhere in the middle of the added--- messages.------ Note that this fetch will add messages to the channel, but it--- performs no notifications or updates of new-unread indicators--- because it is assumed to be used for non-current (previously-seen)--- messages in background mode.-asyncFetchMessagesSurrounding :: ChannelId -> PostId -> MH ()-asyncFetchMessagesSurrounding cId pId = do- let query = MM.defaultPostQuery- { MM.postQueryBefore = Just pId- , MM.postQueryPerPage = Just reqAmt- }- reqAmt = 5 -- both before and after- doAsyncChannelMM Preempt cId- -- first get some messages before the target, no overlap- (\s c -> MM.mmGetPostsForChannel c query s)- (\c p -> Just $ do- let last2ndId = secondToLastPostId p- void $ addObtainedMessages c (-reqAmt) False p- mh $ invalidateCacheEntry (ChannelMessages cId)- -- now start 2nd from end of this fetch to fetch some- -- messages forward, also overlapping with this fetch and- -- the original message ID to eliminate all gaps in this- -- surrounding set of messages.- let query' = MM.defaultPostQuery- { MM.postQueryAfter = last2ndId- , MM.postQueryPerPage = Just $ reqAmt + 2- }- doAsyncChannelMM Preempt cId- (\s' c' -> MM.mmGetPostsForChannel c' query' s')- (\c' p' -> Just $ do- void $ addObtainedMessages c' (reqAmt + 2) False p'- mh $ invalidateCacheEntry (ChannelMessages cId)- )- )- where secondToLastPostId posts =- let pl = toList $ postsOrder posts- in if length pl > 1 then Just $ last $ init pl else Nothing---fetchVisibleIfNeeded :: MH ()-fetchVisibleIfNeeded = do- sts <- use csConnectionStatus- when (sts == Connected) $ do- cId <- use csCurrentChannelId- withChannel cId $ \chan ->- let msgs = chan^.ccContents.cdMessages.to reverseMessages- (numRemaining, gapInDisplayable, _, rel'pId, overlap) =- foldl gapTrail (numScrollbackPosts, False, Nothing, Nothing, 2) msgs- gapTrail a@(_, True, _, _, _) _ = a- gapTrail a@(0, _, _, _, _) _ = a- gapTrail (a, False, b, c, d) m | isGap m = (a, True, b, c, d)- gapTrail (remCnt, _, prev'pId, prev''pId, ovl) msg =- (remCnt - 1, False, msg^.mMessageId <|> prev'pId, prev'pId <|> prev''pId,- ovl + if not (isPostMessage msg) then 1 else 0)- numToReq = numRemaining + overlap- query = MM.defaultPostQuery- { MM.postQueryPage = Just 0- , MM.postQueryPerPage = Just numToReq- }- finalQuery = case rel'pId of- Just (MessagePostId pid) -> query { MM.postQueryBefore = Just pid }- _ -> query- op = \s c -> MM.mmGetPostsForChannel c finalQuery s- addTrailingGap = MM.postQueryBefore finalQuery == Nothing &&- MM.postQueryPage finalQuery == Just 0- in when ((not $ chan^.ccContents.cdFetchPending) && gapInDisplayable) $ do- csChannel(cId).ccContents.cdFetchPending .= True- doAsyncChannelMM Preempt cId op- (\c p -> Just $ do- addObtainedMessages c (-numToReq) addTrailingGap p >>= postProcessMessageAdd- csChannel(c).ccContents.cdFetchPending .= False)--asyncFetchAttachments :: Post -> MH ()-asyncFetchAttachments p = do- let cId = p^.postChannelIdL- pId = p^.postIdL- session <- getSession- host <- use (csResources.crConn.cdHostnameL)- F.forM_ (p^.postFileIdsL) $ \fId -> doAsyncWith Normal $ do- info <- MM.mmGetMetadataForFile fId session- let scheme = "https://"- attUrl = scheme <> host <> urlForFile fId- attachment = mkAttachment (fileInfoName info) attUrl fId- addIfMissing a as =- if isNothing $ Seq.elemIndexL a as- then a Seq.<| as- else as- addAttachment m- | m^.mMessageId == Just (MessagePostId pId) =- m & mAttachments %~ (addIfMissing attachment)- | otherwise =- m- return $ Just $ do- csChannel(cId).ccContents.cdMessages.traversed %= addAttachment- mh $ invalidateCacheEntry $ ChannelMessages cId
− src/State/Messages.hs-boot
@@ -1,11 +0,0 @@-module State.Messages- ( fetchVisibleIfNeeded- )-where--import Types ( MH )---- State.Channels needs this in setFocusWith, but that creates an--- import cycle because several things in State.Messages need things in--- State.Channels.-fetchVisibleIfNeeded :: MH ()
− src/State/NotifyPrefs.hs
@@ -1,87 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-module State.NotifyPrefs- ( enterEditNotifyPrefsMode- , exitEditNotifyPrefsMode- )-where--import Prelude ()-import Prelude.MH--import Types--import Network.Mattermost.Types ( ChannelNotifyProps- , User(..)- , UserNotifyProps(..)- , Type(..)- , channelNotifyPropsMarkUnread- , channelNotifyPropsIgnoreChannelMentions- , WithDefault(..)- , NotifyOption(..)- )--import Brick-import Brick.Forms-import Lens.Micro.Platform ( Lens', (.=), lens )--muteLens :: Lens' ChannelNotifyProps Bool-muteLens = lens (\props -> props^.channelNotifyPropsMarkUnreadL == IsValue NotifyOptionMention)- (\props muted -> props { channelNotifyPropsMarkUnread =- if muted- then IsValue NotifyOptionMention- else IsValue NotifyOptionAll- })--channelMentionLens :: Lens' ChannelNotifyProps Bool-channelMentionLens = lens (\props -> props^.channelNotifyPropsIgnoreChannelMentionsL == IsValue True)- (\props ignoreChannelMentions ->- props { channelNotifyPropsIgnoreChannelMentions = if ignoreChannelMentions- then IsValue True- else Default- })--notifyOptionName :: NotifyOption -> Text-notifyOptionName NotifyOptionAll = "All activity"-notifyOptionName NotifyOptionMention = "Mentions"-notifyOptionName NotifyOptionNone = "Never"--mkNotifyButtons :: ((WithDefault NotifyOption) -> Name)- -> Lens' ChannelNotifyProps (WithDefault NotifyOption)- -> NotifyOption- -> ChannelNotifyProps- -> FormFieldState ChannelNotifyProps e Name-mkNotifyButtons mkName l globalDefault =- let optTuple opt = (IsValue opt, mkName $ IsValue opt, notifyOptionName opt)- defaultField = (Default, mkName Default, "Global default (" <> notifyOptionName globalDefault <> ")")- nonDefault = optTuple <$> [ NotifyOptionAll- , NotifyOptionMention- , NotifyOptionNone- ]- in radioField l (defaultField : nonDefault)--notifyPrefsForm :: UserNotifyProps -> ChannelNotifyProps -> Form ChannelNotifyProps e Name-notifyPrefsForm globalDefaults =- newForm [ checkboxField muteLens MuteToggleField "Mute channel"- , (padTop $ Pad 1) @@= checkboxField channelMentionLens ChannelMentionsField "Ignore channel mentions"- , radioStyle "Desktop notifications" @@=- mkNotifyButtons DesktopNotificationsField channelNotifyPropsDesktopL (userNotifyPropsDesktop globalDefaults)- , radioStyle "Push notifications" @@=- mkNotifyButtons PushNotificationsField channelNotifyPropsPushL (userNotifyPropsPush globalDefaults)- ]- where radioStyle label = (padTop $ Pad 1 ) . (str label <=>) . (padLeft $ Pad 1)--enterEditNotifyPrefsMode :: MH ()-enterEditNotifyPrefsMode = do- chanInfo <- use (csCurrentChannel.ccInfo)- case chanInfo^.cdType of- Direct -> mhError $ GenericError "Cannot open notification preferences for DM channel."- _ -> do- let props = chanInfo^.cdNotifyProps- user <- use csMe- csNotifyPrefs .= (Just (notifyPrefsForm (userNotifyProps user) props))- setMode EditNotifyPrefs--exitEditNotifyPrefsMode :: MH ()-exitEditNotifyPrefsMode = do- setMode Main
− src/State/PostListOverlay.hs
@@ -1,160 +0,0 @@-module State.PostListOverlay- ( enterFlaggedPostListMode- , enterPinnedPostListMode- , enterSearchResultPostListMode- , postListJumpToCurrent- , postListSelectUp- , postListSelectDown- , postListUnflagSelected- , exitPostListMode- )-where--import GHC.Exts ( IsList(..) )-import Prelude ()-import Prelude.MH--import qualified Data.Foldable as F-import qualified Data.Text as T-import Lens.Micro.Platform ( (.=) )-import Network.Mattermost.Endpoints-import Network.Mattermost.Types--import State.Channels-import State.Common-import State.MessageSelect-import State.Messages ( addObtainedMessages- , asyncFetchMessagesSurrounding )-import Types-import Types.DirectionalSeq (emptyDirSeq)----- | Create a PostListOverlay with the given content description and--- with a specified list of messages.-enterPostListMode :: PostListContents -> Messages -> MH ()-enterPostListMode contents msgs = do- csPostListOverlay.postListPosts .= msgs- let mlatest = getLatestPostMsg msgs- pId = mlatest >>= messagePostId- cId = mlatest >>= \m -> m^.mChannelId- csPostListOverlay.postListSelected .= pId- setMode $ PostListOverlay contents- case (pId, cId) of- (Just p, Just c) -> asyncFetchMessagesSurrounding c p- _ -> return ()---- | Clear out the state of a PostListOverlay-exitPostListMode :: MH ()-exitPostListMode = do- csPostListOverlay.postListPosts .= emptyDirSeq- csPostListOverlay.postListSelected .= Nothing- setMode Main---createPostList :: PostListContents -> (Session -> IO Posts) -> MH ()-createPostList contentsType fetchOp = do- session <- getSession- doAsyncWith Preempt $ do- posts <- fetchOp session- return $ Just $ do- messages <- installMessagesFromPosts posts- -- n.b. do not use addNewPostedMessage because these messages- -- are not new, and so no notifications or channel highlighting- -- or other post-processing should be performed.- let plist = F.toList $ postsPosts posts- postsSpec p = Posts { postsPosts = fromList [(postId p, p)]- , postsOrder = fromList [postId p]- }- mapM_ (\p -> addObtainedMessages (postChannelId p) 0 False $ postsSpec p) plist- enterPostListMode contentsType messages----- | Create a PostListOverlay with flagged messages from the server.-enterFlaggedPostListMode :: MH ()-enterFlaggedPostListMode = createPostList PostListFlagged $- mmGetListOfFlaggedPosts UserMe defaultFlaggedPostsQuery---- | Create a PostListOverlay with pinned messages from the server for--- the current channel.-enterPinnedPostListMode :: MH ()-enterPinnedPostListMode = do- cId <- use csCurrentChannelId- createPostList (PostListPinned cId) $ mmGetChannelPinnedPosts cId---- | Create a PostListOverlay with post search result messages from the--- server.-enterSearchResultPostListMode :: Text -> MH ()-enterSearchResultPostListMode terms- | T.null (T.strip terms) = postInfoMessage "Search command requires at least one search term."- | otherwise = do- enterPostListMode (PostListSearch terms True) noMessages- tId <- gets myTeamId- createPostList (PostListSearch terms False) $- mmSearchForTeamPosts tId (SearchPosts terms False)----- | Move the selection up in the PostListOverlay, which corresponds--- to finding a chronologically /newer/ message.-postListSelectUp :: MH ()-postListSelectUp = do- selId <- use (csPostListOverlay.postListSelected)- posts <- use (csPostListOverlay.postListPosts)- let nextMsg = getNextMessage (MessagePostId <$> selId) posts- case nextMsg of- Nothing -> return ()- Just m -> do- let pId = m^.mMessageId >>= messageIdPostId- csPostListOverlay.postListSelected .= pId- case (m^.mChannelId, pId) of- (Just c, Just p) -> asyncFetchMessagesSurrounding c p- o -> mhLog LogError- (T.pack $ "postListSelectUp" <>- " unable to get channel or post ID: " <> show o)---- | Move the selection down in the PostListOverlay, which corresponds--- to finding a chronologically /old/ message.-postListSelectDown :: MH ()-postListSelectDown = do- selId <- use (csPostListOverlay.postListSelected)- posts <- use (csPostListOverlay.postListPosts)- let prevMsg = getPrevMessage (MessagePostId <$> selId) posts- case prevMsg of- Nothing -> return ()- Just m -> do- let pId = m^.mMessageId >>= messageIdPostId- csPostListOverlay.postListSelected .= pId- case (m^.mChannelId, pId) of- (Just c, Just p) -> asyncFetchMessagesSurrounding c p- o -> mhLog LogError- (T.pack $ "postListSelectDown" <>- " unable to get channel or post ID: " <> show o)---- | Unflag the post currently selected in the PostListOverlay, if any-postListUnflagSelected :: MH ()-postListUnflagSelected = do- msgId <- use (csPostListOverlay.postListSelected)- case msgId of- Nothing -> return ()- Just pId -> flagMessage pId False----- | Jumps to the specified message in the message's main channel--- display and changes to MessageSelectState.-postListJumpToCurrent :: MH ()-postListJumpToCurrent = do- msgId <- use (csPostListOverlay.postListSelected)- st <- use id- case msgId of- Nothing -> return ()- Just pId ->- case getMessageForPostId st pId of- Just msg ->- case msg ^. mChannelId of- Just cId -> do- setFocus cId- setMode MessageSelect- csMessageSelect .= MessageSelectState (msg^.mMessageId)- Nothing ->- error "INTERNAL: selected Post ID not associated with a channel"- Nothing ->- error "INTERNAL: selected Post ID not added to global state!"
− src/State/ReactionEmojiListOverlay.hs
@@ -1,125 +0,0 @@-{-# LANGUAGE TupleSections #-}-module State.ReactionEmojiListOverlay- ( enterReactionEmojiListOverlayMode-- , reactionEmojiListSelectDown- , reactionEmojiListSelectUp- , reactionEmojiListPageDown- , reactionEmojiListPageUp- )-where--import Prelude ()-import Prelude.MH--import qualified Brick.Widgets.List as L-import qualified Data.Vector as Vec-import qualified Data.Text as T-import qualified Data.Map as M-import qualified Data.Set as Set-import Data.Function ( on )-import Data.List ( nubBy )-import Lens.Micro.Platform ( to )--import Network.Mattermost.Types-import Network.Mattermost.Endpoints ( mmPostReaction, mmDeleteReaction )--import Emoji-import State.ListOverlay-import State.MessageSelect-import State.Async-import Types---enterReactionEmojiListOverlayMode :: MH ()-enterReactionEmojiListOverlayMode = do- selectedMessage <- use (to getSelectedMessage)- case selectedMessage of- Nothing -> return ()- Just msg -> do- em <- use (csResources.crEmoji)- myId <- gets myUserId- enterListOverlayMode csReactionEmojiListOverlay ReactionEmojiListOverlay- () enterHandler (fetchResults myId msg em)--enterHandler :: (Bool, T.Text) -> MH Bool-enterHandler (mine, e) = do- session <- getSession- myId <- gets myUserId-- selectedMessage <- use (to getSelectedMessage)- case selectedMessage of- Nothing -> return False- Just m -> do- case m^.mOriginalPost of- Nothing -> return False- Just p -> do- case mine of- False ->- doAsyncWith Preempt $ do- mmPostReaction (postId p) myId e session- return Nothing- True ->- doAsyncWith Preempt $ do- mmDeleteReaction (postId p) myId e session- return Nothing- return True--fetchResults :: UserId- -- ^ My user ID, so we can see which reactions I haven't- -- posted- -> Message- -- ^ The selected message, so we can include its current- -- reactions in the list- -> EmojiCollection- -- ^ The emoji collection- -> ()- -- ^ The scope to search- -> Session- -- ^ The connection session- -> Text- -- ^ The search string- -> IO (Vec.Vector (Bool, T.Text))-fetchResults myId msg em () session searchString = do- let currentReactions = [ (myId `Set.member` uIds, k)- | (k, uIds) <- M.toList (msg^.mReactions)- ]- matchingCurrentOtherReactions = [ (mine, r) | (mine, r) <- currentReactions- , matchesEmoji searchString r- , not mine- ]- matchingCurrentMyReactions = [ (mine, r) | (mine, r) <- currentReactions- , matchesEmoji searchString r- , mine- ]- serverMatches <- getMatchingEmoji session em searchString- return $ Vec.fromList $ nubBy ((==) `on` snd) $- matchingCurrentOtherReactions <> matchingCurrentMyReactions <> ((False,) <$> serverMatches)---- | Move the selection up in the emoji list overlay by one emoji.-reactionEmojiListSelectUp :: MH ()-reactionEmojiListSelectUp = reactionEmojiListMove L.listMoveUp---- | Move the selection down in the emoji list overlay by one emoji.-reactionEmojiListSelectDown :: MH ()-reactionEmojiListSelectDown = reactionEmojiListMove L.listMoveDown---- | Move the selection up in the emoji list overlay by a page of emoji--- (ReactionEmojiListPageSize).-reactionEmojiListPageUp :: MH ()-reactionEmojiListPageUp = reactionEmojiListMove (L.listMoveBy (-1 * reactionEmojiListPageSize))---- | Move the selection down in the emoji list overlay by a page of emoji--- (ReactionEmojiListPageSize).-reactionEmojiListPageDown :: MH ()-reactionEmojiListPageDown = reactionEmojiListMove (L.listMoveBy reactionEmojiListPageSize)---- | Transform the emoji list results in some way, e.g. by moving the--- cursor, and then check to see whether the modification warrants a--- prefetch of more search results.-reactionEmojiListMove :: (L.List Name (Bool, T.Text) -> L.List Name (Bool, T.Text)) -> MH ()-reactionEmojiListMove = listOverlayMove csReactionEmojiListOverlay---- | The number of emoji in a "page" for cursor movement purposes.-reactionEmojiListPageSize :: Int-reactionEmojiListPageSize = 10
− src/State/Reactions.hs
@@ -1,52 +0,0 @@-module State.Reactions- ( asyncFetchReactionsForPost- , addReactions- , removeReaction- )-where--import Prelude ()-import Prelude.MH--import Brick.Main ( invalidateCacheEntry )-import qualified Data.Map.Strict as Map-import Lens.Micro.Platform-import qualified Data.Set as S--import Network.Mattermost.Endpoints-import Network.Mattermost.Lenses-import Network.Mattermost.Types--import State.Async-import State.Common ( fetchMentionedUsers )-import Types---asyncFetchReactionsForPost :: ChannelId -> Post -> MH ()-asyncFetchReactionsForPost cId p- | not (p^.postHasReactionsL) = return ()- | otherwise = doAsyncChannelMM Normal cId- (\s _ -> fmap toList (mmGetReactionsForPost (p^.postIdL) s))- (\_ rs -> Just $ addReactions cId rs)--addReactions :: ChannelId -> [Reaction] -> MH ()-addReactions cId rs = do- mh $ invalidateCacheEntry $ ChannelMessages cId- csChannel(cId).ccContents.cdMessages %= fmap upd- let mentions = S.fromList $ UserIdMention <$> reactionUserId <$> rs- fetchMentionedUsers mentions- where upd msg = msg & mReactions %~ insertAll (msg^.mMessageId)- insert mId r- | mId == Just (MessagePostId (r^.reactionPostIdL)) =- Map.insertWith S.union (r^.reactionEmojiNameL) (S.singleton $ r^.reactionUserIdL)- | otherwise = id- insertAll mId msg = foldr (insert mId) msg rs--removeReaction :: Reaction -> ChannelId -> MH ()-removeReaction r cId = do- mh $ invalidateCacheEntry $ ChannelMessages cId- csChannel(cId).ccContents.cdMessages %= fmap upd- where upd m | m^.mMessageId == Just (MessagePostId $ r^.reactionPostIdL) =- m & mReactions %~ (Map.alter delReaction (r^.reactionEmojiNameL))- | otherwise = m- delReaction mUs = S.delete (r^.reactionUserIdL) <$> mUs
− src/State/Setup.hs
@@ -1,281 +0,0 @@-{-# LANGUAGE TypeFamilies #-}-module State.Setup- ( setupState- )-where--import Prelude ()-import Prelude.MH--import Brick.BChan ( newBChan )-import Brick.Themes ( themeToAttrMap, loadCustomizations )-import qualified Control.Concurrent.STM as STM-import Data.Maybe ( fromJust )-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import Data.Time.Clock ( getCurrentTime )-import qualified Graphics.Vty as Vty-import Lens.Micro.Platform ( (.~) )-import System.Exit ( exitFailure, exitSuccess )-import System.FilePath ( (</>), isRelative, dropFileName )--import Network.Mattermost.Endpoints-import Network.Mattermost.Types--import Config-import InputHistory-import LastRunState-import Login-import State.Flagging-import State.Messages ( fetchVisibleIfNeeded )-import State.Setup.Threads-import TeamSelect-import Themes-import TimeUtils ( lookupLocalTimeZone )-import Types-import Types.Common-import Emoji-import FilePaths ( userEmojiJsonPath, bundledEmojiJsonPath )-import qualified Zipper as Z---incompleteCredentials :: Config -> ConnectionInfo-incompleteCredentials config = ConnectionInfo- { _ciHostname = fromMaybe "" (configHost config)- , _ciPort = configPort config- , _ciUrlPath = fromMaybe "" (configUrlPath config)- , _ciUsername = fromMaybe "" (configUser config)- , _ciPassword = case configPass config of- Just (PasswordString s) -> s- _ -> ""- , _ciAccessToken = case configToken config of- Just (TokenString s) -> s- _ -> ""- , _ciType = configConnectionType config- }--apiLogEventToLogMessage :: LogEvent -> IO LogMessage-apiLogEventToLogMessage ev = do- now <- getCurrentTime- let msg = T.pack $ "Function: " <> logFunction ev <>- ", event: " <> show (logEventType ev)- return $ LogMessage { logMessageCategory = LogAPI- , logMessageText = msg- , logMessageContext = Nothing- , logMessageTimestamp = now- }--setupState :: IO Vty.Vty -> Maybe FilePath -> Config -> IO (ChatState, Vty.Vty)-setupState mkVty mLogLocation config = do- initialVty <- mkVty-- eventChan <- newBChan 2500- logMgr <- newLogManager eventChan (configLogMaxBufferSize config)-- -- If we got an initial log location, start logging there.- case mLogLocation of- Nothing -> return ()- Just loc -> startLoggingToFile logMgr loc-- let logApiEvent ev = apiLogEventToLogMessage ev >>= sendLogMessage logMgr- setLogger cd = cd `withLogger` logApiEvent-- (mLastAttempt, loginVty) <- interactiveGetLoginSession initialVty mkVty- setLogger- logMgr- (incompleteCredentials config)-- let shutdown vty = do- Vty.shutdown vty- exitSuccess-- (session, me, cd, mbTeam) <- case mLastAttempt of- Nothing ->- -- The user never attempted a connection and just chose to- -- quit.- shutdown loginVty- Just (AttemptFailed {}) ->- -- The user attempted a connection and failed, and then chose- -- to quit.- shutdown loginVty- Just (AttemptSucceeded _ cd sess user mbTeam) ->- -- The user attempted a connection and succeeded so continue- -- with setup.- return (sess, user, cd, mbTeam)-- teams <- mmGetUsersTeams UserMe session- when (Seq.null teams) $ do- putStrLn "Error: your account is not a member of any teams"- exitFailure-- (myTeam, teamSelVty) <- do- let foundTeam = do- tName <- mbTeam <|> configTeam config- let matchingTeam = listToMaybe $ filter matches $ toList teams- matches t = (sanitizeUserText $ teamName t) == tName- matchingTeam-- case foundTeam of- Just t -> return (t, loginVty)- Nothing -> do- (mTeam, vty) <- interactiveTeamSelection loginVty mkVty $ toList teams- case mTeam of- Nothing -> shutdown vty- Just team -> return (team, vty)-- userStatusChan <- STM.newTChanIO- slc <- STM.newTChanIO- wac <- STM.newTChanIO-- prefs <- mmGetUsersPreferences UserMe session- let userPrefs = setUserPreferences prefs defaultUserPreferences- themeName = case configTheme config of- Nothing -> internalThemeName defaultTheme- Just t -> t- baseTheme = internalTheme $ fromMaybe defaultTheme (lookupTheme themeName)-- -- Did the configuration specify a theme customization file? If so,- -- load it and customize the theme.- custTheme <- case configThemeCustomizationFile config of- Nothing -> return baseTheme- Just path ->- -- If we have no configuration path (i.e. we used the default- -- config) then ignore theme customization.- let pathStr = T.unpack path- in if isRelative pathStr && isNothing (configAbsPath config)- then return baseTheme- else do- let absPath = if isRelative pathStr- then (dropFileName $ fromJust $ configAbsPath config) </> pathStr- else pathStr- result <- loadCustomizations absPath baseTheme- case result of- Left e -> do- Vty.shutdown teamSelVty- putStrLn $ "Error loading theme customization from " <> show absPath <> ": " <> e- exitFailure- Right t -> return t-- requestChan <- STM.atomically STM.newTChan-- emoji <- either (const emptyEmojiCollection) id <$> do- result1 <- loadEmoji =<< userEmojiJsonPath- case result1 of- Right e -> return $ Right e- Left _ -> loadEmoji =<< bundledEmojiJsonPath-- let cr = ChatResources { _crSession = session- , _crWebsocketThreadId = Nothing- , _crConn = cd- , _crRequestQueue = requestChan- , _crEventQueue = eventChan- , _crSubprocessLog = slc- , _crWebsocketActionChan = wac- , _crTheme = themeToAttrMap custTheme- , _crStatusUpdateChan = userStatusChan- , _crConfiguration = config- , _crFlaggedPosts = mempty- , _crUserPreferences = userPrefs- , _crSyntaxMap = mempty- , _crLogManager = logMgr- , _crEmoji = emoji- }-- st <- initializeState cr myTeam me-- return (st, teamSelVty)--initializeState :: ChatResources -> Team -> User -> IO ChatState-initializeState cr myTeam me = do- let session = getResourceSession cr- requestChan = cr^.crRequestQueue- myTId = getId myTeam-- -- Create a predicate to find the last selected channel by reading the- -- run state file. If unable to read or decode or validate the file, this- -- predicate is just `isTownSquare`.- isLastSelectedChannel <- do- result <- readLastRunState $ teamId myTeam- case result of- Right lrs | isValidLastRunState cr me lrs -> return $ \c ->- channelId c == lrs^.lrsSelectedChannelId- _ -> return isTownSquare-- -- Get all channels, but filter down to just the one we want to start- -- in. We get all, rather than requesting by name or ID, because- -- we don't know whether the server will give us a last-viewed preference.- -- We first try to find a channel matching with the last selected channel ID,- -- failing which we look for the Town Square channel by name.- userChans <- mmGetChannelsForUser UserMe myTId session- let lastSelectedChans = Seq.filter isLastSelectedChannel userChans- chans = if Seq.null lastSelectedChans- then Seq.filter isTownSquare userChans- else lastSelectedChans-- -- Since the only channel we are dealing with is by construction the- -- last channel, we don't have to consider other cases here:- chanPairs <- forM (toList chans) $ \c -> do- cChannel <- makeClientChannel (userId me) c- return (getId c, cChannel)-- tz <- lookupLocalTimeZone- hist <- do- result <- readHistory- case result of- Left _ -> return newHistory- Right h -> return h-- --------------------------------------------------------------------- -- Start background worker threads:- --- -- * Syntax definition loader- startSyntaxMapLoaderThread (cr^.crConfiguration) (cr^.crEventQueue)-- -- * Main async queue worker thread- startAsyncWorkerThread (cr^.crConfiguration) (cr^.crRequestQueue) (cr^.crEventQueue)-- -- * User status thread- startUserStatusUpdateThread (cr^.crStatusUpdateChan) session requestChan-- -- * Refresher for users who are typing currently- when (configShowTypingIndicator (cr^.crConfiguration)) $- startTypingUsersRefreshThread requestChan-- -- * Timezone change monitor- startTimezoneMonitorThread tz requestChan-- -- * Subprocess logger- startSubprocessLoggerThread (cr^.crSubprocessLog) requestChan-- -- * Spell checker and spell check timer, if configured- spResult <- maybeStartSpellChecker (cr^.crConfiguration) (cr^.crEventQueue)-- -- End thread startup ------------------------------------------------ now <- getCurrentTime- let chanIds = mkChannelZipperList now (cr^.crConfiguration) Nothing (cr^.crUserPreferences) clientChans noUsers- chanZip = Z.fromList chanIds- clientChans = foldr (uncurry addChannel) noChannels chanPairs- startupState =- StartupStateInfo { startupStateResources = cr- , startupStateChannelZipper = chanZip- , startupStateConnectedUser = me- , startupStateTeam = myTeam- , startupStateTimeZone = tz- , startupStateInitialHistory = hist- , startupStateSpellChecker = spResult- }-- initialState <- newState startupState- let st = initialState & csChannels .~ clientChans-- loadFlaggedMessages (cr^.crUserPreferences.userPrefFlaggedPostList) st-- -- Trigger an initial websocket refresh- writeBChan (cr^.crEventQueue) RefreshWebsocketEvent-- -- Refresh initial channel(s)- writeBChan (cr^.crEventQueue) $ RespEvent $ do- fetchVisibleIfNeeded-- return st
− src/State/Setup/Threads.hs
@@ -1,372 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module State.Setup.Threads- ( startUserStatusUpdateThread- , startTypingUsersRefreshThread- , startSubprocessLoggerThread- , startTimezoneMonitorThread- , maybeStartSpellChecker- , startAsyncWorkerThread- , startSyntaxMapLoaderThread- , module State.Setup.Threads.Logging- )-where--import Prelude ()-import Prelude.MH--import Brick.BChan ( BChan )-import Brick.Main ( invalidateCache )-import Control.Concurrent ( threadDelay, forkIO )-import qualified Control.Concurrent.STM as STM-import Control.Concurrent.STM.Delay-import Control.Exception ( SomeException, try, fromException, catch )-import Data.List ( isInfixOf )-import qualified Data.Map as M-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import Data.Time ( getCurrentTime, addUTCTime )-import Lens.Micro.Platform ( (.=), (%=), (%~), mapped )-import Skylighting.Loader ( loadSyntaxesFromDir )-import System.Directory ( getTemporaryDirectory )-import System.Exit ( ExitCode(ExitSuccess) )-import System.IO ( hPutStrLn, hFlush )-import System.IO.Temp ( openTempFile )-import System.Timeout ( timeout )-import Text.Aspell ( Aspell, AspellOption(..), startAspell )--import Network.Mattermost.Exceptions ( RateLimitException- , rateLimitExceptionReset )-import Network.Mattermost.Endpoints-import Network.Mattermost.Types--import Constants-import State.Editing ( requestSpellCheck )-import State.Setup.Threads.Logging-import TimeUtils ( lookupLocalTimeZone )-import Types---updateUserStatuses :: [UserId] -> Session -> IO (Maybe (MH ()))-updateUserStatuses uIds session =- case null uIds of- False -> do- statuses <- mmGetUserStatusByIds (Seq.fromList uIds) session- return $ Just $ do- forM_ statuses $ \s ->- setUserStatus (statusUserId s) (statusStatus s)- True -> return Nothing--startUserStatusUpdateThread :: STM.TChan [UserId] -> Session -> RequestChan -> IO ()-startUserStatusUpdateThread zipperChan session requestChan = void $ forkIO body- where- seconds = (* (1000 * 1000))- userRefreshInterval = 30- body = refresh []- refresh prev = do- result <- timeout (seconds userRefreshInterval) (STM.atomically $ STM.readTChan zipperChan)- let (uIds, update) = case result of- Nothing -> (prev, True)- Just ids -> (ids, ids /= prev)-- when update $ do- STM.atomically $ STM.writeTChan requestChan $ do- rs <- try $ updateUserStatuses uIds session- case rs of- Left (_ :: SomeException) -> return Nothing- Right upd -> return upd-- refresh uIds---- This thread refreshes the map of typing users every second, forever,--- to expire users who have stopped typing. Expiry time is 3 seconds.-startTypingUsersRefreshThread :: RequestChan -> IO ()-startTypingUsersRefreshThread requestChan = void $ forkIO $ forever refresh- where- seconds = (* (1000 * 1000))- refreshIntervalMicros = ceiling $ seconds $ userTypingExpiryInterval / 2- refresh = do- STM.atomically $ STM.writeTChan requestChan $ return $ Just $ do- now <- liftIO getCurrentTime- let expiry = addUTCTime (- userTypingExpiryInterval) now- let expireUsers c = c & ccInfo.cdTypingUsers %~ expireTypingUsers expiry- csChannels . mapped %= expireUsers-- threadDelay refreshIntervalMicros--startSubprocessLoggerThread :: STM.TChan ProgramOutput -> RequestChan -> IO ()-startSubprocessLoggerThread logChan requestChan = do- let logMonitor mPair = do- ProgramOutput progName args out stdoutOkay err ec <-- STM.atomically $ STM.readTChan logChan-- -- If either stdout or stderr is non-empty or there was an exit- -- failure, log it and notify the user.- let emptyOutput s = null s || s == "\n"-- case ec == ExitSuccess && (emptyOutput out || stdoutOkay) && emptyOutput err of- -- the "good" case, no output and exit sucess- True -> logMonitor mPair- False -> do- (logPath, logHandle) <- case mPair of- Just p ->- return p- Nothing -> do- tmp <- getTemporaryDirectory- openTempFile tmp "matterhorn-subprocess.log"-- let unexpectedStdout = ec == ExitSuccess && (not $ emptyOutput out) && not stdoutOkay-- hPutStrLn logHandle $- unlines [ "Program: " <> progName- , "Arguments: " <> show args- , "Exit code: " <> show ec- , if unexpectedStdout- then "Stdout (unexpected due to successful exit):"- else "Stdout:"- , out- , "Stderr:"- , err- ]- hFlush logHandle-- STM.atomically $ STM.writeTChan requestChan $ do- return $ Just $ mhError $ ProgramExecutionFailed (T.pack progName) (T.pack logPath)-- logMonitor (Just (logPath, logHandle))-- void $ forkIO $ logMonitor Nothing--startTimezoneMonitorThread :: TimeZoneSeries -> RequestChan -> IO ()-startTimezoneMonitorThread tz requestChan = do- -- Start the timezone monitor thread- let timezoneMonitorSleepInterval = minutes 5- minutes = (* (seconds 60))- seconds = (* (1000 * 1000))- timezoneMonitor prevTz = do- threadDelay timezoneMonitorSleepInterval-- newTz <- lookupLocalTimeZone- when (newTz /= prevTz) $- STM.atomically $ STM.writeTChan requestChan $ do- return $ Just $ do- timeZone .= newTz- mh invalidateCache-- timezoneMonitor newTz-- void $ forkIO (timezoneMonitor tz)--maybeStartSpellChecker :: Config -> BChan MHEvent -> IO (Maybe (Aspell, IO ()))-maybeStartSpellChecker config eventQueue = do- case configEnableAspell config of- False -> return Nothing- True -> do- let aspellOpts = catMaybes [ UseDictionary <$> (configAspellDictionary config)- ]- spellCheckerTimeout = 500 * 1000 -- 500k us = 500ms- asResult <- either (const Nothing) Just <$> startAspell aspellOpts- case asResult of- Nothing -> return Nothing- Just as -> do- resetSCChan <- startSpellCheckerThread eventQueue spellCheckerTimeout- let resetSCTimer = STM.atomically $ STM.writeTChan resetSCChan ()- return $ Just (as, resetSCTimer)---- Start the background spell checker delay thread.------ The purpose of this thread is to postpone the spell checker query--- while the user is actively typing and only wait until they have--- stopped typing before bothering with a query. This is to avoid spell--- checker queries when the editor contents are changing rapidly.--- Avoiding such queries reduces system load and redraw frequency.------ We do this by starting a thread whose job is to wait for the event--- loop to tell it to schedule a spell check. Spell checks are scheduled--- by writing to the channel returned by this function. The scheduler--- thread reads from that channel and then works with another worker--- thread as follows:------ A wakeup of the main spell checker thread causes it to determine--- whether the worker thread is already waiting on a timer. When that--- timer goes off, a spell check will be requested. If there is already--- an active timer that has not yet expired, the timer's expiration is--- extended. This is the case where typing is occurring and we want to--- continue postponing the spell check. If there is not an active timer--- or the active timer has expired, we create a new timer and send it to--- the worker thread for waiting.------ The worker thread works by reading a timer from its queue, waiting--- until the timer expires, and then injecting an event into the main--- event loop to request a spell check.-startSpellCheckerThread :: BChan MHEvent- -- ^ The main event loop's event channel.- -> Int- -- ^ The number of microseconds to wait before- -- requesting a spell check.- -> IO (STM.TChan ())-startSpellCheckerThread eventChan spellCheckTimeout = do- delayWakeupChan <- STM.atomically STM.newTChan- delayWorkerChan <- STM.atomically STM.newTChan- delVar <- STM.atomically $ STM.newTVar Nothing-- -- The delay worker actually waits on the delay to expire and then- -- requests a spell check.- void $ forkIO $ forever $ do- STM.atomically $ waitDelay =<< STM.readTChan delayWorkerChan- writeBChan eventChan (RespEvent requestSpellCheck)-- -- The delay manager waits for requests to start a delay timer and- -- signals the worker to begin waiting.- void $ forkIO $ forever $ do- () <- STM.atomically $ STM.readTChan delayWakeupChan-- oldDel <- STM.atomically $ STM.readTVar delVar- mNewDel <- case oldDel of- Nothing -> Just <$> newDelay spellCheckTimeout- Just del -> do- -- It's possible that between this check for expiration and- -- the updateDelay below, the timer will expire -- at which- -- point this will mean that we won't extend the timer as- -- originally desired. But that's alright, because future- -- keystrokes will trigger another timer anyway.- expired <- tryWaitDelayIO del- case expired of- True -> Just <$> newDelay spellCheckTimeout- False -> do- updateDelay del spellCheckTimeout- return Nothing-- case mNewDel of- Nothing -> return ()- Just newDel -> STM.atomically $ do- STM.writeTVar delVar $ Just newDel- STM.writeTChan delayWorkerChan newDel-- return delayWakeupChan--startSyntaxMapLoaderThread :: Config -> BChan MHEvent -> IO ()-startSyntaxMapLoaderThread config eventChan = void $ forkIO $ do- -- Iterate over the configured syntax directories, loading syntax- -- maps. Ensure that entries loaded in earlier directories in the- -- sequence take precedence over entries loaded later.- mMaps <- forM (configSyntaxDirs config) $ \dir -> do- result <- try $ loadSyntaxesFromDir dir- case result of- Left (_::SomeException) -> return Nothing- Right (Left _) -> return Nothing- Right (Right m) -> return $ Just m-- let maps = catMaybes mMaps- finalMap = foldl M.union mempty maps-- writeBChan eventChan $ RespEvent $ do- csResources.crSyntaxMap .= finalMap- mh invalidateCache------------------------------------------------------------------------ Async worker thread--startAsyncWorkerThread :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()-startAsyncWorkerThread c r e = void $ forkIO $ asyncWorker c r e--asyncWorker :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()-asyncWorker c r e = forever $ doAsyncWork c r e--doAsyncWork :: Config -> STM.TChan (IO (Maybe (MH ()))) -> BChan MHEvent -> IO ()-doAsyncWork config requestChan eventChan = do- let rateLimitNotify sec = do- writeBChan eventChan $ RateLimitExceeded sec-- startWork <- case configShowBackground config of- Disabled -> return $ return ()- Active -> do chk <- STM.atomically $ STM.tryPeekTChan requestChan- case chk of- Nothing -> do writeBChan eventChan BGIdle- return $ writeBChan eventChan $ BGBusy Nothing- _ -> return $ return ()- ActiveCount -> do- chk <- STM.atomically $ do- chanCopy <- STM.cloneTChan requestChan- let cntMsgs = do m <- STM.tryReadTChan chanCopy- case m of- Nothing -> return 0- Just _ -> (1 +) <$> cntMsgs- cntMsgs- case chk of- 0 -> do writeBChan eventChan BGIdle- return (writeBChan eventChan $ BGBusy (Just 1))- _ -> do writeBChan eventChan $ BGBusy (Just chk)- return $ return ()-- req <- STM.atomically $ STM.readTChan requestChan- startWork- -- Run the IO action with up to one additional attempt if it makes- -- rate-limited API requests.- res <- try $ rateLimitRetry rateLimitNotify req- case res of- Left e -> do- when (not $ shouldIgnore e) $ do- case fromException e of- Just (_::RateLimitException) ->- writeBChan eventChan RequestDropped- Nothing -> do- let err = case fromException e of- Nothing -> AsyncErrEvent e- Just mmErr -> ServerError mmErr- writeBChan eventChan $ IEvent $ DisplayError err- Right upd ->- case upd of- -- The IO action triggered a rate limit error but could- -- not be retried due to rate limiting information being- -- missing.- Nothing -> writeBChan eventChan RateLimitSettingsMissing-- -- The IO action was run successfully but returned no- -- state transformation.- Just Nothing -> return ()-- -- The IO action was run successfully and returned a state- -- transformation.- Just (Just action) -> writeBChan eventChan (RespEvent action)---- | Run an IO action. If the action raises a RateLimitException, invoke--- the provided rate limit exception handler with the rate limit window--- size (time in seconds until rate limit resets). Then block until the--- rate limit resets and attempt to run the action one more time.------ If the rate limit exception does not contain a rate limit reset--- interval, return Nothing. Otherwise return IO action's result.-rateLimitRetry :: (Int -> IO ()) -> IO a -> IO (Maybe a)-rateLimitRetry rateLimitNotify act = do- let retry e = do- case rateLimitExceptionReset e of- -- The rate limit exception contains no metadata so we- -- cannot retry the action.- Nothing -> return Nothing-- -- The rate limit exception contains the size of the- -- rate limit reset interval, so block until that has- -- passed and retry the action (only) one more time.- Just sec -> do- let adjusted = sec + 1- rateLimitNotify adjusted- threadDelay $ adjusted * 1000000- Just <$> act-- (Just <$> act) `catch` retry---- Filter for exceptions that we don't want to report to the user,--- probably because they are not actionable and/or contain no useful--- information.------ E.g.--- https://github.com/matterhorn-chat/matterhorn/issues/391-shouldIgnore :: SomeException -> Bool-shouldIgnore e =- let eStr = show e- in or [ "getAddrInfo" `isInfixOf` eStr- , "Network.Socket.recvBuf" `isInfixOf` eStr- , "Network.Socket.sendBuf" `isInfixOf` eStr- , "resource vanished" `isInfixOf` eStr- , "timeout" `isInfixOf` eStr- ]
− src/State/Setup/Threads/Logging.hs
@@ -1,317 +0,0 @@-{-# LANGUAGE RecordWildCards #-}--- | This module implements the logging thread. This thread can--- optionally write logged messages to an output file. When the thread--- is created, it is initially not writing to any output file and--- must be told to do so by issuing a LogCommand using the LogManager--- interface.------ The logging thread has an internal bounded log message buffer. Logged--- messages always get written to the buffer (potentially evicting old--- messages to maintain the size bound). If the thread is also writing--- to a file, such messages also get written to the file. When the--- thread begins logging to a file, the entire buffer is written to the--- file so that a historical snapshot of log activity can be saved in--- cases where logging is turned on at runtime only once a problematic--- behavior is observed.-module State.Setup.Threads.Logging- ( newLogManager- , shutdownLogManager- )-where--import Prelude ()-import Prelude.MH--import Brick.BChan ( BChan )-import Control.Concurrent.Async ( Async, async, wait )-import qualified Control.Concurrent.STM as STM-import Control.Exception ( SomeException, try )-import Control.Monad.State.Strict-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import Data.Time ( getCurrentTime )-import System.IO ( Handle, IOMode(AppendMode), hPutStr, hPutStrLn- , hFlush, openFile, hClose )--import Types----- | Used to remember the last logging output point for various log output targets.-newtype LogMemory = LogMemory { logMem :: [(FilePath, LogMessage)] }--blankLogMemory :: LogMemory-blankLogMemory = LogMemory []---- | Adds or updates the last log message output to this destination.--- This is used for the case when logging is re-enabled to that--- destination to avoid repeating logging output.------ The number of log memories is limited by forgetting the oldest ones--- if over a threshold. This handles a pathological case where the--- user has redirected output to a very large number of logfiles.--- This is very unlikely to happen (a script or bad paste buffer--- maybe?) but this provides defensive behavior to prevent--- uncontrolled memory consumption in this case. The limit here is--- arbitrary, but it should suffice and once it's reached the--- degradation case is simply the potential for duplicated messages--- when returning to logfiles that haven't been recently logged to.-rememberOutputPoint :: FilePath -> LogMessage -> LogMemory -> LogMemory-rememberOutputPoint logPath logMsg oldLogMem =- LogMemory $- take 50 $ -- upper limit on number of logpath+message memories retained- (logPath, logMsg) : filter ((/=) logPath . fst) (logMem oldLogMem)---- | Returns the last LogMessage logged to the specified output log--- file path if it was previously logged to.-memoryOfOutputPath :: FilePath -> LogMemory -> Maybe LogMessage-memoryOfOutputPath p = lookup p . logMem---- | The state of the logging thread.-data LogThreadState =- LogThreadState { logThreadDestination :: Maybe (FilePath, Handle)- -- ^ The logging thread's active logging destination.- -- Nothing means log messages are not being written- -- anywhere except the internal buffer.- , logThreadEventChan :: BChan MHEvent- -- ^ The application event channel that we'll use to- -- notify of logging events.- , logThreadCommandChan :: STM.TChan LogCommand- -- ^ The channel on which the logging thread will- -- wait for logging commands.- , logThreadMessageBuffer :: Seq.Seq LogMessage- -- ^ The internal bounded log message buffer.- , logThreadMaxBufferSize :: Int- -- ^ The size bound of the logThreadMessageBuffer.- , logPreviousStopPoint :: LogMemory- -- ^ Previous logging stop points to avoid- -- duplication if logging is re-enabled to the same- -- output- }---- | Create a new log manager and start a logging thread for it.-newLogManager :: BChan MHEvent -> Int -> IO LogManager-newLogManager eventChan maxBufferSize = do- chan <- STM.newTChanIO- self <- startLoggingThread eventChan chan maxBufferSize- let mgr = LogManager { logManagerCommandChannel = chan- , logManagerHandle = self- }- return mgr---- | Shuts down the log manager and blocks until shutdown is complete.-shutdownLogManager :: LogManager -> IO ()-shutdownLogManager mgr = do- STM.atomically $ STM.writeTChan (logManagerCommandChannel mgr) ShutdownLogging- wait $ logManagerHandle mgr---- | The logging thread.-startLoggingThread :: BChan MHEvent -> STM.TChan LogCommand -> Int -> IO (Async ())-startLoggingThread eventChan logChan maxBufferSize = do- let initialState = LogThreadState { logThreadDestination = Nothing- , logThreadEventChan = eventChan- , logThreadCommandChan = logChan- , logThreadMessageBuffer = mempty- , logThreadMaxBufferSize = maxBufferSize- , logPreviousStopPoint = blankLogMemory- }- async $ void $ runStateT logThreadBody initialState--logThreadBody :: StateT LogThreadState IO ()-logThreadBody = do- cmd <- nextLogCommand- continue <- handleLogCommand cmd- when continue logThreadBody---- | Get the next pending log thread command.-nextLogCommand :: StateT LogThreadState IO LogCommand-nextLogCommand = do- chan <- gets logThreadCommandChan- liftIO $ STM.atomically $ STM.readTChan chan--putMarkerMessage :: String -> Handle -> IO ()-putMarkerMessage msg h = do- now <- getCurrentTime- hPutStrLn h $ "[" <> show now <> "] " <> msg---- | Emit a log stop marker to the file.-putLogEndMarker :: Handle -> IO ()-putLogEndMarker = putMarkerMessage "<<< Logging end >>>"---- | Emit a log start marker to the file.-putLogStartMarker :: Handle -> IO ()-putLogStartMarker = putMarkerMessage "<<< Logging start >>>"---- | Emit a log stop marker to the file and close it, then notify the--- application that we have stopped logging.-finishLog :: BChan MHEvent -> FilePath -> Handle -> StateT LogThreadState IO ()-finishLog eventChan oldPath oldHandle = do- liftIO $ do- putLogEndMarker oldHandle- hClose oldHandle- writeBChan eventChan $ IEvent $ LoggingStopped oldPath- modify $ \s ->- let buf = logThreadMessageBuffer s- lastLm = Seq.index buf (Seq.length buf - 1) -- n.b. putLogEndMarker ensures buf not empty- stops = rememberOutputPoint oldPath lastLm $ logPreviousStopPoint s- in s { logThreadDestination = Nothing- , logPreviousStopPoint = stops- }--stopLogOutput :: StateT LogThreadState IO ()-stopLogOutput = do- oldDest <- gets logThreadDestination- case oldDest of- Nothing -> return ()- Just (oldPath, oldHandle) -> do- eventChan <- gets logThreadEventChan- finishLog eventChan oldPath oldHandle---- | Handle a single logging command.-handleLogCommand :: LogCommand -> StateT LogThreadState IO Bool-handleLogCommand (LogSnapshot path) = do- -- LogSnapshot: write the current log message buffer to the- -- specified path. Ignore the request if it is for the path that we- -- are already logging to.- eventChan <- gets logThreadEventChan- dest <- gets logThreadDestination-- let shouldWrite = case dest of- Nothing -> True- Just (curPath, _) -> curPath /= path-- when shouldWrite $ do- result <- liftIO $ try $ openFile path AppendMode- case result of- Left (e::SomeException) -> do- liftIO $ writeBChan eventChan $ IEvent $ LogSnapshotFailed path (show e)- Right handle -> do- flushLogMessageBuffer path handle- liftIO $ hClose handle- liftIO $ writeBChan eventChan $ IEvent $ LogSnapshotSucceeded path-- return True-handleLogCommand GetLogDestination = do- -- GetLogDestination: the application asked us to provide the- -- current log destination.- dest <- gets logThreadDestination- eventChan <- gets logThreadEventChan- liftIO $ writeBChan eventChan $ IEvent $ LogDestination $ fst <$> dest- return True-handleLogCommand ShutdownLogging = do- -- ShutdownLogging: if we were logging to a file, close it. Then- -- unlock the shutdown lock.- stopLogOutput- return False-handleLogCommand StopLogging = do- -- StopLogging: if we were logging to a file, close it and notify- -- the application. Otherwise do nothing.- stopLogOutput- return True-handleLogCommand (LogToFile newPath) = do- -- LogToFile: if we were logging to a file, close that file, notify- -- the application, then attempt to open the new file. If that- -- failed, notify the application of the error. If it succeeded,- -- start logging and notify the application.- eventChan <- gets logThreadEventChan- oldDest <- gets logThreadDestination-- shouldChange <- case oldDest of- Nothing ->- return True- Just (oldPath, _) ->- return (oldPath /= newPath)-- when shouldChange $ do- result <- liftIO $ try $ openFile newPath AppendMode- case result of- Left (e::SomeException) -> liftIO $ do- let msg = "Error in log thread: could not open " <> show newPath <>- ": " <> show e- writeBChan eventChan $ IEvent $ LogStartFailed newPath msg- Right handle -> do- stopLogOutput-- modify $ \s -> s { logThreadDestination = Just (newPath, handle) }- flushLogMessageBuffer newPath handle- liftIO $ putLogStartMarker handle- liftIO $ writeBChan eventChan $ IEvent $ LoggingStarted newPath-- return True-handleLogCommand (LogAMessage lm) = do- -- LogAMessage: log a single message. Write the message to the- -- bounded internal buffer (which may cause an older message to be- -- evicted). Then, if we are actively logging to a file, write the- -- message to that file and flush the output stream.- maxBufSize <- gets logThreadMaxBufferSize-- let addMessageToBuffer s =- -- Ensure that newSeq is always at most maxBufSize elements- -- long.- let newSeq = s Seq.|> lm- toDrop = Seq.length s - maxBufSize- in Seq.drop toDrop newSeq-- -- Append the message to the internal buffer, maintaining the bound- -- on the internal buffer size.- modify $ \s -> s { logThreadMessageBuffer = addMessageToBuffer (logThreadMessageBuffer s) }-- -- If we have an active log destination, write the message to the- -- output file.- dest <- gets logThreadDestination- case dest of- Nothing -> return ()- Just (_, handle) -> liftIO $ do- hPutLogMessage handle lm- hFlush handle-- return True---- | Write a single log message to the output handle.-hPutLogMessage :: Handle -> LogMessage -> IO ()-hPutLogMessage handle (LogMessage {..}) = do- hPutStr handle $ "[" <> show logMessageTimestamp <> "] "- hPutStr handle $ "[" <> show logMessageCategory <> "] "- case logMessageContext of- Nothing -> hPutStr handle "[*] "- Just c -> hPutStr handle $ "[" <> show c <> "] "- hPutStrLn handle $ T.unpack logMessageText---- | Flush the contents of the internal log message buffer.-flushLogMessageBuffer :: FilePath -> Handle -> StateT LogThreadState IO ()-flushLogMessageBuffer pathOfHandle handle = do- buf <- gets logThreadMessageBuffer- when (not $ Seq.null buf) $ do- lastPoint <- memoryOfOutputPath pathOfHandle <$> gets logPreviousStopPoint- case lastPoint of- Nothing ->- -- never logged to this output point before, so dump the- -- current internal buffer of log messages to the beginning of- -- the output file.- dumpBuf buf- Just lm ->- -- There was previous logging to this file. If the log buffer- -- contains the entirety of the log messages during the- -- logging disable, then just dump that portion; otherwise- -- dump the entire buffer and indicate there may be missing- -- entries before the buffer.- let unseen = Seq.takeWhileR (not . (==) lm) buf- firstM = Seq.index buf 0 -- above ensures that buf is not empty here- in do when (Seq.length buf == Seq.length unseen) $ liftIO $- hPutStrLn handle $ mkMsg (logMessageTimestamp firstM)- "<<< Potentially missing log messages here... >>>"- dumpBuf unseen- where- mkMsg t m = "[" <> show t <> "] " <> m- dumpBuf buf = liftIO $ do- let firstLm = Seq.index buf 0- lastLm = Seq.index buf (Seq.length buf - 1)-- hPutStrLn handle $ mkMsg (logMessageTimestamp firstLm)- "<<< Log message buffer begin >>>"-- forM_ buf (hPutLogMessage handle)-- hPutStrLn handle $ mkMsg (logMessageTimestamp lastLm)- "<<< Log message buffer end >>>"-- hFlush handle
− src/State/ThemeListOverlay.hs
@@ -1,86 +0,0 @@-module State.ThemeListOverlay- ( enterThemeListMode-- , themeListSelectDown- , themeListSelectUp- , themeListPageDown- , themeListPageUp-- , setTheme- )-where--import Prelude ()-import Prelude.MH--import Brick ( invalidateCache )-import Brick.Themes ( themeToAttrMap )-import qualified Brick.Widgets.List as L-import qualified Data.Text as T-import qualified Data.Vector as Vec-import Lens.Micro.Platform ( (.=) )--import Network.Mattermost.Types--import State.ListOverlay-import Themes-import Types----- | Show the user list overlay with the given search scope, and issue a--- request to gather the first search results.-enterThemeListMode :: MH ()-enterThemeListMode =- enterListOverlayMode csThemeListOverlay ThemeListOverlay () setInternalTheme getThemesMatching---- | Move the selection up in the user list overlay by one user.-themeListSelectUp :: MH ()-themeListSelectUp = themeListMove L.listMoveUp---- | Move the selection down in the user list overlay by one user.-themeListSelectDown :: MH ()-themeListSelectDown = themeListMove L.listMoveDown---- | Move the selection up in the user list overlay by a page of users--- (themeListPageSize).-themeListPageUp :: MH ()-themeListPageUp = themeListMove (L.listMoveBy (-1 * themeListPageSize))---- | Move the selection down in the user list overlay by a page of users--- (themeListPageSize).-themeListPageDown :: MH ()-themeListPageDown = themeListMove (L.listMoveBy themeListPageSize)---- | Transform the user list results in some way, e.g. by moving the--- cursor, and then check to see whether the modification warrants a--- prefetch of more search results.-themeListMove :: (L.List Name InternalTheme -> L.List Name InternalTheme) -> MH ()-themeListMove = listOverlayMove csThemeListOverlay---- | The number of users in a "page" for cursor movement purposes.-themeListPageSize :: Int-themeListPageSize = 10--getThemesMatching :: ()- -> Session- -> Text- -> IO (Vec.Vector InternalTheme)-getThemesMatching _ _ searchString = do- let matching = filter matches internalThemes- search = T.toLower searchString- matches t = search `T.isInfixOf` T.toLower (internalThemeName t) ||- search `T.isInfixOf` T.toLower (internalThemeDesc t)- return $ Vec.fromList matching--setInternalTheme :: InternalTheme -> MH Bool-setInternalTheme t = do- setTheme $ internalThemeName t- return False--setTheme :: Text -> MH ()-setTheme name =- case lookupTheme name of- Nothing -> enterThemeListMode- Just it -> do- mh invalidateCache- csResources.crTheme .= (themeToAttrMap $ internalTheme it)
− src/State/UrlSelect.hs
@@ -1,48 +0,0 @@-module State.UrlSelect- (- -- * URL selection mode- startUrlSelect- , stopUrlSelect- , openSelectedURL- )-where--import Prelude ()-import Prelude.MH--import Brick.Widgets.List ( list, listMoveTo, listSelectedElement )-import qualified Data.Vector as V-import Lens.Micro.Platform ( (.=), to )--import State.Common-import Types-import Util---startUrlSelect :: MH ()-startUrlSelect = do- urls <- use (csCurrentChannel.to findUrls.to V.fromList)- setMode UrlSelect- csUrlList .= (listMoveTo (length urls - 1) $ list UrlList urls 2)--stopUrlSelect :: MH ()-stopUrlSelect = setMode Main--openSelectedURL :: MH ()-openSelectedURL = whenMode UrlSelect $ do- selected <- use (csUrlList.to listSelectedElement)- case selected of- Nothing -> return ()- Just (_, link) -> do- opened <- openURL (OpenLinkChoice link)- when (not opened) $ do- mhError $ ConfigOptionMissing "urlOpenCommand"- setMode Main--findUrls :: ClientChannel -> [LinkChoice]-findUrls chan =- let msgs = chan^.ccContents.cdMessages- in removeDuplicates $ concat $ toList $ toList <$> msgURLs <$> msgs--removeDuplicates :: [LinkChoice] -> [LinkChoice]-removeDuplicates = nubOn (\ l -> (l^.linkURL, l^.linkUser))
− src/State/UserListOverlay.hs
@@ -1,174 +0,0 @@-module State.UserListOverlay- ( enterChannelMembersUserList- , enterChannelInviteUserList- , enterDMSearchUserList-- , userListSelectDown- , userListSelectUp- , userListPageDown- , userListPageUp- )-where--import Prelude ()-import Prelude.MH--import qualified Brick.Widgets.List as L-import qualified Data.HashMap.Strict as HM-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import qualified Data.Vector as Vec-import Lens.Micro.Platform ( (.~), (.=) )--import qualified Network.Mattermost.Endpoints as MM-import qualified Network.Mattermost.Types.Config as MM-import Network.Mattermost.Types--import State.Async ( doAsyncWith, AsyncPriority(Preempt) )-import State.Channels ( createOrFocusDMChannel, addUserToCurrentChannel )-import State.ListOverlay-import Types----- | Show the user list overlay for searching/showing members of the--- current channel.-enterChannelMembersUserList :: MH ()-enterChannelMembersUserList = do- cId <- use csCurrentChannelId- myId <- gets myUserId- myTId <- gets myTeamId- session <- getSession-- doAsyncWith Preempt $ do- stats <- MM.mmGetChannelStatistics cId session- return $ Just $ do- enterUserListMode (ChannelMembers cId myTId) (Just $ channelStatsMemberCount stats)- (\u -> case u^.uiId /= myId of- True -> createOrFocusDMChannel u Nothing >> return True- False -> return False- )---- | Show the user list overlay for showing users that are not members--- of the current channel for the purpose of adding them to the--- channel.-enterChannelInviteUserList :: MH ()-enterChannelInviteUserList = do- cId <- use csCurrentChannelId- myId <- gets myUserId- myTId <- gets myTeamId- enterUserListMode (ChannelNonMembers cId myTId) Nothing- (\u -> case u^.uiId /= myId of- True -> addUserToCurrentChannel u >> return True- False -> return False- )---- | Show the user list overlay for showing all users for the purpose of--- starting a direct message channel with another user.-enterDMSearchUserList :: MH ()-enterDMSearchUserList = do- myId <- gets myUserId- myTId <- gets myTeamId- config <- use csClientConfig- let restrictTeam = case MM.clientConfigRestrictDirectMessage <$> config of- Just MM.RestrictTeam -> Just myTId- _ -> Nothing- enterUserListMode (AllUsers restrictTeam) Nothing- (\u -> case u^.uiId /= myId of- True -> createOrFocusDMChannel u Nothing >> return True- False -> return False- )---- | Show the user list overlay with the given search scope, and issue a--- request to gather the first search results.-enterUserListMode :: UserSearchScope -> Maybe Int -> (UserInfo -> MH Bool) -> MH ()-enterUserListMode scope resultCount enterHandler = do- csUserListOverlay.listOverlayRecordCount .= resultCount- enterListOverlayMode csUserListOverlay UserListOverlay scope enterHandler getUserSearchResults--userInfoFromPair :: User -> Text -> UserInfo-userInfoFromPair u status =- userInfoFromUser u True & uiStatus .~ statusFromText status---- | Move the selection up in the user list overlay by one user.-userListSelectUp :: MH ()-userListSelectUp = userListMove L.listMoveUp---- | Move the selection down in the user list overlay by one user.-userListSelectDown :: MH ()-userListSelectDown = userListMove L.listMoveDown---- | Move the selection up in the user list overlay by a page of users--- (userListPageSize).-userListPageUp :: MH ()-userListPageUp = userListMove (L.listMoveBy (-1 * userListPageSize))---- | Move the selection down in the user list overlay by a page of users--- (userListPageSize).-userListPageDown :: MH ()-userListPageDown = userListMove (L.listMoveBy userListPageSize)---- | Transform the user list results in some way, e.g. by moving the--- cursor, and then check to see whether the modification warrants a--- prefetch of more search results.-userListMove :: (L.List Name UserInfo -> L.List Name UserInfo) -> MH ()-userListMove = listOverlayMove csUserListOverlay---- | The number of users in a "page" for cursor movement purposes.-userListPageSize :: Int-userListPageSize = 10--getUserSearchResults :: UserSearchScope- -- ^ The scope to search- -> Session- -- ^ The connection session- -> Text- -- ^ The search string- -> IO (Vec.Vector UserInfo)-getUserSearchResults scope s searchString = do- -- Unfortunately, we don't get pagination control when there is a- -- search string in effect. We'll get at most 100 results from a- -- search.- let query = UserSearch { userSearchTerm = if T.null searchString then " " else searchString- -- Hack alert: Searching with the string " "- -- above is a hack to use the search- -- endpoint to get "all users" instead of- -- those matching a particular non-empty- -- non-whitespace string. This is because- -- only the search endpoint provides a- -- control to eliminate deleted users from- -- the results. If we don't do this, and- -- use the /users endpoint instead, we'll- -- get deleted users in those results and- -- then those deleted users will disappear- -- from the results once the user enters a- -- non-empty string string.- , userSearchAllowInactive = False- , userSearchWithoutTeam = False- , userSearchInChannelId = case scope of- ChannelMembers cId _ -> Just cId- _ -> Nothing- , userSearchNotInTeamId = Nothing- , userSearchNotInChannelId = case scope of- ChannelNonMembers cId _ -> Just cId- _ -> Nothing- , userSearchTeamId = case scope of- AllUsers tId -> tId- ChannelMembers _ tId -> Just tId- ChannelNonMembers _ tId -> Just tId- }- users <- MM.mmSearchUsers query s-- let uList = toList users- uIds = userId <$> uList-- -- Now fetch status info for the users we got.- case null uList of- False -> do- statuses <- MM.mmGetUserStatusByIds (Seq.fromList uIds) s- let statusMap = HM.fromList [ (statusUserId e, statusStatus e) | e <- toList statuses ]- usersWithStatus = [ userInfoFromPair u (fromMaybe "" $ HM.lookup (userId u) statusMap)- | u <- uList- ]-- return $ Vec.fromList usersWithStatus- True -> return mempty
− src/State/Users.hs
@@ -1,100 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module State.Users- ( handleNewUsers- , handleTypingUser- , withFetchedUser- , withFetchedUserMaybe- )-where--import Prelude ()-import Prelude.MH--import qualified Data.Text as T-import qualified Data.Foldable as F-import qualified Data.Sequence as Seq-import Data.Time ( getCurrentTime )-import Lens.Micro.Platform--import qualified Network.Mattermost.Endpoints as MM-import Network.Mattermost.Types--import Config-import Types--import State.Common---handleNewUsers :: Seq UserId -> MH () -> MH ()-handleNewUsers newUserIds after = do- doAsyncMM Preempt getUserInfo addNewUsers- where getUserInfo session =- do nUsers <- MM.mmGetUsersByIds newUserIds session- let usrInfo u = userInfoFromUser u True- usrList = toList nUsers- return $ usrInfo <$> usrList-- addNewUsers :: [UserInfo] -> Maybe (MH ())- addNewUsers is = Just $ mapM_ addNewUser is >> after---- | Handle the typing events from the websocket to show the currently--- typing users on UI-handleTypingUser :: UserId -> ChannelId -> MH ()-handleTypingUser uId cId = do- config <- use (csResources.crConfiguration)- when (configShowTypingIndicator config) $ do- withFetchedUser (UserFetchById uId) $ const $ do- ts <- liftIO getCurrentTime- csChannels %= modifyChannelById cId (addChannelTypingUser uId ts)---- | Given a user fetching strategy, locate the user in the state or--- fetch it from the server, and pass the result to the specified--- action. Assumes a single match is the only expected/valid result.-withFetchedUser :: UserFetch -> (UserInfo -> MH ()) -> MH ()-withFetchedUser fetch handle =- withFetchedUserMaybe fetch $ \u -> do- case u of- Nothing -> postErrorMessage' "No such user"- Just user -> handle user--withFetchedUserMaybe :: UserFetch -> (Maybe UserInfo -> MH ()) -> MH ()-withFetchedUserMaybe fetch handle = do- st <- use id- session <- getSession-- let localMatch = case fetch of- UserFetchById uId -> userById uId st- UserFetchByUsername uname -> userByUsername uname st- UserFetchByNickname nick -> userByNickname nick st-- case localMatch of- Just user -> handle $ Just user- Nothing -> do- mhLog LogGeneral $ T.pack $ "withFetchedUserMaybe: getting " <> show fetch- doAsyncWith Normal $ do- results <- case fetch of- UserFetchById uId ->- MM.mmGetUsersByIds (Seq.singleton uId) session- UserFetchByUsername uname ->- MM.mmGetUsersByUsernames (Seq.singleton $ trimUserSigil uname) session- UserFetchByNickname nick -> do- let req = UserSearch { userSearchTerm = trimUserSigil nick- , userSearchAllowInactive = True- , userSearchWithoutTeam = True- , userSearchInChannelId = Nothing- , userSearchNotInTeamId = Nothing- , userSearchNotInChannelId = Nothing- , userSearchTeamId = Nothing- }- MM.mmSearchUsers req session-- return $ Just $ do- infos <- forM (F.toList results) $ \u -> do- let info = userInfoFromUser u True- addNewUser info- return info-- case infos of- [match] -> handle $ Just match- [] -> handle Nothing- _ -> postErrorMessage' "Error: ambiguous user information"
− src/TeamSelect.hs
@@ -1,90 +0,0 @@-module TeamSelect- ( interactiveTeamSelection- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border-import Brick.Widgets.Center-import Brick.Widgets.List-import qualified Data.Function as F-import Data.List ( sortBy )-import qualified Data.Vector as V-import Graphics.Vty hiding (mkVty)-import qualified Data.Text as T--import Network.Mattermost.Types--import Draw.RichText-import Types.Common---data State =- State { appList :: List () Team- , appCancelled :: Bool- }--interactiveTeamSelection :: Vty -> IO Vty -> [Team] -> IO (Maybe Team, Vty)-interactiveTeamSelection vty mkVty teams = do- let state = State { appList = list () (V.fromList sortedTeams) 1- , appCancelled = False- }- sortedTeams = sortBy (compare `F.on` teamName) teams-- (finalSt, finalVty) <- customMainWithVty vty mkVty Nothing app state-- let result = if appCancelled finalSt- then Nothing- else snd <$> listSelectedElement (appList finalSt)-- return (result, finalVty)--app :: App State e ()-app = App- { appDraw = teamSelectDraw- , appChooseCursor = neverShowCursor- , appHandleEvent = onEvent- , appStartEvent = return- , appAttrMap = const colorTheme- }--colorTheme :: AttrMap-colorTheme = attrMap defAttr- [ (listSelectedFocusedAttr, black `on` yellow)- ]--teamSelectDraw :: State -> [Widget ()]-teamSelectDraw st =- [ teamSelect st- ]--teamSelect :: State -> Widget ()-teamSelect st =- center $ hLimit 50 $ vLimit 15 $- vBox [ hCenter $ txt "Welcome to Mattermost. Please select a team:"- , txt " "- , border theList- , txt " "- , renderText "Press Enter to select a team and connect or Esc to exit."- ]- where- theList = renderList renderTeamItem True (appList st)--renderTeamItem :: Bool -> Team -> Widget ()-renderTeamItem _ t =- padRight Max $ txt $ (sanitizeUserText $ teamName t) <>- if not $ T.null (sanitizeUserText $ teamDisplayName t)- then " (" <> (sanitizeUserText $ teamDisplayName t) <> ")"- else ""--onEvent :: State -> BrickEvent () e -> EventM () (Next State)-onEvent st (VtyEvent (EvKey KEsc [])) = do- halt $ st { appCancelled = True }-onEvent st (VtyEvent (EvKey KEnter [])) = halt st-onEvent st (VtyEvent e) = do- list' <- handleListEvent e (appList st)- continue $ st { appList = list' }-onEvent st _ = continue st
− src/Themes.hs
@@ -1,772 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}-module Themes- ( InternalTheme(..)-- , defaultTheme- , internalThemes- , lookupTheme- , themeDocs-- -- * Attribute names- , currentUserAttr- , timeAttr- , channelHeaderAttr- , channelListHeaderAttr- , currentChannelNameAttr- , unreadChannelAttr- , unreadGroupMarkerAttr- , mentionsChannelAttr- , urlAttr- , codeAttr- , emailAttr- , emojiAttr- , channelNameAttr- , clientMessageAttr- , clientHeaderAttr- , clientEmphAttr- , clientStrongAttr- , dateTransitionAttr- , pinnedMessageIndicatorAttr- , newMessageTransitionAttr- , gapMessageAttr- , errorMessageAttr- , helpAttr- , helpEmphAttr- , channelSelectPromptAttr- , channelSelectMatchAttr- , completionAlternativeListAttr- , completionAlternativeCurrentAttr- , dialogAttr- , dialogEmphAttr- , recentMarkerAttr- , replyParentAttr- , loadMoreAttr- , urlListSelectedAttr- , messageSelectAttr- , messageSelectStatusAttr- , misspellingAttr- , editedMarkingAttr- , editedRecentlyMarkingAttr- , tabSelectedAttr- , tabUnselectedAttr-- -- * Username formatting- , colorUsername- , attrForUsername- , usernameColorHashBuckets- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Themes-import Brick.Widgets.List-import qualified Brick.Widgets.FileBrowser as FB-import Brick.Widgets.Skylighting ( attrNameForTokenType- , attrMappingsForStyle- , highlightedCodeBlockAttr- )-import Brick.Forms ( focusedFormInputAttr )-import Data.Hashable ( hash )-import qualified Data.Map as M-import qualified Data.Text as T-import Graphics.Vty-import qualified Skylighting.Styles as Sky-import Skylighting.Types ( TokenType(..) )--import Types ( InternalTheme(..), specialUserMentions )---helpAttr :: AttrName-helpAttr = "help"--helpEmphAttr :: AttrName-helpEmphAttr = "helpEmphasis"--recentMarkerAttr :: AttrName-recentMarkerAttr = "recentChannelMarker"--replyParentAttr :: AttrName-replyParentAttr = "replyParentPreview"--pinnedMessageIndicatorAttr :: AttrName-pinnedMessageIndicatorAttr = "pinnedMessageIndicator"--loadMoreAttr :: AttrName-loadMoreAttr = "loadMoreMessages"--urlListSelectedAttr :: AttrName-urlListSelectedAttr = "urlListCursor"--messageSelectAttr :: AttrName-messageSelectAttr = "messageSelectCursor"--editedMarkingAttr :: AttrName-editedMarkingAttr = "editedMarking"--editedRecentlyMarkingAttr :: AttrName-editedRecentlyMarkingAttr = "editedRecentlyMarking"--dialogAttr :: AttrName-dialogAttr = "dialog"--dialogEmphAttr :: AttrName-dialogEmphAttr = "dialogEmphasis"--channelSelectMatchAttr :: AttrName-channelSelectMatchAttr = "channelSelectMatch"--channelSelectPromptAttr :: AttrName-channelSelectPromptAttr = "channelSelectPrompt"--completionAlternativeListAttr :: AttrName-completionAlternativeListAttr = "tabCompletionAlternative"--completionAlternativeCurrentAttr :: AttrName-completionAlternativeCurrentAttr = "tabCompletionCursor"--timeAttr :: AttrName-timeAttr = "time"--currentUserAttr :: AttrName-currentUserAttr = "currentUser"--channelHeaderAttr :: AttrName-channelHeaderAttr = "channelHeader"--channelListHeaderAttr :: AttrName-channelListHeaderAttr = "channelListSectionHeader"--currentChannelNameAttr :: AttrName-currentChannelNameAttr = "currentChannelName"--channelNameAttr :: AttrName-channelNameAttr = "channelName"--unreadChannelAttr :: AttrName-unreadChannelAttr = "unreadChannel"--unreadGroupMarkerAttr :: AttrName-unreadGroupMarkerAttr = "unreadChannelGroupMarker"--mentionsChannelAttr :: AttrName-mentionsChannelAttr = "channelWithMentions"--tabSelectedAttr :: AttrName-tabSelectedAttr = "tabSelected"--tabUnselectedAttr :: AttrName-tabUnselectedAttr = "tabUnselected"--dateTransitionAttr :: AttrName-dateTransitionAttr = "dateTransition"--newMessageTransitionAttr :: AttrName-newMessageTransitionAttr = "newMessageTransition"--urlAttr :: AttrName-urlAttr = "url"--codeAttr :: AttrName-codeAttr = "codeBlock"--emailAttr :: AttrName-emailAttr = "email"--emojiAttr :: AttrName-emojiAttr = "emoji"--clientMessageAttr :: AttrName-clientMessageAttr = "clientMessage"--clientHeaderAttr :: AttrName-clientHeaderAttr = "markdownHeader"--clientEmphAttr :: AttrName-clientEmphAttr = "markdownEmph"--clientStrongAttr :: AttrName-clientStrongAttr = "markdownStrong"--errorMessageAttr :: AttrName-errorMessageAttr = "errorMessage"--gapMessageAttr :: AttrName-gapMessageAttr = "gapMessage"--misspellingAttr :: AttrName-misspellingAttr = "misspelling"--messageSelectStatusAttr :: AttrName-messageSelectStatusAttr = "messageSelectStatus"--lookupTheme :: Text -> Maybe InternalTheme-lookupTheme n = find ((== n) . internalThemeName) internalThemes--internalThemes :: [InternalTheme]-internalThemes = validateInternalTheme <$>- [ darkColorTheme- , darkColor256Theme- , lightColorTheme- , lightColor256Theme- ]--validateInternalTheme :: InternalTheme -> InternalTheme-validateInternalTheme it =- let un = undocumentedAttrNames (internalTheme it)- in if not $ null un- then error $ "Internal theme " <> show (T.unpack (internalThemeName it)) <>- " references undocumented attribute names: " <> show un- else it--undocumentedAttrNames :: Theme -> [AttrName]-undocumentedAttrNames t =- let noDocs k = isNothing $ attrNameDescription themeDocs k- in filter noDocs (M.keys $ themeDefaultMapping t)--defaultTheme :: InternalTheme-defaultTheme = darkColorTheme--lightColorTheme :: InternalTheme-lightColorTheme = InternalTheme name theme desc- where- theme = newTheme def $ lightAttrs usernameColors16- name = "builtin:light"- def = black `on` white- desc = "A color theme for terminal windows with light background colors"--lightColor256Theme :: InternalTheme-lightColor256Theme = InternalTheme name theme desc- where- theme = newTheme def $ lightAttrs usernameColors256- name = "builtin:light256"- def = black `on` white- desc = "Like builtin:light, but with 256-color username colors"--lightAttrs :: [Attr] -> [(AttrName, Attr)]-lightAttrs usernameColors =- let sty = Sky.kate- in [ (timeAttr, fg black)- , (currentUserAttr, defAttr `withStyle` bold)- , (channelHeaderAttr, fg black `withStyle` underline)- , (channelListHeaderAttr, fg cyan)- , (currentChannelNameAttr, black `on` yellow `withStyle` bold)- , (unreadChannelAttr, black `on` cyan `withStyle` bold)- , (unreadGroupMarkerAttr, fg black `withStyle` bold)- , (mentionsChannelAttr, black `on` red `withStyle` bold)- , (urlAttr, fg brightYellow)- , (emailAttr, fg yellow)- , (codeAttr, fg magenta)- , (emojiAttr, fg yellow)- , (channelNameAttr, fg blue)- , (clientMessageAttr, fg black)- , (clientEmphAttr, fg black `withStyle` bold)- , (clientStrongAttr, fg black `withStyle` bold `withStyle` underline)- , (clientHeaderAttr, fg red `withStyle` bold)- , (dateTransitionAttr, fg green)- , (newMessageTransitionAttr, black `on` yellow)- , (errorMessageAttr, fg red)- , (gapMessageAttr, fg red)- , (helpAttr, fg black)- , (pinnedMessageIndicatorAttr, black `on` cyan)- , (helpEmphAttr, fg blue `withStyle` bold)- , (channelSelectMatchAttr, black `on` magenta)- , (channelSelectPromptAttr, fg black)- , (completionAlternativeListAttr, white `on` blue)- , (completionAlternativeCurrentAttr, black `on` yellow)- , (dialogAttr, black `on` cyan)- , (dialogEmphAttr, fg white)- , (listSelectedFocusedAttr, black `on` yellow)- , (recentMarkerAttr, fg black `withStyle` bold)- , (loadMoreAttr, black `on` cyan)- , (urlListSelectedAttr, black `on` yellow)- , (messageSelectAttr, black `on` yellow)- , (messageSelectStatusAttr, fg black)- , (misspellingAttr, fg red `withStyle` underline)- , (editedMarkingAttr, fg yellow)- , (editedRecentlyMarkingAttr, black `on` yellow)- , (tabSelectedAttr, black `on` yellow)- , (focusedFormInputAttr, black `on` yellow)- , (FB.fileBrowserCurrentDirectoryAttr, white `on` blue)- , (FB.fileBrowserSelectionInfoAttr, white `on` blue)- , (FB.fileBrowserDirectoryAttr, fg blue)- , (FB.fileBrowserBlockDeviceAttr, fg magenta)- , (FB.fileBrowserCharacterDeviceAttr, fg green)- , (FB.fileBrowserNamedPipeAttr, fg yellow)- , (FB.fileBrowserSymbolicLinkAttr, fg cyan)- , (FB.fileBrowserUnixSocketAttr, fg red)- ] <>- ((\(i, a) -> (usernameAttr i, a)) <$> zip [0..usernameColorHashBuckets-1] (cycle usernameColors)) <>- (filter skipBaseCodeblockAttr $ attrMappingsForStyle sty)--darkAttrs :: [Attr] -> [(AttrName, Attr)]-darkAttrs usernameColors =- let sty = Sky.espresso- in [ (timeAttr, fg white)- , (currentUserAttr, defAttr `withStyle` bold)- , (channelHeaderAttr, fg white `withStyle` underline)- , (channelListHeaderAttr, fg cyan)- , (currentChannelNameAttr, black `on` yellow `withStyle` bold)- , (unreadChannelAttr, black `on` cyan `withStyle` bold)- , (unreadGroupMarkerAttr, fg white `withStyle` bold)- , (mentionsChannelAttr, black `on` brightMagenta `withStyle` bold)- , (urlAttr, fg yellow)- , (emailAttr, fg yellow)- , (codeAttr, fg magenta)- , (emojiAttr, fg yellow)- , (channelNameAttr, fg cyan)- , (pinnedMessageIndicatorAttr, fg cyan `withStyle` bold)- , (clientMessageAttr, fg white)- , (clientEmphAttr, fg white `withStyle` bold)- , (clientStrongAttr, fg white `withStyle` bold `withStyle` underline)- , (clientHeaderAttr, fg red `withStyle` bold)- , (dateTransitionAttr, fg green)- , (newMessageTransitionAttr, fg yellow `withStyle` bold)- , (errorMessageAttr, fg red)- , (gapMessageAttr, black `on` yellow)- , (helpAttr, fg white)- , (helpEmphAttr, fg cyan `withStyle` bold)- , (channelSelectMatchAttr, black `on` magenta)- , (channelSelectPromptAttr, fg white)- , (completionAlternativeListAttr, white `on` blue)- , (completionAlternativeCurrentAttr, black `on` yellow)- , (dialogAttr, black `on` cyan)- , (dialogEmphAttr, fg white)- , (listSelectedFocusedAttr, black `on` yellow)- , (recentMarkerAttr, fg yellow `withStyle` bold)- , (loadMoreAttr, black `on` cyan)- , (urlListSelectedAttr, black `on` yellow)- , (messageSelectAttr, black `on` yellow)- , (messageSelectStatusAttr, fg white)- , (misspellingAttr, fg red `withStyle` underline)- , (editedMarkingAttr, fg yellow)- , (editedRecentlyMarkingAttr, black `on` yellow)- , (tabSelectedAttr, black `on` yellow)- , (focusedFormInputAttr, black `on` yellow)- , (FB.fileBrowserCurrentDirectoryAttr, white `on` blue)- , (FB.fileBrowserSelectionInfoAttr, white `on` blue)- , (FB.fileBrowserDirectoryAttr, fg blue)- , (FB.fileBrowserBlockDeviceAttr, fg magenta)- , (FB.fileBrowserCharacterDeviceAttr, fg green)- , (FB.fileBrowserNamedPipeAttr, fg yellow)- , (FB.fileBrowserSymbolicLinkAttr, fg cyan)- , (FB.fileBrowserUnixSocketAttr, fg red)- ] <>- ((\(i, a) -> (usernameAttr i, a)) <$> zip [0..usernameColorHashBuckets-1] (cycle usernameColors)) <>- (filter skipBaseCodeblockAttr $ attrMappingsForStyle sty)--skipBaseCodeblockAttr :: (AttrName, Attr) -> Bool-skipBaseCodeblockAttr = ((/= highlightedCodeBlockAttr) . fst)--darkColorTheme :: InternalTheme-darkColorTheme = InternalTheme name theme desc- where- theme = newTheme def $ darkAttrs usernameColors16- name = "builtin:dark"- def = defAttr- desc = "A color theme for terminal windows with dark background colors"--darkColor256Theme :: InternalTheme-darkColor256Theme = InternalTheme name theme desc- where- theme = newTheme def $ darkAttrs usernameColors256- name = "builtin:dark256"- def = defAttr- desc = "Like builtin:dark, but with 256-color username colors"--usernameAttr :: Int -> AttrName-usernameAttr i = "username" <> (attrName $ show i)---- | Render a string with a color chosen based on the text of a--- username.------ This function takes some display text and renders it using an--- attribute based on the username associated with the text. If the--- username associated with the text is equal to the username of--- the user running Matterhorn, the display text is formatted with--- 'currentAttr'. Otherwise it is formatted with an attribute chosen--- by hashing the associated username and choosing from amongst the--- username color hash buckets with 'usernameAttr'.------ Usually the first argument to this function will be @myUsername st@,--- where @st@ is a 'ChatState'.------ The most common way to call this function is------ @colorUsername (myUsername st) u u------ The third argument is allowed to vary from the second since sometimes--- we call this with the user's status sigil as the third argument.-colorUsername :: Text- -- ^ The username for the user currently running- -- Matterhorn- -> Text- -- ^ The username associated with the text to render- -> Text- -- ^ The text to render- -> Widget a-colorUsername current username display =- let aName = attrForUsername username- maybeWithCurrentAttr = if current == username- then withAttr currentUserAttr- else id- in withDefAttr aName $- maybeWithCurrentAttr $- txt (display)---- | Return the attribute name to use for the specified username.--- The input username is expected to be the username only (i.e. no--- sigil).------ If the input username is a special reserved username such as "all",--- the @clientEmphAttr@ attribute name will be returned. Otherwise--- a hash-bucket username attribute name will be returned based on--- the hash value of the username and the number of hash buckets--- (@usernameColorHashBuckets@).-attrForUsername :: Text- -- ^ The username to get an attribute for- -> AttrName-attrForUsername username =- let normalizedUsername = T.toLower username- aName = if normalizedUsername `elem` specialUserMentions- then clientEmphAttr- else usernameAttr h- h = hash normalizedUsername `mod` usernameColorHashBuckets- in aName---- | The number of hash buckets to use when hashing usernames to choose--- their colors.-usernameColorHashBuckets :: Int-usernameColorHashBuckets = 50--usernameColors16 :: [Attr]-usernameColors16 =- [ fg red- , fg green- , fg yellow- , fg blue- , fg magenta- , fg cyan- , fg brightRed- , fg brightGreen- , fg brightYellow- , fg brightBlue- , fg brightMagenta- , fg brightCyan- ]--usernameColors256 :: [Attr]-usernameColors256 = mkColor <$> username256ColorChoices- where- mkColor (r, g, b) = defAttr `withForeColor` rgbColor r g b--username256ColorChoices :: [(Integer, Integer, Integer)]-username256ColorChoices =- [ (255, 0, 86)- , (158, 0, 142)- , (14, 76, 161)- , (255, 229, 2)- , (149, 0, 58)- , (255, 147, 126)- , (164, 36, 0)- , (98, 14, 0)- , (0, 0, 255)- , (106, 130, 108)- , (0, 174, 126)- , (194, 140, 159)- , (0, 143, 156)- , (95, 173, 78)- , (255, 2, 157)- , (255, 116, 163)- , (152, 255, 82)- , (167, 87, 64)- , (254, 137, 0)- , (1, 208, 255)- , (187, 136, 0)- , (117, 68, 177)- , (165, 255, 210)- , (122, 71, 130)- , (0, 71, 84)- , (181, 0, 255)- , (144, 251, 146)- , (189, 211, 147)- , (229, 111, 254)- , (222, 255, 116)- , (0, 255, 120)- , (0, 155, 255)- , (0, 100, 1)- , (0, 118, 255)- , (133, 169, 0)- , (0, 185, 23)- , (120, 130, 49)- , (0, 255, 198)- , (255, 110, 65)- ]---- Functions for dealing with Skylighting styles--attrNameDescription :: ThemeDocumentation -> AttrName -> Maybe Text-attrNameDescription td an = M.lookup an (themeDescriptions td)--themeDocs :: ThemeDocumentation-themeDocs = ThemeDocumentation $ M.fromList $- [ ( timeAttr- , "Timestamps on chat messages"- )- , ( channelHeaderAttr- , "Channel headers displayed above chat message lists"- )- , ( channelListHeaderAttr- , "The heading of the channel list sections"- )- , ( currentChannelNameAttr- , "The currently selected channel in the channel list"- )- , ( unreadChannelAttr- , "A channel in the channel list with unread messages"- )- , ( unreadGroupMarkerAttr- , "The channel group marker indicating unread messages"- )- , ( mentionsChannelAttr- , "A channel in the channel list with unread mentions"- )- , ( urlAttr- , "A URL in a chat message"- )- , ( codeAttr- , "A code block in a chat message with no language indication"- )- , ( emailAttr- , "An e-mail address in a chat message"- )- , ( emojiAttr- , "A text emoji indication in a chat message"- )- , ( channelNameAttr- , "A channel name in a chat message"- )- , ( clientMessageAttr- , "A Matterhorn diagnostic or informative message"- )- , ( clientHeaderAttr- , "Markdown heading"- )- , ( clientEmphAttr- , "Markdown 'emphasized' text"- )- , ( clientStrongAttr- , "Markdown 'strong' text"- )- , ( dateTransitionAttr- , "Date transition lines between chat messages on different days"- )- , ( pinnedMessageIndicatorAttr- , "The indicator for messages that have been pinned"- )- , ( newMessageTransitionAttr- , "The 'New Messages' line that appears above unread messages"- )- , ( tabSelectedAttr- , "Selected tabs in tabbed windows"- )- , ( tabUnselectedAttr- , "Unselected tabs in tabbed windows"- )- , ( errorMessageAttr- , "Matterhorn error messages"- )- , ( gapMessageAttr- , "Matterhorn message gap information"- )- , ( helpAttr- , "The help screen text"- )- , ( helpEmphAttr- , "The help screen's emphasized text"- )- , ( channelSelectPromptAttr- , "Channel selection: prompt"- )- , ( channelSelectMatchAttr- , "Channel selection: the portion of a channel name that matches"- )- , ( completionAlternativeListAttr- , "Tab completion alternatives"- )- , ( completionAlternativeCurrentAttr- , "The currently-selected tab completion alternative"- )- , ( dialogAttr- , "Dialog box text"- )- , ( dialogEmphAttr- , "Dialog box emphasized text"- )- , ( recentMarkerAttr- , "The marker indicating the channel last visited"- )- , ( replyParentAttr- , "The first line of parent messages appearing above reply messages"- )- , ( loadMoreAttr- , "The 'Load More' line that appears at the top of a chat message list"- )- , ( urlListSelectedAttr- , "URL list: the selected URL"- )- , ( messageSelectAttr- , "Message selection: the currently-selected message"- )- , ( messageSelectStatusAttr- , "Message selection: the message selection actions"- )- , ( misspellingAttr- , "A misspelled word in the chat message editor"- )- , ( editedMarkingAttr- , "The 'edited' marking that appears on edited messages"- )- , ( editedRecentlyMarkingAttr- , "The 'edited' marking that appears on newly-edited messages"- )- , ( highlightedCodeBlockAttr- , "The base attribute for syntax-highlighted code blocks"- )- , ( attrNameForTokenType KeywordTok- , "Syntax highlighting: Keyword"- )- , ( attrNameForTokenType DataTypeTok- , "Syntax highlighting: DataType"- )- , ( attrNameForTokenType DecValTok- , "Syntax highlighting: Declaration"- )- , ( attrNameForTokenType BaseNTok- , "Syntax highlighting: BaseN"- )- , ( attrNameForTokenType FloatTok- , "Syntax highlighting: Float"- )- , ( attrNameForTokenType ConstantTok- , "Syntax highlighting: Constant"- )- , ( attrNameForTokenType CharTok- , "Syntax highlighting: Char"- )- , ( attrNameForTokenType SpecialCharTok- , "Syntax highlighting: Special Char"- )- , ( attrNameForTokenType StringTok- , "Syntax highlighting: String"- )- , ( attrNameForTokenType VerbatimStringTok- , "Syntax highlighting: Verbatim String"- )- , ( attrNameForTokenType SpecialStringTok- , "Syntax highlighting: Special String"- )- , ( attrNameForTokenType ImportTok- , "Syntax highlighting: Import"- )- , ( attrNameForTokenType CommentTok- , "Syntax highlighting: Comment"- )- , ( attrNameForTokenType DocumentationTok- , "Syntax highlighting: Documentation"- )- , ( attrNameForTokenType AnnotationTok- , "Syntax highlighting: Annotation"- )- , ( attrNameForTokenType CommentVarTok- , "Syntax highlighting: Comment"- )- , ( attrNameForTokenType OtherTok- , "Syntax highlighting: Other"- )- , ( attrNameForTokenType FunctionTok- , "Syntax highlighting: Function"- )- , ( attrNameForTokenType VariableTok- , "Syntax highlighting: Variable"- )- , ( attrNameForTokenType ControlFlowTok- , "Syntax highlighting: Control Flow"- )- , ( attrNameForTokenType OperatorTok- , "Syntax highlighting: Operator"- )- , ( attrNameForTokenType BuiltInTok- , "Syntax highlighting: Built-In"- )- , ( attrNameForTokenType ExtensionTok- , "Syntax highlighting: Extension"- )- , ( attrNameForTokenType PreprocessorTok- , "Syntax highlighting: Preprocessor"- )- , ( attrNameForTokenType AttributeTok- , "Syntax highlighting: Attribute"- )- , ( attrNameForTokenType RegionMarkerTok- , "Syntax highlighting: Region Marker"- )- , ( attrNameForTokenType InformationTok- , "Syntax highlighting: Information"- )- , ( attrNameForTokenType WarningTok- , "Syntax highlighting: Warning"- )- , ( attrNameForTokenType AlertTok- , "Syntax highlighting: Alert"- )- , ( attrNameForTokenType ErrorTok- , "Syntax highlighting: Error"- )- , ( attrNameForTokenType NormalTok- , "Syntax highlighting: Normal text"- )- , ( listSelectedFocusedAttr- , "The selected channel"- )- , ( focusedFormInputAttr- , "A form input that has focus"- )- , ( FB.fileBrowserAttr- , "The base file browser attribute"- )- , ( FB.fileBrowserCurrentDirectoryAttr- , "The file browser current directory attribute"- )- , ( FB.fileBrowserSelectionInfoAttr- , "The file browser selection information attribute"- )- , ( FB.fileBrowserDirectoryAttr- , "Attribute for directories in the file browser"- )- , ( FB.fileBrowserBlockDeviceAttr- , "Attribute for block devices in the file browser"- )- , ( FB.fileBrowserRegularFileAttr- , "Attribute for regular files in the file browser"- )- , ( FB.fileBrowserCharacterDeviceAttr- , "Attribute for character devices in the file browser"- )- , ( FB.fileBrowserNamedPipeAttr- , "Attribute for named pipes in the file browser"- )- , ( FB.fileBrowserSymbolicLinkAttr- , "Attribute for symbolic links in the file browser"- )- , ( FB.fileBrowserUnixSocketAttr- , "Attribute for Unix sockets in the file browser"- )- , ( currentUserAttr- , "Attribute for the username of the user running Matterhorn"- )- ] <> [ (usernameAttr i, T.pack $ "Username color " <> show i)- | i <- [0..usernameColorHashBuckets-1]- ]
− src/TimeUtils.hs
@@ -1,70 +0,0 @@-module TimeUtils- ( lookupLocalTimeZone- , startOfDay- , justAfter, justBefore- , asLocalTime- , localTimeText- , originTime- )-where--import Prelude ()-import Prelude.MH--import qualified Data.Text as T-import Data.Time.Clock ( UTCTime(..) )-import Data.Time.Format ( formatTime, defaultTimeLocale )-import Data.Time.LocalTime ( LocalTime(..), TimeOfDay(..) )-import Data.Time.LocalTime.TimeZone.Olson ( getTimeZoneSeriesFromOlsonFile )-import Data.Time.LocalTime.TimeZone.Series ( localTimeToUTC'- , utcToLocalTime')--import Network.Mattermost.Types ( ServerTime(..) )----- | Get the timezone series that should be used for converting UTC--- times into local times with appropriate DST adjustments.-lookupLocalTimeZone :: IO TimeZoneSeries-lookupLocalTimeZone = getTimeZoneSeriesFromOlsonFile "/etc/localtime"----- | Sometimes it is convenient to render a divider between messages;--- the 'justAfter' function can be used to get a time that is after--- the input time but by such a small increment that there is unlikely--- to be anything between (or at) the result. Adding the divider--- using this timestamp value allows the general sorting based on--- timestamps to operate normally (whereas a type-match for a--- non-timestamp-entry in the sort operation would be considerably--- more complex).-justAfter :: ServerTime -> ServerTime-justAfter = ServerTime . justAfterUTC . withServerTime- where justAfterUTC time = let UTCTime d t = time in UTCTime d (succ t)---- | Obtain a time value that is just moments before the input time;--- see the comment for the 'justAfter' function for more details.-justBefore :: ServerTime -> ServerTime-justBefore = ServerTime . justBeforeUTC . withServerTime- where justBeforeUTC time = let UTCTime d t = time in UTCTime d (pred t)---- | The timestamp for the start of the day associated with the input--- timestamp. If timezone information is supplied, then the returned--- value will correspond to when the day started in that timezone;--- otherwise it is the start of the day in a timezone aligned with--- UTC.-startOfDay :: Maybe TimeZoneSeries -> UTCTime -> UTCTime-startOfDay Nothing time = let UTCTime d _ = time in UTCTime d 0-startOfDay (Just tz) time = let lt = utcToLocalTime' tz time- ls = LocalTime (localDay lt) (TimeOfDay 0 0 0)- in localTimeToUTC' tz ls---- | Convert a UTC time value to a local time.-asLocalTime :: TimeZoneSeries -> UTCTime -> LocalTime-asLocalTime = utcToLocalTime'---- | Local time in displayable format-localTimeText :: Text -> LocalTime -> Text-localTimeText fmt time = T.pack $ formatTime defaultTimeLocale (T.unpack fmt) time---- | Provides a time value that can be used when there are no other times available-originTime :: UTCTime-originTime = UTCTime (toEnum 0) 0
− src/Types.hs
@@ -1,2068 +0,0 @@-{-# LANGUAGE GADTs #-}-{-# LANGUAGE KindSignatures #-}-{-# LANGUAGE TemplateHaskell #-}-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE RecordWildCards #-}-{-# LANGUAGE TupleSections #-}-module Types- ( ConnectionStatus(..)- , HelpTopic(..)- , MessageSelectState(..)- , ProgramOutput(..)- , MHEvent(..)- , InternalEvent(..)- , Name(..)- , ChannelSelectMatch(..)- , StartupStateInfo(..)- , MHError(..)- , AttachmentData(..)- , CPUUsagePolicy(..)- , tabbedWindow- , getCurrentTabbedWindowEntry- , tabbedWindowNextTab- , tabbedWindowPreviousTab- , runTabShowHandlerFor- , TabbedWindow(..)- , TabbedWindowEntry(..)- , TabbedWindowTemplate(..)- , OpenInBrowser(..)- , ConnectionInfo(..)- , SidebarUpdate(..)- , PendingChannelChange(..)- , ViewMessageWindowTab(..)- , clearChannelUnreadStatus- , ChannelListEntry(..)- , channelListEntryChannelId- , channelListEntryUserId- , userIdsFromZipper- , entryIsDMEntry- , ciHostname- , ciPort- , ciUrlPath- , ciUsername- , ciPassword- , ciType- , ciAccessToken- , Config(..)- , HelpScreen(..)- , PasswordSource(..)- , TokenSource(..)- , MatchType(..)- , Mode(..)- , ChannelSelectPattern(..)- , PostListContents(..)- , AuthenticationException(..)- , BackgroundInfo(..)- , RequestChan- , UserFetch(..)- , writeBChan- , InternalTheme(..)-- , attrNameToConfig-- , mkChannelZipperList- , ChannelListGroup(..)- , channelListGroupHasUnread-- , trimChannelSigil-- , ChannelSelectState(..)- , channelSelectMatches- , channelSelectInput- , emptyChannelSelectState-- , ChatState- , newState- , csResources- , csFocus- , csCurrentChannel- , csCurrentChannelId- , csUrlList- , csShowMessagePreview- , csShowChannelList- , csPostMap- , csRecentChannel- , csReturnChannel- , csThemeListOverlay- , csPostListOverlay- , csUserListOverlay- , csChannelListOverlay- , csReactionEmojiListOverlay- , csMyTeam- , csMessageSelect- , csConnectionStatus- , csWorkerIsBusy- , csChannel- , csChannels- , csChannelSelectState- , csEditState- , csClientConfig- , csPendingChannelChange- , csViewedMessage- , csNotifyPrefs- , csMe- , timeZone- , whenMode- , setMode- , setMode'- , appMode-- , ChatEditState- , emptyEditState- , cedAttachmentList- , cedFileBrowser- , cedYankBuffer- , cedSpellChecker- , cedMisspellings- , cedEditMode- , cedEphemeral- , cedEditor- , cedInputHistory- , cedAutocomplete- , cedAutocompletePending- , cedJustCompleted-- , AutocompleteState(..)- , acPreviousSearchString- , acCompletionList- , acCachedResponses- , acType-- , AutocompletionType(..)-- , AutocompleteAlternative(..)- , autocompleteAlternativeReplacement- , SpecialMention(..)- , specialMentionName- , isSpecialMention-- , PostListOverlayState- , postListSelected- , postListPosts-- , UserSearchScope(..)- , ChannelSearchScope(..)-- , ListOverlayState- , listOverlaySearchResults- , listOverlaySearchInput- , listOverlaySearchScope- , listOverlaySearching- , listOverlayEnterHandler- , listOverlayNewList- , listOverlayFetchResults- , listOverlayRecordCount-- , getUsers-- , ChatResources(..)- , crUserPreferences- , crEventQueue- , crTheme- , crStatusUpdateChan- , crSubprocessLog- , crWebsocketActionChan- , crWebsocketThreadId- , crRequestQueue- , crFlaggedPosts- , crConn- , crConfiguration- , crSyntaxMap- , crLogManager- , crEmoji- , getSession- , getResourceSession-- , specialUserMentions-- , UserPreferences(UserPreferences)- , userPrefShowJoinLeave- , userPrefFlaggedPostList- , userPrefGroupChannelPrefs- , userPrefDirectChannelPrefs- , userPrefTeammateNameDisplayMode- , dmChannelShowPreference- , groupChannelShowPreference-- , defaultUserPreferences- , setUserPreferences-- , WebsocketAction(..)-- , Cmd(..)- , commandName- , CmdArgs(..)-- , MH- , runMHEvent- , scheduleUserFetches- , scheduleUserStatusFetches- , getScheduledUserFetches- , getScheduledUserStatusFetches- , mh- , generateUUID- , generateUUID_IO- , mhSuspendAndResume- , mhHandleEventLensed- , St.gets- , mhError-- , mhLog- , mhGetIOLogger- , ioLogWithManager- , LogContext(..)- , withLogContext- , withLogContextChannelId- , getLogContext- , LogMessage(..)- , LogCommand(..)- , LogCategory(..)-- , LogManager(..)- , startLoggingToFile- , stopLoggingToFile- , requestLogSnapshot- , requestLogDestination- , sendLogMessage-- , requestQuit- , getMessageForPostId- , getParentMessage- , resetSpellCheckTimer- , withChannel- , withChannelOrDefault- , userList- , resetAutocomplete- , hasUnread- , hasUnread'- , isMine- , setUserStatus- , myUser- , myUsername- , myUserId- , myTeamId- , usernameForUserId- , userByUsername- , userByNickname- , channelIdByChannelName- , channelIdByUsername- , channelByName- , userById- , allUserIds- , addNewUser- , useNickname- , useNickname'- , displayNameForUserId- , displayNameForUser- , raiseInternalEvent- , getNewMessageCutoff- , getEditedMessageCutoff-- , HighlightSet(..)- , UserSet- , ChannelSet- , getHighlightSet-- , module Types.Channels- , module Types.Messages- , module Types.Posts- , module Types.Users- )-where--import Prelude ()-import Prelude.MH--import qualified Graphics.Vty as Vty-import qualified Brick-import Brick ( EventM, Next, Widget )-import Brick.Themes ( Theme )-import Brick.Main ( invalidateCache, invalidateCacheEntry )-import Brick.AttrMap ( AttrMap )-import qualified Brick.BChan as BCH-import Brick.Forms (Form)-import Brick.Widgets.Edit ( Editor, editor )-import Brick.Widgets.List ( List, list )-import qualified Brick.Widgets.FileBrowser as FB-import Control.Concurrent ( ThreadId )-import Control.Concurrent.Async ( Async )-import qualified Control.Concurrent.STM as STM-import Control.Exception ( SomeException )-import qualified Control.Monad.Fail as MHF-import qualified Control.Monad.State as St-import qualified Control.Monad.Reader as R-import qualified Data.Set as Set-import qualified Data.ByteString as BS-import qualified Data.Foldable as F-import qualified Data.Kind as K-import Data.Ord ( comparing )-import qualified Data.HashMap.Strict as HM-import Data.List ( sortBy, nub, elemIndex )-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import Data.Time.Clock ( getCurrentTime, addUTCTime )-import Data.UUID ( UUID )-import qualified Data.Vector as Vec-import Lens.Micro.Platform ( at, makeLenses, lens, (%~), (^?!), (.=)- , (%=), (^?), (.~)- , _Just, Traversal', preuse, to- , SimpleGetter- )-import Network.Connection ( HostNotResolved, HostCannotConnect )-import Skylighting.Types ( SyntaxMap )-import System.Exit ( ExitCode )-import System.Random ( randomIO )-import Text.Aspell ( Aspell )--import Network.Mattermost ( ConnectionData )-import Network.Mattermost.Exceptions-import Network.Mattermost.Lenses-import Network.Mattermost.Types-import Network.Mattermost.Types.Config-import Network.Mattermost.WebSocket ( WebsocketEvent, WebsocketActionResponse )--import Constants ( userSigil, normalChannelSigil )-import InputHistory-import Emoji-import Types.Common-import Types.Channels-import Types.DirectionalSeq ( emptyDirSeq )-import Types.KeyEvents-import Types.Messages-import Types.Posts-import Types.Users-import Zipper ( Zipper, toList, fromList, unsafeFocus )----- * Configuration---- | A user password is either given to us directly, or a command--- which we execute to find the password.-data PasswordSource =- PasswordString Text- | PasswordCommand Text- deriving (Eq, Read, Show)---- | An access token source.-data TokenSource =- TokenString Text- | TokenCommand Text- deriving (Eq, Read, Show)---- | The type of channel list group headings. Booleans indicate whether--- any channels in the group have unread activity.-data ChannelListGroup =- ChannelGroupPublicChannels Bool- | ChannelGroupPrivateChannels Bool- | ChannelGroupDirectMessages Bool- deriving (Eq)--channelListGroupHasUnread :: ChannelListGroup -> Bool-channelListGroupHasUnread (ChannelGroupPublicChannels u) = u-channelListGroupHasUnread (ChannelGroupPrivateChannels u) = u-channelListGroupHasUnread (ChannelGroupDirectMessages u) = u---- | The type of channel list entries.-data ChannelListEntry =- CLChannel ChannelId- -- ^ A non-DM entry- | CLUserDM ChannelId UserId- -- ^ A single-user DM entry- | CLGroupDM ChannelId- -- ^ A multi-user DM entry- deriving (Eq, Show)---- | This is how we represent the user's configuration. Most fields--- correspond to configuration file settings (see Config.hs) but some--- are for internal book-keeping purposes only.-data Config =- Config { configUser :: Maybe Text- -- ^ The username to use when connecting.- , configHost :: Maybe Text- -- ^ The hostname to use when connecting.- , configTeam :: Maybe Text- -- ^ The team name to use when connecting.- , configPort :: Int- -- ^ The port to use when connecting.- , configUrlPath :: Maybe Text- -- ^ The server path to use when connecting.- , configPass :: Maybe PasswordSource- -- ^ The password source to use when connecting.- , configToken :: Maybe TokenSource- -- ^ The token source to use when connecting.- , configTimeFormat :: Maybe Text- -- ^ The format string for timestamps.- , configDateFormat :: Maybe Text- -- ^ The format string for dates.- , configTheme :: Maybe Text- -- ^ The name of the theme to use.- , configThemeCustomizationFile :: Maybe Text- -- ^ The path to the theme customization file, if any.- , configSmartBacktick :: Bool- -- ^ Whether to enable smart quoting characters.- , configSmartEditing :: Bool- -- ^ Whether to enable smart editing behaviors.- , configURLOpenCommand :: Maybe Text- -- ^ The command to use to open URLs.- , configURLOpenCommandInteractive :: Bool- -- ^ Whether the URL-opening command is interactive (i.e.- -- whether it should be given control of the terminal).- , configActivityNotifyCommand :: Maybe T.Text- -- ^ The command to run for activity notifications.- , configActivityBell :: Bool- -- ^ Whether to ring the terminal bell on activity.- , configShowBackground :: BackgroundInfo- -- ^ Whether to show async background worker thread info.- , configShowMessagePreview :: Bool- -- ^ Whether to show the message preview area.- , configShowChannelList :: Bool- -- ^ Whether to show the channel list.- , configEnableAspell :: Bool- -- ^ Whether to enable Aspell spell checking.- , configAspellDictionary :: Maybe Text- -- ^ A specific Aspell dictionary name to use.- , configUnsafeUseHTTP :: Bool- -- ^ Whether to permit an insecure HTTP connection.- , configValidateServerCertificate :: Bool- -- ^ Whether to validate TLS certificates.- , configChannelListWidth :: Int- -- ^ The width, in columns, of the channel list sidebar.- , configLogMaxBufferSize :: Int- -- ^ The maximum size, in log entries, of the internal log- -- message buffer.- , configShowOlderEdits :: Bool- -- ^ Whether to highlight the edit indicator on edits made- -- prior to the beginning of the current session.- , configShowTypingIndicator :: Bool- -- ^ Whether to show the typing indicator for other users,- -- and whether to send typing notifications to other users.- , configAbsPath :: Maybe FilePath- -- ^ A book-keeping field for the absolute path to the- -- configuration. (Not a user setting.)- , configUserKeys :: KeyConfig- -- ^ The user's keybinding configuration.- , configHyperlinkingMode :: Bool- -- ^ Whether to enable terminal hyperlinking mode.- , configSyntaxDirs :: [FilePath]- -- ^ The search path for syntax description XML files.- , configDirectChannelExpirationDays :: Int- -- ^ The number of days to show a user in the channel menu after a direct- -- message with them.- , configCpuUsagePolicy :: CPUUsagePolicy- -- ^ The CPU usage policy for the application.- , configDefaultAttachmentPath :: Maybe FilePath- -- ^ The default path for browsing attachments- , configMessageSelectAfterURLOpen :: Bool- -- ^ Whether to remain in message selection mode after- -- opening URL(s)- } deriving (Eq, Show)---- | The policy for CPU usage.------ The idea is that Matterhorn can benefit from using multiple CPUs,--- but the exact number is application-determined. We expose this policy--- setting to the user in the configuration.-data CPUUsagePolicy =- SingleCPU- -- ^ Constrain the application to use one CPU.- | MultipleCPUs- -- ^ Permit the usage of multiple CPUs (the exact number is- -- determined by the application).- deriving (Eq, Show)---- | The state of the UI diagnostic indicator for the async worker--- thread.-data BackgroundInfo =- Disabled- -- ^ Disable (do not show) the indicator.- | Active- -- ^ Show the indicator when the thread is working.- | ActiveCount- -- ^ Show the indicator when the thread is working, but include the- -- thread's work queue length.- deriving (Eq, Show)--data UserPreferences =- UserPreferences { _userPrefShowJoinLeave :: Bool- , _userPrefFlaggedPostList :: Seq FlaggedPost- , _userPrefGroupChannelPrefs :: HashMap ChannelId Bool- , _userPrefDirectChannelPrefs :: HashMap UserId Bool- , _userPrefTeammateNameDisplayMode :: Maybe TeammateNameDisplayMode- }--hasUnread :: ChatState -> ChannelId -> Bool-hasUnread st cId = fromMaybe False $- hasUnread' <$> findChannelById cId (_csChannels st)--hasUnread' :: ClientChannel -> Bool-hasUnread' chan = fromMaybe False $ do- let info = _ccInfo chan- lastViewTime <- _cdViewed info- return $ _cdMentionCount info > 0 ||- (not (isMuted chan) &&- (((_cdUpdated info) > lastViewTime) ||- (isJust $ _cdEditedMessageThreshold info)))--mkChannelZipperList :: UTCTime- -> Config- -> Maybe ClientConfig- -> UserPreferences- -> ClientChannels- -> Users- -> [(ChannelListGroup, [ChannelListEntry])]-mkChannelZipperList now config cconfig prefs cs us =- [ let (anyUnread, entries) = getChannelEntriesInOrder cs Ordinary- in (ChannelGroupPublicChannels anyUnread, entries)- , let (anyUnread, entries) = getChannelEntriesInOrder cs Private- in (ChannelGroupPrivateChannels anyUnread, entries)- , (ChannelGroupDirectMessages False, getDMChannelEntriesInOrder now config cconfig prefs us cs)- ]--getChannelEntriesInOrder :: ClientChannels -> Type -> (Bool, [ChannelListEntry])-getChannelEntriesInOrder cs ty =- let matches (_, info) = info^.ccInfo.cdType == ty- pairs = filteredChannels matches cs- anyUnread = or $ (hasUnread' . snd) <$> pairs- entries = fmap (CLChannel . fst) $- sortBy (comparing ((^.ccInfo.cdDisplayName.to T.toLower) . snd)) pairs- in (anyUnread, entries)--getDMChannelEntriesInOrder :: UTCTime- -> Config- -> Maybe ClientConfig- -> UserPreferences- -> Users- -> ClientChannels- -> [ChannelListEntry]-getDMChannelEntriesInOrder now config cconfig prefs us cs =- let oneOnOneDmChans = getDMChannelEntries now config cconfig prefs us cs- groupChans = getGroupDMChannelEntries now config prefs cs- allDmChans = groupChans <> oneOnOneDmChans- sorter (u1, n1, _) (u2, n2, _) =- if u1 == u2- then compare n1 n2- else if u1 && not u2- then LT- else GT- sorted = sortBy sorter allDmChans- third (_, _, c) = c- in third <$> sorted--useNickname' :: Maybe ClientConfig -> UserPreferences -> Bool-useNickname' clientConfig prefs =- let serverSetting = case clientConfig^?_Just.to clientConfigTeammateNameDisplay of- Just TMNicknameOrFullname -> Just True- _ -> Nothing- accountSetting = (== TMNicknameOrFullname) <$> (_userPrefTeammateNameDisplayMode prefs)- fallback = False- in fromMaybe fallback $ accountSetting <|> serverSetting--displayNameForUser :: UserInfo -> Maybe ClientConfig -> UserPreferences -> Text-displayNameForUser u clientConfig prefs- | useNickname' clientConfig prefs =- fromMaybe (u^.uiName) (u^.uiNickName)- | otherwise =- u^.uiName--getGroupDMChannelEntries :: UTCTime- -> Config- -> UserPreferences- -> ClientChannels- -> [(Bool, T.Text, ChannelListEntry)]-getGroupDMChannelEntries now config prefs cs =- let matches (_, info) = info^.ccInfo.cdType == Group &&- groupChannelShouldAppear now config prefs info- in fmap (\(cId, ch) -> (hasUnread' ch, ch^.ccInfo.cdDisplayName, CLGroupDM cId)) $- filteredChannels matches cs--getDMChannelEntries :: UTCTime- -> Config- -> Maybe ClientConfig- -> UserPreferences- -> Users- -> ClientChannels- -> [(Bool, T.Text, ChannelListEntry)]-getDMChannelEntries now config cconfig prefs us cs =- let mapping = allDmChannelMappings cs- mappingWithUserInfo = catMaybes $ getInfo <$> mapping- getInfo (uId, cId) = do- c <- findChannelById cId cs- u <- findUserById uId us- case u^.uiDeleted of- True -> Nothing- False ->- if dmChannelShouldAppear now config prefs c- then return (hasUnread' c, displayNameForUser u cconfig prefs, CLUserDM cId uId)- else Nothing- in mappingWithUserInfo---- Always show a DM channel if it has unread activity.------ If it has no unread activity and if the preferences explicitly say to--- hide it, hide it.------ Otherwise, only show it if at least one of the other conditions are--- met (see 'or' below).-dmChannelShouldAppear :: UTCTime -> Config -> UserPreferences -> ClientChannel -> Bool-dmChannelShouldAppear now config prefs c =- let ndays = configDirectChannelExpirationDays config- localCutoff = addUTCTime (nominalDay * (-(fromIntegral ndays))) now- cutoff = ServerTime localCutoff- updated = c^.ccInfo.cdUpdated- Just uId = c^.ccInfo.cdDMUserId- in if hasUnread' c || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)- then True- else case dmChannelShowPreference prefs uId of- Just False -> False- _ -> or [- -- The channel was updated recently enough- updated >= cutoff- ]--groupChannelShouldAppear :: UTCTime -> Config -> UserPreferences -> ClientChannel -> Bool-groupChannelShouldAppear now config prefs c =- let ndays = configDirectChannelExpirationDays config- localCutoff = addUTCTime (nominalDay * (-(fromIntegral ndays))) now- cutoff = ServerTime localCutoff- updated = c^.ccInfo.cdUpdated- in if hasUnread' c || maybe False (>= localCutoff) (c^.ccInfo.cdSidebarShowOverride)- then True- else case groupChannelShowPreference prefs (c^.ccInfo.cdChannelId) of- Just False -> False- _ -> or [- -- The channel was updated recently enough- updated >= cutoff- ]--dmChannelShowPreference :: UserPreferences -> UserId -> Maybe Bool-dmChannelShowPreference ps uId = HM.lookup uId (_userPrefDirectChannelPrefs ps)--groupChannelShowPreference :: UserPreferences -> ChannelId -> Maybe Bool-groupChannelShowPreference ps cId = HM.lookup cId (_userPrefGroupChannelPrefs ps)---- * Internal Names and References---- | This 'Name' type is the type used in 'brick' to identify various--- parts of the interface.-data Name =- ChannelMessages ChannelId- | MessageInput- | ChannelList- | HelpViewport- | HelpText- | ScriptHelpText- | ThemeHelpText- | SyntaxHighlightHelpText- | KeybindingHelpText- | ChannelSelectString- | CompletionAlternatives- | CompletionList- | JoinChannelList- | UrlList- | MessagePreviewViewport- | ThemeListSearchInput- | UserListSearchInput- | JoinChannelListSearchInput- | UserListSearchResults- | ThemeListSearchResults- | ViewMessageArea- | ViewMessageReactionsArea- | ChannelSidebar- | ChannelSelectInput- | AttachmentList- | AttachmentFileBrowser- | MessageReactionsArea- | ReactionEmojiList- | ReactionEmojiListInput- | TabbedWindowTabBar- | MuteToggleField- | ChannelMentionsField- | DesktopNotificationsField (WithDefault NotifyOption)- | PushNotificationsField (WithDefault NotifyOption)- deriving (Eq, Show, Ord)---- | The sum type of exceptions we expect to encounter on authentication--- failure. We encode them explicitly here so that we can print them in--- a more user-friendly manner than just 'show'.-data AuthenticationException =- ConnectError HostCannotConnect- | ResolveError HostNotResolved- | AuthIOError IOError- | LoginError LoginFailureException- | OtherAuthError SomeException- deriving (Show)---- | Our 'ConnectionInfo' contains exactly as much information as is--- necessary to start a connection with a Mattermost server. This is--- built up during interactive authentication and then is used to log--- in.------ If the access token field is non-empty, that value is used and the--- username and password values are ignored.-data ConnectionInfo =- ConnectionInfo { _ciHostname :: Text- , _ciPort :: Int- , _ciUrlPath :: Text- , _ciUsername :: Text- , _ciPassword :: Text- , _ciAccessToken :: Text- , _ciType :: ConnectionType- }---- | We want to continue referring to posts by their IDs, but we don't--- want to have to synthesize new valid IDs for messages from the client--- itself (like error messages or informative client responses). To that--- end, a PostRef can be either a PostId or a newly-generated client ID.-data PostRef- = MMId PostId- | CLId Int- deriving (Eq, Show)---- ** Channel-matching types---- | A match in channel selection mode.-data ChannelSelectMatch =- ChannelSelectMatch { nameBefore :: Text- -- ^ The content of the match before the user's- -- matching input.- , nameMatched :: Text- -- ^ The potion of the name that matched the- -- user's input.- , nameAfter :: Text- -- ^ The portion of the name that came after the- -- user's matching input.- , matchFull :: Text- -- ^ The full string for this entry so it doesn't- -- have to be reassembled from the parts above.- , matchEntry :: ChannelListEntry- -- ^ The original entry data corresponding to the- -- text match.- }- deriving (Eq, Show)--data ChannelSelectPattern = CSP MatchType Text- | CSPAny- deriving (Eq, Show)--data MatchType =- Prefix- | Suffix- | Infix- | Equal- | PrefixDMOnly- | PrefixNonDMOnly- deriving (Eq, Show)---- * Application State Values--data ProgramOutput =- ProgramOutput { program :: FilePath- , programArgs :: [String]- , programStdout :: String- , programStdoutExpected :: Bool- , programStderr :: String- , programExitCode :: ExitCode- }--defaultUserPreferences :: UserPreferences-defaultUserPreferences =- UserPreferences { _userPrefShowJoinLeave = True- , _userPrefFlaggedPostList = mempty- , _userPrefGroupChannelPrefs = mempty- , _userPrefDirectChannelPrefs = mempty- , _userPrefTeammateNameDisplayMode = Nothing- }--setUserPreferences :: Seq Preference -> UserPreferences -> UserPreferences-setUserPreferences = flip (F.foldr go)- where go p u- | Just fp <- preferenceToFlaggedPost p =- u { _userPrefFlaggedPostList =- _userPrefFlaggedPostList u Seq.|> fp- }- | Just gp <- preferenceToDirectChannelShowStatus p =- u { _userPrefDirectChannelPrefs =- HM.insert- (directChannelShowUserId gp)- (directChannelShowValue gp)- (_userPrefDirectChannelPrefs u)- }- | Just gp <- preferenceToGroupChannelPreference p =- u { _userPrefGroupChannelPrefs =- HM.insert- (groupChannelId gp)- (groupChannelShow gp)- (_userPrefGroupChannelPrefs u)- }- | preferenceName p == PreferenceName "join_leave" =- u { _userPrefShowJoinLeave =- preferenceValue p /= PreferenceValue "false" }- | preferenceCategory p == PreferenceCategoryDisplaySettings &&- preferenceName p == PreferenceName "name_format" =- let PreferenceValue txt = preferenceValue p- in u { _userPrefTeammateNameDisplayMode = Just $ teammateDisplayModeFromText txt }- | otherwise = u---- | Log message tags.-data LogCategory =- LogGeneral- | LogAPI- | LogWebsocket- | LogError- | LogUserMark- deriving (Eq, Show)---- | A log message.-data LogMessage =- LogMessage { logMessageText :: !Text- -- ^ The text of the log message.- , logMessageContext :: !(Maybe LogContext)- -- ^ The optional context information relevant to the log- -- message.- , logMessageCategory :: !LogCategory- -- ^ The category of the log message.- , logMessageTimestamp :: !UTCTime- -- ^ The timestamp of the log message.- }- deriving (Eq, Show)---- | A logging thread command.-data LogCommand =- LogToFile FilePath- -- ^ Start logging to the specified path.- | LogAMessage !LogMessage- -- ^ Log the specified message.- | StopLogging- -- ^ Stop any active logging.- | ShutdownLogging- -- ^ Shut down.- | GetLogDestination- -- ^ Ask the logging thread about its active logging destination.- | LogSnapshot FilePath- -- ^ Ask the logging thread to dump the current buffer to the- -- specified destination.- deriving (Show)---- | A handle to the log manager thread.-data LogManager =- LogManager { logManagerCommandChannel :: STM.TChan LogCommand- , logManagerHandle :: Async ()- }--startLoggingToFile :: LogManager -> FilePath -> IO ()-startLoggingToFile mgr loc = sendLogCommand mgr $ LogToFile loc--stopLoggingToFile :: LogManager -> IO ()-stopLoggingToFile mgr = sendLogCommand mgr StopLogging--requestLogSnapshot :: LogManager -> FilePath -> IO ()-requestLogSnapshot mgr path = sendLogCommand mgr $ LogSnapshot path--requestLogDestination :: LogManager -> IO ()-requestLogDestination mgr = sendLogCommand mgr GetLogDestination--sendLogMessage :: LogManager -> LogMessage -> IO ()-sendLogMessage mgr lm = sendLogCommand mgr $ LogAMessage lm--sendLogCommand :: LogManager -> LogCommand -> IO ()-sendLogCommand mgr c =- STM.atomically $ STM.writeTChan (logManagerCommandChannel mgr) c---- | 'ChatResources' represents configuration and connection-related--- information, as opposed to current model or view information.--- Information that goes in the 'ChatResources' value should be limited--- to information that we read or set up prior to setting up the bulk of--- the application state.-data ChatResources =- ChatResources { _crSession :: Session- , _crWebsocketThreadId :: Maybe ThreadId- , _crConn :: ConnectionData- , _crRequestQueue :: RequestChan- , _crEventQueue :: BCH.BChan MHEvent- , _crSubprocessLog :: STM.TChan ProgramOutput- , _crWebsocketActionChan :: STM.TChan WebsocketAction- , _crTheme :: AttrMap- , _crStatusUpdateChan :: STM.TChan [UserId]- , _crConfiguration :: Config- , _crFlaggedPosts :: Set PostId- , _crUserPreferences :: UserPreferences- , _crSyntaxMap :: SyntaxMap- , _crLogManager :: LogManager- , _crEmoji :: EmojiCollection- }---- | A "special" mention that does not map to a specific user, but is an--- alias that the server uses to notify users.-data SpecialMention =- MentionAll- -- ^ @all: notify everyone in the channel.- | MentionChannel- -- ^ @channel: notify everyone in the channel.--data AutocompleteAlternative =- UserCompletion User Bool- -- ^ User, plus whether the user is in the channel that triggered- -- the autocomplete- | SpecialMention SpecialMention- -- ^ A special mention.- | ChannelCompletion Bool Channel- -- ^ Channel, plus whether the user is a member of the channel- | SyntaxCompletion Text- -- ^ Name of a skylighting syntax definition- | CommandCompletion Text Text Text- -- ^ Name of a slash command, argspec, and description- | EmojiCompletion Text- -- ^ The text of an emoji completion--specialMentionName :: SpecialMention -> Text-specialMentionName MentionChannel = "channel"-specialMentionName MentionAll = "all"--isSpecialMention :: T.Text -> Bool-isSpecialMention n = isJust $ lookup (T.toLower $ trimUserSigil n) pairs- where- pairs = mkPair <$> mentions- mentions = [ MentionChannel- , MentionAll- ]- mkPair v = (specialMentionName v, v)--autocompleteAlternativeReplacement :: AutocompleteAlternative -> Text-autocompleteAlternativeReplacement (EmojiCompletion e) =- ":" <> e <> ":"-autocompleteAlternativeReplacement (SpecialMention m) =- userSigil <> specialMentionName m-autocompleteAlternativeReplacement (UserCompletion u _) =- userSigil <> userUsername u-autocompleteAlternativeReplacement (ChannelCompletion _ c) =- normalChannelSigil <> (sanitizeUserText $ channelName c)-autocompleteAlternativeReplacement (SyntaxCompletion t) =- "```" <> t-autocompleteAlternativeReplacement (CommandCompletion t _ _) =- "/" <> t---- | The type of data that the autocompletion logic supports. We use--- this to track the kind of completion underway in case the type of--- completion needs to change.-data AutocompletionType =- ACUsers- | ACChannels- | ACCodeBlockLanguage- | ACEmoji- | ACCommands- deriving (Eq, Show)--data AutocompleteState =- AutocompleteState { _acPreviousSearchString :: Text- -- ^ The search string used for the- -- currently-displayed autocomplete results, for- -- use in deciding whether to issue another server- -- query- , _acCompletionList :: List Name AutocompleteAlternative- -- ^ The list of alternatives that the user- -- selects from- , _acType :: AutocompletionType- -- ^ The type of data that we're completing- , _acCachedResponses :: HM.HashMap Text [AutocompleteAlternative]- -- ^ A cache of alternative lists, keyed on search- -- string, for use in avoiding server requests.- -- The idea here is that users type quickly enough- -- (and edit their input) that would normally lead- -- to rapid consecutive requests, some for the- -- same strings during editing, that we can avoid- -- that by caching them here. Note that this cache- -- gets destroyed whenever autocompletion is not- -- on, so this cache does not live very long.- }---- | The 'ChatEditState' value contains the editor widget itself as well--- as history and metadata we need for editing-related operations.-data ChatEditState =- ChatEditState { _cedEditor :: Editor Text Name- , _cedEditMode :: EditMode- , _cedEphemeral :: EphemeralEditState- , _cedInputHistory :: InputHistory- -- ^ The map of per-channel input history for the- -- application. We don't distribute the per-channel- -- history into the per-channel states (like we do- -- for other per-channel state) since keeping it- -- under the InputHistory banner lets us use a nicer- -- startup/shutdown disk file management API.- , _cedYankBuffer :: Text- , _cedSpellChecker :: Maybe (Aspell, IO ())- , _cedMisspellings :: Set Text- , _cedAutocomplete :: Maybe AutocompleteState- -- ^ The autocomplete state. The autocompletion UI is- -- showing only when this state is present.- , _cedAutocompletePending :: Maybe Text- -- ^ The search string associated with the latest- -- in-flight autocompletion request. This is used to- -- determine whether any (potentially late-arriving)- -- API responses are for stale queries since the user- -- can type more quickly than the server can get us- -- the results, and we wouldn't want to show results- -- associated with old editor states.- , _cedAttachmentList :: List Name AttachmentData- -- ^ The list of attachments to be uploaded with the- -- post being edited.- , _cedFileBrowser :: Maybe (FB.FileBrowser Name)- -- ^ The browser for selecting attachment files.- -- This is a Maybe because the instantiation of the- -- FileBrowser causes it to read and ingest the- -- target directory, so this action is deferred- -- until the browser is needed.- , _cedJustCompleted :: Bool- -- A flag that indicates whether the most recent- -- editing event was a tab-completion. This is used by- -- the smart trailing space handling.- }---- | An attachment.-data AttachmentData =- AttachmentData { attachmentDataFileInfo :: FB.FileInfo- , attachmentDataBytes :: BS.ByteString- }- deriving (Eq, Show)---- | We can initialize a new 'ChatEditState' value with just an edit--- history, which we save locally.-emptyEditState :: InputHistory -> Maybe (Aspell, IO ()) -> IO ChatEditState-emptyEditState hist sp =- return ChatEditState { _cedEditor = editor MessageInput Nothing ""- , _cedEphemeral = defaultEphemeralEditState- , _cedInputHistory = hist- , _cedEditMode = NewPost- , _cedYankBuffer = ""- , _cedSpellChecker = sp- , _cedMisspellings = mempty- , _cedAutocomplete = Nothing- , _cedAutocompletePending = Nothing- , _cedAttachmentList = list AttachmentList mempty 1- , _cedFileBrowser = Nothing- , _cedJustCompleted = False- }---- | A 'RequestChan' is a queue of operations we have to perform in the--- background to avoid blocking on the main loop-type RequestChan = STM.TChan (IO (Maybe (MH ())))---- | The 'HelpScreen' type represents the set of possible 'Help'--- dialogues we have to choose from.-data HelpScreen =- MainHelp- | ScriptHelp- | ThemeHelp- | SyntaxHighlightHelp- | KeybindingHelp- deriving (Eq)---- | Help topics-data HelpTopic =- HelpTopic { helpTopicName :: Text- , helpTopicDescription :: Text- , helpTopicScreen :: HelpScreen- , helpTopicViewportName :: Name- }- deriving (Eq)---- | Mode type for the current contents of the post list overlay-data PostListContents =- PostListFlagged- | PostListPinned ChannelId- | PostListSearch Text Bool -- for the query and search status- deriving (Eq)---- | The 'Mode' represents the current dominant UI activity-data Mode =- Main- | ShowHelp HelpTopic Mode- | ChannelSelect- | UrlSelect- | LeaveChannelConfirm- | DeleteChannelConfirm- | MessageSelect- | MessageSelectDeleteConfirm- | PostListOverlay PostListContents- | UserListOverlay- | ReactionEmojiListOverlay- | ChannelListOverlay- | ThemeListOverlay- | ViewMessage- | ManageAttachments- | ManageAttachmentsBrowseFiles- | EditNotifyPrefs- deriving (Eq)---- | We're either connected or we're not.-data ConnectionStatus = Connected | Disconnected deriving (Eq)---- | An entry in a tabbed window corresponding to a tab and its content.--- Parameterized over an abstract handle type ('a') for the tabs so we--- can give each a unique handle.-data TabbedWindowEntry a =- TabbedWindowEntry { tweValue :: a- -- ^ The handle for this tab.- , tweRender :: a -> ChatState -> Widget Name- -- ^ The rendering function to use when this tab- -- is selected.- , tweHandleEvent :: a -> Vty.Event -> MH ()- -- ^ The event-handling function to use when this- -- tab is selected.- , tweTitle :: a -> Bool -> T.Text- -- ^ Title function for this tab, with a boolean- -- indicating whether this is the current tab.- , tweShowHandler :: a -> MH ()- -- ^ A handler to be invoked when this tab is- -- shown.- }---- | The definition of a tabbed window. Note that this does not track--- the *state* of the window; it merely provides a collection of tab--- window entries (see above). To track the state of a tabbed window,--- use a TabbedWindow.------ Parameterized over an abstract handle type ('a') for the tabs so we--- can give each a unique handle.-data TabbedWindowTemplate a =- TabbedWindowTemplate { twtEntries :: [TabbedWindowEntry a]- -- ^ The entries in tabbed windows with this- -- structure.- , twtTitle :: a -> Widget Name- -- ^ The title-rendering function for this kind- -- of tabbed window.- }---- | An instantiated tab window. This is based on a template and tracks--- the state of the tabbed window (current tab).------ Parameterized over an abstract handle type ('a') for the tabs so we--- can give each a unique handle.-data TabbedWindow a =- TabbedWindow { twValue :: a- -- ^ The handle of the currently-selected tab.- , twReturnMode :: Mode- -- ^ The mode to return to when the tab is closed.- , twTemplate :: TabbedWindowTemplate a- -- ^ The template to use as a basis for rendering the- -- window and handling user input.- , twWindowWidth :: Int- , twWindowHeight :: Int- -- ^ Window dimensions- }---- | Construct a new tabbed window from a template. This will raise an--- exception if the initially-selected tab does not exist in the window--- template, or if the window template has any duplicated tab handles.------ Note that the caller is responsible for determining whether to call--- the initially-selected tab's on-show handler.-tabbedWindow :: (Show a, Eq a)- => a- -- ^ The handle corresponding to the tab that should be- -- selected initially.- -> TabbedWindowTemplate a- -- ^ The template for the window to construct.- -> Mode- -- ^ When the window is closed, return to this application- -- mode.- -> (Int, Int)- -- ^ The window dimensions (width, height).- -> TabbedWindow a-tabbedWindow initialVal t retMode (width, height) =- let handles = tweValue <$> twtEntries t- in if | null handles ->- error "BUG: tabbed window template must provide at least one entry"- | length handles /= length (nub handles) ->- error "BUG: tabbed window should have one entry per handle"- | not (initialVal `elem` handles) ->- error $ "BUG: tabbed window handle " <>- show initialVal <> " not present in template"- | otherwise ->- TabbedWindow { twTemplate = t- , twValue = initialVal- , twReturnMode = retMode- , twWindowWidth = width- , twWindowHeight = height- }---- | Get the currently-selected tab entry for a tabbed window. Raise--- an exception if the window's selected tab handle is not found in its--- template (which is a bug in the tabbed window infrastructure).-getCurrentTabbedWindowEntry :: (Show a, Eq a)- => TabbedWindow a- -> TabbedWindowEntry a-getCurrentTabbedWindowEntry w =- lookupTabbedWindowEntry (twValue w) w---- | Run the on-show handler for the window tab entry with the specified--- handle.-runTabShowHandlerFor :: (Eq a, Show a) => a -> TabbedWindow a -> MH ()-runTabShowHandlerFor handle w = do- let entry = lookupTabbedWindowEntry handle w- tweShowHandler entry handle---- | Look up a tabbed window entry by handle. Raises an exception if no--- such entry exists.-lookupTabbedWindowEntry :: (Eq a, Show a)- => a- -> TabbedWindow a- -> TabbedWindowEntry a-lookupTabbedWindowEntry handle w =- let matchesVal e = tweValue e == handle- in case filter matchesVal (twtEntries $ twTemplate w) of- [e] -> e- _ -> error $ "BUG: tabbed window entry for " <> show (twValue w) <>- " should have matched a single entry"---- | Switch a tabbed window's selected tab to its next tab, cycling back--- to the first tab if the last tab is the selected tab. This also--- invokes the on-show handler for the newly-selected tab.------ Note that this does nothing if the window has only one tab.-tabbedWindowNextTab :: (Show a, Eq a)- => TabbedWindow a- -> MH (TabbedWindow a)-tabbedWindowNextTab w | length (twtEntries $ twTemplate w) == 1 = return w-tabbedWindowNextTab w = do- let curIdx = case elemIndex (tweValue curEntry) allHandles of- Nothing ->- error $ "BUG: tabbedWindowNextTab: could not find " <>- "current handle in handle list"- Just i -> i- nextIdx = if curIdx == length allHandles - 1- then 0- else curIdx + 1- newHandle = allHandles !! nextIdx- allHandles = tweValue <$> twtEntries (twTemplate w)- curEntry = getCurrentTabbedWindowEntry w- newWin = w { twValue = newHandle }-- runTabShowHandlerFor newHandle newWin- return newWin---- | Switch a tabbed window's selected tab to its previous tab, cycling--- to the last tab if the first tab is the selected tab. This also--- invokes the on-show handler for the newly-selected tab.------ Note that this does nothing if the window has only one tab.-tabbedWindowPreviousTab :: (Show a, Eq a)- => TabbedWindow a- -> MH (TabbedWindow a)-tabbedWindowPreviousTab w | length (twtEntries $ twTemplate w) == 1 = return w-tabbedWindowPreviousTab w = do- let curIdx = case elemIndex (tweValue curEntry) allHandles of- Nothing ->- error $ "BUG: tabbedWindowPreviousTab: could not find " <>- "current handle in handle list"- Just i -> i- nextIdx = if curIdx == 0- then length allHandles - 1- else curIdx - 1- newHandle = allHandles !! nextIdx- allHandles = tweValue <$> twtEntries (twTemplate w)- curEntry = getCurrentTabbedWindowEntry w- newWin = w { twValue = newHandle }-- runTabShowHandlerFor newHandle newWin- return newWin---- | This is the giant bundle of fields that represents the current--- state of our application at any given time.-data ChatState =- ChatState { _csResources :: ChatResources- -- ^ Global application-wide resources that don't change- -- much.- , _csFocus :: Zipper ChannelListGroup ChannelListEntry- -- ^ The channel sidebar zipper that tracks which channel- -- is selected.- , _csMe :: User- -- ^ The authenticated user.- , _csMyTeam :: Team- -- ^ The active team of the authenticated user.- , _csChannels :: ClientChannels- -- ^ The channels that we are showing, including their- -- message lists.- , _csPostMap :: HashMap PostId Message- -- ^ The map of post IDs to messages. This allows us to- -- access messages by ID without having to linearly scan- -- channel message lists.- , _csUsers :: Users- -- ^ All of the users we know about.- , _timeZone :: TimeZoneSeries- -- ^ The client time zone.- , _csEditState :: ChatEditState- -- ^ The state of the input box used for composing and- -- editing messages and commands.- , _csMode :: Mode- -- ^ The current application mode. This is used to- -- dispatch to different rendering and event handling- -- routines.- , _csShowMessagePreview :: Bool- -- ^ Whether to show the message preview area.- , _csShowChannelList :: Bool- -- ^ Whether to show the channel list.- , _csChannelSelectState :: ChannelSelectState- -- ^ The state of the user's input and selection for- -- channel selection mode.- , _csRecentChannel :: Maybe ChannelId- -- ^ The most recently-selected channel, if any.- , _csReturnChannel :: Maybe ChannelId- -- ^ The channel to return to after visiting one or more- -- unread channels.- , _csUrlList :: List Name LinkChoice- -- ^ The URL list used to show URLs drawn from messages in- -- a channel.- , _csConnectionStatus :: ConnectionStatus- -- ^ Our view of the connection status.- , _csWorkerIsBusy :: Maybe (Maybe Int)- -- ^ Whether the async worker thread is busy, and its- -- queue length if so.- , _csMessageSelect :: MessageSelectState- -- ^ The state of message selection mode.- , _csThemeListOverlay :: ListOverlayState InternalTheme ()- -- ^ The state of the theme list overlay.- , _csPostListOverlay :: PostListOverlayState- -- ^ The state of the post list overlay.- , _csUserListOverlay :: ListOverlayState UserInfo UserSearchScope- -- ^ The state of the user list overlay.- , _csChannelListOverlay :: ListOverlayState Channel ChannelSearchScope- -- ^ The state of the user list overlay.- , _csReactionEmojiListOverlay :: ListOverlayState (Bool, T.Text) ()- -- ^ The state of the reaction emoji list overlay.- , _csClientConfig :: Maybe ClientConfig- -- ^ The Mattermost client configuration, as we understand it.- , _csPendingChannelChange :: Maybe PendingChannelChange- -- ^ A pending channel change that we need to apply once- -- the channel in question is available. We set this up- -- when we need to change to a channel in the sidebar, but- -- it isn't even there yet because we haven't loaded its- -- metadata.- , _csViewedMessage :: Maybe (Message, TabbedWindow ViewMessageWindowTab)- -- ^ Set when the ViewMessage mode is active. The message- -- being viewed. Note that this stores a message, not- -- a message ID. That's because not all messages have- -- message IDs (e.g. client messages) and we still- -- want to support viewing of those messages. It's the- -- responsibility of code that uses this message to always- -- consult the chat state for the latest *version* of any- -- message with an ID here, to be sure that the latest- -- version is used (e.g. if it gets edited, etc.).- , _csNotifyPrefs :: Maybe (Form ChannelNotifyProps MHEvent Name)- -- ^ A form for editing the notification preferences for- -- the current channel. This is set when entering- -- EditNotifyPrefs mode and updated when the user- -- changes the form state.- }---- | Handles for the View Message window's tabs.-data ViewMessageWindowTab =- VMTabMessage- -- ^ The message tab.- | VMTabReactions- -- ^ The reactions tab.- deriving (Eq, Show)--data PendingChannelChange =- ChangeByChannelId ChannelId- | ChangeByUserId UserId- deriving (Eq, Show)---- | Startup state information that is constructed prior to building a--- ChatState.-data StartupStateInfo =- StartupStateInfo { startupStateResources :: ChatResources- , startupStateChannelZipper :: Zipper ChannelListGroup ChannelListEntry- , startupStateConnectedUser :: User- , startupStateTeam :: Team- , startupStateTimeZone :: TimeZoneSeries- , startupStateInitialHistory :: InputHistory- , startupStateSpellChecker :: Maybe (Aspell, IO ())- }--newState :: StartupStateInfo -> IO ChatState-newState (StartupStateInfo {..}) = do- editState <- emptyEditState startupStateInitialHistory startupStateSpellChecker- return ChatState { _csResources = startupStateResources- , _csFocus = startupStateChannelZipper- , _csMe = startupStateConnectedUser- , _csMyTeam = startupStateTeam- , _csChannels = noChannels- , _csPostMap = HM.empty- , _csUsers = noUsers- , _timeZone = startupStateTimeZone- , _csEditState = editState- , _csMode = Main- , _csShowMessagePreview = configShowMessagePreview $ _crConfiguration startupStateResources- , _csShowChannelList = configShowChannelList $ _crConfiguration startupStateResources- , _csChannelSelectState = emptyChannelSelectState- , _csRecentChannel = Nothing- , _csReturnChannel = Nothing- , _csUrlList = list UrlList mempty 2- , _csConnectionStatus = Connected- , _csWorkerIsBusy = Nothing- , _csMessageSelect = MessageSelectState Nothing- , _csThemeListOverlay = nullThemeListOverlayState- , _csPostListOverlay = PostListOverlayState emptyDirSeq Nothing- , _csUserListOverlay = nullUserListOverlayState- , _csChannelListOverlay = nullChannelListOverlayState- , _csReactionEmojiListOverlay = nullEmojiListOverlayState- , _csClientConfig = Nothing- , _csPendingChannelChange = Nothing- , _csViewedMessage = Nothing- , _csNotifyPrefs = Nothing- }--nullChannelListOverlayState :: ListOverlayState Channel ChannelSearchScope-nullChannelListOverlayState =- let newList rs = list JoinChannelList rs 2- in ListOverlayState { _listOverlaySearchResults = newList mempty- , _listOverlaySearchInput = editor JoinChannelListSearchInput (Just 1) ""- , _listOverlaySearchScope = AllChannels- , _listOverlaySearching = False- , _listOverlayEnterHandler = const $ return False- , _listOverlayNewList = newList- , _listOverlayFetchResults = const $ const $ const $ return mempty- , _listOverlayRecordCount = Nothing- }--nullThemeListOverlayState :: ListOverlayState InternalTheme ()-nullThemeListOverlayState =- let newList rs = list ThemeListSearchResults rs 3- in ListOverlayState { _listOverlaySearchResults = newList mempty- , _listOverlaySearchInput = editor ThemeListSearchInput (Just 1) ""- , _listOverlaySearchScope = ()- , _listOverlaySearching = False- , _listOverlayEnterHandler = const $ return False- , _listOverlayNewList = newList- , _listOverlayFetchResults = const $ const $ const $ return mempty- , _listOverlayRecordCount = Nothing- }--nullUserListOverlayState :: ListOverlayState UserInfo UserSearchScope-nullUserListOverlayState =- let newList rs = list UserListSearchResults rs 1- in ListOverlayState { _listOverlaySearchResults = newList mempty- , _listOverlaySearchInput = editor UserListSearchInput (Just 1) ""- , _listOverlaySearchScope = AllUsers Nothing- , _listOverlaySearching = False- , _listOverlayEnterHandler = const $ return False- , _listOverlayNewList = newList- , _listOverlayFetchResults = const $ const $ const $ return mempty- , _listOverlayRecordCount = Nothing- }--nullEmojiListOverlayState :: ListOverlayState (Bool, T.Text) ()-nullEmojiListOverlayState =- let newList rs = list ReactionEmojiList rs 1- in ListOverlayState { _listOverlaySearchResults = newList mempty- , _listOverlaySearchInput = editor ReactionEmojiListInput (Just 1) ""- , _listOverlaySearchScope = ()- , _listOverlaySearching = False- , _listOverlayEnterHandler = const $ return False- , _listOverlayNewList = newList- , _listOverlayFetchResults = const $ const $ const $ return mempty- , _listOverlayRecordCount = Nothing- }---- | The state of channel selection mode.-data ChannelSelectState =- ChannelSelectState { _channelSelectInput :: Editor Text Name- , _channelSelectMatches :: Zipper ChannelListGroup ChannelSelectMatch- }--emptyChannelSelectState :: ChannelSelectState-emptyChannelSelectState =- ChannelSelectState { _channelSelectInput = editor ChannelSelectInput (Just 1) ""- , _channelSelectMatches = fromList []- }---- | The state of message selection mode.-data MessageSelectState =- MessageSelectState { selectMessageId :: Maybe MessageId- }---- | The state of the post list overlay.-data PostListOverlayState =- PostListOverlayState { _postListPosts :: Messages- , _postListSelected :: Maybe PostId- }--data InternalTheme =- InternalTheme { internalThemeName :: Text- , internalTheme :: Theme- , internalThemeDesc :: Text- }---- | The state of the search result list overlay. Type 'a' is the type--- of data in the list. Type 'b' is the search scope type.-data ListOverlayState a b =- ListOverlayState { _listOverlaySearchResults :: List Name a- -- ^ The list of search results currently shown in- -- the overlay.- , _listOverlaySearchInput :: Editor Text Name- -- ^ The editor for the overlay's search input.- , _listOverlaySearchScope :: b- -- ^ The overlay's current search scope.- , _listOverlaySearching :: Bool- -- ^ Whether a search is in progress (i.e. whether- -- we are currently awaiting a response from a- -- search query to the server).- , _listOverlayEnterHandler :: a -> MH Bool- -- ^ The handler to invoke on the selected element- -- when the user presses Enter.- , _listOverlayNewList :: Vec.Vector a -> List Name a- -- ^ The function to build a new brick List from a- -- vector of search results.- , _listOverlayFetchResults :: b -> Session -> Text -> IO (Vec.Vector a)- -- ^ The function to call to issue a search query- -- to the server.- , _listOverlayRecordCount :: Maybe Int- -- ^ The total number of available records, if known.- }---- | The scope for searching for users in a user list overlay.-data UserSearchScope =- ChannelMembers ChannelId TeamId- | ChannelNonMembers ChannelId TeamId- | AllUsers (Maybe TeamId)---- | The scope for searching for channels to join.-data ChannelSearchScope =- AllChannels---- | Actions that can be sent on the websocket to the server.-data WebsocketAction =- UserTyping UTCTime ChannelId (Maybe PostId) -- ^ user typing in the input box- deriving (Read, Show, Eq, Ord)---- * MH Monad---- | Logging context information, in the event that metadata should--- accompany a log message.-data LogContext =- LogContext { logContextChannelId :: Maybe ChannelId- }- deriving (Eq, Show)---- | A user fetching strategy.-data UserFetch =- UserFetchById UserId- -- ^ Fetch the user with the specified ID.- | UserFetchByUsername Text- -- ^ Fetch the user with the specified username.- | UserFetchByNickname Text- -- ^ Fetch the user with the specified nickname.- deriving (Eq, Show)--data MHState =- MHState { mhCurrentState :: ChatState- , mhNextAction :: ChatState -> EventM Name (Next ChatState)- , mhUsersToFetch :: [UserFetch]- , mhPendingStatusList :: Maybe [UserId]- }---- | A value of type 'MH' @a@ represents a computation that can--- manipulate the application state and also request that the--- application quit-newtype MH a =- MH { fromMH :: R.ReaderT (Maybe LogContext) (St.StateT MHState (EventM Name)) a }---- | Use a modified logging context for the duration of the specified MH--- action.-withLogContext :: (Maybe LogContext -> Maybe LogContext) -> MH a -> MH a-withLogContext modifyContext act =- MH $ R.withReaderT modifyContext (fromMH act)--withLogContextChannelId :: ChannelId -> MH a -> MH a-withLogContextChannelId cId act =- let f Nothing = Just $ LogContext (Just cId)- f (Just c) = Just $ c { logContextChannelId = Just cId }- in withLogContext f act---- | Get the current logging context.-getLogContext :: MH (Maybe LogContext)-getLogContext = MH R.ask---- | Log a message.-mhLog :: LogCategory -> Text -> MH ()-mhLog cat msg = do- logger <- mhGetIOLogger- liftIO $ logger cat msg---- | Get a logger suitable for use in IO. The logger always logs using--- the MH monad log context at the time of the call to mhGetIOLogger.-mhGetIOLogger :: MH (LogCategory -> Text -> IO ())-mhGetIOLogger = do- ctx <- getLogContext- mgr <- use (to (_crLogManager . _csResources))- return $ ioLogWithManager mgr ctx--ioLogWithManager :: LogManager -> Maybe LogContext -> LogCategory -> Text -> IO ()-ioLogWithManager mgr ctx cat msg = do- now <- getCurrentTime- let lm = LogMessage { logMessageText = msg- , logMessageContext = ctx- , logMessageCategory = cat- , logMessageTimestamp = now- }- sendLogMessage mgr lm---- | Run an 'MM' computation, choosing whether to continue or halt based--- on the resulting-runMHEvent :: ChatState -> MH () -> EventM Name (Next ChatState)-runMHEvent st (MH mote) = do- let mhSt = MHState { mhCurrentState = st- , mhNextAction = Brick.continue- , mhUsersToFetch = []- , mhPendingStatusList = Nothing- }- ((), st') <- St.runStateT (R.runReaderT mote Nothing) mhSt- (mhNextAction st') (mhCurrentState st')--scheduleUserFetches :: [UserFetch] -> MH ()-scheduleUserFetches fs = MH $ do- St.modify $ \s -> s { mhUsersToFetch = fs <> mhUsersToFetch s }--scheduleUserStatusFetches :: [UserId] -> MH ()-scheduleUserStatusFetches is = MH $ do- St.modify $ \s -> s { mhPendingStatusList = Just is }--getScheduledUserFetches :: MH [UserFetch]-getScheduledUserFetches = MH $ St.gets mhUsersToFetch--getScheduledUserStatusFetches :: MH (Maybe [UserId])-getScheduledUserStatusFetches = MH $ St.gets mhPendingStatusList---- | lift a computation in 'EventM' into 'MH'-mh :: EventM Name a -> MH a-mh = MH . R.lift . St.lift--generateUUID :: MH UUID-generateUUID = liftIO generateUUID_IO--generateUUID_IO :: IO UUID-generateUUID_IO = randomIO--mhHandleEventLensed :: Lens' ChatState b -> (e -> b -> EventM Name b) -> e -> MH ()-mhHandleEventLensed ln f event = MH $ do- s <- St.get- let st = mhCurrentState s- n <- R.lift $ St.lift $ f event (st ^. ln)- St.put (s { mhCurrentState = st & ln .~ n })--mhSuspendAndResume :: (ChatState -> IO ChatState) -> MH ()-mhSuspendAndResume mote = MH $ do- s <- St.get- St.put $ s { mhNextAction = \ _ -> Brick.suspendAndResume (mote $ mhCurrentState s) }---- | This will request that after this computation finishes the--- application should exit-requestQuit :: MH ()-requestQuit = MH $ do- s <- St.get- St.put $ s { mhNextAction = Brick.halt }--instance Functor MH where- fmap f (MH x) = MH (fmap f x)--instance Applicative MH where- pure x = MH (pure x)- MH f <*> MH x = MH (f <*> x)--instance MHF.MonadFail MH where- fail = MH . MHF.fail--instance Monad MH where- return x = MH (return x)- MH x >>= f = MH (x >>= \ x' -> fromMH (f x'))---- We want to pretend that the state is only the ChatState, rather--- than the ChatState and the Brick continuation-instance St.MonadState ChatState MH where- get = mhCurrentState `fmap` MH St.get- put st = MH $ do- s <- St.get- St.put $ s { mhCurrentState = st }--instance St.MonadIO MH where- liftIO = MH . St.liftIO---- | This represents events that we handle in the main application loop.-data MHEvent =- WSEvent WebsocketEvent- -- ^ For events that arise from the websocket- | WSActionResponse WebsocketActionResponse- -- ^ For responses to websocket actions- | RespEvent (MH ())- -- ^ For the result values of async IO operations- | RefreshWebsocketEvent- -- ^ Tell our main loop to refresh the websocket connection- | WebsocketParseError String- -- ^ We failed to parse an incoming websocket event- | WebsocketDisconnect- -- ^ The websocket connection went down.- | WebsocketConnect- -- ^ The websocket connection came up.- | BGIdle- -- ^ background worker is idle- | BGBusy (Maybe Int)- -- ^ background worker is busy (with n requests)- | RateLimitExceeded Int- -- ^ A request initially failed due to a rate limit but will be- -- retried if possible. The argument is the number of seconds in- -- which the retry will be attempted.- | RateLimitSettingsMissing- -- ^ A request denied by a rate limit could not be retried because- -- the response contained no rate limit metadata- | RequestDropped- -- ^ A request was reattempted due to a rate limit and was rate- -- limited again- | IEvent InternalEvent- -- ^ MH-internal events---- | Internal application events.-data InternalEvent =- DisplayError MHError- -- ^ Some kind of application error occurred- | LoggingStarted FilePath- | LoggingStopped FilePath- | LogStartFailed FilePath String- | LogDestination (Maybe FilePath)- | LogSnapshotSucceeded FilePath- | LogSnapshotFailed FilePath String- -- ^ Logging events from the logging thread---- | Application errors.-data MHError =- GenericError T.Text- -- ^ A generic error message constructor- | NoSuchChannel T.Text- -- ^ The specified channel does not exist- | NoSuchUser T.Text- -- ^ The specified user does not exist- | AmbiguousName T.Text- -- ^ The specified name matches both a user and a channel- | ServerError MattermostError- -- ^ A Mattermost server error occurred- | ClipboardError T.Text- -- ^ A problem occurred trying to deal with yanking or the system- -- clipboard- | ConfigOptionMissing T.Text- -- ^ A missing config option is required to perform an operation- | ProgramExecutionFailed T.Text T.Text- -- ^ Args: program name, path to log file. A problem occurred when- -- running the program.- | NoSuchScript T.Text- -- ^ The specified script was not found- | NoSuchHelpTopic T.Text- -- ^ The specified help topic was not found- | AsyncErrEvent SomeException- -- ^ For errors that arise in the course of async IO operations- deriving (Show)---- ** Application State Lenses--makeLenses ''ChatResources-makeLenses ''ChatState-makeLenses ''ChatEditState-makeLenses ''AutocompleteState-makeLenses ''PostListOverlayState-makeLenses ''ListOverlayState-makeLenses ''ChannelSelectState-makeLenses ''UserPreferences-makeLenses ''ConnectionInfo--getSession :: MH Session-getSession = use (csResources.crSession)--getResourceSession :: ChatResources -> Session-getResourceSession = _crSession--whenMode :: Mode -> MH () -> MH ()-whenMode m act = do- curMode <- use csMode- when (curMode == m) act--setMode :: Mode -> MH ()-setMode m = do- csMode .= m- mh invalidateCache--setMode' :: Mode -> ChatState -> ChatState-setMode' m = csMode .~ m--appMode :: ChatState -> Mode-appMode = _csMode--resetSpellCheckTimer :: ChatEditState -> IO ()-resetSpellCheckTimer s =- case s^.cedSpellChecker of- Nothing -> return ()- Just (_, reset) -> reset---- ** Utility Lenses-csCurrentChannelId :: SimpleGetter ChatState ChannelId-csCurrentChannelId = csFocus.to unsafeFocus.to channelListEntryChannelId--channelListEntryChannelId :: ChannelListEntry -> ChannelId-channelListEntryChannelId (CLChannel cId) = cId-channelListEntryChannelId (CLUserDM cId _) = cId-channelListEntryChannelId (CLGroupDM cId) = cId--channelListEntryUserId :: ChannelListEntry -> Maybe UserId-channelListEntryUserId (CLUserDM _ uId) = Just uId-channelListEntryUserId _ = Nothing--userIdsFromZipper :: Zipper ChannelListGroup ChannelListEntry -> [UserId]-userIdsFromZipper z =- concat $ (catMaybes . fmap channelListEntryUserId . snd) <$> Zipper.toList z--entryIsDMEntry :: ChannelListEntry -> Bool-entryIsDMEntry (CLUserDM {}) = True-entryIsDMEntry (CLGroupDM {}) = True-entryIsDMEntry (CLChannel {}) = False--csCurrentChannel :: Lens' ChatState ClientChannel-csCurrentChannel =- lens (\ st -> findChannelById (st^.csCurrentChannelId) (st^.csChannels) ^?! _Just)- (\ st n -> st & csChannels %~ addChannel (st^.csCurrentChannelId) n)--csChannel :: ChannelId -> Traversal' ChatState ClientChannel-csChannel cId =- csChannels . channelByIdL cId--withChannel :: ChannelId -> (ClientChannel -> MH ()) -> MH ()-withChannel cId = withChannelOrDefault cId ()--withChannelOrDefault :: ChannelId -> a -> (ClientChannel -> MH a) -> MH a-withChannelOrDefault cId deflt mote = do- chan <- preuse (csChannel(cId))- case chan of- Nothing -> return deflt- Just c -> mote c---- ** 'ChatState' Helper Functions--raiseInternalEvent :: InternalEvent -> MH ()-raiseInternalEvent ev = do- queue <- use (csResources.crEventQueue)- writeBChan queue (IEvent ev)--writeBChan :: (MonadIO m) => BCH.BChan MHEvent -> MHEvent -> m ()-writeBChan chan e = do- written <- liftIO $ BCH.writeBChanNonBlocking chan e- when (not written) $- error $ "mhSendEvent: BChan full, please report this as a bug!"---- | Log and raise an error.-mhError :: MHError -> MH ()-mhError err = do- mhLog LogError $ T.pack $ show err- raiseInternalEvent (DisplayError err)--isMine :: ChatState -> Message -> Bool-isMine st msg =- case msg^.mUser of- UserI _ uid -> uid == myUserId st- _ -> False--getMessageForPostId :: ChatState -> PostId -> Maybe Message-getMessageForPostId st pId = st^.csPostMap.at(pId)--getParentMessage :: ChatState -> Message -> Maybe Message-getParentMessage st msg- | InReplyTo pId <- msg^.mInReplyToMsg- = st^.csPostMap.at(pId)- | otherwise = Nothing--setUserStatus :: UserId -> Text -> MH ()-setUserStatus uId t = do- csUsers %= modifyUserById uId (uiStatus .~ statusFromText t)- mh $ invalidateCacheEntry ChannelSidebar--usernameForUserId :: UserId -> ChatState -> Maybe Text-usernameForUserId uId st = _uiName <$> findUserById uId (st^.csUsers)--displayNameForUserId :: UserId -> ChatState -> Maybe Text-displayNameForUserId uId st = do- u <- findUserById uId (st^.csUsers)- return $ displayNameForUser u (st^.csClientConfig) (st^.csResources.crUserPreferences)---- | Note: this only searches users we have already loaded. Be--- aware that if you think you need a user we haven't fetched, use--- withFetchedUser!-userIdForUsername :: Text -> ChatState -> Maybe UserId-userIdForUsername name st =- fst <$> (findUserByUsername name $ st^.csUsers)--channelIdByChannelName :: Text -> ChatState -> Maybe ChannelId-channelIdByChannelName name st =- let matches (_, cc) = cc^.ccInfo.cdName == (trimChannelSigil name)- in listToMaybe $ fst <$> filteredChannels matches (st^.csChannels)--channelIdByUsername :: Text -> ChatState -> Maybe ChannelId-channelIdByUsername name st = do- uId <- userIdForUsername name st- getDmChannelFor uId (st^.csChannels)--useNickname :: ChatState -> Bool-useNickname st =- useNickname' (st^.csClientConfig) (st^.csResources.crUserPreferences)--channelByName :: Text -> ChatState -> Maybe ClientChannel-channelByName n st = do- cId <- channelIdByChannelName n st- findChannelById cId (st^.csChannels)--trimChannelSigil :: Text -> Text-trimChannelSigil n- | normalChannelSigil `T.isPrefixOf` n = T.tail n- | otherwise = n--addNewUser :: UserInfo -> MH ()-addNewUser u = do- csUsers %= addUser u- -- Invalidate the cache because channel message rendering may need- -- to get updated if this user authored posts in any channels.- mh invalidateCache--data SidebarUpdate =- SidebarUpdateImmediate- | SidebarUpdateDeferred- deriving (Eq, Show)---resetAutocomplete :: MH ()-resetAutocomplete = do- csEditState.cedAutocomplete .= Nothing- csEditState.cedAutocompletePending .= Nothing----- * Slash Commands---- | The 'CmdArgs' type represents the arguments to a slash-command; the--- type parameter represents the argument structure.-data CmdArgs :: K.Type -> K.Type where- NoArg :: CmdArgs ()- LineArg :: Text -> CmdArgs Text- UserArg :: CmdArgs rest -> CmdArgs (Text, rest)- ChannelArg :: CmdArgs rest -> CmdArgs (Text, rest)- TokenArg :: Text -> CmdArgs rest -> CmdArgs (Text, rest)---- | A 'CmdExec' value represents the implementation of a command when--- provided with its arguments-type CmdExec a = a -> MH ()---- | A 'Cmd' packages up a 'CmdArgs' specifier and the 'CmdExec'--- implementation with a name and a description.-data Cmd =- forall a. Cmd { cmdName :: Text- , cmdDescr :: Text- , cmdArgSpec :: CmdArgs a- , cmdAction :: CmdExec a- }---- | Helper function to extract the name out of a 'Cmd' value-commandName :: Cmd -> Text-commandName (Cmd name _ _ _ ) = name---- * Channel Updates and Notifications--userList :: ChatState -> [UserInfo]-userList st = filter showUser $ allUsers (st^.csUsers)- where showUser u = not (isSelf u) && (u^.uiInTeam)- isSelf u = (myUserId st) == (u^.uiId)--allUserIds :: ChatState -> [UserId]-allUserIds st = getAllUserIds $ st^.csUsers---- BEWARE: you probably don't want this, but instead--- State.Users.withFetchedUser, since this only looks up users in the--- collection we have already loaded rather than all valid users on the--- server.-userById :: UserId -> ChatState -> Maybe UserInfo-userById uId st = findUserById uId (st^.csUsers)--myUserId :: ChatState -> UserId-myUserId st = myUser st ^. userIdL--myTeamId :: ChatState -> TeamId-myTeamId st = st ^. csMyTeam . teamIdL--myUser :: ChatState -> User-myUser st = st^.csMe--myUsername :: ChatState -> Text-myUsername st = userUsername $ st^.csMe---- BEWARE: you probably don't want this, but instead--- State.Users.withFetchedUser, since this only looks up users in the--- collection we have already loaded rather than all valid users on the--- server.-userByUsername :: Text -> ChatState -> Maybe UserInfo-userByUsername name st = do- snd <$> (findUserByUsername name $ st^.csUsers)---- BEWARE: you probably don't want this, but instead--- State.Users.withFetchedUser, since this only looks up users in the--- collection we have already loaded rather than all valid users on the--- server.-userByNickname :: Text -> ChatState -> Maybe UserInfo-userByNickname name st =- snd <$> (findUserByNickname name $ st^.csUsers)--getUsers :: MH Users-getUsers = use csUsers---- * HighlightSet--type UserSet = Set Text-type ChannelSet = Set Text---- | The set of usernames, channel names, and language names used for--- highlighting when rendering messages.-data HighlightSet =- HighlightSet { hUserSet :: Set Text- , hChannelSet :: Set Text- , hSyntaxMap :: SyntaxMap- }--getHighlightSet :: ChatState -> HighlightSet-getHighlightSet st =- HighlightSet { hUserSet = addSpecialUserMentions $ getUsernameSet $ st^.csUsers- , hChannelSet = getChannelNameSet $ st^.csChannels- , hSyntaxMap = st^.csResources.crSyntaxMap- }--attrNameToConfig :: Brick.AttrName -> Text-attrNameToConfig = T.pack . intercalate "." . Brick.attrNameComponents---- From: https://docs.mattermost.com/help/messaging/mentioning-teammates.html-specialUserMentions :: [T.Text]-specialUserMentions = ["all", "channel", "here"]--addSpecialUserMentions :: Set Text -> Set Text-addSpecialUserMentions s = foldr Set.insert s specialUserMentions--getNewMessageCutoff :: ChannelId -> ChatState -> Maybe NewMessageIndicator-getNewMessageCutoff cId st = do- cc <- st^?csChannel(cId)- return $ cc^.ccInfo.cdNewMessageIndicator--getEditedMessageCutoff :: ChannelId -> ChatState -> Maybe ServerTime-getEditedMessageCutoff cId st = do- cc <- st^?csChannel(cId)- cc^.ccInfo.cdEditedMessageThreshold--clearChannelUnreadStatus :: ChannelId -> MH ()-clearChannelUnreadStatus cId = do- mh $ invalidateCacheEntry (ChannelMessages cId)- csChannel(cId) %= (clearNewMessageIndicator .- clearEditedThreshold)--data OpenInBrowser =- OpenLinkChoice LinkChoice- | OpenLocalFile FilePath- deriving (Eq, Show)
− src/Types/Channels.hs
@@ -1,420 +0,0 @@-{-# LANGUAGE TemplateHaskell #-}-{-# LANGUAGE DeriveFunctor #-}-{-# LANGUAGE DeriveFoldable #-}-{-# LANGUAGE DeriveTraversable #-}-{-# LANGUAGE RankNTypes #-}--module Types.Channels- ( ClientChannel(..)- , ChannelContents(..)- , ChannelInfo(..)- , ClientChannels -- constructor remains internal- , NewMessageIndicator(..)- , EphemeralEditState(..)- , EditMode(..)- , eesMultiline, eesInputHistoryPosition, eesLastInput- , defaultEphemeralEditState- -- * Lenses created for accessing ClientChannel fields- , ccContents, ccInfo, ccEditState- -- * Lenses created for accessing ChannelInfo fields- , cdViewed, cdNewMessageIndicator, cdEditedMessageThreshold, cdUpdated- , cdName, cdDisplayName, cdHeader, cdPurpose, cdType- , cdMentionCount, cdTypingUsers, cdDMUserId, cdChannelId- , cdSidebarShowOverride, cdNotifyProps- -- * Lenses created for accessing ChannelContents fields- , cdMessages, cdFetchPending- -- * Creating ClientChannel objects- , makeClientChannel- -- * Managing ClientChannel collections- , noChannels, addChannel, removeChannel, findChannelById, modifyChannelById- , channelByIdL, maybeChannelByIdL- , filteredChannelIds- , filteredChannels- -- * Creating ChannelInfo objects- , channelInfoFromChannelWithData- -- * Channel State management- , clearNewMessageIndicator- , clearEditedThreshold- , adjustUpdated- , adjustEditedThreshold- , updateNewMessageIndicator- , addChannelTypingUser- -- * Notification settings- , notifyPreference- , isMuted- , channelNotifyPropsMarkUnreadL- , channelNotifyPropsIgnoreChannelMentionsL- , channelNotifyPropsDesktopL- , channelNotifyPropsPushL- -- * Miscellaneous channel-related operations- , canLeaveChannel- , preferredChannelName- , isTownSquare- , channelDeleted- , getDmChannelFor- , allDmChannelMappings- , getChannelNameSet- )-where--import Prelude ()-import Prelude.MH--import qualified Data.HashMap.Strict as HM-import qualified Data.Set as S-import qualified Data.Text as T-import Lens.Micro.Platform ( (%~), (.~), Traversal', Lens'- , makeLenses, ix, at- , to, non )--import Network.Mattermost.Lenses hiding ( Lens' )-import Network.Mattermost.Types ( Channel(..), UserId, ChannelId- , ChannelMember(..)- , Type(..)- , Post- , User(userNotifyProps)- , ChannelNotifyProps- , NotifyOption(..)- , WithDefault(..)- , ServerTime- , emptyChannelNotifyProps- )--import Types.Messages ( Messages, noMessages, addMessage- , clientMessageToMessage, Message, MessageType )-import Types.Posts ( ClientMessageType(UnknownGapBefore)- , newClientMessage )-import Types.Users ( TypingUsers, noTypingUsers, addTypingUser )-import Types.Common----- * Channel representations---- | A 'ClientChannel' contains both the message--- listing and the metadata about a channel-data ClientChannel = ClientChannel- { _ccContents :: ChannelContents- -- ^ A list of 'Message's in the channel- , _ccInfo :: ChannelInfo- -- ^ The 'ChannelInfo' for the channel- , _ccEditState :: EphemeralEditState- -- ^ Editor state that we swap in and out as the current channel is- -- changed.- }---- | The input state associated with the message editor.-data EditMode =- NewPost- -- ^ The input is for a new post.- | Editing Post MessageType- -- ^ The input is ultimately to replace the body of an existing post- -- of the specified type.- | Replying Message Post- -- ^ The input is to be used as a new post in reply to the specified- -- post.- deriving (Show)--data EphemeralEditState =- EphemeralEditState { _eesMultiline :: Bool- -- ^ Whether the editor is in multiline mode- , _eesInputHistoryPosition :: Maybe Int- -- ^ The input history position, if any- , _eesLastInput :: (T.Text, EditMode)- -- ^ The input entered into the text editor last- -- time the user was focused on the channel- -- associated with this state.- }---- Get a channel's name, depending on its type-preferredChannelName :: Channel -> Text-preferredChannelName ch- | channelType ch == Group = sanitizeUserText $ channelDisplayName ch- | otherwise = sanitizeUserText $ channelName ch--data NewMessageIndicator =- Hide- | NewPostsAfterServerTime ServerTime- | NewPostsStartingAt ServerTime- deriving (Eq, Show)--initialChannelInfo :: UserId -> Channel -> ChannelInfo-initialChannelInfo myId chan =- let updated = chan ^. channelLastPostAtL- in ChannelInfo { _cdChannelId = chan^.channelIdL- , _cdViewed = Nothing- , _cdNewMessageIndicator = Hide- , _cdEditedMessageThreshold = Nothing- , _cdMentionCount = 0- , _cdUpdated = updated- , _cdName = preferredChannelName chan- , _cdDisplayName = sanitizeUserText $ channelDisplayName chan- , _cdHeader = sanitizeUserText $ chan^.channelHeaderL- , _cdPurpose = sanitizeUserText $ chan^.channelPurposeL- , _cdType = chan^.channelTypeL- , _cdNotifyProps = emptyChannelNotifyProps- , _cdTypingUsers = noTypingUsers- , _cdDMUserId = if chan^.channelTypeL == Direct- then userIdForDMChannel myId $- sanitizeUserText $ channelName chan- else Nothing- , _cdSidebarShowOverride = Nothing- }--channelInfoFromChannelWithData :: Channel -> ChannelMember -> ChannelInfo -> ChannelInfo-channelInfoFromChannelWithData chan chanMember ci =- let viewed = chanMember ^. to channelMemberLastViewedAt- updated = chan ^. channelLastPostAtL- in ci { _cdViewed = Just viewed- , _cdNewMessageIndicator = case _cdNewMessageIndicator ci of- Hide -> if updated > viewed then NewPostsAfterServerTime viewed else Hide- v -> v- , _cdUpdated = updated- , _cdName = preferredChannelName chan- , _cdDisplayName = sanitizeUserText $ channelDisplayName chan- , _cdHeader = (sanitizeUserText $ chan^.channelHeaderL)- , _cdPurpose = (sanitizeUserText $ chan^.channelPurposeL)- , _cdType = (chan^.channelTypeL)- , _cdMentionCount = chanMember^.to channelMemberMentionCount- , _cdNotifyProps = chanMember^.to channelMemberNotifyProps- }---- | The 'ChannelContents' is a wrapper for a list of--- 'Message' values-data ChannelContents = ChannelContents- { _cdMessages :: Messages- , _cdFetchPending :: Bool- }---- | An initial empty 'ChannelContents' value. This also contains an--- UnknownGapBefore, which is a signal that causes actual content fetching.--- The initial Gap's timestamp is the local client time, but--- subsequent fetches will synchronize with the server (and eventually--- eliminate this Gap as well).-emptyChannelContents :: MonadIO m => m ChannelContents-emptyChannelContents = do- gapMsg <- clientMessageToMessage <$> newClientMessage UnknownGapBefore "--Fetching messages--"- return $ ChannelContents { _cdMessages = addMessage gapMsg noMessages- , _cdFetchPending = False- }------------------------------------------------------------------------------- | The 'ChannelInfo' record represents metadata--- about a channel-data ChannelInfo = ChannelInfo- { _cdChannelId :: ChannelId- -- ^ The channel's ID- , _cdViewed :: Maybe ServerTime- -- ^ The last time we looked at a channel- , _cdNewMessageIndicator :: NewMessageIndicator- -- ^ The state of the channel's new message indicator.- , _cdEditedMessageThreshold :: Maybe ServerTime- -- ^ The channel's edited message threshold.- , _cdMentionCount :: Int- -- ^ The current number of unread mentions- , _cdUpdated :: ServerTime- -- ^ The last time a message showed up in the channel- , _cdName :: Text- -- ^ The name of the channel- , _cdDisplayName :: Text- -- ^ The display name of the channel- , _cdHeader :: Text- -- ^ The header text of a channel- , _cdPurpose :: Text- -- ^ The stated purpose of the channel- , _cdType :: Type- -- ^ The type of a channel: public, private, or DM- , _cdNotifyProps :: ChannelNotifyProps- -- ^ The user's notification settings for this channel- , _cdTypingUsers :: TypingUsers- -- ^ The users who are currently typing in this channel- , _cdDMUserId :: Maybe UserId- -- ^ The user associated with this channel, if it is a DM channel- , _cdSidebarShowOverride :: Maybe UTCTime- -- ^ If set, show this channel in the sidebar regardless of other- -- considerations as long as the specified timestamp meets a cutoff.- -- Otherwise fall back to other application policy to determine- -- whether to show the channel.- }---- ** Channel-related Lenses--makeLenses ''ChannelContents-makeLenses ''ChannelInfo-makeLenses ''ClientChannel-makeLenses ''EphemeralEditState--isMuted :: ClientChannel -> Bool-isMuted cc = cc^.ccInfo.cdNotifyProps.channelNotifyPropsMarkUnreadL ==- IsValue NotifyOptionMention--notifyPreference :: User -> ClientChannel -> NotifyOption-notifyPreference u cc =- if isMuted cc then NotifyOptionNone- else case cc^.ccInfo.cdNotifyProps.channelNotifyPropsDesktopL of- IsValue v -> v- Default -> (userNotifyProps u)^.userNotifyPropsDesktopL---- ** Miscellaneous channel operations--makeClientChannel :: (MonadIO m) => UserId -> Channel -> m ClientChannel-makeClientChannel myId nc = emptyChannelContents >>= \contents ->- return ClientChannel- { _ccContents = contents- , _ccInfo = initialChannelInfo myId nc- , _ccEditState = defaultEphemeralEditState- }--defaultEphemeralEditState :: EphemeralEditState-defaultEphemeralEditState =- EphemeralEditState { _eesMultiline = False- , _eesInputHistoryPosition = Nothing- , _eesLastInput = ("", NewPost)- }--canLeaveChannel :: ChannelInfo -> Bool-canLeaveChannel cInfo = not $ cInfo^.cdType `elem` [Direct]---- ** Manage the collection of all Channels---- | Define a binary kinded type to allow derivation of functor.-data AllMyChannels a =- AllChannels { _chanMap :: HashMap ChannelId a- , _userChannelMap :: HashMap UserId ChannelId- , _channelNameSet :: S.Set Text- }- deriving (Functor, Foldable, Traversable)---- | Define the exported typename which universally binds the--- collection to the ChannelInfo type.-type ClientChannels = AllMyChannels ClientChannel--makeLenses ''AllMyChannels--getChannelNameSet :: ClientChannels -> S.Set Text-getChannelNameSet = _channelNameSet---- | Initial collection of Channels with no members-noChannels :: ClientChannels-noChannels = AllChannels HM.empty HM.empty mempty---- | Add a channel to the existing collection.-addChannel :: ChannelId -> ClientChannel -> ClientChannels -> ClientChannels-addChannel cId cinfo =- (chanMap %~ HM.insert cId cinfo) .- (if cinfo^.ccInfo.cdType `notElem` [Direct, Group]- then channelNameSet %~ S.insert (cinfo^.ccInfo.cdName)- else id) .- (case cinfo^.ccInfo.cdDMUserId of- Nothing -> id- Just uId -> userChannelMap %~ HM.insert uId cId- )---- | Remove a channel from the collection.-removeChannel :: ChannelId -> ClientChannels -> ClientChannels-removeChannel cId cs =- let mChan = findChannelById cId cs- removeChannelName = case mChan of- Nothing -> id- Just ch -> channelNameSet %~ S.delete (ch^.ccInfo.cdName)- in cs & chanMap %~ HM.delete cId- & removeChannelName- & userChannelMap %~ HM.filter (/= cId)--getDmChannelFor :: UserId -> ClientChannels -> Maybe ChannelId-getDmChannelFor uId cs = cs^.userChannelMap.at uId--allDmChannelMappings :: ClientChannels -> [(UserId, ChannelId)]-allDmChannelMappings = HM.toList . _userChannelMap---- | Get the ChannelInfo information given the ChannelId-findChannelById :: ChannelId -> ClientChannels -> Maybe ClientChannel-findChannelById cId = HM.lookup cId . _chanMap---- | Transform the specified channel in place with provided function.-modifyChannelById :: ChannelId -> (ClientChannel -> ClientChannel)- -> ClientChannels -> ClientChannels-modifyChannelById cId f = chanMap.ix(cId) %~ f---- | A 'Traversal' that will give us the 'ClientChannel' in a--- 'ClientChannels' structure if it exists-channelByIdL :: ChannelId -> Traversal' ClientChannels ClientChannel-channelByIdL cId = chanMap . ix cId---- | A 'Lens' that will give us the 'ClientChannel' in a--- 'ClientChannels' wrapped in a 'Maybe'-maybeChannelByIdL :: ChannelId -> Lens' ClientChannels (Maybe ClientChannel)-maybeChannelByIdL cId = chanMap . at cId---- | Apply a filter to each ClientChannel and return a list of the--- ChannelId values for which the filter matched.-filteredChannelIds :: (ClientChannel -> Bool) -> ClientChannels -> [ChannelId]-filteredChannelIds f cc = fst <$> filter (f . snd) (HM.toList (cc^.chanMap))---- | Filter the ClientChannel collection, keeping only those for which--- the provided filter test function returns True.-filteredChannels :: ((ChannelId, ClientChannel) -> Bool)- -> ClientChannels -> [(ChannelId, ClientChannel)]-filteredChannels f cc = filter f $ cc^.chanMap.to HM.toList------------------------------------------------------------------------------ * Channel State management----- | Add user to the list of users in this channel who are currently typing.-addChannelTypingUser :: UserId -> UTCTime -> ClientChannel -> ClientChannel-addChannelTypingUser uId ts = ccInfo.cdTypingUsers %~ (addTypingUser uId ts)---- | Clear the new message indicator for the specified channel-clearNewMessageIndicator :: ClientChannel -> ClientChannel-clearNewMessageIndicator c = c & ccInfo.cdNewMessageIndicator .~ Hide---- | Clear the edit threshold for the specified channel-clearEditedThreshold :: ClientChannel -> ClientChannel-clearEditedThreshold c = c & ccInfo.cdEditedMessageThreshold .~ Nothing---- | Adjust updated time based on a message, ensuring that the updated--- time does not move backward.-adjustUpdated :: Post -> ClientChannel -> ClientChannel-adjustUpdated m =- ccInfo.cdUpdated %~ max (maxPostTimestamp m)--adjustEditedThreshold :: Post -> ClientChannel -> ClientChannel-adjustEditedThreshold m c =- if m^.postUpdateAtL <= m^.postCreateAtL- then c- else c & ccInfo.cdEditedMessageThreshold %~ (\mt -> case mt of- Just t -> Just $ min (m^.postUpdateAtL) t- Nothing -> Just $ m^.postUpdateAtL- )--maxPostTimestamp :: Post -> ServerTime-maxPostTimestamp m = max (m^.postDeleteAtL . non (m^.postUpdateAtL)) (m^.postCreateAtL)--updateNewMessageIndicator :: Post -> ClientChannel -> ClientChannel-updateNewMessageIndicator m =- ccInfo.cdNewMessageIndicator %~- (\old ->- case old of- Hide ->- NewPostsStartingAt $ m^.postCreateAtL- NewPostsStartingAt ts ->- NewPostsStartingAt $ min (m^.postCreateAtL) ts- NewPostsAfterServerTime ts ->- if m^.postCreateAtL <= ts- then NewPostsStartingAt $ m^.postCreateAtL- else NewPostsAfterServerTime ts- )---- | Town Square is special in that its non-display name cannot be--- changed and is a hard-coded constant server-side according to the--- developers (as of 8/2/17). So this is a reliable way to check for--- whether a channel is in fact that channel, even if the user has--- changed its display name.-isTownSquare :: Channel -> Bool-isTownSquare c = (sanitizeUserText $ c^.channelNameL) == "town-square"--channelDeleted :: Channel -> Bool-channelDeleted c = c^.channelDeleteAtL > c^.channelCreateAtL
− src/Types/Common.hs
@@ -1,41 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-module Types.Common- ( sanitizeUserText- , sanitizeUserText'- , userIdForDMChannel- )-where--import Prelude ()-import Prelude.MH--import qualified Data.Text as T--import Network.Mattermost.Types ( UserText, unsafeUserText, UserId(..), Id(..) )--sanitizeUserText :: UserText -> T.Text-sanitizeUserText = sanitizeUserText' . unsafeUserText--sanitizeUserText' :: T.Text -> T.Text-sanitizeUserText' =- T.replace "\ESC" "<ESC>" .- T.replace "\t" " " .- T.filter (\c -> c >= ' ' || c == '\n') -- remove non-printable---- | Extract the corresponding other user from a direct channel name.--- Returns Nothing if the string is not a direct channel name or if it--- is but neither user ID in the name matches the current user's ID.-userIdForDMChannel :: UserId- -- ^ My user ID- -> Text- -- ^ The channel name- -> Maybe UserId-userIdForDMChannel me chanName =- -- Direct channel names are of the form "UID__UID" where one of the- -- UIDs is mine and the other is the other channel participant.- let vals = T.splitOn "__" chanName- in case vals of- [u1, u2] -> if | (UI $ Id u1) == me -> Just $ UI $ Id u2- | (UI $ Id u2) == me -> Just $ UI $ Id u1- | otherwise -> Nothing- _ -> Nothing
− src/Types/DirectionalSeq.hs
@@ -1,97 +0,0 @@-{-# LANGUAGE DeriveFoldable #-}-{-# LANGUAGE DeriveFunctor #-}-{-# LANGUAGE DeriveTraversable #-}-{-# LANGUAGE TypeFamilies #-}--{- | These declarations allow the use of a DirectionalSeq, which is a- Seq that uses a phantom type to identify the ordering of the- elements in the sequence (Forward or Reverse). The constructors- are not exported from this module so that a DirectionalSeq can only- be constructed by the functions in this module.--}--module Types.DirectionalSeq where--import Prelude ()-import Prelude.MH--import qualified Data.Sequence as Seq---data Chronological-data Retrograde-class SeqDirection a where- type ReverseDirection a-instance SeqDirection Chronological- where type ReverseDirection Chronological = Retrograde-instance SeqDirection Retrograde- where type ReverseDirection Retrograde = Chronological--data SeqDirection dir => DirectionalSeq dir a =- DSeq { dseq :: Seq a }- deriving (Show, Functor, Foldable, Traversable)--emptyDirSeq :: DirectionalSeq dir a-emptyDirSeq = DSeq mempty--appendDirSeq :: DirectionalSeq dir a -> DirectionalSeq dir a -> DirectionalSeq dir a-appendDirSeq a b = DSeq $ mappend (dseq a) (dseq b)--onDirectedSeq :: SeqDirection dir => (Seq a -> Seq b)- -> DirectionalSeq dir a -> DirectionalSeq dir b-onDirectedSeq f = DSeq . f . dseq---- | Uses a start-predicate and and end-predicate to--- identify (the first matching) subset that is delineated by--- start-predicate and end-predicate (inclusive). It will then call--- the passed operation function on the subset messages to get back a--- (possibly modified) set of messages, along with an extracted value.--- The 'onDirSeqSubset' function will replace the original subset of--- messages with the set returned by the operation function and return--- the resulting message list along with the extracted value.--onDirSeqSubset :: SeqDirection dir =>- (e -> Bool) -> (e -> Bool)- -> (DirectionalSeq dir e -> (DirectionalSeq dir e, a))- -> DirectionalSeq dir e- -> (DirectionalSeq dir e, a)-onDirSeqSubset startPred endPred op entries =- let ml = dseq entries- (bl, ml1) = Seq.breakl startPred ml- (ml2, el) = Seq.breakl endPred ml1- -- move match from start of el to end of ml2- (ml2', el') = if not (Seq.null el)- then (ml2 <> Seq.take 1 el, Seq.drop 1 el)- else (ml2, el)- (ml3, rval) = op $ DSeq ml2'- in (DSeq bl `appendDirSeq` ml3 `appendDirSeq` DSeq el', rval)---- | dirSeqBreakl splits the DirectionalSeq into a tuple where the--- first element is the (possibly empty) DirectionalSeq of all--- elements from the start for which the predicate returns false; the--- second tuple element is the remainder of the list, starting with--- the first element for which the predicate matched.-dirSeqBreakl :: SeqDirection dir =>- (e -> Bool) -> DirectionalSeq dir e- -> (DirectionalSeq dir e, DirectionalSeq dir e)-dirSeqBreakl isMatch entries =- let (removed, remaining) = Seq.breakl isMatch $ dseq entries- in (DSeq removed, DSeq remaining)---- | dirSeqPartition splits the DirectionalSeq into a tuple of two--- DirectionalSeq elements: the first contains all elements for which--- the predicate is true and the second contains all elements for--- which the predicate is false.-dirSeqPartition :: SeqDirection dir =>- (e -> Bool) -> DirectionalSeq dir e- -> (DirectionalSeq dir e, DirectionalSeq dir e)-dirSeqPartition isMatch entries =- let (match, nomatch) = Seq.partition isMatch $ dseq entries- in (DSeq match, DSeq nomatch)---withDirSeqHead :: SeqDirection dir => (e -> r) -> DirectionalSeq dir e -> Maybe r-withDirSeqHead op entries =- case Seq.viewl (dseq entries) of- Seq.EmptyL -> Nothing- e Seq.:< _ -> Just $ op e
− src/Types/KeyEvents.hs
@@ -1,434 +0,0 @@-module Types.KeyEvents- (- -- * Types- KeyEvent(..)- , KeyConfig- , Binding(..)- , BindingState(..)-- -- * Data- , allEvents-- -- * Parsing and pretty-printing- , parseBinding- , parseBindingList- , ppBinding- , nonCharKeys- , eventToBinding-- -- * Key event name resolution- , keyEventFromName- , keyEventName- )-where--import Prelude ()-import Prelude.MH--import qualified Data.Map.Strict as M-import qualified Data.Text as T-import qualified Graphics.Vty as Vty----- | This enum represents all the possible key events a user might--- want to use.-data KeyEvent- = VtyRefreshEvent- | ShowHelpEvent- | EnterSelectModeEvent- | ReplyRecentEvent- | ToggleMessagePreviewEvent- | InvokeEditorEvent- | EnterFastSelectModeEvent- | QuitEvent- | NextChannelEvent- | PrevChannelEvent- | NextChannelEventAlternate- | PrevChannelEventAlternate- | NextUnreadChannelEvent- | NextUnreadUserOrChannelEvent- | LastChannelEvent- | EnterOpenURLModeEvent- | ClearUnreadEvent- | ToggleMultiLineEvent- | EnterFlaggedPostsEvent- | ToggleChannelListVisibleEvent- | ShowAttachmentListEvent-- | EditorKillToBolEvent- | EditorKillToEolEvent- | EditorBolEvent- | EditorEolEvent- | EditorTransposeCharsEvent- | EditorDeleteCharacter- | EditorPrevCharEvent- | EditorNextCharEvent- | EditorPrevWordEvent- | EditorNextWordEvent- | EditorDeleteNextWordEvent- | EditorDeletePrevWordEvent- | EditorHomeEvent- | EditorEndEvent- | EditorYankEvent-- | SelectNextTabEvent- | SelectPreviousTabEvent-- -- generic cancel- | CancelEvent-- -- channel-scroll-specific- | LoadMoreEvent- | OpenMessageURLEvent-- -- scrolling events---maybe rebindable?- | ScrollUpEvent- | ScrollDownEvent- | ScrollLeftEvent- | ScrollRightEvent- | PageUpEvent- | PageDownEvent- | PageRightEvent- | PageLeftEvent- | ScrollTopEvent- | ScrollBottomEvent- | SelectOldestMessageEvent-- -- select events---not the same as scrolling sometimes!- | SelectUpEvent- | SelectDownEvent-- -- search select events---these need to not be valid editor inputs- -- (such as 'j' and 'k')- | SearchSelectUpEvent- | SearchSelectDownEvent-- -- E.g. Pressing enter on an item in a list to do something with it- | ActivateListItemEvent-- | ViewMessageEvent- | FillGapEvent- | FlagMessageEvent- | PinMessageEvent- | YankMessageEvent- | YankWholeMessageEvent- | DeleteMessageEvent- | EditMessageEvent- | ReplyMessageEvent- | ReactToMessageEvent-- -- Attachments- | AttachmentListAddEvent- | AttachmentListDeleteEvent- | AttachmentOpenEvent-- -- Form submission- | FormSubmitEvent- deriving (Eq, Show, Ord, Enum)--allEvents :: [KeyEvent]-allEvents =- [ QuitEvent- , VtyRefreshEvent- , ClearUnreadEvent-- , ToggleMessagePreviewEvent- , InvokeEditorEvent- , ToggleMultiLineEvent- , CancelEvent- , ReplyRecentEvent- , SelectNextTabEvent- , SelectPreviousTabEvent-- , EnterFastSelectModeEvent- , NextChannelEvent- , PrevChannelEvent- , NextChannelEventAlternate- , PrevChannelEventAlternate- , NextUnreadChannelEvent- , NextUnreadUserOrChannelEvent- , LastChannelEvent-- , ShowAttachmentListEvent-- , EditorKillToBolEvent- , EditorKillToEolEvent- , EditorBolEvent- , EditorEolEvent- , EditorTransposeCharsEvent- , EditorDeleteCharacter- , EditorPrevCharEvent- , EditorNextCharEvent- , EditorPrevWordEvent- , EditorNextWordEvent- , EditorDeleteNextWordEvent- , EditorDeletePrevWordEvent- , EditorHomeEvent- , EditorEndEvent- , EditorYankEvent-- , EnterFlaggedPostsEvent- , ToggleChannelListVisibleEvent- , ShowHelpEvent- , EnterSelectModeEvent- , EnterOpenURLModeEvent-- , LoadMoreEvent- , OpenMessageURLEvent-- , ScrollUpEvent- , ScrollDownEvent- , ScrollLeftEvent- , ScrollRightEvent- , PageUpEvent- , PageDownEvent- , PageLeftEvent- , PageRightEvent- , ScrollTopEvent- , ScrollBottomEvent- , SelectOldestMessageEvent-- , SelectUpEvent- , SelectDownEvent-- , ActivateListItemEvent-- , SearchSelectUpEvent- , SearchSelectDownEvent-- , FlagMessageEvent- , PinMessageEvent- , ViewMessageEvent- , FillGapEvent- , YankMessageEvent- , YankWholeMessageEvent- , DeleteMessageEvent- , EditMessageEvent- , ReplyMessageEvent- , ReactToMessageEvent- , AttachmentListAddEvent- , AttachmentListDeleteEvent- , AttachmentOpenEvent- , FormSubmitEvent- ]--eventToBinding :: Vty.Event -> Binding-eventToBinding (Vty.EvKey k mods) = Binding mods k-eventToBinding k = error $ "BUG: invalid keybinding " <> show k--data Binding = Binding- { kbMods :: [Vty.Modifier]- , kbKey :: Vty.Key- } deriving (Eq, Show, Ord)--data BindingState =- BindingList [Binding]- | Unbound- deriving (Show, Eq, Ord)--type KeyConfig = M.Map KeyEvent BindingState--parseBinding :: Text -> Either String Binding-parseBinding kb = go (T.splitOn "-" $ T.toLower kb) []- where go [k] mods = do- key <- pKey k- return Binding { kbMods = mods, kbKey = key }- go (k:ks) mods = do- m <- case k of- "s" -> return Vty.MShift- "shift" -> return Vty.MShift- "m" -> return Vty.MMeta- "meta" -> return Vty.MMeta- "a" -> return Vty.MAlt- "alt" -> return Vty.MAlt- "c" -> return Vty.MCtrl- "ctrl" -> return Vty.MCtrl- "control" -> return Vty.MCtrl- _ -> Left ("Unknown modifier prefix: " ++ show k)- go ks (m:mods)- go [] _ = Left "Empty keybinding not allowed"- pKey "esc" = return Vty.KEsc- pKey "backspace" = return Vty.KBS- pKey "enter" = return Vty.KEnter- pKey "left" = return Vty.KLeft- pKey "right" = return Vty.KRight- pKey "up" = return Vty.KUp- pKey "down" = return Vty.KDown- pKey "upleft" = return Vty.KUpLeft- pKey "upright" = return Vty.KUpRight- pKey "downleft" = return Vty.KDownLeft- pKey "downright" = return Vty.KDownRight- pKey "center" = return Vty.KCenter- pKey "backtab" = return Vty.KBackTab- pKey "printscreen" = return Vty.KPrtScr- pKey "pause" = return Vty.KPause- pKey "insert" = return Vty.KIns- pKey "home" = return Vty.KHome- pKey "pgup" = return Vty.KPageUp- pKey "del" = return Vty.KDel- pKey "end" = return Vty.KEnd- pKey "pgdown" = return Vty.KPageDown- pKey "begin" = return Vty.KBegin- pKey "menu" = return Vty.KMenu- pKey "space" = return (Vty.KChar ' ')- pKey "tab" = return (Vty.KChar '\t')- pKey t- | Just (c, "") <- T.uncons t =- return (Vty.KChar c)- | Just n <- T.stripPrefix "f" t =- case readMaybe (T.unpack n) of- Nothing -> Left ("Unknown keybinding: " ++ show t)- Just i -> return (Vty.KFun i)- | otherwise = Left ("Unknown keybinding: " ++ show t)--ppBinding :: Binding -> Text-ppBinding (Binding mods k) =- T.intercalate "-" $ (ppMod <$> mods) <> [ppKey k]--ppKey :: Vty.Key -> Text-ppKey (Vty.KChar c) = ppChar c-ppKey (Vty.KFun n) = "F" <> (T.pack $ show n)-ppKey Vty.KBackTab = "BackTab"-ppKey Vty.KEsc = "Esc"-ppKey Vty.KBS = "Backspace"-ppKey Vty.KEnter = "Enter"-ppKey Vty.KUp = "Up"-ppKey Vty.KDown = "Down"-ppKey Vty.KLeft = "Left"-ppKey Vty.KRight = "Right"-ppKey Vty.KHome = "Home"-ppKey Vty.KEnd = "End"-ppKey Vty.KPageUp = "PgUp"-ppKey Vty.KPageDown = "PgDown"-ppKey Vty.KDel = "Del"-ppKey Vty.KUpLeft = "UpLeft"-ppKey Vty.KUpRight = "UpRight"-ppKey Vty.KDownLeft = "DownLeft"-ppKey Vty.KDownRight = "DownRight"-ppKey Vty.KCenter = "Center"-ppKey Vty.KPrtScr = "PrintScreen"-ppKey Vty.KPause = "Pause"-ppKey Vty.KIns = "Insert"-ppKey Vty.KBegin = "Begin"-ppKey Vty.KMenu = "Menu"--nonCharKeys :: [Text]-nonCharKeys = map ppKey- [ Vty.KBackTab, Vty.KEsc, Vty.KBS, Vty.KEnter, Vty.KUp, Vty.KDown- , Vty.KLeft, Vty.KRight, Vty.KHome, Vty.KEnd, Vty.KPageDown- , Vty.KPageUp, Vty.KDel, Vty.KUpLeft, Vty.KUpRight, Vty.KDownLeft- , Vty.KDownRight, Vty.KCenter, Vty.KPrtScr, Vty.KPause, Vty.KIns- , Vty.KBegin, Vty.KMenu- ]--ppChar :: Char -> Text-ppChar '\t' = "Tab"-ppChar ' ' = "Space"-ppChar c = T.singleton c--ppMod :: Vty.Modifier -> Text-ppMod Vty.MMeta = "M"-ppMod Vty.MAlt = "A"-ppMod Vty.MCtrl = "C"-ppMod Vty.MShift = "S"--parseBindingList :: Text -> Either String BindingState-parseBindingList t =- if T.toLower t == "unbound"- then return Unbound- else BindingList <$> mapM (parseBinding . T.strip) (T.splitOn "," t)--keyEventFromName :: Text -> Either String KeyEvent-keyEventFromName t =- let mapping = M.fromList [ (keyEventName e, e) | e <- allEvents ]- in case M.lookup t mapping of- Just e -> return e- Nothing -> Left ("Unknown event: " ++ show t)--keyEventName :: KeyEvent -> Text-keyEventName ev = case ev of- QuitEvent -> "quit"- VtyRefreshEvent -> "vty-refresh"- ClearUnreadEvent -> "clear-unread"- CancelEvent -> "cancel"-- ToggleMessagePreviewEvent -> "toggle-message-preview"- InvokeEditorEvent -> "invoke-editor"- ToggleMultiLineEvent -> "toggle-multiline"- ReplyRecentEvent -> "reply-recent"-- EnterFastSelectModeEvent -> "enter-fast-select"- NextChannelEvent -> "focus-next-channel"- PrevChannelEvent -> "focus-prev-channel"- NextChannelEventAlternate -> "focus-next-channel-alternate"- PrevChannelEventAlternate -> "focus-prev-channel-alternate"- NextUnreadChannelEvent -> "focus-next-unread"- NextUnreadUserOrChannelEvent -> "focus-next-unread-user-or-channel"- LastChannelEvent -> "focus-last-channel"-- SelectNextTabEvent -> "select-next-tab"- SelectPreviousTabEvent -> "select-previous-tab"-- ShowAttachmentListEvent -> "show-attachment-list"-- EditorKillToBolEvent -> "editor-kill-to-beginning-of-line"- EditorKillToEolEvent -> "editor-kill-to-end-of-line"- EditorBolEvent -> "editor-beginning-of-line"- EditorEolEvent -> "editor-end-of-line"- EditorTransposeCharsEvent -> "editor-transpose-chars"- EditorDeleteCharacter -> "editor-delete-char"- EditorPrevCharEvent -> "editor-prev-char"- EditorNextCharEvent -> "editor-next-char"- EditorPrevWordEvent -> "editor-prev-word"- EditorNextWordEvent -> "editor-next-word"- EditorDeleteNextWordEvent -> "editor-delete-next-word"- EditorDeletePrevWordEvent -> "editor-delete-prev-word"- EditorHomeEvent -> "editor-home"- EditorEndEvent -> "editor-end"- EditorYankEvent -> "editor-yank"-- EnterFlaggedPostsEvent -> "show-flagged-posts"- ToggleChannelListVisibleEvent -> "toggle-channel-list-visibility"- ShowHelpEvent -> "show-help"- EnterSelectModeEvent -> "select-mode"- EnterOpenURLModeEvent -> "enter-url-open"-- LoadMoreEvent -> "load-more"- OpenMessageURLEvent -> "open-message-url"-- ScrollUpEvent -> "scroll-up"- ScrollDownEvent -> "scroll-down"- ScrollLeftEvent -> "scroll-left"- ScrollRightEvent -> "scroll-right"- PageUpEvent -> "page-up"- PageDownEvent -> "page-down"- PageLeftEvent -> "page-left"- PageRightEvent -> "page-right"- ScrollTopEvent -> "scroll-top"- ScrollBottomEvent -> "scroll-bottom"- SelectOldestMessageEvent -> "select-oldest-message"-- SelectUpEvent -> "select-up"- SelectDownEvent -> "select-down"-- SearchSelectUpEvent -> "search-select-up"- SearchSelectDownEvent -> "search-select-down"-- ActivateListItemEvent -> "activate-list-item"-- FlagMessageEvent -> "flag-message"- PinMessageEvent -> "pin-message"- ViewMessageEvent -> "view-message"- FillGapEvent -> "fetch-for-gap"- YankMessageEvent -> "yank-message"- YankWholeMessageEvent -> "yank-whole-message"- DeleteMessageEvent -> "delete-message"- EditMessageEvent -> "edit-message"- ReplyMessageEvent -> "reply-message"- ReactToMessageEvent -> "react-to-message"-- AttachmentListAddEvent -> "add-to-attachment-list"- AttachmentListDeleteEvent -> "delete-from-attachment-list"- AttachmentOpenEvent -> "open-attachment"-- FormSubmitEvent -> "submit-form"
− src/Types/Messages.hs
@@ -1,739 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE DeriveAnyClass #-}-{-# LANGUAGE DeriveGeneric #-}-{-# LANGUAGE FlexibleInstances #-}-{-# LANGUAGE MultiParamTypeClasses #-}-{-# LANGUAGE TemplateHaskell #-}--{-|--The 'Message' is a single displayed event in a Channel. All Messages-have a date/time, and messages that represent posts to the channel-have a (hash) ID, and displayable text, along with other attributes.--All Messages are sorted chronologically. There is no assumption that-the server date/time is synchronized with the local date/time, so all-of the Message ordering uses the server's date/time.--The mattermost-api retrieves a 'Post' from the server, briefly encodes-the useful portions of that as a 'ClientPost' object and then converts-it to a 'Message' inserting this result it into the collection of-Messages associated with a Channel. The PostID of the message-uniquely identifies that message and can be used to interact with the-server for subsequent operations relative to that message's 'Post'.-The date/time associated with these messages is generated by the-server.--There are also "messages" generated directly by the Matterhorn client-which can be used to display additional, client-related information to-the user. Examples of these client messages are: date boundaries, the-"new messages" marker, errors from invoking the browser, etc. These-client-generated messages will have a date/time although it is locally-generated (usually by relation to an associated Post).--Most other Matterhorn operations primarily are concerned with-user-posted messages (@case mMessageId of Just _@ or @case mType of CP-_@), but others will include client-generated messages (@case mMessageId-of Nothing@ or @case mType of C _@).----}--module Types.Messages- ( -- * Message and operations on a single Message- Message(..)- , isDeletable, isReplyable, isReactable, isEditable, isReplyTo, isGap, isFlaggable- , isPinnable, isEmote, isJoinLeave, isTransition, isNewMessagesTransition- , mText, mUser, mDate, mType, mPending, mDeleted, mPinned- , mAttachments, mInReplyToMsg, mMessageId, mReactions, mFlagged- , mOriginalPost, mChannelId, mMarkdownSource- , isBotMessage- , MessageType(..)- , MessageId(..)- , ThreadState(..)- , MentionedUser(..)- , isPostMessage- , messagePostId- , messageIdPostId- , UserRef(..)- , ReplyState(..)- , clientMessageToMessage- , clientPostToMessage- , clientPostReactionUserIds- , newMessageOfType- -- * Message Collections- , Messages- , ChronologicalMessages- , RetrogradeMessages- , MessageOps (..)- , noMessages- , messagesLength- , filterMessages- , reverseMessages- , unreverseMessages- , splitMessages- , splitDirSeqOn- , chronologicalMsgsWithThreadStates- , retrogradeMsgsWithThreadStates- , findMessage- , getRelMessageId- , messagesHead- , messagesDrop- , getNextMessage- , getPrevMessage- , getNextMessageId- , getPrevMessageId- , getNextPostId- , getPrevPostId- , getEarliestPostMsg- , getLatestPostMsg- , getEarliestSelectableMessage- , getLatestSelectableMessage- , findLatestUserMessage- -- * Operations on any Message type- , messagesAfter- , removeMatchesFromSubset- , withFirstMessage- , msgURLs-- , LinkChoice(LinkChoice)- , linkUser- , linkURL- , linkTime- , linkLabel- , linkFileId- )-where--import Prelude ()-import Prelude.MH--import Control.Monad-import qualified Data.Foldable as F-import Data.Hashable ( Hashable )-import qualified Data.Map.Strict as Map-import Data.Sequence as Seq-import qualified Data.Set as S-import Data.Tuple-import Data.UUID ( UUID )-import GHC.Generics ( Generic )-import Lens.Micro.Platform ( makeLenses )--import Network.Mattermost.Types ( ChannelId, PostId, Post- , ServerTime, UserId, FileId )--import Types.DirectionalSeq-import Types.Posts-import Types.RichText ( RichTextBlock(..), Element(..)- , ElementData(..), findUsernames, blockGetURLs- , ElementStyle(..), URL(..), parseMarkdown- )----- | The state of a message's thread context.-data ThreadState =- NoThread- -- ^ The message is not in a thread at all.- | InThreadShowParent- -- ^ The message is in a thread, and the thread's root message- -- (parent) should be displayed above this message.- | InThread- -- ^ The message is in a thread but the thread's root message should- -- not be displayed above this message.- deriving (Show, Eq)---- ------------------------------------------------------------------------- * Messages--data MessageId = MessagePostId PostId- | MessageUUID UUID- deriving (Eq, Read, Show, Generic, Hashable)--messageIdPostId :: MessageId -> Maybe PostId-messageIdPostId (MessagePostId p) = Just p-messageIdPostId _ = Nothing---- | A 'Message' is any message we might want to render, either from--- Mattermost itself or from a client-internal source.-data Message = Message- { _mText :: Seq RichTextBlock- , _mMarkdownSource :: Text- , _mUser :: UserRef- , _mDate :: ServerTime- , _mType :: MessageType- , _mPending :: Bool- , _mDeleted :: Bool- , _mAttachments :: Seq Attachment- , _mInReplyToMsg :: ReplyState- , _mMessageId :: Maybe MessageId- , _mReactions :: Map.Map Text (S.Set UserId)- , _mOriginalPost :: Maybe Post- , _mFlagged :: Bool- , _mPinned :: Bool- , _mChannelId :: Maybe ChannelId- } deriving (Show)--isPostMessage :: Message -> Bool-isPostMessage m =- isJust (_mMessageId m >>= messageIdPostId)--messagePostId :: Message -> Maybe PostId-messagePostId m = do- mId <- _mMessageId m- messageIdPostId mId--isDeletable :: Message -> Bool-isDeletable m =- isJust (messagePostId m) &&- case _mType m of- CP NormalPost -> True- CP Emote -> True- _ -> False--isFlaggable :: Message -> Bool-isFlaggable = isJust . messagePostId--isPinnable :: Message -> Bool-isPinnable = isJust . messagePostId--isReplyable :: Message -> Bool-isReplyable m =- isJust (messagePostId m) &&- case _mType m of- CP NormalPost -> True- CP Emote -> True- _ -> False--isReactable :: Message -> Bool-isReactable m =- isJust (messagePostId m) &&- case _mType m of- CP NormalPost -> True- CP Emote -> True- _ -> False--isEditable :: Message -> Bool-isEditable m =- isJust (messagePostId m) &&- case _mType m of- CP NormalPost -> True- CP Emote -> True- _ -> False--isReplyTo :: PostId -> Message -> Bool-isReplyTo expectedParentId m =- case _mInReplyToMsg m of- NotAReply -> False- InReplyTo actualParentId -> actualParentId == expectedParentId--isGap :: Message -> Bool-isGap m = case _mType m of- C UnknownGapBefore -> True- C UnknownGapAfter -> True- _ -> False--isTransition :: Message -> Bool-isTransition m = case _mType m of- C DateTransition -> True- C NewMessagesTransition -> True- _ -> False--isNewMessagesTransition :: Message -> Bool-isNewMessagesTransition m = case _mType m of- C NewMessagesTransition -> True- _ -> False--isEmote :: Message -> Bool-isEmote m = case _mType m of- CP Emote -> True- _ -> False--isJoinLeave :: Message -> Bool-isJoinLeave m = case _mType m of- CP Join -> True- CP Leave -> True- _ -> False---- | A 'Message' is the representation we use for storage and--- rendering, so it must be able to represent either a--- post from Mattermost or an internal message. This represents--- the union of both kinds of post types.-data MessageType = C ClientMessageType- | CP ClientPostType- deriving (Show)---- | There may be no user (usually an internal message), a reference to--- a user (by Id), or the server may have supplied a specific username--- (often associated with bots). The boolean flag indicates whether the--- user reference is for a message from a bot.-data UserRef = NoUser | UserI Bool UserId | UserOverride Bool Text- deriving (Eq, Show, Ord)--isBotMessage :: Message -> Bool-isBotMessage m =- case _mUser m of- UserI bot _ -> bot- UserOverride bot _ -> bot- NoUser -> False---- | The 'ReplyState' of a message represents whether a message--- is a reply, and if so, to what message-data ReplyState =- NotAReply- | InReplyTo PostId- deriving (Show, Eq)---- | This type represents links to things in the 'open links' view.-data LinkChoice =- LinkChoice { _linkTime :: ServerTime- , _linkUser :: UserRef- , _linkLabel :: Maybe (Seq Element)- , _linkURL :: URL- , _linkFileId :: Maybe FileId- } deriving (Eq, Show)--makeLenses ''LinkChoice---- | Convert a 'ClientMessage' to a 'Message'. A 'ClientMessage' is--- one that was generated by the Matterhorn client and which the--- server knows nothing about. For example, an error message--- associated with passing a link to the local browser.-clientMessageToMessage :: ClientMessage -> Message-clientMessageToMessage cm = Message- { _mText = parseMarkdown (cm^.cmText)- , _mMarkdownSource = cm^.cmText- , _mUser = NoUser- , _mDate = cm^.cmDate- , _mType = C $ cm^.cmType- , _mPending = False- , _mDeleted = False- , _mAttachments = Seq.empty- , _mInReplyToMsg = NotAReply- , _mMessageId = Nothing- , _mReactions = Map.empty- , _mOriginalPost = Nothing- , _mFlagged = False- , _mPinned = False- , _mChannelId = Nothing- }---data MentionedUser =- UsernameMention Text- | UserIdMention UserId- deriving (Eq, Show, Ord)--clientPostReactionUserIds :: ClientPost -> S.Set UserId-clientPostReactionUserIds cp =- S.unions $ F.toList $ cp^.cpReactions---- | Builds a message from a ClientPost and also returns the set of--- usernames mentioned in the text of the message.-clientPostToMessage :: ClientPost -> (Message, S.Set MentionedUser)-clientPostToMessage cp = (m, mentions)- where- mentions =- S.fromList $- (UsernameMention <$> (F.toList $ findUsernames $ cp^.cpText)) <>- (UserIdMention <$> (F.toList $ clientPostReactionUserIds cp))- m = Message { _mText = cp^.cpText- , _mMarkdownSource = cp^.cpMarkdownSource- , _mUser =- case cp^.cpUserOverride of- Just n | cp^.cpType == NormalPost -> UserOverride (cp^.cpFromWebhook) n- _ -> maybe NoUser (UserI (cp^.cpFromWebhook)) $ cp^.cpUser- , _mDate = cp^.cpDate- , _mType = CP $ cp^.cpType- , _mPending = cp^.cpPending- , _mDeleted = cp^.cpDeleted- , _mAttachments = cp^.cpAttachments- , _mInReplyToMsg =- case cp^.cpInReplyToPost of- Nothing -> NotAReply- Just pId -> InReplyTo pId- , _mMessageId = Just $ MessagePostId $ cp^.cpPostId- , _mReactions = cp^.cpReactions- , _mOriginalPost = Just $ cp^.cpOriginalPost- , _mFlagged = False- , _mPinned = cp^.cpPinned- , _mChannelId = Just $ cp^.cpChannelId- }---newMessageOfType :: Text -> MessageType -> ServerTime -> Message-newMessageOfType text typ d = Message- { _mText = parseMarkdown text- , _mMarkdownSource = text- , _mUser = NoUser- , _mDate = d- , _mType = typ- , _mPending = False- , _mDeleted = False- , _mAttachments = Seq.empty- , _mInReplyToMsg = NotAReply- , _mMessageId = Nothing- , _mReactions = Map.empty- , _mOriginalPost = Nothing- , _mFlagged = False- , _mPinned = False- , _mChannelId = Nothing- }---- ** 'Message' Lenses--makeLenses ''Message---- -------------------------------------------------------------------------- * Message Collections---- | A wrapper for an ordered, unique list of 'Message' values.------ This type has (and promises) the following instances: Show,--- Functor, Monoid, Foldable, Traversable-type ChronologicalMessages = DirectionalSeq Chronological Message-type Messages = ChronologicalMessages---- | There are also cases where the list of 'Message' values are kept--- in reverse order (most recent -> oldest); these cases are--- represented by the `RetrogradeMessages` type.-type RetrogradeMessages = DirectionalSeq Retrograde Message---- ** Common operations on Messages--filterMessages :: SeqDirection seq- => (a -> Bool)- -> DirectionalSeq seq a- -> DirectionalSeq seq a-filterMessages f = onDirectedSeq (Seq.filter f)--class MessageOps a where- -- | addMessage inserts a date in proper chronological order, with- -- the following extra functionality:- -- * no duplication (by PostId)- -- * no duplication (adjacent UnknownGap entries)- addMessage :: Message -> a -> a--instance MessageOps ChronologicalMessages where- addMessage m ml =- case viewr (dseq ml) of- EmptyR -> DSeq $ singleton m- _ :> l ->- case compare (m^.mDate) (l^.mDate) of- GT -> DSeq $ dseq ml |> m- EQ -> if m^.mMessageId == l^.mMessageId && isJust (m^.mMessageId)- then ml- else dirDateInsert m ml- LT -> dirDateInsert m ml--dirDateInsert :: Message -> ChronologicalMessages -> ChronologicalMessages-dirDateInsert m = onDirectedSeq $ finalize . foldr insAfter initial- where initial = (Just m, mempty)- insAfter c (Nothing, l) = (Nothing, c <| l)- insAfter c (Just n, l) =- case compare (n^.mDate) (c^.mDate) of- GT -> (Nothing, c <| (n <| l))- EQ -> if n^.mMessageId == c^.mMessageId && isJust (c^.mMessageId)- then (Nothing, c <| l)- else (Just n, c <| l)- LT -> (Just n, c <| l)- finalize (Just n, l) = n <| l- finalize (_, l) = l--noMessages :: Messages-noMessages = DSeq mempty--messagesLength :: DirectionalSeq seq a -> Int-messagesLength (DSeq ms) = Seq.length ms---- | Reverse the order of the messages-reverseMessages :: Messages -> RetrogradeMessages-reverseMessages = DSeq . Seq.reverse . dseq---- | Unreverse the order of the messages-unreverseMessages :: RetrogradeMessages -> Messages-unreverseMessages = DSeq . Seq.reverse . dseq--splitDirSeqOn :: SeqDirection d- => (a -> Bool)- -> DirectionalSeq d a- -> (Maybe a, (DirectionalSeq (ReverseDirection d) a,- DirectionalSeq d a))-splitDirSeqOn f msgs =- let (removed, remaining) = dirSeqBreakl f msgs- devomer = DSeq $ Seq.reverse $ dseq removed- in (withDirSeqHead id remaining, (devomer, onDirectedSeq (Seq.drop 1) remaining))---- ------------------------------------------------------------------------- * Operations on Posted Messages---- | Searches for the specified MessageId and returns a tuple where the--- first element is the Message associated with the MessageId (if it--- exists), and the second element is another tuple: the first element--- of the second is all the messages from the beginning of the list to--- the message just before the MessageId message (or all messages if not--- found) *in reverse order*, and the second element of the second are--- all the messages that follow the found message (none if the message--- was never found) in *forward* order.-splitMessages :: Maybe MessageId- -> DirectionalSeq Chronological (Message, ThreadState)- -> (Maybe (Message, ThreadState),- ( DirectionalSeq Retrograde (Message, ThreadState),- DirectionalSeq Chronological (Message, ThreadState)))-splitMessages mid msgs = splitDirSeqOn (\(m, _) -> isJust mid && m^.mMessageId == mid) msgs---- | Given a message and its chronological predecessor, return--- the thread state of the specified message with respect to its--- predecessor.-threadStateFor :: Message- -- ^ The message whose state is to be obtained.- -> Message- -- ^ The message's predecessor.- -> ThreadState-threadStateFor msg prev = case msg^.mInReplyToMsg of- InReplyTo rootId ->- if | (prev^.mMessageId) == Just (MessagePostId rootId) ->- InThread- | prev^.mInReplyToMsg == msg^.mInReplyToMsg ->- InThread- | otherwise ->- InThreadShowParent- _ -> NoThread--retrogradeMsgsWithThreadStates :: RetrogradeMessages -> DirectionalSeq Retrograde (Message, ThreadState)-retrogradeMsgsWithThreadStates msgs = DSeq $ checkAdjacentMessages (dseq msgs)- where- getMessagePredecessor ms =- let visiblePredMsg m = not (isTransition m || m^.mDeleted) in- case Seq.viewl ms of- prev Seq.:< rest ->- if visiblePredMsg prev- then Just prev- else getMessagePredecessor rest- Seq.EmptyL -> Nothing-- checkAdjacentMessages s = case Seq.viewl s of- Seq.EmptyL -> mempty- m Seq.:< t ->- let new_m = case getMessagePredecessor t of- Just prev -> (m, threadStateFor m prev)- Nothing -> case m^.mInReplyToMsg of- InReplyTo _ -> (m, InThreadShowParent)- _ -> (m, NoThread)- in new_m Seq.<| checkAdjacentMessages t--chronologicalMsgsWithThreadStates :: Messages -> DirectionalSeq Chronological (Message, ThreadState)-chronologicalMsgsWithThreadStates msgs = DSeq $ checkAdjacentMessages (dseq msgs)- where- getMessagePredecessor ms =- let visiblePredMsg m = not (isTransition m || m^.mDeleted) in- case Seq.viewr ms of- rest Seq.:> prev ->- if visiblePredMsg prev- then Just prev- else getMessagePredecessor rest- Seq.EmptyR -> Nothing-- checkAdjacentMessages s = case Seq.viewr s of- Seq.EmptyR -> mempty- t Seq.:> m ->- let new_m = case getMessagePredecessor t of- Just prev -> (m, threadStateFor m prev)- Nothing -> case m^.mInReplyToMsg of- InReplyTo _ -> (m, InThreadShowParent)- _ -> (m, NoThread)- in checkAdjacentMessages t Seq.|> new_m---- | findMessage searches for a specific message as identified by the--- PostId. The search starts from the most recent messages because--- that is the most likely place the message will occur.-findMessage :: MessageId -> Messages -> Maybe Message-findMessage mid msgs =- findIndexR (\m -> m^.mMessageId == Just mid) (dseq msgs)- >>= Just . Seq.index (dseq msgs)---- | Look forward for the first Message with an ID that follows the--- specified Id and return it. If no input Id supplied, get the--- latest (most recent chronologically) Message in the input set.-getNextMessage :: Maybe MessageId -> Messages -> Maybe Message-getNextMessage = getRelMessageId---- | Look backward for the first Message with an ID that follows the--- specified MessageId and return it. If no input MessageId supplied,--- get the latest (most recent chronologically) Message in the input--- set.-getPrevMessage :: Maybe MessageId -> Messages -> Maybe Message-getPrevMessage mId = getRelMessageId mId . reverseMessages--messagesHead :: (SeqDirection seq) => DirectionalSeq seq a -> Maybe a-messagesHead = withDirSeqHead id--messagesDrop :: (SeqDirection seq) => Int -> DirectionalSeq seq a -> DirectionalSeq seq a-messagesDrop i = onDirectedSeq (Seq.drop i)---- | Look forward for the first Message with an ID that follows the--- specified MessageId and return that found Message's ID; if no input--- MessageId is specified, return the latest (most recent--- chronologically) MessageId (if any) in the input set.-getNextMessageId :: Maybe MessageId -> Messages -> Maybe MessageId-getNextMessageId mId = _mMessageId <=< getNextMessage mId---- | Look backwards for the first Message with an ID that comes before--- the specified MessageId and return that found Message's ID; if no--- input MessageId is specified, return the latest (most recent--- chronologically) MessageId (if any) in the input set.-getPrevMessageId :: Maybe MessageId -> Messages -> Maybe MessageId-getPrevMessageId mId = _mMessageId <=< getPrevMessage mId---- | Look forward for the first Message with an ID that follows the--- specified PostId and return that found Message's PostID; if no--- input PostId is specified, return the latest (most recent--- chronologically) PostId (if any) in the input set.-getNextPostId :: Maybe PostId -> Messages -> Maybe PostId-getNextPostId pid = messagePostId <=< getNextMessage (MessagePostId <$> pid)---- | Look backwards for the first Post with an ID that comes before--- the specified PostId.-getPrevPostId :: Maybe PostId -> Messages -> Maybe PostId-getPrevPostId pid = messagePostId <=< getPrevMessage (MessagePostId <$> pid)---getRelMessageId :: SeqDirection dir =>- Maybe MessageId- -> DirectionalSeq dir Message- -> Maybe Message-getRelMessageId mId =- let isMId = const ((==) mId . _mMessageId) <$> mId- in getRelMessage isMId---- | Internal worker function to return a different user message in--- relation to either the latest point or a specific message.-getRelMessage :: SeqDirection dir =>- Maybe (Message -> Bool)- -> DirectionalSeq dir Message- -> Maybe Message-getRelMessage matcher msgs =- let after = case matcher of- Just matchFun -> case splitDirSeqOn matchFun msgs of- (_, (_, ms)) -> ms- Nothing -> msgs- in withDirSeqHead id $ filterMessages validSelectableMessage after---- | Find the most recent message that is a Post (as opposed to a--- local message) (if any).-getLatestPostMsg :: Messages -> Maybe Message-getLatestPostMsg msgs =- case viewr $ dropWhileR (not . validUserMessage) (dseq msgs) of- EmptyR -> Nothing- _ :> m -> Just m---- | Find the oldest message that is a message with an ID.-getEarliestSelectableMessage :: Messages -> Maybe Message-getEarliestSelectableMessage msgs =- case viewl $ dropWhileL (not . validSelectableMessage) (dseq msgs) of- EmptyL -> Nothing- m :< _ -> Just m---- | Find the most recent message that is a message with an ID.-getLatestSelectableMessage :: Messages -> Maybe Message-getLatestSelectableMessage msgs =- case viewr $ dropWhileR (not . validSelectableMessage) (dseq msgs) of- EmptyR -> Nothing- _ :> m -> Just m---- | Find the earliest message that is a Post (as opposed to a--- local message) (if any).-getEarliestPostMsg :: Messages -> Maybe Message-getEarliestPostMsg msgs =- case viewl $ dropWhileL (not . validUserMessage) (dseq msgs) of- EmptyL -> Nothing- m :< _ -> Just m---- | Find the most recent message that is a message posted by a user--- that matches the test (if any), skipping local client messages and--- any user event that is not a message (i.e. find a normal message or--- an emote).-findLatestUserMessage :: (Message -> Bool) -> Messages -> Maybe Message-findLatestUserMessage f ml =- case viewr $ dropWhileR (\m -> not (validUserMessage m && f m)) $ dseq ml of- EmptyR -> Nothing- _ :> m -> Just m--validUserMessage :: Message -> Bool-validUserMessage m =- not (m^.mDeleted) && case m^.mMessageId of- Just (MessagePostId _) -> True- _ -> False--validSelectableMessage :: Message -> Bool-validSelectableMessage m = (not $ m^.mDeleted) && (isJust $ m^.mMessageId)---- ------------------------------------------------------------------------- * Operations on any Message type---- | Return all messages that were posted after the specified date/time.-messagesAfter :: ServerTime -> Messages -> Messages-messagesAfter viewTime = onDirectedSeq $ takeWhileR (\m -> m^.mDate > viewTime)---- | Removes any Messages (all types) for which the predicate is true--- from the specified subset of messages (identified by a starting and--- ending MessageId, inclusive) and returns the resulting list (from--- start to finish, irrespective of 'firstId' and 'lastId') and the--- list of removed items.------ start | end | operates-on | (test) case--- --------------------------------------------------------|---------------- Nothing | Nothing | entire list | C1--- Nothing | Just found | start --> found] | C2--- Nothing | Just missing | nothing [suggest invalid] | C3--- Just found | Nothing | [found --> end | C4--- Just found | Just found | [found --> found] | C5--- Just found | Just missing | [found --> end | C6--- Just missing | Nothing | nothing [suggest invalid] | C7--- Just missing | Just found | start --> found] | C8--- Just missing | Just missing | nothing [suggest invalid] | C9------ @removeMatchesFromSubset matchPred fromId toId msgs = (remaining, removed)@----removeMatchesFromSubset :: (Message -> Bool) -> Maybe MessageId -> Maybe MessageId- -> Messages -> (Messages, Messages)-removeMatchesFromSubset matching firstId lastId msgs =- let knownIds = fmap (^.mMessageId) msgs- in if isNothing firstId && isNothing lastId- then swap $ dirSeqPartition matching msgs- else if isJust firstId && firstId `elem` knownIds- then onDirSeqSubset- (\m -> m^.mMessageId == firstId)- (if isJust lastId then \m -> m^.mMessageId == lastId else const False)- (swap . dirSeqPartition matching) msgs- else if isJust lastId && lastId `elem` knownIds- then onDirSeqSubset- (const True)- (\m -> m^.mMessageId == lastId)- (swap . dirSeqPartition matching) msgs- else (msgs, noMessages)---- | Performs an operation on the first Message, returning just the--- result of that operation, or Nothing if there were no messages.--- Note that the message is not necessarily a posted user message.-withFirstMessage :: SeqDirection dir => (Message -> r) -> DirectionalSeq dir Message -> Maybe r-withFirstMessage = withDirSeqHead--msgURLs :: Message -> Seq LinkChoice-msgURLs msg =- let uRef = msg^.mUser- msgUrls = (\ (url, text) -> LinkChoice (msg^.mDate) uRef text url Nothing) <$>- (Seq.fromList $ mconcat $ blockGetURLs <$> (toList $ msg^.mText))- attachmentURLs = (\ a ->- LinkChoice- (msg^.mDate)- uRef- (Just $ attachmentLabel a)- (URL $ a^.attachmentURL)- (Just (a^.attachmentFileId)))- <$> (msg^.mAttachments)- attachmentLabel a =- Seq.fromList [ Element Normal $ EText "attachment"- , Element Normal ESpace- , Element Code $ EText $ a^.attachmentName- ]- in msgUrls <> attachmentURLs
− src/Types/Posts.hs
@@ -1,228 +0,0 @@-{-# LANGUAGE MultiWayIf #-}-{-# LANGUAGE TemplateHaskell #-}-module Types.Posts- ( ClientMessage- , newClientMessage- , cmDate- , cmType- , cmText-- , ClientMessageType(..)-- , Attachment- , mkAttachment- , attachmentName- , attachmentFileId- , attachmentURL-- , ClientPostType(..)-- , ClientPost- , toClientPost- , cpUserOverride- , cpMarkdownSource- , cpUser- , cpText- , cpType- , cpReactions- , cpPending- , cpOriginalPost- , cpFromWebhook- , cpInReplyToPost- , cpDate- , cpChannelId- , cpAttachments- , cpDeleted- , cpPostId- , cpPinned-- , unEmote-- , postIsLeave- , postIsJoin- , postIsTopicChange- , postIsEmote- )-where--import Prelude ()-import Prelude.MH--import qualified Data.Map.Strict as Map-import qualified Data.Sequence as Seq-import qualified Data.Text as T-import qualified Data.Set as S-import Data.Time.Clock ( getCurrentTime )-import Lens.Micro.Platform ( makeLenses )--import Network.Mattermost.Lenses-import Network.Mattermost.Types--import Types.Common-import Types.RichText ( RichTextBlock(Blockquote), parseMarkdown )----- * Client Messages---- | A 'ClientMessage' is a message given to us by our client,--- like help text or an error message.-data ClientMessage = ClientMessage- { _cmText :: Text- , _cmDate :: ServerTime- , _cmType :: ClientMessageType- } deriving (Show)---- | Create a new 'ClientMessage' value. This is a message generated--- by this Matterhorn client and not by (or visible to) the Server.--- These should be visible, but not necessarily integrated into any--- special position in the output stream (i.e., they should generally--- appear at the bottom of the messages display, but subsequent--- messages should follow them), so this is a special place where--- there is an assumed approximation of equality between local time--- and server time.-newClientMessage :: (MonadIO m) => ClientMessageType -> Text -> m ClientMessage-newClientMessage ty msg = do- now <- liftIO getCurrentTime- return (ClientMessage msg (ServerTime now) ty)---- | We format 'ClientMessage' values differently depending on--- their 'ClientMessageType'-data ClientMessageType =- Informative- | Error- | DateTransition- | NewMessagesTransition- | UnknownGapBefore -- ^ a region where the server may have- -- messages before the given timestamp that are- -- not known locally by this client- | UnknownGapAfter -- ^ a region where server may have messages- -- after the given timestamp that are not known- -- locally by this client- deriving (Show)---- ** 'ClientMessage' Lenses--makeLenses ''ClientMessage---- * Mattermost Posts---- | A 'ClientPost' is a temporary internal representation of--- the Mattermost 'Post' type, with unnecessary information--- removed and some preprocessing done.-data ClientPost = ClientPost- { _cpText :: Seq RichTextBlock- , _cpMarkdownSource :: Text- , _cpUser :: Maybe UserId- , _cpUserOverride :: Maybe Text- , _cpDate :: ServerTime- , _cpType :: ClientPostType- , _cpPending :: Bool- , _cpDeleted :: Bool- , _cpAttachments :: Seq Attachment- , _cpInReplyToPost :: Maybe PostId- , _cpPostId :: PostId- , _cpChannelId :: ChannelId- , _cpReactions :: Map.Map Text (S.Set UserId)- , _cpOriginalPost :: Post- , _cpFromWebhook :: Bool- , _cpPinned :: Bool- } deriving (Show)---- | An attachment has a very long URL associated, as well as--- an actual file URL-data Attachment = Attachment- { _attachmentName :: Text- , _attachmentURL :: Text- , _attachmentFileId :: FileId- } deriving (Eq, Show)--mkAttachment :: Text -> Text -> FileId -> Attachment-mkAttachment = Attachment---- | A Mattermost 'Post' value can represent either a normal--- chat message or one of several special events.-data ClientPostType =- NormalPost- | Emote- | Join- | Leave- | TopicChange- deriving (Eq, Show)---- ** Creating 'ClientPost' Values---- | Determine the internal 'PostType' based on a 'Post'-postClientPostType :: Post -> ClientPostType-postClientPostType cp =- if | postIsEmote cp -> Emote- | postIsJoin cp -> Join- | postIsLeave cp -> Leave- | postIsTopicChange cp -> TopicChange- | otherwise -> NormalPost---- | Find out whether a 'Post' represents a topic change-postIsTopicChange :: Post -> Bool-postIsTopicChange p = postType p == PostTypeHeaderChange---- | Find out whether a 'Post' is from a @/me@ command-postIsEmote :: Post -> Bool-postIsEmote p =- and [ p^.postPropsL.postPropsOverrideIconUrlL == Just (""::Text)- , ("*" `T.isPrefixOf` (sanitizeUserText $ postMessage p))- , ("*" `T.isSuffixOf` (sanitizeUserText $ postMessage p))- ]---- | Find out whether a 'Post' is a user joining a channel-postIsJoin :: Post -> Bool-postIsJoin p =- p^.postTypeL == PostTypeJoinChannel---- | Find out whether a 'Post' is a user leaving a channel-postIsLeave :: Post -> Bool-postIsLeave p =- p^.postTypeL == PostTypeLeaveChannel---- | Undo the automatic formatting of posts generated by @/me@-commands-unEmote :: ClientPostType -> Text -> Text-unEmote Emote t = if "*" `T.isPrefixOf` t && "*" `T.isSuffixOf` t- then T.init $ T.tail t- else t-unEmote _ t = t---- | Convert a Mattermost 'Post' to a 'ClientPost', passing in a--- 'ParentId' if it has a known one.-toClientPost :: Post -> Maybe PostId -> ClientPost-toClientPost p parentId =- let src = unEmote (postClientPostType p) $ sanitizeUserText $ postMessage p- in ClientPost { _cpText = parseMarkdown src <> getAttachmentText p- , _cpMarkdownSource = src- , _cpUser = postUserId p- , _cpUserOverride = p^.postPropsL.postPropsOverrideUsernameL- , _cpDate = postCreateAt p- , _cpType = postClientPostType p- , _cpPending = False- , _cpDeleted = False- , _cpPinned = fromMaybe False $ postPinned p- , _cpAttachments = Seq.empty- , _cpInReplyToPost = parentId- , _cpPostId = p^.postIdL- , _cpChannelId = p^.postChannelIdL- , _cpReactions = Map.empty- , _cpOriginalPost = p- , _cpFromWebhook = fromMaybe False $ p^.postPropsL.postPropsFromWebhookL- }---- | Right now, instead of treating 'attachment' properties specially, we're--- just going to roll them directly into the message text-getAttachmentText :: Post -> Seq RichTextBlock-getAttachmentText p =- case p^.postPropsL.postPropsAttachmentsL of- Nothing -> Seq.empty- Just attachments ->- fmap (Blockquote . render) attachments- where render att = parseMarkdown (att^.ppaTextL) <> parseMarkdown (att^.ppaFallbackL)---- ** 'ClientPost' Lenses--makeLenses ''Attachment-makeLenses ''ClientPost
− src/Types/RichText.hs
@@ -1,409 +0,0 @@--- | This module provides a set of data types to represent message text.--- The types here are based loosely on the @cheapskate@ package's types--- but provide higher-level support for the kinds of things we find in--- Mattermost messages such as user and channel references.------ To parse a Markdown document, use 'parseMarkdown'. To actually render--- text in this representation, see the module 'Draw.RichText'.-module Types.RichText- ( RichTextBlock(..)- , ListType(..)- , CodeBlockInfo(..)- , NumDecoration(..)- , Element(..)- , ElementData(..)- , ElementStyle(..)-- , URL(..)- , unURL-- , parseMarkdown- , setElementStyle-- , findUsernames- , blockGetURLs- , findVerbatimChunk-- -- Exposed for testing only:- , fromMarkdownBlocks- )-where--import Prelude ()-import Prelude.MH--import qualified Cheapskate as C-import Data.Char ( isAlphaNum, isAlpha )-import qualified Data.Foldable as F-import Data.Monoid (First(..))-import qualified Data.Set as S-import qualified Data.Sequence as Seq-import Data.Sequence ( (<|), ViewL((:<)) )-import qualified Data.Text as T--import Constants ( userSigil, normalChannelSigil )---- | A block in a rich text document.-data RichTextBlock =- Para (Seq Element)- -- ^ A paragraph.- | Header Int (Seq Element)- -- ^ A section header with specified depth and contents.- | Blockquote (Seq RichTextBlock)- -- ^ A blockquote.- | List Bool ListType (Seq (Seq RichTextBlock))- -- ^ An itemized list.- | CodeBlock CodeBlockInfo Text- -- ^ A code block.- | HTMLBlock Text- -- ^ A fragment of raw HTML.- | HRule- -- ^ A horizontal rule.- deriving (Show)---- | The type of itemized list items.-data ListType =- Bullet Char- -- ^ Decorate the items with bullet using the specified character.- | Numbered NumDecoration Int- -- ^ Number the items starting at the specified number; use the- -- indicated decoration following the number.- deriving (Eq, Show)---- | Information about a code block.-data CodeBlockInfo =- CodeBlockInfo { codeBlockLanguage :: Maybe Text- -- ^ The language of the source code in the code- -- block, if any. This is encoded in Markdown as a- -- sequence of non-whitespace characters following the- -- fenced code block opening backticks.- , codeBlockInfo :: Maybe Text- -- ^ Any text that comes after the language token.- -- This text is separated from the language token by- -- whitespace.- }- deriving (Eq, Show)---- | Ways to decorate numbered itemized list items. The decoration--- follows the list item number.-data NumDecoration =- Paren- | Period- deriving (Eq, Show)---- | A single logical inline element in a rich text block.-data Element =- Element { eStyle :: ElementStyle- -- ^ The element's visual style.- , eData :: ElementData- -- ^ The element's data.- }- deriving (Show, Eq)--setElementStyle :: ElementStyle -> Element -> Element-setElementStyle s e = e { eStyle = s }---- | A URL.-newtype URL = URL Text- deriving (Eq, Show, Ord)--unURL :: URL -> Text-unURL (URL url) = url---- | The kinds of data that can appear in rich text elements.-data ElementData =- EText Text- -- ^ A sequence of non-whitespace characters.- | ESpace- -- ^ A single space.- | ESoftBreak- -- ^ A soft line break.- | ELineBreak- -- ^ A hard line break.- | ERawHtml Text- -- ^ Raw HTML.- | EEditSentinel Bool- -- ^ A sentinel indicating that some text has been edited (used- -- to indicate that mattermost messages have been edited by their- -- authors). This has no parsable representation; it is only used- -- to annotate a message prior to rendering to add a visual editing- -- indicator. The boolean indicates whether the edit was "recent"- -- (True) or not (False).- | EUser Text- -- ^ A user reference. The text here includes only the username, not- -- the sigil.- | EChannel Text- -- ^ A channel reference. The text here includes only the channel- -- name, not the sigil.- | EHyperlink URL (Maybe (Seq Element))- -- ^ A hyperlink to the specified URL. Optionally provides an- -- element sequence indicating the URL's text label; if absent, the- -- label is understood to be the URL itself.- | EImage URL (Maybe (Seq Element))- -- ^ An image at the specified URL. Optionally provides an element- -- sequence indicating the image's "alt" text label; if absent, the- -- label is understood to be the URL itself.- | EEmoji Text- -- ^ An emoji reference. The text here includes only the text- -- portion, not the colons, e.g. "foo" instead of ":foo:".- deriving (Show, Eq)---- | Element visual styles.-data ElementStyle =- Normal- -- ^ Normal text- | Emph- -- ^ Emphasized text- | Strong- -- ^ Bold text- | Code- -- ^ Inline code segment or code block- | Hyperlink URL ElementStyle- -- ^ A terminal hyperlink to the specified URL, composed with- -- another element style- deriving (Eq, Show)--parseMarkdown :: T.Text -> Seq RichTextBlock-parseMarkdown t = fromMarkdownBlocks bs where C.Doc _ bs = C.markdown C.def t---- | Convert a sequence of markdown (Cheapskate) blocks into rich text--- blocks.-fromMarkdownBlocks :: C.Blocks -> Seq RichTextBlock-fromMarkdownBlocks = fmap fromMarkdownBlock---- | Convert a single markdown block into a single rich text block.-fromMarkdownBlock :: C.Block -> RichTextBlock-fromMarkdownBlock (C.Para is) =- Para $ fromMarkdownInlines is-fromMarkdownBlock (C.Header level is) =- Header level $ fromMarkdownInlines is-fromMarkdownBlock (C.Blockquote bs) =- Blockquote $ fromMarkdownBlock <$> bs-fromMarkdownBlock (C.List f ty bss) =- List f (fromMarkdownListType ty) $ fmap fromMarkdownBlock <$> Seq.fromList bss-fromMarkdownBlock (C.CodeBlock attr body) =- CodeBlock (fromMarkdownCodeAttr attr) body-fromMarkdownBlock (C.HtmlBlock body) =- HTMLBlock body-fromMarkdownBlock C.HRule =- HRule--fromMarkdownCodeAttr :: C.CodeAttr -> CodeBlockInfo-fromMarkdownCodeAttr (C.CodeAttr lang info) =- let strippedLang = T.strip lang- strippedInfo = T.strip info- maybeText t = if T.null t then Nothing else Just t- in CodeBlockInfo (maybeText strippedLang)- (maybeText strippedInfo)--fromMarkdownListType :: C.ListType -> ListType-fromMarkdownListType (C.Bullet c) =- Bullet c-fromMarkdownListType (C.Numbered wrap i) =- let dec = case wrap of- C.PeriodFollowing -> Period- C.ParenFollowing -> Paren- in Numbered dec i---- | Remove hyperlinks from an inline sequence. This should only be used--- for sequences that are themselves used as labels for hyperlinks; this--- prevents them from embedding their own hyperlinks, which is nonsense.------ Any hyperlinks found in the sequence will be replaced with a text--- represent of their URL (if they have no label) or the label itself--- otherwise.-removeHyperlinks :: Seq C.Inline -> Seq C.Inline-removeHyperlinks is =- case Seq.viewl is of- h :< t ->- case h of- C.Link label theUrl _ ->- if Seq.null label- then C.Str theUrl <| removeHyperlinks t- else removeHyperlinks label <> removeHyperlinks t- _ -> h <| removeHyperlinks t- Seq.EmptyL -> mempty---- | Convert a sequence of Markdown inline values to a sequence of--- Elements.------ This conversion converts sequences of Markdown AST fragments into--- RichText elements. In particular, this function may determine that--- some sequences of Markdown text fragments belong together, such as--- the sequence @["@", "joe", "-", "user"]@, which should be treated as--- a single "@joe-user" token due to username character validation. When--- appropriate, this function does such consolidation when converting to--- RichText elements.------ This function is also partially responsible for paving the way for--- line-wrapping later on when RichText is rendered. This means that,--- when possible, the elements produced by this function need to be as--- small as possible, without losing structure information. An example--- of this is Markdown inline code fragments, such as "`this is code`".--- This function will convert that one Markdown inline code fragment--- into a sequence of five RichText elements, each with a "Code" style--- assigned: @[EText "this", ESpace, EText "is", ESpace, EText "code"]@.--- This "flattening" of the original Markdown structure makes it much--- easier to do line-wrapping without losing style information because--- it is possible to gather up tokens that do not exceed line widths--- without losing style information. This is key to rendering the text--- with the right terminal attributes.------ However, there are a couple of cases where such flattening does *not*--- happen: hyperlinks and images. In these cases we do not flatten the--- (arbitrary) text label structure of links and images because we need--- to be able to recover those labels to gather up URLs to show to the--- user in the URL list. So we leave the complete text structure of--- those labels behind in the 'EHyperlink' and 'EImage' constructors as--- sequences of Elements. This means that logic in 'Draw.RichText' that--- does line-wrapping will have to explicitly break up link and image--- labels across line breaks.-fromMarkdownInlines :: Seq C.Inline -> Seq Element-fromMarkdownInlines inlines =- let go sty is = case Seq.viewl is of- C.Str ":" :< xs ->- let validEmojiFragment (C.Str f) =- f `elem` ["_", "-"] || T.all isAlphaNum f- validEmojiFragment _ = False- (emojiFrags, rest) = Seq.spanl validEmojiFragment xs- em = T.concat $ unsafeGetStr <$> F.toList emojiFrags- in case Seq.viewl rest of- C.Str ":" :< rest2 ->- Element Normal (EEmoji em) <| go sty rest2- _ ->- Element sty (EText ":") <| go sty xs- C.Str t :< xs | userSigil `T.isPrefixOf` t ->- let (uFrags, rest) = Seq.spanl isNameFragment xs- t' = T.concat $ t : (unsafeGetStr <$> F.toList uFrags)- u = T.drop 1 t'- in Element sty (EUser u) <| go sty rest- C.Str t :< xs | normalChannelSigil `T.isPrefixOf` t ->- let (cFrags, rest) = Seq.spanl isNameFragment xs- cn = T.drop 1 $ T.concat $ t : (unsafeGetStr <$> F.toList cFrags)- in Element sty (EChannel cn) <| go sty rest- C.Str t :< xs ->- -- When we encounter a string node, we go ahead and- -- process the rest of the nodes in the sequence. If the- -- new sequence starts with *another* string node with- -- the same style, we merge them. We do this because- -- Cheapskate will parse things like punctuation as- -- individual string nodes, but we want to keep those- -- together with any text is adjacent to them to avoid- -- e.g. breaking up quotes from quoted text when doing- -- line-wrapping. It's important to make sure we only do- -- this for adjacent string (i.e. not whitespace) nodes- -- and that we make sure we only merge when they have the- -- same style.- let rest = go sty xs- e = Element sty (EText t)- in case Seq.viewl rest of- Element sty2 (EText t2) :< tail_ | sty2 == sty ->- (Element sty (EText (t <> t2))) <| tail_- _ ->- e <| rest- C.Space :< xs ->- Element sty ESpace <| go sty xs- C.SoftBreak :< xs ->- Element sty ESoftBreak <| go sty xs- C.LineBreak :< xs ->- Element sty ELineBreak <| go sty xs- C.Link label theUrl _ :< xs ->- let mLabel = if Seq.null label- then Nothing- else Just $ fromMarkdownInlines $ removeHyperlinks label- url = URL theUrl- in (Element (Hyperlink url sty) $ EHyperlink url mLabel) <| go sty xs- C.Image altIs theUrl _ :< xs ->- let mLabel = if Seq.null altIs- then Nothing- else Just $ fromMarkdownInlines altIs- url = URL theUrl- in (Element (Hyperlink url sty) $ EImage url mLabel) <| go sty xs- C.RawHtml t :< xs ->- Element sty (ERawHtml t) <| go sty xs- C.Code t :< xs ->- -- We turn a single code string into individual Elements- -- so we can perform line-wrapping on the inline code- -- text.- let ts = [ Element Code frag- | wd <- T.split (== ' ') t- , frag <- case wd of- "" -> [ESpace]- _ -> [ESpace, EText wd]- ]- ts' = case ts of- (Element _ ESpace:rs) -> rs- _ -> ts- in Seq.fromList ts' <> go sty xs- C.Emph as :< xs ->- go Emph as <> go sty xs- C.Strong as :< xs ->- go Strong as <> go sty xs- C.Entity t :< xs ->- Element sty (EText t) <| go sty xs- Seq.EmptyL -> mempty-- in go Normal inlines--unsafeGetStr :: C.Inline -> Text-unsafeGetStr (C.Str t) = t-unsafeGetStr _ = error "BUG: unsafeGetStr called on non-Str Inline"---- | Obtain all username references in a sequence of rich text blocks.-findUsernames :: Seq RichTextBlock -> S.Set T.Text-findUsernames = S.unions . F.toList . fmap blockFindUsernames--blockFindUsernames :: RichTextBlock -> S.Set T.Text-blockFindUsernames (Para is) =- elementFindUsernames $ F.toList is-blockFindUsernames (Header _ is) =- elementFindUsernames $ F.toList is-blockFindUsernames (Blockquote bs) =- findUsernames bs-blockFindUsernames (List _ _ bs) =- S.unions $ F.toList $ findUsernames <$> bs-blockFindUsernames _ =- mempty--elementFindUsernames :: [Element] -> S.Set T.Text-elementFindUsernames [] = mempty-elementFindUsernames (e : es) =- case eData e of- EUser u -> S.insert u $ elementFindUsernames es- _ -> elementFindUsernames es---- | Obtain all URLs (and optional labels) in a rich text block.-blockGetURLs :: RichTextBlock -> [(URL, Maybe (Seq Element))]-blockGetURLs (Para is) =- catMaybes $ elementGetURL <$> toList is-blockGetURLs (Header _ is) =- catMaybes $ elementGetURL <$> toList is-blockGetURLs (Blockquote bs) =- mconcat $ blockGetURLs <$> toList bs-blockGetURLs (List _ _ bss) =- mconcat $ mconcat $- (fmap blockGetURLs . F.toList) <$> F.toList bss-blockGetURLs _ =- mempty--elementGetURL :: Element -> Maybe (URL, Maybe (Seq Element))-elementGetURL (Element _ (EHyperlink url label)) =- Just (url, label)-elementGetURL (Element _ (EImage url label)) =- Just (url, label)-elementGetURL _ =- Nothing---- | Find the first code block in a sequence of rich text blocks.-findVerbatimChunk :: Seq RichTextBlock -> Maybe Text-findVerbatimChunk = getFirst . F.foldMap go- where go (CodeBlock _ t) = First (Just t)- go _ = First Nothing--isValidNameChar :: Char -> Bool-isValidNameChar c = isAlpha c || c == '_' || c == '.' || c == '-'--isNameFragment :: C.Inline -> Bool-isNameFragment (C.Str t) =- not (T.null t) && isValidNameChar (T.head t)-isNameFragment _ = False
− src/Types/Users.hs
@@ -1,196 +0,0 @@-{-# LANGUAGE TemplateHaskell #-}-{-# LANGUAGE DeriveFunctor #-}--module Types.Users- ( UserInfo(..)- , UserStatus(..)- , Users -- constructor remains internal- -- * Lenses created for accessing UserInfo fields- , uiName, uiId, uiStatus, uiInTeam, uiNickName, uiFirstName, uiLastName, uiEmail- , uiDeleted- -- * Various operations on UserInfo- -- * Creating UserInfo objects- , userInfoFromUser- -- * Miscellaneous- , getUsernameSet- , trimUserSigil- , statusFromText- , findUserById- , findUserByUsername- , findUserByNickname- , noUsers, addUser, allUsers- , modifyUserById- , userDeleted- , TypingUsers- , noTypingUsers- , addTypingUser- , allTypingUsers- , expireTypingUsers- , getAllUserIds- )-where--import Prelude ()-import Prelude.MH--import qualified Data.HashMap.Strict as HM-import qualified Data.Set as S-import Data.Semigroup ( Max(..) )-import qualified Data.Text as T-import Lens.Micro.Platform ( (%~), makeLenses, ix )--import Network.Mattermost.Types ( UserId(..), User(..) )--import Types.Common-import Constants ( userSigil )---- * 'UserInfo' Values---- | A 'UserInfo' value represents everything we need to know at--- runtime about a user-data UserInfo = UserInfo- { _uiName :: Text- , _uiId :: UserId- , _uiStatus :: UserStatus- , _uiInTeam :: Bool- , _uiNickName :: Maybe Text- , _uiFirstName :: Text- , _uiLastName :: Text- , _uiEmail :: Text- , _uiDeleted :: Bool- } deriving (Eq, Show)---- | Is this user deleted?-userDeleted :: User -> Bool-userDeleted u =- case userCreateAt u of- Nothing -> False- Just c -> userDeleteAt u > c---- | Create a 'UserInfo' value from a Mattermost 'User' value-userInfoFromUser :: User -> Bool -> UserInfo-userInfoFromUser up inTeam = UserInfo- { _uiName = userUsername up- , _uiId = userId up- , _uiStatus = Offline- , _uiInTeam = inTeam- , _uiNickName =- let nick = sanitizeUserText $ userNickname up- in if T.null nick then Nothing else Just nick- , _uiFirstName = sanitizeUserText $ userFirstName up- , _uiLastName = sanitizeUserText $ userLastName up- , _uiEmail = sanitizeUserText $ userEmail up- , _uiDeleted = userDeleted up- }---- | The 'UserStatus' value represents possible current status for--- a user-data UserStatus- = Online- | Away- | Offline- | DoNotDisturb- | Other Text- deriving (Eq, Show)--statusFromText :: Text -> UserStatus-statusFromText t = case t of- "online" -> Online- "offline" -> Offline- "away" -> Away- "dnd" -> DoNotDisturb- _ -> Other t---- ** 'UserInfo' lenses--makeLenses ''UserInfo---- ** Manage the collection of all Users---- | Define a binary kinded type to allow derivation of functor.-data AllMyUsers a =- AllUsers { _ofUsers :: HashMap UserId a- , _usernameSet :: S.Set Text- }- deriving Functor--makeLenses ''AllMyUsers---- | Define the exported typename which universally binds the--- collection to the UserInfo type.-type Users = AllMyUsers UserInfo--getUsernameSet :: Users -> S.Set Text-getUsernameSet = _usernameSet---- | Initial collection of Users with no members-noUsers :: Users-noUsers = AllUsers HM.empty mempty--getAllUserIds :: Users -> [UserId]-getAllUserIds = HM.keys . _ofUsers---- | Add a member to the existing collection of Users-addUser :: UserInfo -> Users -> Users-addUser userinfo u =- u & ofUsers %~ HM.insert (userinfo^.uiId) userinfo- & usernameSet %~ S.insert (userinfo^.uiName)---- | Get a list of all known users-allUsers :: Users -> [UserInfo]-allUsers = HM.elems . _ofUsers---- | Define the exported typename to represent the collection of users--- | who are currently typing. The values kept against the user id keys are the--- | latest timestamps of typing events from the server.-type TypingUsers = AllMyUsers (Max UTCTime)---- | Initial collection of TypingUsers with no members-noTypingUsers :: TypingUsers-noTypingUsers = AllUsers HM.empty mempty---- | Add a member to the existing collection of TypingUsers-addTypingUser :: UserId -> UTCTime -> TypingUsers -> TypingUsers-addTypingUser uId ts = ofUsers %~ HM.insertWith (<>) uId (Max ts)---- | Get a list of all typing users-allTypingUsers :: TypingUsers -> [UserId]-allTypingUsers = HM.keys . _ofUsers---- | Remove all the expired users from the collection of TypingUsers.--- | Expiry is decided by the given timestamp.-expireTypingUsers :: UTCTime -> TypingUsers -> TypingUsers-expireTypingUsers expiryTimestamp =- ofUsers %~ HM.filter (\(Max ts') -> ts' >= expiryTimestamp)---- | Get the User information given the UserId-findUserById :: UserId -> Users -> Maybe UserInfo-findUserById uId = HM.lookup uId . _ofUsers---- | Get the User information given the user's name. This is an exact--- match on the username field. It will automatically trim a user sigil--- from the input.-findUserByUsername :: Text -> Users -> Maybe (UserId, UserInfo)-findUserByUsername name allusers =- case filter ((== trimUserSigil name) . _uiName . snd) $ HM.toList $ _ofUsers allusers of- (usr : []) -> Just usr- _ -> Nothing---- | Get the User information given the user's name. This is an exact--- match on the nickname field, not necessarily the presented name. It--- will automatically trim a user sigil from the input.-findUserByNickname:: Text -> Users -> Maybe (UserId, UserInfo)-findUserByNickname nick us =- case filter ((== (Just $ trimUserSigil nick)) . _uiNickName . snd) $ HM.toList $ _ofUsers us of- (pair : []) -> Just pair- _ -> Nothing--trimUserSigil :: Text -> Text-trimUserSigil n- | userSigil `T.isPrefixOf` n = T.tail n- | otherwise = n---- | Extract a specific user from the collection and perform an--- endomorphism operation on it, then put it back into the collection.-modifyUserById :: UserId -> (UserInfo -> UserInfo) -> Users -> Users-modifyUserById uId f = ofUsers.ix(uId) %~ f
− src/Util.hs
@@ -1,25 +0,0 @@-module Util- ( nubOn- )-where--import Prelude ()-import Prelude.MH--import qualified Data.Set as Set----- | The 'nubOn' function removes duplicate elements from a list. In--- particular, it keeps only the /last/ occurrence of each--- element. The equality of two elements in a call to @nub f@ is--- determined using @f x == f y@, and the resulting elements must have--- an 'Ord' instance in order to make this function more efficient.-nubOn :: (Ord b) => (a -> b) -> [a] -> [a]-nubOn f = snd . go Set.empty- where go before [] = (before, [])- go before (x:xs) =- let (before', xs') = go before xs- key = f x in- if key `Set.member` before'- then (before', xs')- else (Set.insert key before', x : xs')
− src/Windows/ViewMessage.hs
@@ -1,217 +0,0 @@-module Windows.ViewMessage- ( viewMessageWindowTemplate- , viewMessageKeybindings- , viewMessageKeyHandlers- , viewMessageReactionsKeybindings- , viewMessageReactionsKeyHandlers- )-where--import Prelude ()-import Prelude.MH--import Brick-import Brick.Widgets.Border--import qualified Data.Set as S-import qualified Data.Map as M-import Data.Maybe ( fromJust )-import qualified Data.Text as T-import qualified Data.Foldable as F-import qualified Graphics.Vty as Vty-import Lens.Micro.Platform ( to )--import Constants-import Events.Keybindings-import Themes-import Types-import Draw.RichText-import Draw.Messages ( nameForUserRef )---- | The template for "View Message" windows triggered by message--- selection mode.-viewMessageWindowTemplate :: TabbedWindowTemplate ViewMessageWindowTab-viewMessageWindowTemplate =- TabbedWindowTemplate { twtEntries = [ messageEntry- , reactionsEntry- ]- , twtTitle = const $ txt "View Message"- }--messageEntry :: TabbedWindowEntry ViewMessageWindowTab-messageEntry =- TabbedWindowEntry { tweValue = VMTabMessage- , tweRender = renderTab- , tweHandleEvent = handleEvent- , tweTitle = tabTitle- , tweShowHandler = onShow- }--reactionsEntry :: TabbedWindowEntry ViewMessageWindowTab-reactionsEntry =- TabbedWindowEntry { tweValue = VMTabReactions- , tweRender = renderTab- , tweHandleEvent = handleEvent- , tweTitle = tabTitle- , tweShowHandler = onShow- }--tabTitle :: ViewMessageWindowTab -> Bool -> T.Text-tabTitle VMTabMessage _ = "Message"-tabTitle VMTabReactions _ = "Reactions"---- When we show the tabs, we need to reset the viewport scroll position--- for viewports in that tab. This is because an older View Message--- window used the same handle for the viewport and we don't want that--- old state affecting this window. This also means that switching tabs--- in an existing window resets this state, too.-onShow :: ViewMessageWindowTab -> MH ()-onShow VMTabMessage = resetVp ViewMessageArea-onShow VMTabReactions = resetVp ViewMessageArea--resetVp :: Name -> MH ()-resetVp n = do- let vs = viewportScroll n- mh $ do- vScrollToBeginning vs- hScrollToBeginning vs--renderTab :: ViewMessageWindowTab -> ChatState -> Widget Name-renderTab tab cs =- let latestMessage = case cs^.csViewedMessage of- Nothing -> error "BUG: no message to show, please report!"- Just (m, _) -> getLatestMessage cs m- in case tab of- VMTabMessage -> viewMessageBox cs latestMessage- VMTabReactions -> reactionsText cs latestMessage--getLatestMessage :: ChatState -> Message -> Message-getLatestMessage cs m =- case m^.mMessageId of- Nothing -> m- Just mId -> fromJust $ findMessage mId $ cs^.csCurrentChannel.ccContents.cdMessages--handleEvent :: ViewMessageWindowTab -> Vty.Event -> MH ()-handleEvent VMTabMessage =- void . handleKeyboardEvent viewMessageKeybindings (const $ return ())-handleEvent VMTabReactions =- void . handleKeyboardEvent viewMessageReactionsKeybindings (const $ return ())--reactionsText :: ChatState -> Message -> Widget Name-reactionsText st m = viewport ViewMessageReactionsArea Vertical body- where- body = case null reacList of- True -> txt "This message has no reactions."- False -> vBox $ mkEntry <$> reacList- reacList = M.toList (m^.mReactions)- mkEntry (reactionName, userIdSet) =- let count = str $ "(" <> show (S.size userIdSet) <> ")"- name = withDefAttr emojiAttr $ txt $ ":" <> reactionName <> ":"- usernameList = usernameText userIdSet- in (name <+> (padLeft (Pad 1) count)) <=>- (padLeft (Pad 2) usernameList)-- hs = getHighlightSet st-- usernameText uids =- renderText' (myUsername st) hs $- T.intercalate ", " $- fmap (userSigil <>) $- catMaybes (lookupUsername <$> F.toList uids)-- lookupUsername uid = usernameForUserId uid st--viewMessageBox :: ChatState -> Message -> Widget Name-viewMessageBox st msg =- let maybeWarn = if not (msg^.mDeleted) then id else warn- warn w = vBox [w, hBorder, deleteWarning]- deleteWarning = withDefAttr errorMessageAttr $- txtWrap $ "Alert: this message has been deleted and " <>- "will no longer be accessible once this window " <>- "is closed."- mkBody vpWidth =- let hs = getHighlightSet st- parent = case msg^.mInReplyToMsg of- NotAReply -> Nothing- InReplyTo pId -> getMessageForPostId st pId- md = MessageData { mdEditThreshold = Nothing- , mdShowOlderEdits = False- , mdMessage = msg- , mdUserName = msg^.mUser.to (nameForUserRef st)- , mdParentMessage = parent- , mdParentUserName = parent >>= (^.mUser.to (nameForUserRef st))- , mdRenderReplyParent = True- , mdHighlightSet = hs- , mdIndentBlocks = True- , mdThreadState = NoThread- , mdShowReactions = False- , mdMessageWidthLimit = Just vpWidth- , mdMyUsername = myUsername st- }- in renderMessage md-- in Widget Greedy Greedy $ do- ctx <- getContext- render $ maybeWarn $ viewport ViewMessageArea Both $ mkBody (ctx^.availWidthL)--viewMessageKeybindings :: KeyConfig -> KeyHandlerMap-viewMessageKeybindings = mkKeybindings viewMessageKeyHandlers--viewMessageKeyHandlers :: [KeyEventHandler]-viewMessageKeyHandlers =- let vs = viewportScroll ViewMessageArea- in [ mkKb PageUpEvent "Page up" $- mh $ vScrollBy vs (-1 * pageAmount)-- , mkKb PageDownEvent "Page down" $- mh $ vScrollBy vs pageAmount-- , mkKb PageLeftEvent "Page left" $- mh $ hScrollBy vs (-2 * pageAmount)-- , mkKb PageRightEvent "Page right" $- mh $ hScrollBy vs (2 * pageAmount)-- , mkKb ScrollUpEvent "Scroll up" $- mh $ vScrollBy vs (-1)-- , mkKb ScrollDownEvent "Scroll down" $- mh $ vScrollBy vs 1-- , mkKb ScrollLeftEvent "Scroll left" $- mh $ hScrollBy vs (-1)-- , mkKb ScrollRightEvent "Scroll right" $- mh $ hScrollBy vs 1-- , mkKb ScrollBottomEvent "Scroll to the end of the message" $- mh $ vScrollToEnd vs-- , mkKb ScrollTopEvent "Scroll to the beginning of the message" $- mh $ vScrollToBeginning vs- ]--viewMessageReactionsKeybindings :: KeyConfig -> KeyHandlerMap-viewMessageReactionsKeybindings = mkKeybindings viewMessageReactionsKeyHandlers--viewMessageReactionsKeyHandlers :: [KeyEventHandler]-viewMessageReactionsKeyHandlers =- let vs = viewportScroll ViewMessageReactionsArea- in [ mkKb PageUpEvent "Page up" $- mh $ vScrollBy vs (-1 * pageAmount)-- , mkKb PageDownEvent "Page down" $- mh $ vScrollBy vs pageAmount-- , mkKb ScrollUpEvent "Scroll up" $- mh $ vScrollBy vs (-1)-- , mkKb ScrollDownEvent "Scroll down" $- mh $ vScrollBy vs 1-- , mkKb ScrollBottomEvent "Scroll to the end of the reactions list" $- mh $ vScrollToEnd vs-- , mkKb ScrollTopEvent "Scroll to the beginning of the reactions list" $- mh $ vScrollToBeginning vs- ]
− src/Zipper.hs
@@ -1,119 +0,0 @@-module Zipper- ( Zipper- , fromList- , toList- , focus- , unsafeFocus- , left- , leftL- , right- , rightL- , findRight- , maybeFindRight- , updateList- , updateListBy- , filterZipper- , maybeMapZipper- , isEmpty- )-where--import Prelude ()-import Prelude.MH hiding (toList)--import Data.Maybe ( fromJust )-import qualified Data.Foldable as F-import qualified Data.Sequence as Seq-import qualified Data.CircularList as C-import Lens.Micro.Platform--data Zipper a b =- Zipper { zRing :: C.CList b- , zTrees :: Seq.Seq (a, Seq.Seq b)- }--instance F.Foldable (Zipper a) where- foldMap f = foldMap f .- F.toList .- mconcat .- F.toList .- fmap snd .- zTrees--instance Functor (Zipper a) where- fmap f z =- Zipper { zRing = f <$> zRing z- , zTrees = zTrees z & mapped._2.mapped %~ f- }--isEmpty :: Zipper a b -> Bool-isEmpty = C.isEmpty . zRing---- Move the focus one element to the left-left :: Zipper a b -> Zipper a b-left z = z { zRing = C.rotL (zRing z) }---- A lens on the zipper moved to the left-leftL :: Lens (Zipper a b) (Zipper a b) (Zipper a b) (Zipper a b)-leftL = lens left (\ _ b -> right b)---- Move the focus one element to the right-right :: Zipper a b -> Zipper a b-right z = z { zRing = C.rotR (zRing z) }---- A lens on the zipper moved to the right-rightL :: Lens (Zipper a b) (Zipper a b) (Zipper a b) (Zipper a b)-rightL = lens right (\ _ b -> left b)---- Return the focus element-focus :: Zipper a b -> Maybe b-focus = C.focus . zRing--unsafeFocus :: Zipper a b -> b-unsafeFocus = fromJust . focus---- Turn a list into a wraparound zipper, focusing on the head-fromList :: (Eq b) => [(a, [b])] -> Zipper a b-fromList xs =- let ts = Seq.fromList $ xs & mapped._2 %~ Seq.fromList- tsList = F.toList $ mconcat $ F.toList $ snd <$> ts- maybeFocus = if null tsList- then id- else fromJust . C.rotateTo (tsList !! 0)- in Zipper { zRing = maybeFocus $ C.fromList tsList- , zTrees = ts- }--toList :: Zipper a b -> [(a, [b])]-toList z = F.toList $ zTrees z & mapped._2 %~ F.toList---- Shift the focus until a given element is found, or return the--- same zipper if none applies-findRight :: (b -> Bool) -> Zipper a b -> Zipper a b-findRight f z = fromMaybe z $ maybeFindRight f z---- Shift the focus until a given element is found, or return--- Nothing if none applies-maybeFindRight :: (b -> Bool) -> Zipper a b -> Maybe (Zipper a b)-maybeFindRight f z = do- newRing <- C.findRotateTo f (zRing z)- return z { zRing = newRing }--updateList :: (Eq b) => [(a, [b])] -> Zipper a b -> Zipper a b-updateList newList oldZip = updateListBy (\old b -> old == Just b) newList oldZip--updateListBy :: (Eq b) => (Maybe b -> b -> Bool) -> [(a, [b])] -> Zipper a b -> Zipper a b-updateListBy f newList oldZip = findRight (f (focus oldZip)) $ fromList newList--maybeMapZipper :: (Eq c) => (b -> Maybe c) -> Zipper a b -> Zipper a c-maybeMapZipper f z =- let oldTrees = zTrees z- newTrees = F.toList $ oldTrees & mapped._2 %~ (catMaybes . F.toList . fmap f)- in fromList newTrees--filterZipper :: (Eq b) => (b -> Bool) -> Zipper a b -> Zipper a b-filterZipper f oldZip = maintainFocus newZip- where maintainFocus = findRight ((== focus oldZip) . Just)- newZip = Zipper { zTrees = zTrees oldZip & mapped._2 %~ Seq.filter f- , zRing = C.filterR f (zRing oldZip)- }
test/Message_QCA.hs view
@@ -13,10 +13,11 @@ import Network.Mattermost.QuickCheck import Network.Mattermost.Types import Test.Tasty.QuickCheck-import Types.Messages-import Types.Posts-import Types.RichText +import Matterhorn.Types.Messages+import Matterhorn.Types.Posts+import Matterhorn.Types.RichText+ genMap :: Ord key => Gen key -> Gen value -> Gen (Map key value) genMap gk gv = let kv = (,) <$> gk <*> gv in fromList <$> listOf kv @@ -31,7 +32,7 @@ genMessage :: Gen Message genMessage = Message- <$> (fromMarkdownBlocks <$> genBlocks)+ <$> (fromMarkdownBlocks Nothing <$> genBlocks) <*> genText <*> genUserRef <*> genTime@@ -59,7 +60,7 @@ genMessage__DeletedPost :: Gen Message__DeletedPost genMessage__DeletedPost = Message__DeletedPost <$> (Message- <$> (fromMarkdownBlocks <$> genBlocks)+ <$> (fromMarkdownBlocks Nothing <$> genBlocks) <*> genText <*> genUserRef <*> genTime@@ -81,7 +82,7 @@ genMessage__Posted :: Gen Message__Posted genMessage__Posted = Message__Posted <$> (Message- <$> (fromMarkdownBlocks <$> genBlocks)+ <$> (fromMarkdownBlocks Nothing <$> genBlocks) <*> genText <*> genUserRef <*> genTime
test/test_messages.hs view
@@ -14,18 +14,22 @@ import Data.Time.Calendar (Day(..)) import Data.Time.Clock (UTCTime(..), getCurrentTime , secondsToDiffTime)-import Message_QCA-import Network.Mattermost.Types import System.Exit-import Test.QuickCheck.Checkers+ import Test.Tasty import Test.Tasty.HUnit import Test.Tasty.QuickCheck-import TimeUtils-import Types.DirectionalSeq-import Types.Messages-import Types.Posts-import Prelude.MH+import Test.QuickCheck.Checkers++import Network.Mattermost.Types++import Message_QCA++import Matterhorn.Types.DirectionalSeq+import Matterhorn.Types.Messages+import Matterhorn.Types.Posts+import Matterhorn.Prelude+import Matterhorn.TimeUtils main :: IO () main = defaultMain tests `catch` (\e -> do