pax_global_header00006660000000000000000000000064143361725110014515gustar00rootroot0000000000000052 comment=5aa494dc5eeb6980230f3a92bc704a5b26460304 gomuks-0.3.0/000077500000000000000000000000001433617251100130225ustar00rootroot00000000000000gomuks-0.3.0/.codeclimate.yml000066400000000000000000000003501433617251100160720ustar00rootroot00000000000000version: "2" checks: method-count: config: threshold: 50 engines: golint: enabled: true checks: GoLint/Comments/DocComments: enabled: false gofmt: enabled: true govet: enabled: true gomuks-0.3.0/.editorconfig000066400000000000000000000002631433617251100155000ustar00rootroot00000000000000root = true [*] indent_style = tab indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [.gitlab-ci.yml] indent_size = 2 gomuks-0.3.0/.github/000077500000000000000000000000001433617251100143625ustar00rootroot00000000000000gomuks-0.3.0/.github/workflows/000077500000000000000000000000001433617251100164175ustar00rootroot00000000000000gomuks-0.3.0/.github/workflows/go.yml000066400000000000000000000021331433617251100175460ustar00rootroot00000000000000name: Go on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest strategy: fail-fast: false matrix: go-version: [1.19] steps: - uses: actions/checkout@v3 - name: Set up Go ${{ matrix.go-version }} uses: actions/setup-go@v3 with: go-version: ${{ matrix.go-version }} - name: Install goimports run: | go install golang.org/x/tools/cmd/goimports@latest export PATH="$HOME/go/bin:$PATH" - name: Install pre-commit run: pip install pre-commit - name: Lint run: pre-commit run -a build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: go-version: [1.18, 1.19] steps: - uses: actions/checkout@v3 - name: Set up Go ${{ matrix.go-version }} uses: actions/setup-go@v3 with: go-version: ${{ matrix.go-version }} - name: Install libolm run: sudo apt-get install libolm-dev libolm3 - name: Build run: go build -v ./... - name: Test run: go test -v ./... gomuks-0.3.0/.gitignore000066400000000000000000000001221433617251100150050ustar00rootroot00000000000000.idea/ target/ .tmp/ gomuks *.exe *.deb coverage.out coverage.html deb/usr *.prof gomuks-0.3.0/.gitlab-ci.yml000066400000000000000000000063541433617251100154660ustar00rootroot00000000000000stages: - build - package default: before_script: - mkdir -p .cache - export GOPATH="$CI_PROJECT_DIR/.cache" cache: paths: - .cache .build-linux: &build-linux stage: build before_script: - export GO_LDFLAGS="-s -w -linkmode external -extldflags -static -X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" script: - go build -ldflags "$GO_LDFLAGS" -o gomuks artifacts: paths: - gomuks linux/amd64: <<: *build-linux image: dock.mau.dev/tulir/gomuks-build-docker:linux-amd64 linux/arm: <<: *build-linux image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm linux/arm64: <<: *build-linux image: dock.mau.dev/tulir/gomuks-build-docker:linux-arm64 windows/amd64: image: dock.mau.dev/tulir/gomuks-build-docker:windows-amd64 stage: build script: - go build -o gomuks.exe artifacts: paths: - gomuks.exe macos/amd64: stage: build tags: - macos - amd64 before_script: - export GO_LDFLAGS="-X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" script: - mkdir gomuks-macos-amd64 - go build -ldflags "$GO_LDFLAGS" -o gomuks-macos-amd64/gomuks - install_name_tool -change /usr/local/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks-macos-amd64/gomuks - install_name_tool -add_rpath @executable_path gomuks-macos-amd64/gomuks - install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks-macos-amd64/gomuks - cp /usr/local/opt/libolm/lib/libolm.3.dylib gomuks-macos-amd64/ artifacts: paths: - gomuks-macos-amd64 macos/arm64: stage: build tags: - macos - arm64 before_script: - export LIBRARY_PATH=/opt/homebrew/lib - export CPATH=/opt/homebrew/include - export PATH=/opt/homebrew/bin:$PATH - export GO_LDFLAGS="-X main.Tag=$CI_COMMIT_TAG -X main.Commit=$CI_COMMIT_SHA -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" script: - mkdir gomuks-macos-arm64 - go build -ldflags "$GO_LDFLAGS" -o gomuks-macos-arm64/gomuks - install_name_tool -change /opt/homebrew/opt/libolm/lib/libolm.3.dylib @rpath/libolm.3.dylib gomuks-macos-arm64/gomuks - install_name_tool -add_rpath @executable_path gomuks-macos-arm64/gomuks - install_name_tool -add_rpath /opt/homebrew/opt/libolm/lib gomuks-macos-arm64/gomuks - install_name_tool -add_rpath /usr/local/opt/libolm/lib gomuks-macos-arm64/gomuks - cp /opt/homebrew/opt/libolm/lib/libolm.3.dylib gomuks-macos-arm64/ artifacts: paths: - gomuks-macos-arm64 macos/universal: stage: package tags: - macos dependencies: - macos/amd64 - macos/arm64 needs: - macos/amd64 - macos/arm64 variables: GIT_STRATEGY: none script: - lipo -create -output libolm.3.dylib gomuks-macos-arm64/libolm.3.dylib gomuks-macos-amd64/libolm.3.dylib - lipo -create -output gomuks gomuks-macos-arm64/gomuks gomuks-macos-amd64/gomuks artifacts: name: gomuks-macos-universal paths: - libolm.3.dylib - gomuks debian: image: debian stage: package dependencies: - linux/amd64 only: - tags script: - mkdir -p deb/usr/bin - cp gomuks deb/usr/bin/gomuks - chmod -R -s deb/DEBIAN && chmod -R 0755 deb/DEBIAN - dpkg-deb --build deb gomuks.deb artifacts: paths: - gomuks.deb gomuks-0.3.0/.pre-commit-config.yaml000066400000000000000000000005561433617251100173110ustar00rootroot00000000000000repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.1.0 hooks: - id: trailing-whitespace exclude_types: [markdown] - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/tekwizely/pre-commit-golang rev: v1.0.0-beta.5 hooks: - id: go-imports-repo gomuks-0.3.0/CHANGELOG.md000066400000000000000000000147121433617251100146400ustar00rootroot00000000000000# v0.3.0 (2022-11-19) * Bumped minimum Go version to 1.18. * Switched from `/r0` to `/v3` paths everywhere. * The new `v3` paths are implemented since Synapse 1.48, Dendrite 0.6.5, and Conduit 0.4.0. Servers older than these are no longer supported. * Added config flags for backspace behavior. * Added `/rainbownotice` command to send a rainbow as a `m.notice` message. * Added support for editing messages in an external editor. * Added arrow key support for navigating results in fuzzy search. * Added initial support for configurable keyboard shortcuts (thanks to [@3nprob] in [#328]). * Added support for shortcodes *without* tab-completion in `/react` (thanks to [@tleb] in [#354]). * Added background color to differentiate `inline code` (thanks to [@n-peugnet] in [#361]). * Added tab-completion support for `/toggle` options (thanks to [@n-peugnet] in [#362]). * Added initial support for rendering spoilers in messages. * Added support for sending spoilers (with `||reason|spoiler||` or `||spoiler||`). * Added support for inline links (limited terminal support; requires `/toggle inlineurls`). * Added graphical file picker for `/upload` when no path is provided (requires `zenity`). * Updated more places to use default/reverse colors instead of white/black to better work on light themed terminals (thanks to [@n-peugnet] in [#401]). * Fixed mentions being lost when editing messages. * Fixed date change messages showing the wrong date. * Fixed some whitespace in HTML being rendered even when it shouldn't. * Fixed copying non-text messages with `/copy`. * Fixed rendering code blocks with unknown languages (thanks to [@n-peugnet] in [#386]). * Fixed newlines not working in code blocks with certain syntax highlightings (thanks to [@n-peugnet] in [#387]). * Fixed rendering more than one reaction of the same type in a single message (thanks to [@n-peugnet] in [#391]). * Fixed line-wrapped messages getting corrupted when receiving a reaction (thanks to [@n-peugnet] in [#397]). [@3nprob]: https://github.com/3nprob [@tleb]: https://github.com/tleb [@n-peugnet]: https://github.com/n-peugnet [#328]: https://github.com/tulir/gomuks/pull/328 [#354]: https://github.com/tulir/gomuks/pull/354 [#361]: https://github.com/tulir/gomuks/pull/361 [#362]: https://github.com/tulir/gomuks/pull/362 [#401]: https://github.com/tulir/gomuks/pull/401 # v0.2.4 (2021-09-21) * Added `is_direct` flag when creating DMs (thanks to [@gsauthof] in [#261]). * Added `newline` toggle for swapping enter and alt-enter behavior (thanks to [@octeep] in [#270]). * Added `timestamps` toggle for disabling timestamps in the UI (thanks to [@lxea] in [#304]). * Added support for getting custom download directory with `xdg-user-dir`. * Added support for updating homeserver URL based on well-known data in `/login` response. * Updated some places to use default color instead of white to better work on light themed terminals (thanks to [@zavok] in [#280]). * Updated notification library to work on all unix-like systems with `notify-send`. * Notification sounds will now work if either `paplay` or `ogg123` is available. * Based on work by [@negatethis] (in [#298]) and [@begss] (in [#312]). * Disabled logging request content for sensitive requests like `/login` and cross-signing key uploads. * Fixed caching state of rooms where the room ID contains slashes. * Fixed index error in fuzzy search (thanks to [@Evidlo] in [#268]). [@gsauthof]: https://github.com/gsauthof [@octeep]: https://github.com/octeep [@lxea]: https://github.com/lxea [@zavok]: https://github.com/zavok [@negatethis]: https://github.com/negatethis [@begss]: https://github.com/begss [@Evidlo]: https://github.com/Evidlo [#261]: https://github.com/tulir/gomuks/pull/261 [#268]: https://github.com/tulir/gomuks/pull/268 [#270]: https://github.com/tulir/gomuks/pull/270 [#280]: https://github.com/tulir/gomuks/pull/280 [#298]: https://github.com/tulir/gomuks/pull/298 [#304]: https://github.com/tulir/gomuks/pull/304 [#312]: https://github.com/tulir/gomuks/pull/312 # v0.2.3 (2021-02-19) * Switched crypto store to use SQLite to prevent it from getting corrupted all the time. * Added macOS builds (both x86 and arm64). * Allowed password login to servers with both SSO and password login enabled. # v0.2.2 (2021-01-06) * Added some initial cross-signing/SSSS commands. * Updated mautrix-go to fix Go 1.15.3+ compatibility. * Fixed text selection panic caused by clipboard. * Fixed incoming encryption state events not being detected. * Fixed zombie processes left from opening files (thanks to [@Midek] in [#234]). [@Midek]: https://github.com/Midek [#234]: https://github.com/tulir/gomuks/pull/234 # v0.2.1 (2020-10-23) * Moved help into a modal (partially done by [@wvffle] in [#223]). * Fixed choosing a login flow when logging in. * Fixed edits by different users than the original message sender being rendered. * Fixed panic when rendering empty code block. * Fixed panic in `/open` command (thanks to [@dec05eba] in [#226]). * Fixed command autocompletion (thanks to [@wvffle] in [#222]). [@dec05eba]: https://github.com/dec05eba [#222]: https://github.com/tulir/gomuks/pull/222 [#223]: https://github.com/tulir/gomuks/pull/223 [#226]: https://github.com/tulir/gomuks/pull/226 # v0.2.0 (2020-09-04) * Added interactive device verification support (only outgoing requests currently). * Added option to show inline link target as text (thanks to [@r3k2] in [#189]). * Added `/edit` command as an alternative to /. * Added support for importing and exporting message decryption keys. * Added command for uploading files (started by [@wvffle] in [#206]). * Added parameter autocompletion for some commands (mostly the new crypto and upload commands, but also `/download` and `/open`). * Fixed autocompleting HTML pills when markdown is disabled. * Fixed editing the same message many times. * Fixed mangled comment newlines in code blocks (thanks to [@wvffle] in [#214]). [@wvffle]: https://github.com/wvffle [@r3k2]: https://github.com/r3k2 [#189]: https://github.com/tulir/gomuks/pull/189 [#206]: https://github.com/tulir/gomuks/pull/206 [#214]: https://github.com/tulir/gomuks/pull/214 # v0.1.2 (2020-06-24) * Fixed panic when clicking Shift+Tab on the first item of the fuzzy room search dialog. * Fixed panic when rendering `m.room.canonical_alias` events with no `prev_content`. * Fixed rendering displayname changes. # v0.1.1 (2020-06-24) No changelog available. # v0.1.0 (2020-05-10) Initial release. gomuks-0.3.0/LICENSE000066400000000000000000001033331433617251100140320ustar00rootroot00000000000000 GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . gomuks-0.3.0/README.md000066400000000000000000000017601433617251100143050ustar00rootroot00000000000000# gomuks ![Languages](https://img.shields.io/github/languages/top/tulir/gomuks.svg) [![License](https://img.shields.io/github/license/tulir/gomuks.svg)](LICENSE) [![Release](https://img.shields.io/github/release/tulir/gomuks/all.svg)](https://github.com/tulir/gomuks/releases) [![GitLab CI](https://mau.dev/tulir/gomuks/badges/master/pipeline.svg)](https://mau.dev/tulir/gomuks/pipelines) [![Maintainability](https://img.shields.io/codeclimate/maintainability/tulir/gomuks.svg)](https://codeclimate.com/github/tulir/gomuks) [![Packaging status](https://repology.org/badge/tiny-repos/gomuks.svg)](https://repology.org/project/gomuks/versions) ![Chat Preview](chat-preview.png) A terminal Matrix client written in Go using [mautrix](https://github.com/tulir/mautrix-go) and [mauview](https://github.com/tulir/mauview). ## Docs For installation and usage instructions, see [docs.mau.fi](https://docs.mau.fi/gomuks/). ## Discussion Matrix room: [#gomuks:maunium.net](https://matrix.to/#/#gomuks:maunium.net) gomuks-0.3.0/build.sh000077500000000000000000000002671433617251100144650ustar00rootroot00000000000000#!/bin/sh go build -ldflags "-X main.Tag=$(git describe --exact-match --tags 2>/dev/null) -X main.Commit=$(git rev-parse HEAD) -X 'main.BuildTime=`date '+%b %_d %Y, %H:%M:%S'`'" "$@" gomuks-0.3.0/chat-preview.png000066400000000000000000005057211433617251100161400ustar00rootroot00000000000000PNG  IHDRi{8sBIT|d IDATxwx-TZM*+6Tgzk;zXb7)C {ݙ[6ai~g$s&;=S^>w!B!B }14k a@)B!B!?a8d4!B!Bq8iN!B!B; QB!B!?$F!B!rHF!B!յB!B!8 ɤB!B!8hH#B!BqIF!B!2B!B!),B!BqI`!B!Bch@!B!uԙ1看a̜#;Ҏaf6t$B#Ds^0VOT,GA_&_B4Rll Jǎm,[‚=:=ߟ˛tXiޢ%3˶nL##B@_6敖QlhC7IӵkW7ە3زeK Bc #]{;ɼTx=vc?HwJB؁. /::~Ng^Lܱ(Pqe|4fV FFE~1k*܇9y4gsmΙcIF!b9隻84f2եG ذQG^M^(hҶmcтyd琘#ة3Nz=fN1fNh_x%R; Pq}|,B5&M ȆmB!(eO SFB 9v~a y`TWqzXYsdzBQShd32L :v!Ø5㧣8va$eҸ2> ´ ]'LPqlQ7cX'':} !ME: ܅V [ 9X;k +2 Тi{Ꙍ83)eYXl,ue~u>C:%,& 6 wb>=i2K3s^*L8+hu>]Zz;wBޗ/33omv-߬&BR9sH7ZX˗|(w,JvS GW'${=LqtqCz%9Z|&͙  ޚp|֙c0=D`-1g&+Vbw&hOL 7~ WXxg/8= i{'l{Vw)TD̙.dt֫Ld; !Sdd$yyyu>/'.")PS4MCu.Z 5 z?=Bk|Vt$3J,-O&iܞ %F!8B2h`8O'*ǓMfp;=K- >3˾L{SUܝ uضj;EjKiވLtw&m~g3I =}2:YBF8a:QvUB$G{B۷CQc.Y*Ǜ-3|@%ˎHP~UtLզ)'cF2hB#Ki ]BjӉMSޢ8uRʽ=p{ZfPg}8ipv|1JwKG&Ǝ}9Aa0e,a$cїL^LR6~^ڸ)E?gNtJJ: .Z=fHqB#$ ңgO:ū˯`f琐ر3֭=#xYpfLr),\]Ytyȱ;hmz_ ĸ8,*ŚWo\Gr`!o!O?VcP kdӺM,^-6mpdeqPQV|i8?LgܤB>f y2UX/;ְ錛j@?Z/N *sImv'9^Q>vQS9HL-IV3 PUױzjJtM! p¥tԙ:QV:"T?0(/$Y,[d@MUQU#HƹQCcVKʀ ͺJ$A6m꼿 6mؓ^BxFFVm%.〉lʅN=HR{bV a l>85+gѴö rc,ϪĈK&BFp-3sc8| G\WETbi ekll#+VS'0^_=-$n>lIS3Er(ӣ qJ=6yURLX!sMFΝ۶~Zƍ$.c.P#U3+<2ϊlD& MS2MZva!Brv̚ q ')Wvog1ԋ4ʳ,@qz,c>B*Afi퐂}|~=ןv.l i0¯?,%+h3SW~COí([19f˺12Ì(#^d"8r,Ҝ=F8,B!8՘diُ=lY #6Fbh6Hlۂ(%@B icOceZA=86BLt;{un/ɞ=,B!8i]k`oeX"H?Ckg:? }su7p=Id%GcLDwW^̨n p61 {~˿w>} !B!?X݅w627ڼV!$&87ܢ& L H";2A2DLŧ`-#@!6`-{ 85 Z'r44J yh.1 $G琕[)^07+f>lve<SX az=gE1ExfU8sLf+88o4B,v⛍+ ʂEٱٙX@ A8uhL@B!B]1صj)K/%svz- 6Ɓ߳ G?+z`u=b'rӛ1,O_ g]w+lNR+S'. Ʀh@nXK”u³԰3w|FgŹ?ho\Ӹɧc`'w/C!-26ٞHNl,t]-^\| 필zYβ41Jػz+yeF,/GN'*Pzs Hu;~VĄ`ܐιK|&4:ۗY-P~Mܾ`5ETpxyt S8llO| 3+@Z ΝCƚϺpן{J0惶19L+Cig-`p_?e VoK6PJms˅]0c;5LvtLz=ƌog| iwdIމswelEZYHyitrvl% ڶ'd77Hq<7Ƞ0mO*ېA'og-)c]ܣ, nc7LRo;ߐ8;^}}}GivK8 mYT+P:cP=;21Hs5>ҌޠGL SPǬLL-2N&HY!Bq;Hc%Ͼû˻˛w#Ɲ9۫s|ʓ{҈oӆhFjoc(+N't'A3pl]|PN2Ysɻ{~bS~Ni 'Y8 +rٹ~?|~/9B&ZOEdOi#iF6's?m>@:Ub*ʱ6[EE|0t=w-Bg_K+h94S kX4aJpR;)/R}ξEn3+ls+3k96_O_╵>捜U3 ;)uS<.s%A&cB!B躎Wl7q玤_c#-O5t"U63go"@s:m{F3l^|\YsպڷL'ĚRHmi]>1pn$x뀝E+LڑjQ@=.T+G%"YJ:ԑ&og9Lq1OqQmKTԷ@ڈc^iz3( *7@hZ75*?7[g&"ń59<]?3}d>]HNd=;۳vvj`_uecg^hׂdjhzzXٷrjrel܃yP7)!6B!UOm|SI<{r6-wc|2(\-U؍nthFVwO/R4= 6mdoK5z?3 g`cwy si߉A;ҫ}(;ȘݯX9+w̚k<1^pY!Bq76E≧%BaTy]u6Tu-p56UѫOzHj(86:82q@@kS2-pe*)&@?ތZ0 t"IoÙN ` rt_ h}"~܌'==%~Bo:Bv" ݵ1ma:7 cRl3}Q-Y%XBr2FU%ݜoy3W%@_MfCJ{ȝ.=1ۯj!KjlsUR{}r+9[]mB!xҨeE PwLAn;# 0E/O7QN&V^T91/C0a*ʴYHx PD:k=Ѿt9:nF.*ѝqȍW '[q . tPуy!*0r2B Ak㞫/+o⡗KҎq[=]s 7]cQ8.)imۓR"Tۈ㌑i q䰠Qh؞MN}79 O&瓼rR'|ãjpң<b5@zd\gny!WuL NNbؤ:@xk:'cEz+ gٚd6x78f3VhBzV^6&cB!BIcP|1 Oݩֶ}.:[u5hCPayչ0~fa7y{q^`]-; 0Atp_|ϨVdΜ_( -- iɉ#Zbh|oG3;pԏxà2+"/{*+ )ƕ06B!wnL؈XJiEJ}YtLd\"w٩LP*sv/H!1)HD/&+_ZH -@-$(+:10 9u]9͒0e_Ye׏ך}-k pEHxkR#0ܪ3ѱZ/@PƭfY׏һƴ&rO}k̦ZERAq}6cB!BGоSL)S-\|G{p]@* wPPl{H+B_qP\↚tAqᮆ Hcv/ &6AB!x HA!B!Q-ZJn޻+u= ^0 0.]c\:N݉pp8q:8Ntwn>ޱ:cx{}.N IDATyı=ǪoɄdB4L&šYLwFgք;1 !B!3Iw:]ʳ̮Q+Q;P³v0 x>n5 =_A@w>3vjtѝ:O`J) hhx >7 <ϻ\T(U XƩ,ޣ=F:,B!>o&'fƆ_PV @KS)R|n}dx718n(PaohAWgx-Tދ/g;T}yZ[ulz|t)h)Sta!B4^B=h|iyROݩ&l|O'kj5o3 7Z:@J Bw(]5S4U`B!B6' 0</ Cy45ih S{C9qMhlG*M9FGzuFFg5K>utb9h`w:PNAtCLPjy2xln"5BlV$G JBTLb2!mR5ɘ &![2kJP @)M) wPCsM44̈́hJsgx7 Io8S03[Ch4b>PiHa /SVW|{ꙖY3Sw+SW(gXB46]ֱ-ImZ͟%u_PwMQ ՚W;6bGaJQķ]I$cJUQNA.$XJؼ?wކI& ۘA7?NPR)d/+;ndt|Z[96c %6ݺ55=[ٴe7U~L|NoQEvF|\{1~̐_Tܩt4)Q8<íaeÞX[p\ܽ/bFI~Y[!τB f2y кň=Ea9ND9{ֿ^fp emd~~S0{o7!)'sޤ9w?˟; fXKF՚d{ǎUokQv {&h*.~;*B6]pᕌ?<=e {Dw5D v}3-#q!qLs163(@#ۘl(";* w/c2ذ75f_x=#˧r9e%@#Fo|oAwAfwuc`eW99=u> j>l5t3hGbvTRUb5㨲7ؗf6q&Y 19lr_ʄI9q'L2va3 e&<6h[ѭw;ȺFZ :<`5L& klAsک%}_;+>/pb"n.5ţ& !?d2c7@49u ]L \AٌR3Hc|P|qͥBuvjt]) j`ESߪH @O '@wjyI+3 ^ܳA>O`@Pe6oNDcᒴ_xlpo`6C8odK+y6*{<4 ePtߟljt;7֍4G9#H`O'wú|%F]+EUTf$13t$JXB^WmCH.lMIȟ;> rhl3O4>YRQ{111V3' G7ldoľ\@7JFjM3Kkk+a©tJS?y{q QGxw @234y"n}ZbUNgg3u]:8>Au|NWeC8I2v/7?YDfW>_*txBڛO9p_$$&ٛaTl^fkҜA]eCR8LDŚs 3 ( `ZszqƷ %<4w8F"޸K6z;Rš:)ǺL>O@Xw<찻١y8mNKIacBF4%dB31̮bb`ZZ,XV-J5k֐B7|Z FHŊbqof8fbb6Wi㞢e20ik+ykh{&̈́T=d\N\{jx\%D0(BSZ̖|JJŻٚHJ缳`z c̈> =C98{ኛ[⬞񵗎 /Ox0bfq{Z(;{ĔX["A;Ίc|LNRG  yG u]\[ d3;GlÆ{#N_2 9њ@ljفfԼK-[hF2zHz$ms2Ff7?փOhb"C~)B"c<'Qz")grp~[ȭ/-%{'<˾4NܗXznF?]UQ2yZv r|ؗ94]<$r6n[瀞/0rq03+PS}6Fj5\o}s8nwsSt޻nzn}y9Zs[~źzt7w k*Yy7lh;N '9n'N:AEq-gʩomunx}@޴ܰtnijtoB!@S> 3=l2BX,fo0lD<S@F x(!S̀7UFL&=ur4Lɽ/7&Q>_UU*vmûZur|3O!e%P#ŜY!w 3aU5k~] _ zރh:gDO%aM\<9eVf,}E ^1_~*׶>_} '"J0FIfuV 4ѫ99] a܆r**ՙ=|}ldee9jn*(En?zIDBC<(\sQ;()wƉU5f]4ӠZp1׽ӊ\wkoCU➂ C`(FƆwk'W'ϔ(w@4{vz;ލ(DH_z+g1joҭpkݿ\}GZ~.5?GW& Ly_tA^x``< p_&,@Wqw+,3 p)93y&S'bE XB6VbsvִB!|= M߅F3t'NL?h61㬷^LShl-gZ2--͠\LC[AqcX8wήKn,[yWRh6ёQjHK sb24( p/ 8t5ߺ7Xjw_v-?t>͇0x]9v΍F2'7'\sB46x^i}>qg26 WrpusrY([Z) Qg&Qcg%CԄae|V (O4 Co( HoMƱxry Bpʲ oELg^F{Ӫ*bwɘ(,p7/"gBNk>::u0m8d=~d62I.{ lI6h[(81}UϹ3[ϥKԎc;w%p yq$+# ]յ@Kfdx ZHnlp\~ ៙h+~sqkr,]'#SFfM]⪉dNv-]NX XvGrM<$]Saκ!~Cu}BԠf>+/8ѝ8u'NӁp۱v݁npz:}~v:8sp]نapׁqw||Np7>~lN#DBH=n:|̊B ᄇb)5,to/MWBWqND\?'q-퓍پ_q^oMO6ܮN2i[^V(PusZrW: vZHEHvn4?ŵ rp:^>z1y$8~]ql8pPY*p~®w&jTج~Gc59yZ3~gw}kkn} C+)'k::}9wM㥷*s.}i$DPF~ńܫq;s){i( f0H.)YXQb+XM):E(e/q P}Kl->' Zx4uԓ+HpڱoNop}uO _QʧOwt+]55mq̪;wE7L,0:2_H CSD< a6JQKI֗FHl"1[h !6>M )PL>CN%)SpFPè+F%g)%7ס)P򩨫BLB,(Cz*,$DCi~>%zv^'"2{_ySXLϬ#Afs)0ʊ&PZ_m.z]OΥ,.Њ\ *8Ma%b.'Cڗp[`ʤا>QԠ;xpy5!EÉKA+%`X08!ѾSwNkYI=K/TRgi<ʓ㹰@\Ao}kҸ(uN7x8.lL?4پg5jӟ5H9S|^)X꽸oT@|lWNJnQǁQEqN!c+<@Aa0;mM)G~SJ r5\(4YN^v]ktE n҄! IDAT!r<e8XX{2.ysݰW㏪ʦ^/'Tl[ nu(L8ۉ㼻 TH -Z4ZEQpΡcsBYu<o{Z79?ݪŻSx]h[D`~A6|wNj㷯A@2+OX9Jf=D{Bq<\4;To&gm:Q cꥯ7۪4i(~EWtFP+CH.@=n5иu~c+TnOۧ۩=zZT2Kؙ$hLw5za`D'?{D]=iK=d P "֯[8+8ODd2Ef-н6q#4m6a$M>}^yr}ԅ^0bRVG^ApIC؍tP*"{hbpuuo'V,Vƺѯ\p|ũHmʇXaA FuhA Tl+Wz#~ye-|̿Hӵ˝,Iĸé,L*^2?m @Pvs/V*UTTK-;`n ȴ$1yZ_ RێޚLXtq,(e*7PQjʨ3YC\Y"X==U|=SDUPi@ @ lgL+ ;sӢJp^<,`ͻ ú]ŝyS@0D< 1pa<2Uk6hN"=Q'IVdX-YgV4F,YƬeI\רy+ReL*j Y\ .P$PH4B hc.A˞2\SuP .@ Ac`l "?HwF65wdmV߲$v;ve[HSE1D.98A-'٫MH( .RQ:dYFQUGdPqL.:ҿ|zlBg-b :o(䄁\yTlq`%ߒ7Mv'R :=q;~Rڞ/&4ra?>փ"#uwlBW~'P(.{[7[(-}ʹuGüz,?ƥb= =1: < @ {=Iɰo IX].NN-Ը\\NӒP@U$U"v6;v nZbwzi#uf*fLK3KQ`K$b4lI\N|b fnymf5L-od}tӒ,&/zlEDXU˰L ͥMAdEV]AQ*)I_;SW5H,>Q^*O"GyLCʹ,p% iuߙ2oKø 5׷EH ~|]7ּ+k,cI%Y4r a5XnMH8TRfA\bK˫[U5 9qTִHAv H\qtV'Y+аkd'HvQj%a(NWMiUK=<d եI!*5M e9W%;MhMt|+ބyK^Yk@ f#6MX26Ija[hx~$9ݢ˅fr!˚P +zf ;A _fM#i/*V(Ļ1ܴ( iG\"CiP2Pm]6l6C "(( .YxXx4f>4F/^"^ 9eܫ;KFw/Z5?X_ ?4X*ڿ}iN{ J-9z1?)I$A<[mMmڍY?DV#.洴> bC >. /~N ;vnx,,҄)^ ;9ou'\Ddc^{O}0 1/>-V{v >;fAw Ͱ q@ ytH_".Iq$FN}ۆ;wZ7.μ-G>eMgg/~1gۋxqiK4}}> ?R,_q;7Cp Wy;2Bd)e\R8.I*>=.um4<>֤co{&.#[9,Y9!sg9!܃fIŔC?U[=9"-E=9_3x?ƝבyE$W~s%1۸vh6bmdn#iZ?@  M]l6aI  75dnuܮ< "KXXM(@,aXV%cXY #M KխFd-4wf= 4K#&l4UKq 82aȺc7D1"66E1o$Q&V5RA*bptJe4+őrkr:M͈#K.Dh92Huo#-`=o*,R=2eA9 A9d<;6URBBt b N[R<:m^TX8^;[_oσppxrVy'lwtvp6/ùulˮ۸N j:ޚ9=ϰPuSdk.wy#N]NTߦc)'DI(v=/>0Ϧ0c}%۶"% 'p*$z{'^ˡfnI~M7*o<Ԩ܍:|0.{f>.ٻ7x%Vj13ɝ^a$Nč[K>ͤ#KXHFGµDĝT^ޚOT Lzqn/̍E7zrpbOI%#x y&;޺!+0}L窝⮞;yU$ƞc{< @pz&1`x0@M~nC,Lӊ\"fbmfCٍ=bGFp]Mfnd6Gc1D8$ÒqzaCp25j!cFvsd2k_@dis>lsĘ O }'{F^ևeK5H5S`|t'9|B2#uNO#xDM>F$a)&M]s$JmbKz,L~όjnK=*wT>wV~K`e7i(+2LP\ Ud5W9WELe8 OYk^znN,(*>(hϐ<ؿpe;xq8PCf⠤B|Rq:(-uBQJii)eu>c)GHlN,ӴsWœйKlrS3qjy{8ȱJ)WRҮ),Ir*GA*/[Eu?.סoxMpdbO:ׅ M}.1o].GCg̝8rؿ~*"Ery ۺy kng>˟zq5yTPLaχ)(Id>wzM;?H\oȁ! aԁ:@ ,OCX,<<ⲀWZݮMPl:?ƻd;Xn,iMcCtۦE,qm5 Ae3n?Z*3=W`cFB5z&Q)޻~WI_G_cI:$cw^]A7rK0orr,ےI~!=n`#49UJb[$.HIέCO @]Q$U2* +迵hb"#zc܀;M;~տ%.U0# M5#v/p?P%UbF%nQ"݋>zV+=u4A+9OMNJQ(Z E`ɪ 3Z*Y1klL9_T<ӲR>F%,~OŞ4玃O* (j % )ٟ$3# z'Dnrmm6=% I<-0seMdZ~ q75i"㩀 '`HgD.WֈKBj0%u$<7*$Jil:Abr~<%Gr(#/qoOeg9 {xi|t֜hCIFAqJl6sb?;*q ͡:Ÿ>:?~j MN''wg[D_!w;g@ N0vB\oUI҄ ]0b t,Nfp^;[d R5J4E$zpծňѳPf! [d X$bVz̘6 *.m_B6$KbZ) Om*6loTTU_wUSQUkǤ1ל[xS !>ȔKJ;ljB\b8%pԶlR1QPY#j ĴjAGh&1GwתkK)fϊTddTEw )##:_y據Ed7QsR-==NX}wpz;w&*$EiD]Bˈ;JZ%`̐8ߞ}*-ظ6~ 1d: KZF9]|`CEfZ& @0ȩo6y%*Û7##Pp͒Q2959aUZ4p!}jhSB-In> !v@Xd-MvtJ*GET8*p8rG9k T:N.'.SrSЩ*ʨ_ B=i-X6XFr9%o.Ņ"Y\<-N4ܖDXQ6oK11^m*5x׷ s+o~ Ad$DF@Fq2h׽=Q67%!RIM)%N;DjE:) 6)K#'[&Bz^8vBul\~V+"lDlδ wQPP-v¾ip!(JH*ٿb4;f!a4}C;y E5oI${aL;fd0$$_~u۟9g\uXϳ?~\{tiB˶MitlnY@?@ EP\2i-I_)Zq3$ɒI[="ddb=dƤzdK*#hIӘ/}_L1H!S(s-T4wYdnyfrm ZX[ke0L;\1sN{Y$?%e+Y %#8.fg͹wLF8a.,?z}6jj*# ٞ>"ĵ^\W +Lw* _eàV΀U|T/߃SaDXXs;X6` p\Wwǟ_2U>ʠ0)?eo*?hw^ ֻOR#g,V..Goa!^ZJޏ3=L|CFWRŽ7kyُ2~Pu.38;NX\+49^z/ej@ Ժ]'U%lzXٚ}C k/xVmZ`,Q<ʣҰܩtt)U&<,EqcDGi9b[x]2V-" IDATX0NLDU5;OY y1˪,"&x4|j!S.WTSDR\.nԭm*q:=D`Iި...x_UU/"B)/*ֵOBܜb*U%NAϦBOCBQ@"y͆ I eeh l> 3:_KÛ!Y>l]10ylZ/͛DŽ 4IA.&QyRPqq8r)Ta2eA$DCqn.E\d\xr)iHG%$ܲS04^KNl '>>Ҝ\|V:.D?sej,] 'lJނ_S@ l'@ 5׭UHG#-{BpO{C@ 5ۈ4ryாul$4%DߌJrjK=ݺvl*k)+C >-@QEnSȬg*NWѐrT,~8PjDo+ڀml0 2\4Rv29d0Ux @ i ;6Yt+Y* 5s†ſw#SbYsi|"oP 5)I7bE{w@"(Ft@R*sH۷-N.%pۿr珃8p bv{v$qz-JRiUu\Hݻ5*9{31B;pZn*'m&*V.MG¬.Al]0mcO !iVõOA | | O7fc!hL %PDl@dv(P0ڐO*2_=d'G<Å c_BmH `i†g3VZm/*R¥>H(2}O"зw,Ƿ PX ?&%uWtrfetM>u"4}zyW^2}wsL8.Ʌ\GT+y/Ɯ@ilMί&' {Σbn+dwh:n;۶6g似Cuhֶ04>]=rl!^+?^~pN(ֽ-A$=`pp.N]/GH4y 8(fLp#xKq8OJ%/@ ~a4RT4؛ۗ2L=$":Jm- J` '}@o #f[X$ŔUiEqAqR#*<Ĉo?\Ȝkځ÷CEE-6jG &y 6iva~yoC?f8Z*xw.7ytQ|̚>Olvݠu;ijMRIIzޙ9.@bW1pt~*EyLCʹ,p% iuߙ2oKø 5aV-5qг3]mJp,^'F`pVr4+A.Rv._O;ixIZ]Ѡ L28 Ʋ aljIaC@ S|M'';AdQDkE'`[:%JJ-O_'[ii(5ɬ]suS8c#a222HH$ݼ#L(,.LF7Zr6% 22RYH//zMZ-3(V[?' i)M8?|%"6$%? „Tvm[V/r@rڟ9mMLey곭rp$ne呹Q5`lܫu8LYĮc1B:~ʼnB-bt@%?ƸwCEWy_I/]"0 V$GjV@? >aqb̛de~/%+KukXJkc^$3]{ q 6 KBZ>9k+7ʟ71a|Td0p10x͝(&7$<<6z;>s 0Bo*0ګڰmz4.'YΙܛYRJTRfsC>{1o#!s'yɡDJ-&|c3落NŴ4}kӦz<>ӣaִqmނ8GnD-<޴2?~U ϸ;r,wGf)ĞC9v,CpCإ %=~&^>P m˿cQqpKPώ/D-<w b o pݐR=S.gSa+PVe='͟[!9Z_vwĉöS'Ђ~fyӣ̀ˀ`Z`_ck h}V.8{& C_\Q4W/+=4ۿQ>%[,< Iˁ灥hW: ;kf O-Re 25j)qW0,#3}8pK!cq5S‹l5s5&@ "DtZvY}7S&NZVNrn9;ݛ>mBEI!T* (,.0V+J(,*GNC%lscFAW)yǏqXE-n{g-^~3%"c x _:0Iˋ<8{'do|fqc"?걵cą]8g+[ӫ܈:͡?kde8ȯom<& zƟyr|]t=SW{܎kt+ ط>d}jM {F^ևeK5H5S`|tgje!:'t`"ӦBEQI;r=*}ؒ3?; l+c@Y ee*b(mqJ9@n^~+ˠ }:Zusw fAQ!TAE!GC~̃`>(Wd2B .x7@N'H3-65<W]z44P'5_ GU}>-{ƵG=i$qE5O9PDn5I$"0X]C.$Y|Q{@0Nz@  ࣢,+菬;z?\sG~B.("&J'o:fZ&DW{>+Fܭ:dλW-eu|1lav>ُWYod\o,'޻3=]n}Q G=HD7:9O=,RRi޵ 16rT[tCqؔ4 Yz8Wn~Kncpu+8? Yw{WzwI^js5m Hz\3|ϫgRpwLdm>a<;H26lykyd8~#' t,'\ NdQ^^_ן܃$-@ XCw}`<!Iz,X}f.J@ V4Ɓ&m ^Ns\~ !`!y$0αbl46fs#[ӂ 9dzJ9F2G@ zDPT4!%G) 6w8]G3`Dyaȝ͵ܐ6d%6g'}SvE:?Ubp$l%(vgď,@rn|$]P\8.iR$3 "+kN~SM&XX (3>u1MٍrMw lŗӠgr0*f;!!#!IdZX$).HDEG!RVͯ}KAڅ"B5ud3T>-kl񥒐|1j;];|ɕqOuuQ+"(b-3[Od[:.e_̷>o6~iR@kf\G9rwlmN$*Aa4rI)s_x Y J!%Sr<Gm㓢Rm󠖐KLDH)Tdb@~]kH[@wկ&/Χ 3ƖoxT -:zZ XZ|R] {Wh@(g3ɨ]d޵dphEz!$ õd@WF6"(^v;RDA!s4ZA %r'(3ݫBV1ЗvxhyCH3{L[ i͗<úǔIKi,'9JYgd|ׯL8=3s7B.gfթ;-A &*nwHfItݎO1>DT_k`aDۼ G;\B7sXHĹ6ຎ G<}벦'\SǸXGl"<<0BlHH,| o+e-Ͻ/LMq(uqhdv cG!fC=$%[ d[(dFB ۂ ٹвc'龂 'Z21Edj<6>~ Ad$DF@p ߓfbd;? h?OYx_c\ާ1 -ST^~h"Q!]uv]#Xzї.u\.\f71I@"hLjĿ8H$$B:$)~*%Eڏl?LP((($NfN~W|ѣHNG\B;7<4-ʱ_cIfw3ΩKsoMz?O~?pazZ=9??Bsx`)y_]e4<*UT%wF-Z0UwBBGO4^\[_-Sg=JNhɺ{:廇ΥcRWJ64ߵ}u-Lqzʖ=56q\{ǬL%xtv}7v%p_gXw*<:&ڇs!_{>uY#]և,>{mt-b.sČgscMN;~0vN؈y瓏`LWs7E(0΃Y:}&?`֫n&~7-?ǵ^u7UX5rG'X"'||PK}nJ }[tA+ޅOnFxF 珃h qX c#N1Oi\a`1ZGkʕenClD1{7jяmSт l~nFK}3ZlBv'^28;j"$&;y˨|gL'gyLgSI+3sBO32Y4S}|-@  R=ԃrPHZh "2Fq%-6͑&*)8mr4`)`nMTQR0٣h.fVu)=)ݿ,3ND•_Yߦ$!qi&#5k:iPUՇKc`#"*jC="IsSLeuNAϦBjKP;A< AW0q9 7j"EF}ft7Cra#4$A}$غ£c`(\y1a„zu҈A˒Z(J5hB߱pTBxlh.jĠY%nFdhN R\M"%rW-J B=#5%]IqW~W)ԧ+] R%,"@Q ϔPnE.ÛѥkKB pn[Dő;H]5=pQRT]PDIj.jYY'-ATCj5NJ9W 60l)ӯmiס="$@O1nq+Pl~8g|6s>6t>v|61nFn{9>Ma`L&Q] @ )HQDV`oF_ @靲d%(M-#ȉtЛomalEqA17hծ6$#a]vqJ6k w-t_7!_t9Ck j[VԀsǻ4f4.Bnװ̚ܧFDpP-s(Ԏn3tM<6c 5z-8XX@ Q0rT4Q 8'vttYדFȂm8J(*)%w<}aE }J."%5ɬ:s)d0|sLp$#ՓBŅ=nM ##T>kBI9e!8Nt6DKv:։w툝`uhѽ(7'33'~oWRO2b)R5?ܜ \άR )=̦O3Mq\r|e>CGrVFI+uzn{2)?sH]5w3$$&ԺRC,bױBǂ-taWrX*6-+y9&e_mI#M|3@ @ >X*fÚ44xՄVm(b xh4Élُ}σ_hwA 4 le㚓d޻'N%Y̻7ݺuY_iMsz䙏s89dR()_7điM`O97ƓCB[L fn48t0jGѥkkn"Kb^ރ8u%}Mg&Ѵ-0_Ws@̸Ń݉p]3?fRV=Aľ9k .K.LDPdSvh>MgcS؈3W9 -P.x,Ҝ%v$Ig}nG'N0LJnӒhժX%;<{w4 Po ED """ڨ**ƫb((" 4A@P@@:i7Rv7eB=19sΝUv{L6'RTVMkx>$B!_N6vm'*#im!ع;Br'z]?ALviQÞ k\Guf (QUlya]lE-l)BBXx}=I?h/ '#Lŗ9]^|r!3<f1c؛-(zܛDйK\3MQPɍ!~c4|&7Ҙi8_~}2|7) aN(oWp>ZG鉫CY))8ٟcV,.y+[@a˷ ]4xDmc=U.F S!Z@Ǝ1&n+)S%]ׄdk5@{aؑ=O: ^iT786f,f;qh:i*5Qi t֎\OYpW{ OuF1,s1*ތPjv+!^%dgg>>ŋ2[C~ \ O37HjO//Zоs|<~,떲*3 Gv} #G30:}M-QϟMI˶DyUح~N}xs >wJA!B!UYT-XUExB<=PJ3C2ZGRR41 {x>ik ۗ܌>JH#-0kGC7?Kn!}+e] 3?H̊- _Ԅ)QQ|͍W-(+@p^X8 2yPj67fₗpSS@lL5 Ğǎp!xef\12>&L7]C grI2,#moؘvceݑӆs) G"+;__! A-1Z:DZՌ~!Iv@wJ%9G2HOg ) ?ȣ=>ehvLgٽ17ÃdegmN⻱] w !B!6UcOA.hm*`CR F~2I˿cb~#_ى$eUFi ^q|\j5$'UPٵGzZO=]m. q7'bW2RpݐzJ:gl& /Rp>g^Ll҃O3+հ!$slN:V1UU/,8-vV\IddUw)WVuo.\H`!<͌4Rm9$TI VKɅA2bWGrtt冥i ^q|Tl9 ʹ̯+srkNTl $\Wƶi)>:1tI4;jI/( C B!Bq,*čxo[GG1gŜ7y3r<;oB!B!*W5F9qoggtufZbnDv-k'9# CB!B!D$(3*aI7wC!B!Ŀ$iB9\stUtUu^]P]y@_y]Z/O7Lq;1rcWl !B\;I#B!BQ c4i1jJ`\s<ڐ#B!Bq]_ }4*>Ŷ T.=ܔ!Tux{cO0Y*|4s(S3_^bOׅtMu7 ~ާcڪ7gL!B!'i\;lfxY䍕 O>}Y^[;20'\rMMYB:YOВa/3ٗR!SQY[rz !B!7${q4'ùm n(ԦO+?4_d| is%]1[)iXWJ~$\9LLK\>7+3bTJ(ld I,-1Wҟ1\W#%+$qs62Q /׭K݆7ӹ?O}9.!B!65itxpJiw{K  ;wzy6iVH!ߖ%cݿBq=!v~1xU [Dž7M //V&55a<׎՜NM% a1ϡMG>!TRSb+.JBq7{˒A5;1ly?U^Ť5I̵R`%qr&P:|nɱXM%Yz?'&רJE^w*s!]3=´~4]Ώ&*KkvԫvA>e>y%y ֚`Bn{嫞TҖ &eXZǴO㖝:x)iMLp$,fPDaatm| mLi#j$1'w]Ag2 *P-xv. &o\}ZCX3=͞aOQ:^{д 꾺6kIiZ=e^'m[m637I_v\H}omX ~~5֨r^}?1n,ՌڮndOiZwFKs2rS扶x7}_[+~e1&"^Y~? ԣCѵ0룙l.+ !B!wĽF֮Dv:m-;wR-ETIv1EE~j{&qevUrPo&/UŖG^^fk?|̖"T.ō7aysr2IO\|'g`2C_a(3ނǽIt:hmF.`e2 PI=[utl.[fBet,"_g1eq!Fv}<9|/Siֻ7@M8b-)wַ.<0'>Ƙ Lv5=rP]r &OO\JNAa-+}KS~i?(oPlfXuouKٙJI۴uaPך>B!B*:O $Ďc@+{܀sRUWvVoNc(uƼT0i"> !7$%ؾ[Ϣgaj &V<]@qX{_;ɭ=u=v}{ 5Kh[\8^ݰstf/Z~*xkAv;}&W`+ #xkҮEwR_{( gFZbOGﻈAQ=}4^{A ngHF>'N(֮-jNl<#>x-eӻ !B!7(K(xxzXXbNmQa!JY{}"@g,SAU]we¥_|@Q~v0xʝr+)%X4 .ю='-kngkּՒ!n;q4 n+9U 7}l Zbq9z=msav7.];-J^{q~ּ35sV1z̒7\l1W=ΗޘnlNXd*{e|U幄B!B#,6<1̜}v CCz_HэR-2S!0:0&l v08< Ԇ:F0x5QԶ:imL⽝4Oªl%M~z% ad77 CqErZ=>/VPYl x.60ƥP5\Ńz}͔Zjiv#A2x~aUBFzvYHKqvԢ= ioOlKDWIUw.!B!Leϐ o,Os,RŜ:IËL[k`ؾRT`nFV%r 5gҸ͢tOf璛yGߊkYFl/zb 6y#%*#꾳ňE} 'Y&o7*_M`L\2U8)3 Du\ȱ\ k6?Bx}wLM4 YdnlW.8h =LZj:N2s3I,C3yjGYiYN>X^v?\,}>LE]Ck\2B?c݄I;{H3!L.[F|:YKqvp+ x7j >OO-G1J՞K!B!?g^Ll҃O3+ v.UU5B}V\IddUw)m\8X\YU+g8.i6B IDATB! lzfFc*-jXm},7&M¬x8Jj19 1\Lq1_)$8'c%Ul9 hr+0MKԉKAV7PMzE_:\B!B!B\Y6c >>9ofx;(B!FpU3iىr_?>*R2f]!B!$i*%fͤB!BqC$BQ i)DױNc.U 4(򵢺R|YZ ;^ӘB!&B!B!D5IbB⟣r݄_p+2^~ !B!u>I }H"{S Y.=#۞jqx_bh{OONj6F''?ߍvj]ӚJwŇ? 0[!B!kOvWb[.(>=yc V,{ήƎLڲI}f!4ӘGWVADEIKX I=Agɔ{I,^\gWxr<9u9MƗhuu~߅B!BHyjAĽ8޶6hp=D]Y#| iך^vyV֣y|LWSLx7!u[:+3_ZdDWkZ'Wzh.{ 搩,v5t#&4Π7dB!BY[GٹvĀڐs'i^gC fmX >+< ص/R*ʖܦRݗA+NJr (ȡ?d5SSIq<qfsf*wQOH%55DꢇhZL쾙]]#oÖKyIJ)tpiA[NOoV@ f0mY[Ȏͼ!-q\Uq/'MTf<3x~[5Mm捚;P)R/}őt3tK/Yr_29 ɿ俷pB!BK0 ~mQPvbW=v Ek_7"xb],==7jt^m :j^@oᓇZgِa &WYre d1eh^JZ~ek[ FX|]wE3JlD͝2N+5hlvWVm|2rS扶x7}_[+~e}7Jw=iРMZ]dsPc[uض&{h3s3)^ͨOpOF֜N!<vUrtUu|/ͣAxjNeBUeAVMkx>TH!B!Ŀх˝JmdNThGnӖBsw.N$~" ҢX?o5=[ָ2@+DU瑗Zt8ի aqyow{v&\t31_t{yXdW=ƌaoqoA.]rD4q #h̅RXha:>Whv ~7W6 ݺrmZ޺0kͲ.צ~xlocpx[-B鉫CY))8ٟcV+:hv2v1qk^yM*& u?%_ͭ)qwX[̓qƅΣq˞YEnI1YbBMm!B!" taIIsǀxO,3wyQ1zY/ޞde:>eU}9M{/59wUFLᣑ.RJ'3hYA~t;g{e;_pQm6˳E82IH,x)8~s]38\6GD`I_R'ı Bhkrao\2c,`<h#+΢qTǗw^}o ȫn!:b:NbmQhLj9w5@gl<pӘ^{Ɣjf]^|՘w UGK3խAq~KU-ßJv;]!FV7$%ؾ[Ϣgaj &V<]@qX{_;ɭ=u=v}{ 5Kh[\8^>љh5y33d-1_ ]Kê}^F1}gٴE^R:P8,9q CCz_Hэ~dC`8u `Mذp]qD'x֩ u`jL:U)t4 5d3-/@I V{;i}nўznMZ%"|EMg-zO<< 'CF"_ebߛ6J n%īl /bp oO˺l H]C 2΃Et)y47m?RF!B!D){Up| Ve*36@gOo(֑ML^eZ*kf07HK9od3^g]zfQ:'Rs<ףoŵk#6q vY|ٿgڼՑ}u?煅_,&0asc&.x*MMMWoԼA} )[Fcr<)KЌ|ڑFVZi;7#VuU &ś!|3$o`F~Zq,'|5C{rPe~k ~ cΑ Y) ;&c&x I|7+Anr!B!j#;ȥ x@M{Z*ջ]F:1tI4;jI/+\IIqW**N\Ւ-;K uB!B!nUYUTm=no%ǜA||sK| B!Bjf҈N#eqW1gae8 !B!>$ISI7wNY!B!D5 I!k(:P1RJ:oG˩T Uo-;4܋^T-_;k(-BTWRF!B!3iw?7G=ǥB!B!Lc'mYw֪B!B!vr[݀B!B!nXdӿ)͚Hͷr0=?M//gjMN[|2:j6%qH/QMDλ 5&7xbP伾B!B!DqLזO0$OǎXh6#JX:y~qrE|v'N0R76pѯ$Eq~fwӽOQRϖQP kI Yrz6eݵQn^|h;3dgs-B!Bq8сia| 0SNj2e &<;S.>F|+K3=(:=t^:؝68jlaDAo0`0ПCS`0`/R>]4n~(/mFœ[+ݛ`JGOeͯ% ]>B!B!$Y7 28Y3yޛ.c>AqFQyl q f=җiQͨ񣸻J⦩<ǝ/%> ?@r ԋua^{47=)7]!{~!ٻ=}קw3䡷B!B!%y{(5"~ Jҏs8CF=xs6;2gJV/&X^y:Bٯ#'(*7z7|DhN%!qSUECM!gʕDFF^qR 흶kNcvΔWNsiDNcpBU`s˝eP}Wr ՎoHMPHKS~}rz(&_5udTA[B!B!$/_rt`yZĮcIԫV!!í>fN6kIRc[uض&{h3s39;'$?ϲ!?ZLm亍B!B!D`#k;OCna֭<(Zԩ}6Td׏]DAZgkW~Sw[γGL2f{jәm,֒No}C{8(7W@b#//{ B!B!_Š)Jx/O1w;M\订LI '}<5D`I_R~|NK0(z,)BA+Nc_I4Ƨo=1v 8Dg;k#i9iLӘ=h9TuJH#-0kph{tx_eGʊ:HY:fԎ4ҲH1}"¶J1{,JGDjv.zf!B!BToJ`P5ԑR ֍ecGyٕQ-|8}puu<ߨ݇z*$Ktǽ,aᓇZgِa &WY>mE%m`Y}L=n9_@Aٚ&GbE~/FpQMQs'!|8J ھ8]9'9n xS#G2><ߚWbK{5>=SB!B! j8lr->̺1y]P:sTIvx([g#uQZ9(7S\Z@b#/OQjq>fKWzXٳ Ne/ '=?7qV^|r0Gywނsx0|͠uLyhWEjRhc8ه5^B!B!i+\ȧֻ|&C.[tqң,*"W}o3:&GJetYyFV.E|B+0ar4\I`ྒEO1%wqc*p~ӘB3]74&-c4Ә#9Nc4ƻ[m =rNcp|M.BXj*9c?LiɱO'T>`jG׸ÒޟI%; 227-k%~x Cen!B!B\%i00s&0ĥeg):mTT2Za ͂i Y4vK6t&T#A2x~aUBFz6I!B!עrI(֑ML^eZn*kf07HK9od3^]zfQ:'Rs<ףoŵk#6q vY|ٟWڼՑ}ub?煅_,&0asc&.xWo¤x=ϐ~&-#P>R5EB!B!%y{HrE'%mS#z xn6COZ(&/7EHRvQ5R!| '9, \zXr%WuܥTlg7jiNcL6#8zyݤ1Z P/z}>iLD1Z np;Ncg8Zt^%!wwj!1◣ 7^@F\Hλ Tl9 rP!;|fۚU.峼3ܯone7ٹ""""""""˞1VdkY gv()Z Z&d?8݄GNu,ٹkL+8j){4("YΌ^:.wiˎ3H1DGZaŸ+#'/L2릜4gOq)+\7''zr3y붓ߞ{̉m|۷}f=܂}S9}u}y߿93_MNgCNRx!23ios҅tTyE.DΤ7GjaDh+sAU +PqO21Uir[<7; m3şs7JOfEw]_.fFu_]7?1|)3m9T7ggZ*;U|Oҟ> ?n(k'YK2h"歉ٰe#Wl&D +LO&%gw^Lsۣ2""""""rhr0B)X>4=3+S@KO(apđXEƬYTĶ8נvԿ\J6|#JoŒT'9TbBj8pU#9eS`7EޚuXQ$5`˪mY=lcz϶?Pvmڶ1VBZE)1[SlckInedSj~]ocs{)b `㌰ 4c `ew!""r*L98CPUڞ߅൫I_,\6r/UNrX8=ͻq&\NVyIx涮=F[{""""""r:L9XX/)KW֫5VnGu (?iqPpq l#- ? q, {$7 ѳo1;xp^tZs\Uuo?1K TXDDDDDt4r  ,y,AI2Y,}?zsca{>+|H;3pFRn4|/c!=?\7#MПxُXafJDDDDDD4FN1n7$ic{NJ4Xv>*ΐgFѯ<|9K@YĞ_ߜPŠaj;,ʮ/Z)/AC`p! 7|u[ͤessX#yl#"""""".%icqmy?2µR_baTJ~F&LX1ddקG䜱W ۘڵmc:7d-/؟djd=ԸN C1)1Amcu*m:ۘ@.DDDNeI#"""""""R(I#"""""""R(I#"""""""R(I#"""""""R(I#"""""""R(I#"""""""R(I#"""""""R(I#"""""""RNDDD@>|1y>yhm%1V}yۘj>cd2bmo3'6&("6ƓYl1<~̮hshE eWh߅ȩL3iDDDDDDDD*%iDDDDDDDD*%iDDDDDDDD*%iDDDDDDDD*%iDDDDDDDD*%iDDDDDDDD*%iDDDDDDDD*%iDDDDDDDD*%iDDDDDDDD*}?<ۘoM鷶mmL+6Ʋdc{sF8?N|ۘe1jNJؿ.rrJTۘ?smc㷍4FDDDDDDD3i"R%2h7%7#]'sT""""""""$MpMhFҹ5\2we:N/^V=zLyƮ:cqYզZh)`NGvl]5hѺ6eg-;VcMfSEDDDDDD䴲'Icq7jSƟ_=a?KHZĜnqq= hʥmyܱO[ x5;;a_1ܸ3c}9G/jЧmmà-v!H7}i98êP=MAv2X!1T'w6 `^+Wv0gׂ:zǖ?uSc EYI+XUzpe1Lm <jIuY-3Y8sڳ&B""""""r*'I'k|fJ>HhZsb!56nWN E>ked1<,)"u 5\eCo"W/[d~~%>Q0yI]N $YPV%θJ۫ܰ}.4I.}=dDDDDDDDvw2{fXtCMµu͔Ti{%y.q`U. vdX"ȾI8oHV3x#,09,]lßF07b2}aM{rF <b樗x8w,ӗM^wG_IٌIU+Z&}&q##}4+%Wlɺ>eAĴk:3 ]BPR;DR<}zY#GB)G9˝O+>e],M{.1V&Rشd)PlA3yw_]%yRᇪb+mTgMp<|c GKnGMN)x쏝4lDƆO/e#s|mEl$[4ߦxhwIL+"LDNk~߿vbW}oFtuu)22feu{_B쟗M(;@~\V936Ղ7mcd"""rkX3sg+#+˲sMd|` ~7ʱXy? 0fkzf? {ti7p$^ɘW_6wܕ r/׏->[Kf咝ŮBni )$o&z Q.>=N/8->?8}[Hw3ZDpD❀?[9Ur$ RRƘbJrT 9rI+LfCtۘIU);X%&ZȿӏYL`l-Pui)7Isp&}S]L㮕hwÓ_w}HjݖE'(4\6A$ TmnW,Xzq噅l.w ؖI3#COo߮ v.TrFDDDDDDqXIL6sF>;yqا1sbA߿ܷŭZҫ_K0ÿg2ICE|2-h Π-ݨМN񆂰ETa3i'&YT8#( ہ;^'Gd6cd?>DA+9_e h˷k=3>W?,b3""""""R+A3~Ͳ~#$p-u'pGjm#5hoWn=#},.^J)ɩ4+*5W™췔~<=+B+U%ʟEzgxeţr1XvƏOq9cRh5G^x6&cV88e "Xc3Y8@@ ?m ?YRf{grw])ܙJ1חA6rIɝ#L;y JDDDDDDDj+ L}9~$m{4"""""""r;9Iņ%Y'k$iDDDTD5骏O> gu&᧯h^I?$y7A:w,M8D)h"9]""""""rz(gw'?k^mQ˩튧AHYـރ0s!1Eᣦ"$ca2I>][]iC# ?^~*nI;nojX_&fOSאwX /kוډDޖd2wgLg|"#2^ ߛ<'I dnH4㽨Z^(~|~[I! VB0dXU9ǸK+67Vfx5s ΠʴjI]tka|pVM:8q~]\ڪ*%/jlEZEQ$Ha4=F1_,`OXஓD|F6_cYDtb.ݝU|0rZ(\:)+ʙMf(Z1K O¨DDDDDD")g&M}jҨa`Gje9%]XHbx4>K]oϺo04?Ȳ^x&XVcðA:Lg4>󕻼^-o|ωǤ";i ]BPR;nݨ7/o;4F¤1m459o4tMF~uDDDDDDN{&i,v2~+&ԬMiKI-g<5 ǔ$i ~'U_sQTN=ӓAkYT&~=MG4c8Z\r;>2|ObbS')د7Qwoc0~wcE)L^ރACKk OffшI~Ǘ ް/}U1] mcT,~ySmtb8""";bfq`Q)ồ'KH !c<0~+7%w̽P툮a ޢ'' rX)̙*Cn@!$PE&kYL5""""""ԠYfa>AHtիRrJHW!DWN\E^F )ub6JUg~c) Lwx4BcL@E&E?~~x)4w~l~uemQ}ҟډmƘ>55Cǟ1ނLƚ70ݗeNԵÄI?jjjjjjjjGޒ43{;Q;ݱ%mϺ{Gdٜp"ռ MVMҩC E#,-.o*zlߎf+ziE*G+}##u[*>Ge{&hqZ2k6Q\($29-jGW =C@Ϗ\Poﲜ8+¿hj֯Ml:] ѯS_PCrr,SG0!A6'rV*5jףI\zŴ ?`^""""=&H3O'sa-IV)ESk~qoFO*Ϛ1_Laq]l 皵>nW*=1W ֞Y5/6Xckl~o )y/yur3fƷL1aMo4)(yΞ4d3&n2ߍl/s`3_޼+@7&{|<%syI&lϿSߓAL+3o?|{[{-3Y&9okL `u3iyc|iKc,QigƳG3٧͋onؔlvɅU\f5~c-w?m5^dtIr~\m3K=R7~ck.A%1Φyohf[c͜oI4IsިT3~M[h~bo0e|k62moi>;LZf|ߖwͿIr8?j)IV,w\!&2|peL|X wyŐC-w 2o||٦n~s倚e\!n7fC 3Р2cLx̻573S͵1#̝"M5G 61Eώ5]Q8F3|fpJ&82'5nwsI%7kӸy2!1/1#rzW 5c4"-N6N\'پ^jjjjjjjjo,w2?e/68":4a 7|zQ.g6FguP/9kcb۞2+lÉ!oTOͩ:"fgW.}9`rVx /‡SO~~>9Vm{W_J'Bz;EegYek'<,'ߏ)?Ҕ!sGDDN2~q)y|I3KRhsɥ\z\zEtU5ز74|>V󐚒ѱDFQϚYnL6QXYG/`֪o5G5\dlJ7"mMǙPj'3zg^M<̓Ͼ&iaYfb'V-RՂI,=?z &w#yk$1lڂ /hLf"tNFP<_'<3N&2>^/-,L lԎZť2f%3hO='iL͜x9U{p:{wL&,/]ˬ;iԱ QV(M;`, X%&Fu piqӟyA|+nţa6nN}šXQUq.g!5xt& GL6/V,aP5V\Z.\/ހ6rp~?:[,,XhЩ)q]m& κ4Ɯφܓ8898&[ Yw 7+cSI?Ā T'ZW͡iӞ}a%,ޥLW~ լ:!+%BVH Ͽ coO IJi <9wH]3 =ֆ&-e|s<5KXY/61C߷~o<˳Cۿ73D2d Bү{7uF"/XA{>|ˏi~pa;xI|x{=JEw5j$/\^`ŜG1iΏTTEjӟH I3l%l w!ssD5ur ʗ<9>_ʝ8?t7u% ΰT,}o7Y,}?zsca{>D'7N22rZ;G4{u+a HDU wGQ5$DDDD~_58F `/ u/Լ?؄1QLR'0dNx.mś&檇h OΧ?˃}xMuyW Vpgakή%J-C׿b/@*nYߛ:p͸ DrloU:~°~L?X.|!~VAsWU|6+B\JbBhx &9##""'E칏 uY 6mϢlh xxgy,.gvRA \-~ޱ_M4ŏ WPp{xם{|3'Kh _piq糌oŶI/(ưVv<3~ˡ]Nsֽ/^ZR+|vxϐ~dWc:;Z0uHg_'0an.ۉo"@yK9IG rᎭIb _z-w7gD Ӥmsf6a(l%TI0_fיrcZ\Ծw~/Vf E!&"i&s,gVҥiR3EK[Xiжi(D߶YYFou8Gb5mVl}Xr*qJx~[9l=ަ4j6Lը.iV\ϛ~ GfLl|w%TkΓZZAr\U{Km u0SV2]6>jzFY[Rf怅3ܭXuo"h5ar.9!A x/n?"2.&c-X?ۗ`A'mbG_~*k`?on;,#""񒗞J!|dQ`w,_;Sʏ ܂Kn?k1|2S1|2xH-{}N%r$>=?X!TKpA E]z6~8hzٽ\U_69ՅnM괏x哿;0PeLx zуQ)ٗ??DDDNY]->ByZcW@֎N8DDD04"""rƏ_O9'{q>P% яpFȪԅFǭY:tcG~:F~:Ae"""eU4VQ<]S8 } {y/"TI3N)"J P hnYz~ dC!ޑuk ?#ݑ0$ӵGvCApY"Xa!}` EDDvۓ*wTqgqVI:\nݘʎI?U;o0?xN˷7L^n dŴt0o~&)6l|fAMk\Y79p %hkp͈serDڹp8/5LgKPo¿;Ytk,?lP1Ӥ~eBo)#JBV,XłuY]T E˖/{xH]uYݚ5{/P@hH\X *y I$S,N4!lo1 EwV?V*|?H?c9V'9`o1U`ʭ܃7zs<;9%:\so?HU//Ghn IDATds\w8.?k/YuR>v Ѓܐ%|4y1A1sDy;{/!h߱s2\;OOMNyg{DC\^%ɬXz7.qBCN`P+\&`x BL-l3%:w{w|Y]xK+eHP/YMՎ^fg){/2$ [U!Y.ýWҙk8E+H`&-`]ɓ&*#:ƔISaSܿLp[Z4s]%?WRpZ|b /ӟ"pH` ~I"iBz$h$I"ǩ,U\l>}Z۷{w_G0C$u"O{ \IMe$6kBFM1V$ё{?9ªRN-{HO("ո>Y-y~eAxFl`U&Ա6|S ",,pEqq=Uxqs޾/ 4hX UlH/,g$Ռc#6gPOEXb+7#9Zm@ؘh%mxrW.(:FIQڰY~9|ww%q㼽tL!חxlٟ޾5/ŗL`O^<)ҳlԥ]C]L+*?~yf8XL:1{t3sKfD̿&ϙ໙>GtrJ37Q41?[s2f 5y|K߮aSj-՜=)I=m>ћX>٥OQ@ \x4cĉV Y4cX坨@uOԠ$ehϟ#7?MHx9N'JONP0xOk+""p#2Hώ404 HOKo9ILe̙SXt]l5|Fm5p`Tr=G7LL*>|Y~ܔt*&԰4sB1{IvgϜ93ٙ{3svz_>S=gq!~lncƍcܸ)lbY٘.eq 3D[;.5x}ħDqa's<x#4#7%.腻)i\1zٓ oY* *!R+/6/UB]V+'l&hZxA&\+͊Iﰅ1la EfWyv,e{b=j]-00v?!0x˖WpST3hR> :Q:zgSD|A|N脺 ,m~=lAS6/ƮMa F_V_pXRǦFx t`ywša``L05xg M\6mkD"H$["(xULD])kPrW#S7W9yQ׳hX}ϟț4Z-Zw㤪hZMލY^j[m[ ٠ ^4;-f͑dԛ>RT Z i˱C'S[ 9Gh]a <$vCg!,M0 + }yD"4(@ɣSkP]=kNse끻; f"f#A$͎ĤhP& ہ'_EI0 ,A'Fԇ^}T͋tlgÈq#j~܃=ј"~#C|.xTOBB^eޢCpBpqt84"-a@f3dBf O޵RQi }y(aT(V\GR-fo}eɌwnSPyW*`"bY:=9RմGOXg8Y#R3-;An%SICO,WmQe o9LLVƭn焳bA_tRQܨsX7.@Aƙu)f+$cghx [n;v Lpئoij/{4>DUUrU(k),n0"]׹dQ \>J l}cNar<"(Gi3'A1[G0~ˆD"%;yx 0Fph8ZsYY7(<ٝRªĺ.*L<+Dgl㽝"ڄ[&x^{.rp'YB~J!r7DZ$*B5یe{O}D+v @-R|)% >2F"H$p# 05=-mIq{"Ppt#=6u8CcԔjC*㈎3:#SgagMː4֍2s FwChf27Ŏusb&/Ŭxa_HF~35";. 8OI_CxE *ܳG9o&AbLɌXa?GL`sƓS:ʮh=;G0[}&v KA}֟mGQcL#~ oNDp:Ĭo;ujآHx]Q]kl5 rTq,ۆ7Rf]&t}6]N{W2l.MT0e-TwlM3f4H0q%|G2fc'oДab7gb3Oo DޚiRʨkǒ[k4]3ߝlwr'vJRAOZ\*&HZ\LcIPLa,h3s54c1b$'Xa$%"T/P0S-Zpz_)iB uY.}cL9w! RYgKr 6a^Up&FF94V7z0O= T`yq;uY%Uȼ™ 1d6Ѹ_:a*)9w5-4*.aTE_'NGK@(5*~4㲊oM-Vk3drxoSo7*V!lD OOd۪w_=Bg:%("N݉bSꋟ EF#*GrxBB^FH #OH"._Bu,Y=zsEQY O,$ޱPXNV*)͐e;sx}p4XV%v\tniqZ' )KH #owCpLf.݁mU@Sw!a+H$= }K`I<:)Ɉ:޻ne=8+4Ifq(=+J!Q֕y1rTn+_7ZvZ Lq'x(R93=L/.Kh^DJ,bBo)U:f)qė^P3)})T+Xsoƒ(Wb";\D"Ca5s xOB%D"H I#H$~kgtH$D" 'P DD"H$D"ZD"H$D"H$t:P|\ƉǶؘGRÙQŲ@``]"Drߢԥ 'h}?n{ハ7^r^H$Db;7|:'ªu+vwQo3{&ny@ف_~s nPnV Of]wOtGRÊ<=ww"NGǪD"ʀ^+C4y+AuQWڗΡzliV[NVk}ފsX)Bh5:ޮRqMTGqfm-cXUx#R5=N7Pv?D"@H\IC ua1*q,VaР q `}ڕC8ʊ50pR?Ti'&jKug>~ዥYh5ʺhdA@)q7#OU[u<1-Xs܊v0܊4,Ӯ'&ť [9%Kݵj}oNÚpH3o}Qj 5۬g &tVmNڅ9u$۳ rZL|]'`}dևmg3TuH܉yp)F۷##k2[ѷ~_2&#Z6=f|;F+ݪjEY=$6 yB5&׌e75v>k%'\\OD&o>'+yx^ʙy' |!/仒Mߏ3n7TA1}Lgّ>kPEL䱭Ѿ IDAT,׬miBhwܾ!!Nd^o~Tܤ^#oٞQүwL~x,\dq,ɦu@PW⣄9bHe3%6YcC+UTɨtLC,cy|c=sr v2kƪ./K-+3jp{_^g򎓷H$ 8i \߃J9}P6tHc N2?M\3xt.¬'De-A[q ΄><}.Xc0֤UG_.k0]Kv341pZ7'*SGoMYvFKpk@E_-]cEDosbNk_Ov|8{'wuY kš2:r||_lE~#` &鵇X9 [k$Oy`&3X.y6d7ڷF9ԃ5ab'}E 1:(Nx5/k8=c-GvH$ĞdE/WpbT/<+>D\}Y@O%5;4}7rGk=.]H1pi̦Q=pVT1l'θ>YSuY9} 9dcewG[(.-b2^I3ӧqP69%hc:|.;^y 5w$ n d$Zl˥ӍqzNQh֞'b/;۫߯S\I$=y;9Pze|u17!j< kޚVか75> H۹ޡǨxav-[ygb1_l)>V(Һw31XE[.ҭzVmjΤF;*kIEN=Q h _46]w/Jn:[ٮƪH#!рW \ˤ PwHr$DHջ u >._q8MXAI(hUm1[{}=ŒPi裼w0yOPx6wé?Ofd WsR.ƐFUЕUN 7OLZk*ڛR-r̦^7 3`gh-eL%hnCo<ۧoH${H 2Ap/Jr+QDƛAqY \ݲߍe\)u_SѿgٓNŶwCx2\UTTBA_|IR.WPUfyofl5/ֶ͆A kt.>VUɮ9 /ueV2ڦD"H$vƽ9t;NK"苇$o-,'|ӱ2qo$rS~2ռ۴֜(*.km`-qm˜DTL]/edMFBP8CbVYI &^x`\O# sl4S mlW}qv\BkǪS9/_)ضQi7xD"H'؜&0aΛ.awL,ZlS:/)c"{6̠((W|!ZM'7~9N_^Ղ1KnvowJ$IY1LUp׀ƥ~6DK"#jք Q̍&"֏ZM+aqd7*zRMK*kqr.HC@,W NTrث]9vGpиS呖%!HصJs^| {΅fRSq G ӜH(;.*mm?@ZމКvmgkVj|8W**R?5~$D"v}h}N9FpL{4+5%hvb"Wpv};ZI6yvK#$A΃ܫ2"v[3/|~m$6;Z8\W1y<{WD#.%_ƘIe՘ fV_ĵ ]]9Z;Jk9Si&b70}㮱/+S:fw9_Q,sqrLfӪ.ק}A˙<2s$ǻF.ve8V/D")ʋ|3!us`<>_mc`ݼu8u +3yQaKŸsDE&Qke.8M & $ {Kf,a'BVq+y~,#4N5[mwv@r!_OQfA1~ˉTժL[_`֢d$ęuK94缀^U93}; yt&g3阓1KaZgeC_`(cXCOlʣ:->!T P|?Hf¬8]DfX)8T$D[ tu7!d&Gd7' 9VmW9wR2֠$ۍ(Ҋ(G (qDfP{꬛BH,HK,YB=((Z64 ]-DmH$DR`3L82PF"H$we;LH"MyY'.oNbMI#H$`a+w{T%0F\GH$DrQpݙ6AZ?t@d9v/.9BgNt_PׂO~w૝ەvp$XLE{&΃i>jޑ\gD"(:xGqB:nOH?L3R j|oZe-÷gEC5wox6gsß%_;|FݬK"H$HFCZ{Ն=/5{G(GfzZح"8tZJ^6&x~9ѿ! Gʇv*RW].9żo}$DrBWߧv=#΅ ]G|Fpz~2'*G||]ɈDB^P0>*Zt PAU1M֣̕:85{˒ºyYjh Q&i$ۜMfN R ZՌe U5FwڱzRY֬IT,ZNlS]vBѠQLT"H$w2Q{#"9=}6ϑф3h}1g͟W]!&&cD=ު ދq$gfphyBT#['NfPs&Н_ 1$gsa>x>4z]!p4L3H:/0[#H$ <̉kZ4K_sƏE+ԥazTTqFFh԰6!epkLe> ´ѵOA-z;?OOV)}2yH+|Wx?i<9 OfL#}'S;.sE#&^s?g c?Nrd(3g<~>w ŖjҸ|w> `W?_ڣ7\|"&ջ|w31Leyb 5a7әp>,`ӽ~[XmRPh7b3'?3f0}«.lJq!<9S@-uYsfOZVr.z a/'mI\kI$N^[7]_#Y;Ő-i[ω_ߨ+t¯`.Pn*$/ӞdX'Ge4-VRNxzz 7'ɗ>?,rqb˘΢NT)Gd }(»lq%)T\DgӢĂn~yeĈv|tBSUZ'PJZ)WB-5:#jV0'XWtK<˱>z/!=A^Wג%K9M+bog_'W543g.'%>:T MH9Ŧ:͉ ĔψjގB+[vmkϕo6NGQfr߈_~Jl[l{yO: Zg& =DTm9@Lqy=1{'spRUYAW1sIk=yK^򒗼v6F8yhX>{C. {<]L#55 3SIMM%-#ooJ6(Kоww׍?/aڑӑν:W@g d%' b$ (&r8c$.&ԐuDYrȊ=8Q>D"P= {Yd↻ a R719&\$ d=DXōFO=,9GIϵCXq@K'݆@aԓm@Ԣr<&-ǥ9{*TE 0k6OMO.5w$ n d$Zl˥ӍqZgv#zS\VuC:yr^*J9Ziq2K`"f}· † rb8}"q)k7Ε\ X&+K AEVV9[CKxQ& '8 >^Hl}]֑˱ k&~]s6/G2ya=eQANsD"l;Q QrM;<(2tr!Hfa/9y5lEɛ95aԩŧn̩֤#r) LBE-UZ[R5/ kSؤD"< )Ӣ|1eA%$2՜I+F@`-;FEOtTǂW!'lxܒ[~ A΅̭=FijWlٺN+ŇʞEUZwJ*rn>s;ٛ:zuK*NR'k?ߝ1ܹH$-FѠрI(hKWLJ0%ǪTK]-^JyؼD6\ejU) ŋpOY[I$D$'^7Oê$d׉UUl.!RAUl2+Pcs*iŴC Ϙ'md@=բPh4?,x[c<=ToʼnZM뒽o'}wšh,uпЕ~g+pu"7QspsV'D"Xm*ϦsđT}6AI+d8s  t 0c+|Ee{/qhd[&o lV6#H$):Sʅ<瀂w]VTqĬ"<XrM|:BrS p0^b3س~-ٶ<94'X_ʿk=&.MghP.Z2sl9kP+tP 5heY$(N,>6qkuC]PݫФ?q)=8)OD"ؘ&ͳpݗ|B8.Z? O&M&^&8xT9KAF@Y6ĩ8V `5@Aѻ 餰njQN.{"YCVq;%oMk:X'hP6^@ϓ^Wg;G"H$w)6HŮ}R óz8 IDAT>uNA®VpT@u&S ;Fp`Qi挂stl KJUjhh]^ v1#=NZHw<Ғڶ')bjXVˮnfM8w ЂL/읰H&1 |C*'uڴBˑc xb\ и݆ :v]8썇,cd-O(8WyM8v]CJ$DbHmh_~&"W8w4k&pLZe"yS:Vww1()'88;Ƅ yt&g3阓1KaԼRO65"=ȵ Y6OפpNZ^)~'9ehj228M_S59^֟\STo`9cߢr"PUS$wFD-[Zhn"\X1yC,^1yd 7Mr)ཡ$dq/cٞtЌ?$?}MyG2N!z>]|ƺ<6|uc$e$+-"CK2Skv_CJ$D}U +ގ%uV˹}4rO\5\Tq*:OR%XxhY,@="dvY?)@G]\syW5Cz|f\z]k?r&75h\gz'\~t⏤=/#e=/J졢̩瓑^|D"H*-s9%W.䥟lqX8XX;Z.Ŗu]38Yn2RnahmFئ7XS\l{FIQN }grq+92^dq!~,^Թl/W5ke溺9Y KY"H$LL/H$ŷ8^0k?Owa\Z"9{a&.H$* f0|`|-\/۾`˰|t  p4gطP,6CNe6u]Ct:9^shmՑn}PǛmsOsۯk؅${}5U{Ȏp9EEAUUL&&%Ԩ*jo3o0S坣*v=Ls=c{Z ЌwFƣ=Y㙛]ldlB&h l~>=LM4DR2"HӜMpjj`X ܝڅa\]+y Iu f\9fukhN[@RH$oq+ӆcjmG %z\4jWf<8g早#?ٟ ?W-D"H$KdAULb q4”V7/ GQ11qz(p!ÓE!٢k*!7SGC<4 jXȷ}aT&P(vAƄljLFz;ol#1E8DHVZ2<.ry^QVMlNvǐFPK$Ia^}9I 7YW"ͷX*>&ȳ^d%#;r%| Etpu7fl~ΒW*D"H$U3DdR1tM0 !SPb=_ ONi 9&(LG(ÔMPS^_Eu}u]%-P'dl6c61c6O&Kq S{FƐǕ^_EAvG4UAQ\4š1JO1&hvbi~4$\.x ٶҔq|Lکڏ?UJ?E;H$D"LF; t^5·u.40l<yp&\x; .gaQ Sȱ%Gi&U 4!WnnkB jmM2v˰=Pn$2ᮤ.Oaf&9E$E]3fAash8#H$Nxd`؍cC qRԹpu-5K.y|dV0Y!'rD} @S^sC͚W*vr^% |ٙYRD"H$IYPl!/V$İ#ոG_IgLP& N+ 2pJѝ\ Q]_+F)sUnF Ip 6x] bk=."$ɆDV4e e;5bMl&IP.=k$DRO/.*%^aW5mc[z2_}1/f2),i8sKCD"H$dMP3?B`Xڰ|+@9)-Q!0x8D W8p4`m kCP(*ocmBnê?CGZj (QWRHciNNhVGp%mօpI4 M nQ('=h5Xk'7AH$8q+*\ s~#_XY\;d_{|z<}4\(>Qi͙WEPL4&KCD"H$ dG @U_34FU]h]Gf0.)ux8򍪊˃fˮ[9W"}hj躊*b4@a\""p.9m/BXV[G{纖v}IT!F'u"µ#ٱClQ]yxq?c|4wIÖgi WhK6DR$sc4v:J#ShW9VsFnn䓙[7חFsWy&^'5՝ͮis[_y??|; ν|o甛r:Ɲf3ahU]e[|3irCD"H$ FԩۼfY\"p,]HSŒbsyh-LƤ9c ))W20&Tڗw K>yy搛Knn.yy,Vگ0̘fLf&rƕr4r4Q7xd 2`hV'%ugHSr[:ݶcE/cc_b}X,n6<:IǠD",Y!C]LXP )J8sy"zju0眝"lF2nRч*D"H4hL8ѝ bC( 7#(̸`1>;ň4e(jb5YP&.MװZmՑ3VC̰X=bHD:\ i8kB"a7gCHȊ&<+w}(H$DRQ4ZJ\-Awp #pa8w[ڀ0,&bidj5aZ_ۼLyp4 jbd]6`(Q4cؖŹoBы0՚sFQ 1椱Ewe?#=|s ]^S9h$Dr K|g ӡz0c©Ig$D"T1LF@GGp),t)RS7C0 Qȱ$n9pém Ŗ+Ŷ|]Q-(bKl ױؓ:)ʝ㲭kCuK]D"\"H;.]NtL"Eb",.5*֠nL5̗""%7~ #/1U| Nxx8 ѯsTy۪2cZEU$"YyԆΜ'is L%3mRhڔOѯu|Ax ]jҪP&L^DWH$b0V%]z̭}]Y-:ѡaEX] [4 s$׆.W{su7judž'iٵsh* |m@uw;K=|<zs/]kA%!B߮3M xծC 7MVU,xW?$[PLhU=~E6n@FSj09̙//٤+U̘V|5h:z34&Ifnw"7<ɧ ?3^?K71{{rW@>,EX9g7痩ߍ~?٩VT;:* G]daNj ¯aƲy<c孜O$Vr%N1:?>DhGw3)J|kv`:5єRfDvfO8Bt[GC~KZ*xw:n/1=2Njmcg}aq}.T Bh !g(3ȳ ϰw.O~NN.Bh3d"mLHLÑh#3RIW IDAT[ᓗAv"dfY˟*<_@@,"'Ɍi| ^v;7 2]!cքXKcWc`> q-a+~ "ڻ-b_g\7.on#C<调׶dVIGK8q}5rL3&,X<"**Z1{T(Zg5"GOw ,_Ҩ^:ٵ &X,Z*>rwt9\6wCgk{\~=c?fÜsc,/Okg\h/)'"B0ac{^ӊhҥ-6l shkBCHbj5/>N53&ڈI+V3uA_<В>家SY [늲eݽ!,dJz/ƆYJq|n&G|i|wu):ve/ہ]x>],"#|6 k'oƇeJ9HBOoorI6T:p(BGS^7tox"2,W+ٖzEwz5<Ưo((*Gva[WnUjO!I drY: <(tF+WJl`=/+}} W,qܮޑGbB2" ѵkAZQ(dQF5'A"}&p6Ԏ#7kgJw#'$N>JVnV~7J4}z~O^@ xяW5Uq)ٜٹc~!0q "}ɿp-Kaoqs=3q-<p'-3&f : ¸g!eFA7ZkN2嫏㜭K U2%rr4jNO'6Λ#i1Pf2#=Gaٵeأ!q_3g7֮ Ӛ~˼p(jYiX_1g;YQd٪<.>= }#['~?U oc]ۋj7LaƈIM-#1]XŜc[sx5 %wF\yQ%W4q7G[]06D;3S>q_[NN!NOV*@\()'!"8`k~JT.2EбS5X ݄Y¼)7ӪFA1}-~BY4i+2ϱV4i҄]d⡓=S[e/ndnc2N;ۦde[3ٍ̛ G^ggLS;9q̯5ȴ|Kf-su{`7G3 -f—-wKLqI`|scHv|JG6%A`k1$[Zj|gG@Xhu XDё6&\7B *$Âx,=0X!ru>0hKw8].감;MFbB"DT'B'};P+55BQmpߤy+v|fz0}7sӄ;iș%KSI2yd&Orw^=GmD16ԩ^fmӸo(]t#dt`9()֕AЪK;|HL~!r|+]N0/럟ȻqY?ɮı]Q-GՖ̝41O/'X nhϡPN}X^ǘ)fm<f|OftIWkXG->4PHnic1a!.sGÊ DZo;yuw $.A2Qr 0եﭱ$ZΎBÑJ"=+? =xN3=?@Z^߂!45Ela}Z %:qcgt]!2fvvGر+ݮLdcR*IA`"FeM ',Γ΅"<1AM'6/$pE#~,Ҝ&.6Xa+ ijdV$\5qbi.TEMSu"S?jY62&@_lt-e?NCxDڣ咙Jjjمk B:]|hsdoi#ooX$V Wd]]z'jcZ\qEGl>ΡH5 lQ/olyEcjUұQ"ϋ*syEh͹9VRvnHHWmgT#»p# b]P <*Ç7$h$[SPv*׳\?8mNldNRr O(S ýtʘDؔ/E YVx!EfAΎ-l}Qlϋ;v6q۬ M+G=-X ϼf|#7WLI%#h>t}?ť8#,S-g^~g~;_]_ }Zpv/V-z"i-OT!342Dž|?8 PȒ@!elg!WWR&,X߬C-̤A,gPh@PϬ 3zj&i:H_"5=߂,Mj( {+B5j֢^m?~Vc8ru%>. j(楢x3>A6!-iW旻IϐoN~ߴn[ǢvynDl qpm "8NeaQn}#ߦ tj9ӊ8p\EUs|cLգW 4\ܘVxiYj-5?+xue?Gy9UظX[<_Tg=Պ&@']Q->`˥;}dY3ts{c.YlY0GҨ=?{2~_ay<()b98t2vJmsҒNZ!! o~ߟGo,OGZ"gxEQiߡ )4уyd駢f2M~adwlCYǖdoaW 'i?SDe*7DOPގErJ<.&bE{t Ϻ@UUf9e&_T.%ͯ*%{|yML~56G`Qu<:~o۟J9wݬ^ Kn&iI'Yۖ~*GDR vOApH0"# =ÿm!l܍ظ|*R!84ܒw5gƪShb.l](ٽ W{ >^=k?ɿŽ/l0nD:ePyuWĭa͡|\^PC-ğs:&n+$S-]mWbyΝfQQI_~Ւ8s](|p$5ObNWVټ3;[=f2=tFAP-w8}ưn)4ǢvyC݈Z5 qV@/>{rrKB\tl@}8_Xv39Xt"p1ERcUU<8/*<_^x[gDc;7 \G ۿi}Te&ɿGP NM*Չ .~SYuO(b~W)a7m@_7IQ E<;[pߏ<^IKNVA wsUK *Q]q[0vE#bb,m7–Z35 F|֮}+ òc}Xn$ wՉ(_?5[[#'=k֓f\/L<vŎԔtknay-y^^̯3ey//V#V#^uC^! K3wh2Ǖez ,~U4涇&q[| {׆a(J8=iB!3ݦP&qptR!$,aSqgs֓GߟŬex|@KgRn^5z~3e+8B_L6 1cW^;GP/nMmgkt9w$OoۆGߛAym<#wɻnOF>_~<&# ҋH.!=n%2Ra qpҳowR˩N`9fGÒ 99+RNߣ,Cvo監FUR劮]:3k-VAt2f9azdE<|g@s>~ (<%XS|I*Ob0`;ʟd,bw`"Ls_\Ϧ`nFb9441Wd\'>owH"Yktԉe6L`=ɏHX̂ocƗ7ڎi|X.b³HAO;~}8|̈́HUl@ߟ G{orr#]ȊSV=1Np<&ݫ1zWG$'ܹMƽc cՋUx3JYEKrs__0hڑk&kS7JecK1wDuZ![~E -~U>͞VFN`'u?̛ϏqiPR4h\f&|u}=W>z̕~RJ;UvSfw?s(CO~[r%jm*E%lFQAY]Z.݄^VSd=&7E7SK$kNF ݯ-Y ]zq]Vu?szdYeOH=2zDp_-cۢ>]u$[Z#Js޷\&ts`^o«v۸xuaُr<6*uOpu=*ܿ0遑Qzd`YƋ1_V3Z[Xn‹X-y~΋=W¸zh=~ wyPi !ǥ7sG ԃ-i+~U}âa+ɭ[FiLG˓,O䃣q4$61pvѠQs}] I+HK,aȐ!'>Yd_fOk*هOXx5tӒIN>/DrҠQs'D"H$G^&ڮDDr`&\6)I&H$D"H$D"DzH$\C>8] i1\8 dȄD"H$jP6F UMiPJұ>M[]oB(1=| ??.$;޿Fg} ټvxf8}>;rqekF  vKغOJt3C~F2cTpj[8FI([ҿW}N^ή?ڑx7ْ\_j&*P#l y=_TH$I!\HԼq'D'ݮ+/jF@L֟5{d/>D5oKj IDATPn%yN$)[N|.Roso34:@C͵r/0weʙM<n$#N͐~N1:?>DhGw3) ߚ0j$ksu47!JdgMAގ$:]9jW`،tL >;ewؖRYMˣ]5Xx~x{"캈9 n^֗pdd_LKtAEK|3˾,,ljc=1<Lyl[Q>eT~~5/O\ 2}F\ I$U {R#g- f|_eeq/,ڑ9sUzJPH#0q#@Lj쿖k9y(5hڱi%-E\R1|<9XH^>%OfL[_筀۞Oʰe6f }̚_`u bzgCό zEom~i+#q ~(zSrn-e-߮N ,.ʽCUj?>`ɣJ?mNʶxe(u1wng _(ÝP1)bV4S0RDazv&Z69ERzd8,U94rrʡrDŠ?L YB|U >zy?E?+9]xkjq:UWލW%<"dBϿK6{[oϽ62^[mvɌbZъ?Vu~ƊJHL|!OW*qV"!s;T/ܸ?jX|l!lѝ"R׺з(V=~_ɺxڈI+V3uA_<В>家SY%Y?y81{ | Bj25 )&\-o\vQUs |~bG %"^X {yo/f,kX any cCL 羄[z; 󁔎p]mmށ1 ϳ>,p$,]FDo؛b͙8V c(җ زv lst&G3Okb">-^ZL39qP;m&#/}:X@Ok~>o/)GРD߆~d/~~ћf `%~\tmw iɝS23^^{~Vh9dz5''?Sobk"zk?ϜTa s6FOenj*<{v%ZF&OHl0|ݬp!KwEfxXj9ɜڳ>^c` Mn澑H?Up⥭(]u. sS=4N ޙ1EE=3yn`* ^ގW ӂ+qEvm$ә)|-<-Pf2#=Gaٵeأ!q_3g7KCZI;~K 3$%/tc'vﴝ3o̫\;{=0Z8n Gy{P ~Xro+4iB.Oy`N3>w75 fO&7E\cmӇyG‰ 2Y[ҝW~(}Cقwnͮ_L$j"< \Gv^!vhj—-wKLqfX9e#qq #ӝWy޷?v۟dxf0v+xx03! 4Jz{k1NB걃?> _!;{S ?Ou…AǡN?hh L Τҵ_ <: m'rψIQNMm2clSN.}FC$;rBk$CB洱uoa`C=}+X%KSI2yd&O1JQQmpߤy+uYOZ?#aEB~j"9CM[A)dyz92epCGp.%ލWRov-zuEuz٫zvn@S>xSoνb1֬ l?.2u|y .r: Ŀ&J);87߻̾Ř%؍-Q!e!RRBI%) DHT"""uɖm۝wf1{Ũ|gs99y9"m=ut|,.mzi?gXO#G?J^3瀁h^2k.4j( suOGBk7fr3HM"O smJg y?f̴-H>9?N)8H+S~c){cMo$9it5֧[7MaM}o|q}Fl3huziӆѬ*}9jnj˘3.|5a e˒T3dgBF6[2 #nR9BRr1yِiP K gHu۝RHOpzr d8wΞP оksֱx2y,bȶE,s -:#d :6+Mor%Ä9'W+: 02`Bg"++<+3Կ9HT CINGWk: dFv+j8gW鐲o-m X(Yyd^aT -h ;;ԐZ_3A"\׼)? EN O\ .#CJ$GcMjP9:a(cf3'ү!-KOp^>;"4I?;riP(.uMr& 謆.3" &H 6;SC>YBb/Dy:dyyE._6U Пy%22%+~ٽ3Lߚ0{k8o0f2)7~`$n9\Imi*K_<|=szoՇB$1F*1j T;UYGWâvD@Z~+ 䒗2seESJ5tz :meܛ3`2xkQe`έ'@/|_ZwBкTrѓjL)Jbyb_{vCD*W,ŋڵ1tyԳh᥁tzYRnT*թc ޤIkm85kCH>ͻ9Ye{޹0Enl34:Ĝ3>)DE~0 +V@<)::ȕgzJ$eRēFc9؁VfMh&׋_4Z-tNut:ˡ&ɝǎꏼ̘Q|1{ oו P-▯Me)N&v ?/yi?pݩ+Y%$H B5 \~Ň [EJd c2+-ȕ 4J9[ߑ^}ڞtG1vuPqLP+~gs_k%~_8#'tj<2ƃ~P4*Zmc[! 62^juV"aaoچw7ǶX)|7?8аucqVZ^~2 +VԹLJDLHn Y8afh8Rpb ?CL(eMu'n "-BFOCPT#";w˛ݳva[(x:\gwWȊͲl9x))iE /-\guF)`@S8%xf|-՜HL~QpX?*c%c2;[!H >\ S2#4f 'b[@):SnMu֣}?"MףW-7hsK]P3F%u/#رn$brڟdӺ=V ɲ2^ҹlL\Cth0o/_Ʋ"quRӰI&? z!ҚrXj~0^^ᎴecUcH7ZvlC&] Ito~C{cBPPpm1z+l^;ULGѬC A^UVkOqDHɿ{j<*za2qrr{mR(4ZK?bO`ž xp(A橕Vgc.cu%b+Ye+czt֍Q{.Z(l+} 8M3&*5PM~*^ԋꗍmoض?ECwĽj0}ZeV#DR&EZ-3+?+e\z6n_F|w\d|I,rFwȶBgsB:#{.S˞% W[t->4,HN6nIPED 0GdXw7|S)_c0٣XC%hйO35 њKE6{<ϼCbu`}V?xf9c'B\a0c40b$'XlHJd4ϽOk6d|E$g\|ŽNÎă3gY5eRx19/a嫗ӿ,>!3~"#^ &@ )j < / 6W`~YE]Yc >υi`]eI>R#5}0ͫcʃvM0^c؜\aY,{.<#ݬ:T`#Y. Dn74Ox+>/΢p G}p;{ oZf_QtOx;綑.^نd)~Ż_~ zW TCexYS/ʪ_|ns{ʏG>j Jz!¹{da>N)$w BGߠCi->G_sބ37mڵk8pH$ۇJc5Q xkQK"H$vneD"Hܷ0 ?No(D"ط|O^nF4H$Ie?`1q4$؁|D"H$b2uF"H*# $DrN+!H$D"Tr3ɼ:H$DFD"H*k׮-syD"H$͝V@"H$D"H$DR#& :R?Zg@@@5z2$]=ZQh`0Tkp;ΝB-x`^&H$EF n]x!0f's'_"IBf„l;܉cOЪlv0s'vFfFSm0Lkc.Z/ֲ2VـີK?9r)aTq! )jqGOFq݋l%K[F4_o}QHD*%^8Ҕ3:"Vrsa÷lÉ]p9yy,xun-6Il=t)\ !Xu}JG=ID"H$:#~Y!Νj S7oœ0gHvj,yg$ܓSHFj͛xQ9؟q̗ҿR]e^E_2 m_;֋fp,JK~1cejèC c `,řdLG3&<2֞&HڊGdmƑ)Ir{ׅz{Mnese7ܳ÷篠R ˪x;p 0&^#M*v~ 'ܺCb=XJ2 i6~1#yd~Y"H$I!7"Vr9aUV}6{gu6^AoØWDKp&9+7 WS~DNޮ|?ut.7s#\2sʛLhiwԓm9NC5nO%7b#3zN#WM]bShk630PpiBY5C~O0ʉyǙ/a+Y" W0s\†F0'},|w_j߿eyLJn/Wh7 f 4kdJlESZl]JjdB(@cWyT(r"tËU\-3j0b,5h1B"H$CMaDƄ[FZCLL 11>q ۍg'Hjf:IF|ǘ_#. 111D6! ϬRL R IDATF〮54..SM%M!,NmdJW" %xo;gd:z|huO"=5n =jYrQ6ɯwIt5  H9N:*L! 5;2tܪ)v'w`?&򜖦-sD7MYN{m~7SW#:GNG*5Z_! .nr:D΂#Iر}z# cW~0"#rP,C_|+TkC|n^Yxh e;g4^bْ4:|9V% @b(mf>o),4cc:Pc` 8%=nT6qGiSJ2?Lcҍ|L0 k/o2d9N"H$ɭirNY&D mF07 o]ɖg^mFż-,m k:ht4d&&4hЀvo]O"f(&xٴ ]AlfY<Ӆߘj/:~ڛ.>0)~_{}{==* KX*&u{.] iF+fr|*~7pyVpv̌L*zoQRB3N%nVVLg{e>/v+!n<\!lM ROb5@z?i'7ά۝ ( Xa c1öXt;+X yW +wQXl vA ;+5L88ś5tds?CE!w06a bR.q'3M\XV0qG,H$rro@ٟH;H½E[F 츲4LΜD7$ -$) ?X)c/ΗЁl ͟~۔a0[##Pk1}/ݰ(^>W8l,*0VmD !_2 XK #8ϔ[̘̠ظ&3Uv(fӍ UHVA,}:c+i(/| 몴Hl1>;e1ݲ}nh* ;? F`)[07z%P[Xt`1lKN<hR9g܋7Y}V4@r.TCO E =YTiH$. 7LJd<պqAtl6`kU㉉/aTGFНpT1SQ mE͚WiMCkfq]V$fl ggt;avhuNa,?Zsmc<΂𪟸'/6h ɪv)<8s/WIq>KW1Ʀy?Mp6=2ZFsY4fk)' u/]rhйO35 њcD6{<ϼua 1c40b$'x1zs|{]"b}I>*d+?e♗CY8azcsԵںO0>T+O`e'0YI' Zo_/Z&dzÔ [a8ҍ~=l`ϰq0/O/S,_7 {_|rE#;@(0| y._JUht-A%mcYXn4b 7{@"o`Fb))OO6 HjV4b9ĸXZ~˹7vX<ߤ<8oXS4-j'kɬw8J\{4ءp]0̤2LeL:8VsUD"(녈 *?\zw]HEZf;wEt+I9wB$0Ѝ+$UKFz/&=SH*cΕjK儬v[MPe~q6yhqvu '={<8}5CF`;)$ֿ ;G ɐTXiTwYN9@MwHK[w+Z֮]ow%q3ccI@-,Wo9~X3X#% > b;.Mh4詊;i$QJ.xD%H$nvr'yd[qյ7aH\r!1^a}l*l`"3SBRKֲR∷$I%#7Η#iʅćr*Z)P]crKI}xS tT<)vd"78I$DA^"H$Qe;;D"H$mzH$D"oqY3=<0*tբD"H$]4H$D"T4H$D"";I$D"H$D"TqtЖL"H$D"H$ nH «5 gco4u_b5<{;J.&58~D"$huux1^uF';ȿū1=]-7O5zz兗'η6ogekY&O+i;_YG"QF)?ĀA4p {uf5q/eL4&ɰnƗc*?Um1ط1 pӫcg [HcР ׃NmPV5Z O|é'ǽDBq'e[J<)uiZwEC` jW\iT^(w+mq.¾N[L}9"e,ৼ4l$fVr[Gȋ/f_ywZJmY/l(n)ԕ?Θ-Y.iCibTq)?5qw>A p,YJ ZռQtDz<Ӿ~67Mdf`* z;}U/p3uL0X+- ރT)VZuka:ٕoїȑ{_~f;>#E>7Xz=҅_8b nYV`h_G\N~OMH$1'oO4(;?fLG4TM5te;e}N`ݚx`$hiw:mZO [VE[f0 ˘F{MEVqe/dpC,C_6g9`W QjyZQYVՋJΫHUTM+:<}Y4-anSrrR9^4ş}̔ 9}yض_Y\ؽe+fGL^8CrwR)~=ؘwMЋc-FNNjkgL>{ tu_ @[1.#D\0'~Ó-^c*~6qkR7EGusuБJոr&4x7_[7J16L}ыZ쫭Jw71~tL蚉 97JT{OZbK5I71qQ<46fGSo/fG`̈_1rR||MKBm:_h +kk}ś)O1{DG{x jK"H$%>6ǚ8*o5?4dX qʼnڏb\Vq SYNE[a^Տf=SLdEcWh7Ɵn&hM|J+#]c'^Ņ}^dDfT&\a mxmO^[?/SB[2<(6^ mG2_*M\?yjdi5 0i6xpxCbVwݚQInzJ$by:^S448m,\B96HC1m|__:LJүsg8W i|xC F-Zbإ(p2AMɍ昢鎛. Mx)8@N\/ey ',MfE9Tj h8:h%3gqߜ"G^ϴNOUm*'o.tG"<{,z.z'QGPZX[( @#݄⻴Xe_.\w(wq=L%tQ\He#ؗ*~!;FlO>'_cᡷmƉhEܼE]w{U% NL dۉVN"+ċds^]LI1N>V⍶END/NJa*2'b$qEcl5&' }Z!-"c#:G>)YvMZEw(V$ji]#^&<(\b|GgQs,zbV"Y+}ڋыV]< _4hT[腽G T|K",h%]BQlZY gwwQSyo=(膱H<3oX8aq9V:<* q)|j71b5V'\o}PɊz&Ov o\l,^(:{px$bBVnĢOz"TRSR*ˆ6zQG]=UN4K,qUhGs_m<G1bj:+E+ĚkĊ}aqbḎ|SS^LہgϛLud挘ނ=,㈋K -ŋy:S~@F^6Ǵ3' 4RSCf*e䔳}( IVuit[NrV}פNXΜ8ZI%q|&/fxY;0Ӷj Bnބ7R|3Y{VXk8OJ}pT4C&Y'b%ܣ=S~^Syx"NfJ.?UW~1sps~LÝ'AJ$dǑM}U}|QU烟w 1|NL?ٚDe0$t4xU(t91>q< )}5S!,&y,SEоksֱx2y,bȶ"a\hѹ-G6p9& GֱᰎVU7xgV5 NHulDF \RytN) ˜Cٌ!27Ɖk}wy"^*)_cO,]ج<2/lᇰdR] Fe 'w4L2 Hvn&\\l?ٷaœrW-fm, nmzQ]W9kR:EX#LsX#vbe FyԊq"gO]&HVRWREy/Q.EiAU_OUQtLpt qȗd]QP2tr9io~9^r8secyiDR"7nwһHCx8 y V*Cv\Yskmf&gNELn2܈1"KL}v>Ūz]#XsڴhG5#/lhU@-\-'/8y!A߅:it>9TaMu8" IDAT}/ݰ(^>W8\ܪ)/jQ5u]~TO*cD"Hc%A9dz=CQ#@հ(r u6cHSbw.oѶL!51Le -lݳ?"yQnԢ6M"dFb⡣/LCa4x%b Bwy#gJ)^ԮQԥˣРK֤sYyz+|[O)_뿮x~eC*^6nVlmIy:4Q'"O|R_; mq-?HMAƙ|~ Yt VxSP^&aUi ^sffCfr@jj]Shjd4QfP46l'`ysᜍ aԸzsr`LBA>aڢĖi\(kūɚ0aWjIDI>YGY25k_9t{]l t~GDy_VTîDr"jH Ɋ%&՗FΈ&&C@OqLP+~gs_t+&j&)=%\bݘNdn@֍>ƹkN }Fėө(>ER6ES,b1h,,Zmc[!/SINI%ՖW%Mog;o+,2|Ǥ׆Ƅ8%?_ECD %^ahAls _O5Qgۂl}MI*%EZ6<@zq@ ؛ǽˆTǺ#M ϦwμV6NZ >^kH\eKLg߱FF nSO2^V碍MMqHg( DD^ 2wDrb'=~+WqD'Tm$N|H[X7_^E6beUBC?$bB4\kN$&^pU?(8xV̱[53'M2Q8]JYBJQBEq oHvYu^/5XEWOoӰeL56HC kWKr6'$A VT%beY-~njuvhV/mbʭ4Tf&I c^b)NeJıǘP7\[ Wl$h!6EF-{MjTbu۩5g1ϥsod8dDz$t{9Šx5hƏ17:)d8p{^ _^F'hwpŷn]O{8eR:fBxŁG|\R P &~}[V:[_xv(6q}ػ~31ww(H$S7KRt,y"ؘl7GnlHjT иӨkjq+Ol#pՂoW+L9|8v=ő"kB"û[GW(yv*2r/qp'f`"*Y "U ZjuB{ʾFϣmqZp2yQf`,m-`_3}ڻwe+Ӱe *6CVmW@H&P1f5{кtoBܭؕIMIDZj^:Pt8d۪^@UYUQf*Z/ng;o+TꬫV uTdcMq^WrwWDy=OdP1<ڸeLPttgܘ(]f_{f6^EHÂ_v!wH,"Z^1_|G,ҳqpNw2۸Q1>7.+w36\{30Ņ8™0~8}šc=jꓣ$~Aǿa l,|Tuؚ vzxh3l x{ѻN-{G\D" ǥ >:3fb;z}S$DdzU"ⶭcنs(<7x{,aWa9U76g)O<]aAϘ]e$/M^939,Z?2]f߾x jAԺ%H3[ʫZm$a*6AѡSyj*6g.ӿ/ZeKMw%:v.⣍ 6N 1kҠim̚֏uF3X'[I wtG}~5<%.?A٘?pf#w:'S VTv`80}9FÙed_#t C y0r+TʪeCMh|fCs|˼ ܚ:!bLlaEQޤ9;w^ wQA$ba#5b&b$`!6D)"*(;r\Svv,>d^'w|<3|}|8 p F-f\ rι>ԓIv9yaY'-w5{(6slm&Bf>;o!Bf׾̾]/`7{e:||&?+Hn v#̴}jKVڂ;J)?H!7iiv 3O.favϫfA|3Cmg݊N2oΗ݋̼4.[@BgB3;e{zU3#5c|>¾ѡ0C̢ftYEfaV{}MD0ivIk~qᣭ|3{vf>=͜`lsԋ7GK?cG2R1Qͬ1Wdvka\Cm3wA̢H-&5i:SoŸoK^6S gulOIѩkB-(ɏ]+M4ݿVH$D"Iy ^9-<=}{[ՑHJ${J^}S9Az^ ҥyE>O. P"4kVvHpһ^ΆEI4M\^"٣y嗙:uN}%vZ?jBYdE'ZDP"{P3JvIöjk&t2&#i$hZB6B"H$/Mu)H [ՑHJ${z5[vH$(>?wZ6M 4MLӰ:G_c- ίHǛ]l!bR4CsZ"bQ 6l-IUayǗ?=ny2CA((n&i`ޟ-'/czڵױUAUUAQTE9i \Em2 íi8 3{Ƃ\EQQU5^UUغµAFH${0D"H$I;8xEUɪ3A& 4x&QQ L0'щm)x'!bşhn=_DGK^1 '*ĊI¶#$*cvOGq,Pc"L((Qڅ (*0$Z9Kc`ۨ4 fJ@cqxkf`FL[8HcĈd1"T*(H!W"H$D"H${1@<ب kN퉨akB(#*Dx?9vPh#$.ĺ-هUDc4ZZFlCofI¶@k9+`#ND0E@ε#GE}.-=%膁a`z8c[h@̡ቬqL~ln0LCt]-ȵ> "L '&&lv'ZkDf=[ck6/=@^q/B5euTPdл7THٽsY)΋HK'(idf^~0;kӁ>+a[Hv"^)2ض͵M2 vY{~$"8@o"v|tfL  Z\EA(v:3\Pe ak2ℴZi;7ęL-4NIcV},c3P ﵣIۨgdVV|cEUmiv:bĈ"jLTWZROZOA$y @uۋ9D'8腉 PA ``0` é2d)<1 'y#I3 t]C7Ksv&°Ӹ"( CBABPjҟlK`DP"D͈> C-b&ipgb۩"H$IŬ|/{2g6Hut|7vIi"3.\u\h A?A$c}$ElI->h6g0PJ q&:"'%F&NHtݎ"IEE y S|-PDE *3Jt'ЮPEDH(*jTWr#\h&ATDjmeW [$W³J!K48<ɴ"Wt'D1pSz\F YD-U`ܞDk4,vҌDvIW]^m}$ 1%WJٹ^{|#ނ} ?>M|鸾65@UTkZjTitM 4'2B ᮅ#H7Ic!"g݁YZN|ZuQ7R݉4K=&]+m&D0[7Í1;aL3-4N]lߋ"MH:9cXm)t@xbGJRsyO}]~_(C׉N[:R];ɉm BMf>VdbqG( ttiTOAJ+" Jh%o6tE?kaJ$4F^'j|_X&2۸U+ q]N7fU 8(z<4ڍ:|:wVˬ~.]r%\tz5Ɋ9l.<ZOD_M7NbDl|2)zA_#))>wPyN,f/Ap(c:̣os;xYٴsuo%SoQgS{ Mo1U& 8d껙2 ](w=Z˜rBUy3| T ?mJO&x S9 ܾG{;Nvg[$jSAvا[*}9L 3Lw_oOJcz;+㽃W"Ow?o;*7Jl˖)?·u8w؇`U?cK/ڗU-|Ӂ}/RD⦵j0Dќ(w\ 3!FΈm͘u|j8f${ߓcA؋<޹2fpTƜu,}FrqA5is 7^ő5g_/~n l6Wuz2|E\~Yq>m"߳;kIA%CnIm|4c?]W'2㦋vQr|Q%oqݖꃙzOzէclWSR5.EB?}W1?}?2?_s+5L#B89Kqc؞Hܦ yvSI9i`loOjgplT_0+ K<~rOW\gJϩ{8i^Ϩypi€Qd S}=#_g}8NzHIᦼ8i&H%vu=i3H 'qܸV؟TٶJT@xF⴪j99R)ۓ%y=ݟ$bY8?]5M~Mwhg3j"z=>y~.rd| //BfyeYCI2td~c 7 IDAT /Nؾ-^ĘG"IHL& \[ʲì,ef+7@~A.޽=nϮ_Gd_I^m f&j [vDהŔd7|ڡsHu9cG쾑\}:OYuk˫ 1j}ğMS ܟ/ȜݰVRynԎn>I$\O}}= hs;m6Y=jsWn~wOHxaq@]JmدI7~O3z&a^縫xf3cΥS=1L]+0uW# ~V]$Z'o_koqVUZQɻQ~f?=⁳L|[MqHj-^̈1C|86uSQz#(Ȝ͢V5n[44]C=f4%+uhS⍌νV gK$nяA}c ]QZw,\R͐&GaiXIh4Euunܫ4]:1qmG̙55y>d;}'\\EJR%_,b{ga݉ JT!~g}?]۬aױpa9Cƌ$OdèZk%5QK4үSWq;N;p%Q[t~w줚{g_m=SvEk}50:`a읿o4{OF0 !(kB+0ͨp Z8;yV @17 r 3Vm5\m+\'M^u^ bt<∡[G]hD 1 u'ݶ,Mӎıõi L@%o p\Hpb(7L*ofDxTVGQ @ݦ۱JT%ETqoab`Ex:G1\!0 wG2M2 ݩ 1t""v{>nl7چ;1bwt]&D#@ C[ϔ]y#el36PYV˻' E4k&͚F}4YGSHM-Bgҩm5݊NТb{ wרƹ,Эim7RY;ɷ>"HHDC^r ]Fp8uu}z:jq1Z;὆᭳ŃM۱ G1=tۦV;vԯgB[v:@jΎZn '+FD"MDhjj"HCC 7X 444Hcc#0MMNal1;:N_c;D"1k(-CSҦl.d$ d+c/hMa,[c&qdQDG,\iְl ĨL@1`G.Êl{jlOl=aΚ99){"H^gh ]lw-c ɴ+aVSYJ.p$ibO56.⣒{D/+ٺp/Hs,˥d6.4@mv2z #Vm5P4*]9oPŧSW;~\I(gsE [5)v9n{O[j$;G^㹓ʶh噒sۤM*b gr4Ei zx,GΈd;SI,ÛMLڙGN 1%fT|E낱bۓn7Jtw1ؑ ѝbB`*c뭘VbX aEĨH ^7ȉnѭA L! 7 BxP gw'gUAN2-[-9'%OS;Jh5B >nnmMlm?LkC׭-S|07mShcbg)Yb>vq`Gq>cXmD";]˲M4N4LhۘήKvw3j/upޢM‰lm5>V"$(>q8yfeU ҟ6&&xq9fk'u~x^(xuꖿ/e{I`:ձ_Z>%~\ayo3J>e6 L=3.ש7 [[KqyA7 = 8ﱿq֧sɃ ܗSW;z\~2yrR-(_La_q-dҵ0eG;?s_u;N*IPd?mW*{G?wȲ ]H*Z)%#1'N W$OQݵUb&^k'ǎu-ZeG5O-VxvEՒhT0=q/]'>Ub(#ވ# ͨ@ᦊm*{0]\hJGt'p#]#[;qO:i)OBD& MS?']ȉp򴍧n; kp 4g~D"I#sRH$D""MVD7  GEW.-≘"A[rňsb퉴G$NZH=+&-6ͷn?Fˈ׾x!"<єb̼hPP`vr4RQGN'ќ(!wM304 mf"#Pc|mWLR ֧޶1-~%$͞-H$=/E:#H$D hZTXÛ/x'Esx]&zzt]!qMD& !Mjgk6섯b싦6\vkDSZ4WtRD3J;_O+x"ZLOj@-;ŴE[1Zl kmf%:b<86:*S BmxDTGA"H$D]fmD"H$D"H$ɏd7H$D"٫ 9 J* By%P۰-H$D"ED"H*"TSFy.9AY"8@ob^BYi QTZMQ1u/6I}X"H$ކ\C =1#.Àc3z0r]DcFds3޻!FYibʔS9fp.ݷNdtgȣxd3]GUsy?=sB,ٚGOŨPe_o"37q_trR[D"H$)H:r f\ F*ə+Nahh7ۗe_r4vjD"{i\7յ(:3QwAƀԑ~q+ƓH8K5W{Dz-) m>=s5_"H$H#tLҵl~LoFdf4J\ UBZJ AeǞv,CBd@ sgQ8OԈ u2hoD'%#?-uP{X˷s[/瓼9uL^R;tk/Fк1> Ǎ1BUx 7$җic(H"H$$rMICddfC:z;mV>%Ԙ@3`pa5,$Ͽ_Ju%}31Qz1/1gJo29w#d#9O5'D"بNde1O!Sqo=7˫73:i o_fξ. 9.:y=|dX^, :߼_+5JO&x S9 ܾG{6"NiG+S7VS9|:in򔝙KN ŽᖣwZcYvsݫHݗrcQhV=Eօ NI&H$D:Rt:Dz&F#߬\aGKW}gt;b?;Fu,}FrqA5=.飸i4[až3G+&W19`~459FqDLGI]fv%5;(34 ;(zlKHD"H$NGFo~ oh" @tc'Wxy0[?+zHih`fOxdRnXwJ5 4߽K5~71Qee$b6n⛯ְeGpM YLIF?w}4ì,ef+7@~Awhg3j"z=>y~ВǽoYFZjkТ&Z0:&p=44FƲ tW`wXc:X&β/?܇tufҊ݊W`{nC҇?O"va÷J$?"\9cFC23KcU͈ǚ4Nlڋ~[>)kϽN,_l[1S 'E|JI]H#(<|TĮBkh ݿonKaS5U~%H$$E ICM nQ/o0Q7P #3 ݈IG2 4jւ":@UU}Ĝy_Sꊘ2c[qˢjw\t+L~{f\s_hD Yò+&3qPAƀ Lm0_!ʯ$ @f#fW.iq#Ury?9U׿bVSYJ.p$i^ʲe[:fG@dP|9idqKYY6F9+2?t*(v^KTƜΔѽR$`3ujkw-؞I3ʓ'Y) xwQhi:Iԍ 옿5^U4;(H"H$ޏt2D"ٺi'[JG`R,/Υ>&iu׽<~g}8<Ўw?0{y7Dh%OXYmY};_υC1H#H: z o#ZA [ޛ%S9yh!͖^iB#P|̠n IDAT =4g5-_ʘ$MJ|W:L4z;>p??Σک^2yrR-(Dd39rH9m|@"jn;v"??pU;"&ؐz+v) WR'qKaYdҵ0eG;'fP;n妩f?K.tR@#ֵOTUGXzH$D"id$G^Oey}cjUl1jXH$NrӸSjIdG)1*ѽ.eVO& 6%uloyj6m#PK2T L3^2ћZҝ/S"H$%H#H$GͿ?)T0a%GF Tk >[%hrD"H$)H$D"QӤ9dn%]3aF< S"H$'RH$D0u D"H$?vD"H$D"H${RH$D"H$NJ((rވD"e|tI~t+K,uwarP1լ-J4擟O~~WZ=35dӳ{N{Бe^:vrD#I̞fDw<$A g̱9eO/(](>O>qËB*9}b9夣5 ~&,&PA "W>;9irg9k](j(>F4}yܹYnCi:<^.d6s{=/jK${j>#-ΚQ.Fr/~ƿ=sK㻼nf.دO>DOgke_UDO+ ُ38)w 7TY..syt[d'T¬`OSB614y;@:Ik(?+7w[B](ߗ^၇^cu}ag̸b^?,U_^g _5n[G7/ͿnP{=>i\Ifٻ@1W=˯wA=ma;.yyygA桙<|o8kٗ 6QV{qs&nCNDͣZ-tJH$)H$ s;ߟ5?|OE` a;EHN= G<C=gX'ǜXHjJhR˜4}zOY7Re# eYAkxMb%Zޢ)柼k\@g ()Ή\8!FjY;ѝ'џ 43nc߳'}XFf{;M7NbDl|2)ou8w؇`U?cK/ڗUDf\v#:WsB䛹qr{Tf,<:MALXM]O#/g5>/"Ttm\Q*ͧfó[G1mAd4UmEÿz.SsKgO9mᆱq|ָE3f03WMA؋<>|JIXA!Rql&5 㺇`^2>&&lwVb>p-^777w!;]#e~F}/G3V{8 o}QqZ*8g˞ L[c_ox=Nᴱ=OY~Ӧ )~~ƅqfeאNfo0{*{:$9H:3LS=_YߦmSL?8g9 ^ʘ(Tӹ#-t'"025-o[n@1|2cMcSMz?q+^,d1;f~,yhoY =:*>w€Jzf!i/0sY%VJCyִ>"H k=BO=[؄@ ;_F5zl5Jy3yc}o+'s}8nJWfi nE+]W=?7>\9cFC23KcHfl[1S 'E|JF ؼI"ؔ3i*wH _Ng?fDCsD H.?z |R+f "\XF[0*P]m>˷ |8+L`<8+Je?LرuOrߝBqϱİr'${߲D=N}3 ?4~p\=-_ʞdA~-tJBp%W^ƳoZF9m`lfIf@9'KzYYjҠVe;Çp^o0~R1450Osx?;PzȻY'g%Y>L"/驩@A6(̦mo–%|up2f  )PU=u1gؓEQs-?Æ⎇f od[pw?p GЗYYBic7zǠ>[~l1݊.(-7埤#G9FG8KXeFL] #q^ZrguOtOHhqg\ڜDQ.,|u>7y#MH 冓'xkK,?؈4gRW^kVvM G=ŭ =<+5Z iҷ)7Mo LCvH yH6%9թFos6Qhzt N"L%U0#"Mi+ykǴ1p@d@eK_wdkg啖,65{7M@x'ւڵ,yI  ㊙?×u:x0iRVJZ {`Q7T*xVۢ}TEp RԳcFⵕVN?!}K^ݡ|%I\0q.{Tٓ{{qc~؟rH\nv?T=SHX98ʗVRuD+=K$4x7TaIML233HSE8:> 64nVΧ4#7U}˘jt@dao`BY 8t~χK6ƽ,̭|1̙cp]xO&${,&XH>o-UUddэ9.*~O@/8JT'ddȉf4|!w@/mOWc{f3` ˍT,z/h(8Xaݬ z!K\8HHwW6V]EڅǜzQ)s:SF&KD)ԩuֿcOJ $PBA J2 PZROчҧl-M)\'}Su$0\AVAFa?z)˖mIYA?}w{1kX`bd&ʰRNL`(eA?WR7gã(ݻ\.F" TQPP(""( UWEП ;~w&=r$A=/\|-O_blX`!7QgV a2$ UBTczsIba%?2 '_3&iG>ǃLU‚ ;6vFYTt2/Iv̈́~ M'䐞š-x㻿w^sT:r)0Dɗ9V8E&N3SO2Q (Y [jei9E>nD+XmY[m?8kMFĭY-FS?Jvt?${2nT|Rg2/:۸p.;R2E>/Ro)\eb54=b{W:F.X9j6>1(J־ٰC<4#'/On>FS-i vF}Πsx-}oGإtM~`9o%P>936 .{,x+wX9L<@rl3>q]_Np-:rqoڗYKԵLڛU]%hK%)"7r16y9נDr)(|]SYdFE@TBфʫ0)eQ;HzzE*x›@ZѾ&_,A wPʬk.u)Fw E}i&|CG۵"9+&}Yz_T+;64a >r4 5JWuS'W:srK9ƅqZM-c;z[gs粢:iudڔzB)N8D"^!Pɋ)LH$Dl\h!-{ǣH$zEDם$D"H$ZȁMU-D"DD"H$D"H$H#H$D"H$DR 4D"H$D"H$Հ"̾$D"H$D"\wo:͢Ovx^ˏTf_DAFM+Sj___|}}v7VDA~nZW7M5/!5߯;(&CӨa]B\AWDR[k@+FzUAl#oM "4[7 ]\%6]"d#[~&pW9Ջ:~:5T ZW}9D6^D>NJ& ;NP]D\ˆwxc4CKbϹ lщAhd {y EyYC5j7y3>=gp|mGWS3C;s&*T=&LmR9ځ&qs䗨t3kr! mx~j ̈@SpƲa*#] lN:W0 2`t.8~m؟U}xt; /~sk~7#kH6 CnÐ{|RU|W>ySl֔f`H'O D\hѦ) d&\ÜXPvUMQ'DvN8HTFᝑ%Շ8VW\Aҫ*b"F̓zUUs#zqZ\P% ~!b5 : 8c?Yԏ" (4c:+}`(9zǨv4:=+7!unD0[ y*Y:Mx䱟^,"'޸b&Wx W \ em58W3G8n V(6cP4ך4%AbZr0:GW+ꕻc.#{~oBm=V f&08]W05[@1Q EGyqoK<#^[4`nԏ /CH)JFkݝO ~5=6|p4&׶=24e.-9'V ;m VP7Q쌎yɬSظꝴ?5$ĒeV~[ nߓh5^χ2JWK y ٰjRp~@$CmaUh?o63W>PhhΟ߭cd 4NBQ V@V&{wwLཇM33S諣Oj< bh6msf"D6\ƴNp.S v??&cξtuM8Ul'ג#hhD|YNrum䑭M#,44lԨ4h߲?Wou4vþ|V: FrR *6=; IDAT*tX{;@Տind͹k=SεSǎa 6ٟqU,aPmX*T E*C=>:t _?w2ΐTp. JۗIWC6ԏK~)ڷ>;^:6ӫt4j~gizpXC @ } =g(5ъxd66sr~SXm'ěi>7.XvpNE\ T<8sr d3/W/z7*iz C0) B-# xhdz,>mv?MOXx3a6ROS[<=h٦Lxw2Uyu ŬXطiZMyfsq"WY¡O+MCNKyo j ~w1,\2~Mſ$CnzLmxaqscNRsG|x<ߴW#9ӌ8p궪JDrf\`D'~/'H=u$C+`{rawy\kˡlIB;:Ѣ]\ AnqΕ+7.p KO;  Ϳ4VE_:Kl OR+<ŇG ȝ "46wYGBQ]g = $dJظǼGi؅}\Qio8Cz57e+ٝxiu!Ìxf"M\< Xy?QXiStWC$xf., 261e*Ql܇ae-Osl_%kO]كF<[l!vN;x[@q]߫a~d]3H@:|+6tnZhA/[CZv&H8~ YzvQѲ7.t>+~ˆ*}&rL]}'fPmS6BiFnZ^LecggОefYdzҖqDvrRS-+i'vvycˬӦW7cJRbq*h3.]Y~/߉޸(gֱ$-CjAc1ݎs윜LWy83tNuQ8Hp < ţ=vi!^r7"> jxw S&oM OUk,Dʉ9&{4QԌ fCY$&d!\<ͪ WT+zgԗȉmdnNpOȏӤ8r2 in nJC׆4g`z͉4ZOcW+ mP~>y 9ޑƮ?ZܐSpSXϘ5(.41߾cEEkns짬eq}ù g__4v8<ܢ7ej0\zww{@_WJ\j;oX+Ћ;6$Pȑ?du?!5F>})SB͘bV7M8yER)xIQ c>ҏH]5FoOuؕ7¸d05_N |iś[ǽIgW;OnwDZ-OBa+ Ţ|_Kz=x$<=nzfQ8}Cu`GыHrS供ֺTNڏ =Aŷig?@õF;aK`Ӆ\ߗ-~??-=gϓjy'=rgag~\kQ+ &oj^MBߊO݊ȧ=Gf=vv ]^j5GPzMmx^5ΎSW:dĕ3U +K`P?"c/+f|3!v 6CCf&mtAr\ ns/w*U&zQΝ&JnUp:K{ gYX7VtpCmzӒ?ן˭ρYWeYY5>RsKqgy<.\a>>ԆyWYw;ƛnMax*OE@ ~4)N.$2)x0lG3b%,biE^DX"n Lr0Fо w!OdQZCiA`vEQ\m6γkͯtx1Y׻ɵ摸gvhw[B[YiiS*XrÊ /lrr J+zE!ֳ?0vp!ӊ-.W Ǝvh qVse'ǘy2}QSbޮ^C;iaᄨ5s _8K`jjJ <éqYjPMࠓ4yI} 'lٜ[7O]߳`!BN|վ^.p [vu no-Pv83M?JL؜Lz$m̊7uxpC_%si٥5Α?UF#DI~fNnA5,d U1BR0ѥ/0_DK ˧:Gwgņ޾4/wm[75,+vk%3?|GdvRѱ\^:†@-g9|Ԃk@;ьcW@+۶nz B7xf*T>ahB ~ܼڍg%7袹@縸|za/8S/' 1vr~@=6&;f `9ʶ#Hm?7M1tW~- _s-/Ts ˘Y2+N9IMըG!lt/(tG~* ,l4kyUU<)Ef̨$g7B Cl oo Oo *`"*:7Р}T+=@Wō`| n׀e/ץͭ~(޾ 'x`.?l݉]Zhi6{j Za[ubӧGw/ŝ_VQߒsdyti8QR^z8ԅc,t .&B}UH"/s] J +綱꣝l;?wsgUUZrbOܯ|(8ۙp\j%rSt:6hv$*4.ٕ\U9Ly*)o\n[:Hc@ jt<\BkxGc-!ސdv#g$ QmI%8J|UE^_۬'FWLaɮ~ m -4rmpU'|ޚW|ȱ df]s5l<[Y g Vh,P3:ͥN @7R.W]\To~λ29J0>8CG ӎx]9AziqxxAj-ܒꊍVqiCJQ@$a@JubI@Ё0'Y;_hiƋӛ>cw͘M n۞_ᘃӥ% ۾_bS(VfK"6A%8.h$([\ ZU{q|Rꍁ"ѱhC^R?gK&1TrˑGСӗ!NqfT~ޗ\Ш| e(+ĩ2~VJI7;;+ɩX &\4@"ݱ?|/^M>\l/M""4gUy<3zҠE.4Gƅ*,;;* _zNc)9܉T6IfҢOKeO6b=O"/n<]V'ֳ[bR>I"TIV[Wj?Qh}S7M%]Jx'xcpb{$}?Jm ]z9BϜ!JN"M|oRsUSxaï:AA_|r%+ʏS1e׫XwEEUU#83siJ7ْ fӗ3xnK|jU,И}8^w-f[777̘ s #M˜sywH.? ʉ8o6Bçm_ՎfXŭ}C~(Nub0-/}X9y M{rwOԉ>ٷ~cCRe |w@?q鼢dG\(bCoOc76lP\~'g}о^imf`xiuGakbĻNnV2 sHeצ(mУƺwѧ-ٸhS'hhPQ`h4W3_t^UqIf4[.8~#M!4Ot ^EzYϳkW<>{qK37?@φ$S<:S=ѡїH!"]T/u:.W4W )Th:K}Ê̱lì$摎iIX`E+73fDu z6/ ~Bט?ّ!+gw${q7# m_uujF%LQI߉-&= cM?ͦ埰Td h ޞ~.r>ؖN16,yyXk)ʕ;LJ}Gl I9o+/rsG<6r]s }0\rێw|y0DWKɟ>bƋm9įXOUF3kf5" x0)[XpAa'.2Hc2`vr1Q%ɕ?ڵj^޳-~';vX~DS,+|Þ CSU-~ק 0`P܈|x*G[.0QÐusXǥf R6gӌ>MYf q[y(_<ԙrq}; 2^l:_E|c-)Bbgkh>z za1*|~u| rޞ,Kь%OΡ<ʩU,9AV}͆䱏곏}ݝݾQl5-ǾH~+ X9k 5kCAڶX}&#Bm(v6ՠ0sOǁ80zO/XXG0rĻ,edFg*dD^~Z߄!_|p1pvrf.L+3|Jw{/Ro)zpӎ촜bMcΛ?/;Kf=9ȣ[/;TED _r"F!^&:յT^JQ"i;K1 Q5j6:nC$ sN)9,<]W e2+w+ܮZ8gƅ;;-'8RMNcN1^QR/"R(""믑\? 0E\c(32"H$D"Tz$of:_.K%D"H$D"\\;kK.Ƥo≺LοD"H$D"HG.TKQI_Q"H$D"H$ɿD"H$D"H$ED"H$D"H$j\H$D"q{ C}0:Qńwh}5K o)ݴF O_|}}ݩ^W} !FH$JF]D"H ϰ4k\C:gxy%>zԢE6psbUMQ'DvN8HTIQ {-kydcʅ,Y{lQZhA/[CZv&H8~ YТGE ΋`%iL>_k47ٝь;aٷ me8Ug"[ҭ .Y.DCo o}o e~oK29],{|őv75ZJU|0[#H$bED @YdfEQ2",W@?$f}X_+&z&|d_ѱ0"[?%~6C<6Hj&jx{|1P@`h& 1NW8V,qOpUUa%&y*Lfutx] &̮FhGq AFa->4U,]0R5c0 '&/k'2R̛e_fgU?C|1_ fz CLdskmgD1ad+"h"Α'wX_/>)o;.DZ'>C^^lSTp^)Zbam|K~+} hXO_/_E{Ĥ%_ ,>(:[&o^o}%aP}:/)/=ɳ|xm`; S@'1z21p}dEYd),""D"H$i06ȱc;v,cGXz:Ժ=\x oU|+\k{VjvS ^xXIK@THj EAXrɷK;Ϯ5r0RK^yXeMNne@XǪo|G;3cW;βt1]$#H$ם$D"d!PPO,g9|Ԃk@;ьcW@+۶nz B7xA5%+QyD'u%PtPP ҷ,gi'<6o?vEwеm}kf~mظ Y8ϯ"b87B\tt_6_"pG_E3S! _t}lKKS 'S]%C/^*$K}ဝEKO)#J,H$2ND"H*q1|-z>|[PځsX$&Ew@AUf]KfRx+eM .Q%($~9y'2}(~9n reŝ_c1iϔyƬ- K(hV\,gdäVSKR &u SvRs/ƏD"Tr'D"H$50G ah9)m+ɩX &\4 A \t`?lq1$]؉鴤a _ wu e%^Q-NqfT~ޗ-\I9}9$3n@ݜpp+`Htl.Wl&jm:'s]`$0O9׼gG"H$!wH$DRfO' 4t M4Oc&͌:An@A#3w9{r8ڴmz7`{}žHN枌ӛ3b= ]MYf /B|Jw{z za1*|~u|k?/[M˱/q$Rį_mwVBP6mAaG3hzKѮ}9"3O%K$9YWBǂ08w{N=G3X<,Kԟ|Ciy7zφΑG!dܨ²δe܇_cul}oGإ-ͦe?\7֒KwY\ӈUpa\]x߭[ {& O OU9^_α)vD"H EY'eW{E1"ӥjf_+܍|6Zs~¬ߟbpAN""$_xT;} a :ilj;ח>u3u+Y`M}($KׁITRe{Ef},|CD@ SYqˏJn_?",UWED F"H$DWnkEsc&9T%,G d% ab\2,/JnNN)/}2Ѥ>_ي~JDuOEYT;\L(Rʩ,8|%D"|"D"H$9dQH$D""i$D"HllZ<81L5i\ nD"ΐ4D"H$I$WD"\D"H$D"H$H#H$D"H$DR 4D".PK$b;9o==nMg)YO6ĝSP!b5譣:>1ፓؾ;[s%L/%+*xϨ|e5X>v;QnOs* y5:=+7!unD0[ y*8zP&O/@œQ a opxpl[Z#M_ͷȹ;2r ٹU]_F$QF:[£U&IM-2FN5ĉTOH]EzV@V&{wwLཇM33S諣 v6/ͺDn`0:쭙(tޚq(jSflIcu=KיLJ6W"JR'8_6SӧǎM/soH]29K,@t~;;_cO/@gdwF3he V&cTNBY0[]8 >\|Q|4;Kԋ,R͊BG=Ew|V|x`:&e⫯˞(L%Ӿ7smDO+BKu]E'E$<EGtw ?(ˊQtXD afqӸaD͖O=+ƍ|DhwE=Y naMіb2mUL S\U's)'"n+(dn^qKnnnj֪I{y /pqϔebuv^po X ᪙D@E}"/aP}:/)Ʊb'{" W-ѦG7SqvGExu/:U l#x{b仲}K/|EqKcz;;/ȣk -E,_(^NkILLہa+tR~- \tseSY6/GtߝT_.nF^h9鳏=ż;Wfs;1zL1w|efBLj4) (MĂg9yߝq(XwTEDz"{$[GB 2K_ B)j.B03,cRCiJJp*K L. 褕qAU ֗yMIc JfLL yge:vJ'&׫ٻ.%$/@cBС]6>~c) 9z!:آQEe:.G%TU%u`/-ցR9aX,\=ga!!^G}܏7qƒd@dTXCd(j^/ |rݷzn~ ģp޸A7gb\J).eM\[j֯LJc Գߎg# A<_2Md=8 v7 "i\]>8;m ٸfOgsYQhȩta??x0] =|+^ ۊql^FN#``cC_3h|Mt؟~o<$DG;ȷháF 0SBjJrF'Q@ʼneGXt'vH\g0i\tED],*8"U(Z_ĘA'vhOTܫ;+;ɝDku䆃QĪPV;_wqץq}twk*qf ?P1։nr}>#m)/CZ[Xgϭxt1^kY_ cRlүtO6ae?sOGdXUH3Rl/ ? b-C9I (i2[hO\ƷRBא ʼ֍Qi`USJlP\՗W1 4nt·38+:]l 1p/MTTb*=n$6-U[AӴ/z5,غ/1t>1_.<YG K2(51{ yP,+9˸ wԥ;y1PGjm HQm\Ͼsر1g?n]C ֏Zٟ45빽|jıL?h-i _'o e_xJ>2k:ISKkÞW'^D~~Y^PIJ@jb{ ~N/w^ΆVo,/#t;EuDr*aaPR\x踼䱐T}tdP:c[(B4q5V6-"3Ͳo毥8odgš!'yܗG99pnOzXdz K{ ӹ >.j&.! oNy'Oݥl56~[^x %rAG@4#cBF5*>Mcm꯼kvQF1fOܼA# ukɚ47ugxC ҂e4Ѧ'\vqb!oyF+_eνyLl|zA.^1Ե{1 ALq1 -@|vx:KYG$8 /E|3FJptBv((r-mSXAp,Z=F_),]R5Fd!0~t Ve8P, VϼIcڛGN}zf!:ć z ɫ ^֚%{L``ز*O[i +v&H3ݓHy(-0>g~ dKI3b%\| dE={)-)H kxug>hi4‡fgMzNH̵˪nxs<-SV\q=M]{0އxuH:.p%CY::ūyI+ϼ]&,}g'N'%.aO$nB֢OL~i>;-X)^53ډbggںRلIﺙ\uJPS{>`) _> U{o[0pj4ۦj-~۴-þ%~k*UeߛbtC3RAa<6M?oyNƯX:~lʫoo6cO4զ>u$fZ Lzg?K2@~5˪I,ov2c 9e]_ƏV%M{?6M :mE-J+edSИ=+be-lnMp É[׌dnIӣ S1Qq1zp]@})z@Him*&G7MQUkI'$D!c~ G^ _jM۠5DŽZ1fOy_`xnxT=xiM]ӭ1z~dO{cuMF6Z~|ٺGk"}GqjA!:- w^ȡSaܵ 'xzIFͅ4am-gل*NWד̗Wqo[ O={;R6RGhsynxbNul9T|ķ'BGk3||zP=4 ?/-}ERg'Y{ٙYqSkdGO}rVhv7[S9\7(f3<@##gG~`KPXtѣ̺n*V?ǚ XCLc<>|){ 173-y/nκyΦt?Ւkc!}y6\cǃZ"4C9vmj?TL&pR5 A£q:%>d&P\ɓOc۳pK*F`t53 鲃fR='aR4jOFes[X47?\X5ز?~6SF)df-<_g QATRP{SCw8]DE1a "4Ш9+O`~ӲAz6gϯO5/Ex&^n ~771zT_kSlp4Q.d3RG}-hpmY7)š93_HZӤvP9[a@:I\E051jT'^c-HQbW`z_v;]2xK[WrׁE5<{!nWﱫZwv '@PoޞmōL{Vd~soɨrdn$v A)?O ^gCTpr=x(<1/07L8(+U³SwTιL~uܬO^W==Qxpf|` c`'moj۽.9-&a*t on؍3(_~ǭ\2 SRf&;N5'0|L[#$\#gZ+`o>#׉sB tdUݿ0p-\>rn2RBi^>zd~ɧԙ躳G ߳F1~`l'& BlLr&H$K?_ͶoW+3O>L?{9 x F."[3ðِ[pO0?~5ojJЭ|슿)#j3;uOr?>rwfKE3ن=,z}O1O c;jeG!?#|t|c?=n; eS`(azKp8܃Rq^ΦwqM.>֝76Xw}u7w;_4|70s6pɝ8Hti9o?>t%3uN+7 4J䚰 y6ns.^ʔܗod( w낳(#P:]8y/N<ǡv7v<7O%(ƎCnChX^t.]mS%|a5]q7s<*Jχ&zM??̫?|nja[n,1_{c}9J!ڊ ё2,]ύ-d';e烯w;@d{]g̤86,FIlA?Q‡2~'SD̔ \ ]ed1w ;3 &2»64vI{5:Aס J$M8! *t@ N🭰'dN/&2؛QuG/ņmŸvt6KiLpb8MQ0Ym( ׋4%߳؞dٕ`ξp- 49j-Ylbsg7,QSIO_<ŧJt\J*++vs_fCVplntYSgdG|~)uǶ>&sBY7|}9&Txa =KnĪ*1I!hj4^2r|C_3o6r*]SeJ ODiqhv<{8 ,>t<<[}Kw;r=]^\#iߓAѱ 5g&y{uC֍ =Q}sByIJ ߵw2oyC4xl}Kx}_CɌ^(^OO ѿ`4ml*/f}s}rsrEKEֱӘΌП史-Q;=eB]nu:^/С_SCg\|x+׮I{d8&(\Iu]YhU_kZ'uV)ux5" >Pl:iv+ǵ˘{dLR{E&Dҳ{n4."nڒb#Rrc4mS'.'Ե3Cl^Q\L݉2gGzOA9I b>IakϾ=ª;@760{TԚuM6w,MѢ 8Y/L':n ){LJ&y~h#ɇ;:[x5/o)e'XoȺxaڟxK֚e?&%'̤ ˸ŕ|[,d"֥9pዯ嫩$?M"DaSV>x8  YNވ!ڌ2p^W0aQU dA5?3 -ꅜ @@ ^1~ ٌ٤(*ɄlBS}Ј~%W \vb&{6׏KH^ed& U{o[0pJgɻKp6Ɂ۬?}8=|7 !^R@35\~P_/ Y{>ʞxo3\8vSsT o\lŝ2bWi/>crp6P;TN23qEwR|ᣗU8Ydsd<,aݗ">7'Ф0y?-3{.s.MX#;5N%uy}T*/sBD=LFM6Ѧ׌v¦A!ATŢGEqѼ4bڼ}dq Չ4wZNh uz}f'$D!elzTlnjbpG zLEW98Z1@1=:SAzT\l>ZF6ŤEA'_iT͘G+n#'84/K1]Ps+z{{D?_{Q&l~z&e&yJ ޙma^{!M&G(u9 {V`l?jH?fN-x*)̫l^yUEqN#!: ݕ4Sx̉Cu  riIU5C6YZI3L'ҋ=-} Nͫ" !B4B!D{Ww.aq~_֗xBOΠޠdE(yx*]y6B! BaԌF{FiZDmU?CF_;t-w'X{b.]VEBxv<&B!MiBfv_O_`Ĥ(oSWRÕ|^b“]`BTfX4 \|dcJreF!ݒA!2ҁޜs O,!<.]${ɣz(3=|w?ABO JJ&qUJ%(l !B B&TEEQ4TPT4UCսx[xŋ .^wTb DL0ݺ٫\C'B!7v'! z&&Mi6\Z7p>f\7%71k Wݏ_ivP@ߣV+FIR]%_2`B4ā;OJVf9⽷W1Rv@ רK?4, ^g9[YtK*Ep@&-XNRr @*XB!BS ёqyNW6)V aA)W3hsf9>o/@4)Sa|+|y_ =x"۰E-4knQy/$:iNz}_[†,'r-'3劋B,tAg2{|=^Ƥ{'n-YY^Ceͬ}E6ebѿ+./qi\F]ͥSx{V*1/RI_B!Bd`!:+?ϬW?b iA|5j#q~Vooޜu|^IˇРIxvdաr^{ֲ%yrEQWE.wg#>ܷ4v㩮ړǾ]98TXᲛ,(qu\ n=/>aKn0rUkRn}7(>Ys[B!BS z\^FA- +vP7B/ GRZ3FQ%@L3#^>?jmL-c=lv?Ⓕq;fnOi+ Hs6ۓShRؿmz3|Y J )6J#B!h]&^xe;3)*w߭Jԗ.mَ1fFB3CfLl*F1O *DzWTHc\ZTg:NFxl':EZ v^! LL2_iB!Mfс(1|a=B5c!~ELߟ+W2>\p?$'nO!2՟]T.o\RmϠ/A{siNZ? ]h V'>ۧ͗]CS>f.6c3IO^{1`~գ8[T,!1EdVf0QNJ q s+B!)=j8uE93f(MJi@F*'JTPZy@fYZBM(**Ng%n,fˮYAUYi6M{l-,{}Tm8+xd_iB!gR?I#DGYB4CUiQgxJ)m>TQZT?/Nh잪R f4U.-󯛂y,ZSLTT&Gv1ꦃ[!B!DkA!8Yy&B!MA!8itJv~K  㰗Snw#FB!B_A!8ټ*JTuB!BvM_?t`g[!B!B3?&AKw4C!B!B6W8_J?I~9IENDB`gomuks-0.3.0/config/000077500000000000000000000000001433617251100142675ustar00rootroot00000000000000gomuks-0.3.0/config/config.go000066400000000000000000000257061433617251100160750ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package config import ( _ "embed" "encoding/json" "fmt" "io/ioutil" "os" "path/filepath" "strconv" "strings" "gopkg.in/yaml.v3" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" "go.mau.fi/cbind" "go.mau.fi/tcell" "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/matrix/rooms" ) type AuthCache struct { NextBatch string `yaml:"next_batch"` FilterID string `yaml:"filter_id"` FilterVersion int `yaml:"filter_version"` InitialSyncDone bool `yaml:"initial_sync_done"` } type UserPreferences struct { HideUserList bool `yaml:"hide_user_list"` HideRoomList bool `yaml:"hide_room_list"` HideTimestamp bool `yaml:"hide_timestamp"` BareMessageView bool `yaml:"bare_message_view"` DisableImages bool `yaml:"disable_images"` DisableTypingNotifs bool `yaml:"disable_typing_notifs"` DisableEmojis bool `yaml:"disable_emojis"` DisableMarkdown bool `yaml:"disable_markdown"` DisableHTML bool `yaml:"disable_html"` DisableDownloads bool `yaml:"disable_downloads"` DisableNotifications bool `yaml:"disable_notifications"` DisableShowURLs bool `yaml:"disable_show_urls"` AltEnterToSend bool `yaml:"alt_enter_to_send"` InlineURLMode string `yaml:"inline_url_mode"` } var InlineURLsProbablySupported bool func init() { vteVersion, _ := strconv.Atoi(os.Getenv("VTE_VERSION")) term := os.Getenv("TERM") // Enable inline URLs by default on VTE 0.50.0+ InlineURLsProbablySupported = vteVersion > 5000 || os.Getenv("TERM_PROGRAM") == "iTerm.app" || term == "foot" || term == "xterm-kitty" } func (up *UserPreferences) EnableInlineURLs() bool { return up.InlineURLMode == "enable" || (InlineURLsProbablySupported && up.InlineURLMode != "disable") } type Keybind struct { Mod tcell.ModMask Key tcell.Key Ch rune } type ParsedKeybindings struct { Main map[Keybind]string Room map[Keybind]string Modal map[Keybind]string Visual map[Keybind]string } type RawKeybindings struct { Main map[string]string `yaml:"main,omitempty"` Room map[string]string `yaml:"room,omitempty"` Modal map[string]string `yaml:"modal,omitempty"` Visual map[string]string `yaml:"visual,omitempty"` } // Config contains the main config of gomuks. type Config struct { UserID id.UserID `yaml:"mxid"` DeviceID id.DeviceID `yaml:"device_id"` AccessToken string `yaml:"access_token"` HS string `yaml:"homeserver"` RoomCacheSize int `yaml:"room_cache_size"` RoomCacheAge int64 `yaml:"room_cache_age"` NotifySound bool `yaml:"notify_sound"` SendToVerifiedOnly bool `yaml:"send_to_verified_only"` Backspace1RemovesWord bool `yaml:"backspace1_removes_word"` Backspace2RemovesWord bool `yaml:"backspace2_removes_word"` AlwaysClearScreen bool `yaml:"always_clear_screen"` Dir string `yaml:"-"` DataDir string `yaml:"data_dir"` CacheDir string `yaml:"cache_dir"` HistoryPath string `yaml:"history_path"` RoomListPath string `yaml:"room_list_path"` MediaDir string `yaml:"media_dir"` DownloadDir string `yaml:"download_dir"` StateDir string `yaml:"state_dir"` Preferences UserPreferences `yaml:"-"` AuthCache AuthCache `yaml:"-"` Rooms *rooms.RoomCache `yaml:"-"` PushRules *pushrules.PushRuleset `yaml:"-"` Keybindings ParsedKeybindings `yaml:"-"` nosave bool } // NewConfig creates a config that loads data from the given directory. func NewConfig(configDir, dataDir, cacheDir, downloadDir string) *Config { return &Config{ Dir: configDir, DataDir: dataDir, CacheDir: cacheDir, DownloadDir: downloadDir, HistoryPath: filepath.Join(cacheDir, "history.db"), RoomListPath: filepath.Join(cacheDir, "rooms.gob.gz"), StateDir: filepath.Join(cacheDir, "state"), MediaDir: filepath.Join(cacheDir, "media"), RoomCacheSize: 32, RoomCacheAge: 1 * 60, NotifySound: true, SendToVerifiedOnly: false, Backspace1RemovesWord: true, AlwaysClearScreen: true, } } // Clear clears the session cache and removes all history. func (config *Config) Clear() { _ = os.Remove(config.HistoryPath) _ = os.Remove(config.RoomListPath) _ = os.RemoveAll(config.StateDir) _ = os.RemoveAll(config.MediaDir) _ = os.RemoveAll(config.CacheDir) config.nosave = true } // ClearData clears non-temporary session data. func (config *Config) ClearData() { _ = os.RemoveAll(config.DataDir) } func (config *Config) CreateCacheDirs() { _ = os.MkdirAll(config.CacheDir, 0700) _ = os.MkdirAll(config.DataDir, 0700) _ = os.MkdirAll(config.StateDir, 0700) _ = os.MkdirAll(config.MediaDir, 0700) } func (config *Config) DeleteSession() { config.AuthCache.NextBatch = "" config.AuthCache.InitialSyncDone = false config.AccessToken = "" config.DeviceID = "" config.Rooms = rooms.NewRoomCache(config.RoomListPath, config.StateDir, config.RoomCacheSize, config.RoomCacheAge, config.GetUserID) config.PushRules = nil config.ClearData() config.Clear() config.nosave = false config.CreateCacheDirs() } func (config *Config) LoadAll() { config.Load() config.Rooms = rooms.NewRoomCache(config.RoomListPath, config.StateDir, config.RoomCacheSize, config.RoomCacheAge, config.GetUserID) config.LoadAuthCache() config.LoadPushRules() config.LoadPreferences() config.LoadKeybindings() err := config.Rooms.LoadList() if err != nil { panic(err) } } // Load loads the config from config.yaml in the directory given to the config struct. func (config *Config) Load() { err := config.load("config", config.Dir, "config.yaml", config) if err != nil { panic(fmt.Errorf("failed to load config.yaml: %w", err)) } config.CreateCacheDirs() } func (config *Config) SaveAll() { config.Save() config.SaveAuthCache() config.SavePushRules() config.SavePreferences() err := config.Rooms.SaveList() if err != nil { panic(err) } config.Rooms.SaveLoadedRooms() } // Save saves this config to config.yaml in the directory given to the config struct. func (config *Config) Save() { config.save("config", config.Dir, "config.yaml", config) } func (config *Config) LoadPreferences() { _ = config.load("user preferences", config.CacheDir, "preferences.yaml", &config.Preferences) } func (config *Config) SavePreferences() { config.save("user preferences", config.CacheDir, "preferences.yaml", &config.Preferences) } //go:embed keybindings.yaml var DefaultKeybindings string func parseKeybindings(input map[string]string) (output map[Keybind]string) { output = make(map[Keybind]string, len(input)) for shortcut, action := range input { mod, key, ch, err := cbind.Decode(shortcut) if err != nil { panic(fmt.Errorf("failed to parse keybinding %s -> %s: %w", shortcut, action, err)) } // TODO find out if other keys are parsed incorrectly like this if key == tcell.KeyEscape { ch = 0 } parsedShortcut := Keybind{ Mod: mod, Key: key, Ch: ch, } output[parsedShortcut] = action } return } func (config *Config) LoadKeybindings() { var inputConfig RawKeybindings err := yaml.Unmarshal([]byte(DefaultKeybindings), &inputConfig) if err != nil { panic(fmt.Errorf("failed to unmarshal default keybindings: %w", err)) } _ = config.load("keybindings", config.Dir, "keybindings.yaml", &inputConfig) config.Keybindings.Main = parseKeybindings(inputConfig.Main) config.Keybindings.Room = parseKeybindings(inputConfig.Room) config.Keybindings.Modal = parseKeybindings(inputConfig.Modal) config.Keybindings.Visual = parseKeybindings(inputConfig.Visual) } func (config *Config) SaveKeybindings() { config.save("keybindings", config.Dir, "keybindings.yaml", &config.Keybindings) } func (config *Config) LoadAuthCache() { err := config.load("auth cache", config.CacheDir, "auth-cache.yaml", &config.AuthCache) if err != nil { panic(fmt.Errorf("failed to load auth-cache.yaml: %w", err)) } } func (config *Config) SaveAuthCache() { config.save("auth cache", config.CacheDir, "auth-cache.yaml", &config.AuthCache) } func (config *Config) LoadPushRules() { _ = config.load("push rules", config.CacheDir, "pushrules.json", &config.PushRules) } func (config *Config) SavePushRules() { if config.PushRules == nil { return } config.save("push rules", config.CacheDir, "pushrules.json", &config.PushRules) } func (config *Config) load(name, dir, file string, target interface{}) error { err := os.MkdirAll(dir, 0700) if err != nil { debug.Print("Failed to create", dir) return err } path := filepath.Join(dir, file) data, err := ioutil.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil } debug.Print("Failed to read", name, "from", path) return err } if strings.HasSuffix(file, ".yaml") { err = yaml.Unmarshal(data, target) } else { err = json.Unmarshal(data, target) } if err != nil { debug.Print("Failed to parse", name, "at", path) return err } return nil } func (config *Config) save(name, dir, file string, source interface{}) { if config.nosave { return } err := os.MkdirAll(dir, 0700) if err != nil { debug.Print("Failed to create", dir) panic(err) } var data []byte if strings.HasSuffix(file, ".yaml") { data, err = yaml.Marshal(source) } else { data, err = json.Marshal(source) } if err != nil { debug.Print("Failed to marshal", name) panic(err) } path := filepath.Join(dir, file) err = ioutil.WriteFile(path, data, 0600) if err != nil { debug.Print("Failed to write", name, "to", path) panic(err) } } func (config *Config) GetUserID() id.UserID { return config.UserID } const FilterVersion = 1 func (config *Config) SaveFilterID(_ id.UserID, filterID string) { config.AuthCache.FilterID = filterID config.AuthCache.FilterVersion = FilterVersion config.SaveAuthCache() } func (config *Config) LoadFilterID(_ id.UserID) string { if config.AuthCache.FilterVersion != FilterVersion { return "" } return config.AuthCache.FilterID } func (config *Config) SaveNextBatch(_ id.UserID, nextBatch string) { config.AuthCache.NextBatch = nextBatch config.SaveAuthCache() } func (config *Config) LoadNextBatch(_ id.UserID) string { return config.AuthCache.NextBatch } func (config *Config) SaveRoom(_ *mautrix.Room) { panic("SaveRoom is not supported") } func (config *Config) LoadRoom(_ id.RoomID) *mautrix.Room { panic("LoadRoom is not supported") } gomuks-0.3.0/config/doc.go000066400000000000000000000001371433617251100153640ustar00rootroot00000000000000// Package config contains the wrappers for gomuks configurations and sessions. package config gomuks-0.3.0/config/keybindings.yaml000066400000000000000000000014371433617251100174660ustar00rootroot00000000000000main: 'Ctrl+Down': next_room 'Ctrl+Up': prev_room 'Ctrl+k': search_rooms 'Ctrl+Home': scroll_up 'Ctrl+End': scroll_down 'Ctrl+Enter': add_newline 'Ctrl+l': show_bare 'Alt+Down': next_room 'Alt+Up': prev_room 'Alt+k': search_rooms 'Alt+Home': scroll_up 'Alt+End': scroll_down 'Alt+Enter': add_newline 'Alt+a': next_active_room 'Alt+l': show_bare modal: 'Tab': select_next 'Down': select_next 'Backtab': select_prev 'Up': select_prev 'Enter': confirm 'Escape': cancel visual: 'Escape': clear 'h': clear 'Up': select_prev 'k': select_prev 'Down': select_next 'j': select_next 'Enter': confirm 'l': confirm room: 'Escape': clear 'Ctrl+p': scroll_up 'Ctrl+n': scroll_down 'PageUp': scroll_up 'PageDown': scroll_down 'Enter': send gomuks-0.3.0/deb/000077500000000000000000000000001433617251100135545ustar00rootroot00000000000000gomuks-0.3.0/deb/DEBIAN/000077500000000000000000000000001433617251100144765ustar00rootroot00000000000000gomuks-0.3.0/deb/DEBIAN/control000066400000000000000000000002751433617251100161050ustar00rootroot00000000000000Package: gomuks Version: 0.3.0-1 Section: net Priority: optional Architecture: amd64 Maintainer: Tulir Asokan Description: A terminal based Matrix client written in Go. gomuks-0.3.0/debug/000077500000000000000000000000001433617251100141105ustar00rootroot00000000000000gomuks-0.3.0/debug/debug.go000066400000000000000000000110121433617251100155200ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package debug import ( "bytes" "fmt" "io" "io/ioutil" "os" "path/filepath" "runtime/debug" "time" "github.com/sasha-s/go-deadlock" ) var writer io.Writer var RecoverPrettyPanic bool var DeadlockDetection bool var WriteLogs bool var OnRecover func() var LogDirectory = filepath.Join(os.TempDir(), "gomuks") func Initialize() { err := os.MkdirAll(LogDirectory, 0750) if err != nil { RecoverPrettyPanic = false DeadlockDetection = false WriteLogs = false return } if WriteLogs { writer, err = os.OpenFile(filepath.Join(LogDirectory, "debug.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640) if err != nil { panic(err) } _, _ = fmt.Fprintf(writer, "======================= Debug init @ %s =======================\n", time.Now().Format("2006-01-02 15:04:05")) } if DeadlockDetection { deadlocks, err := os.OpenFile(filepath.Join(LogDirectory, "deadlock.log"), os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640) if err != nil { panic(err) } deadlock.Opts.LogBuf = deadlocks deadlock.Opts.OnPotentialDeadlock = func() { if OnRecover != nil { OnRecover() } _, _ = fmt.Fprintf(os.Stderr, "Potential deadlock detected. See %s/deadlock.log for more information.", LogDirectory) os.Exit(88) } _, err = fmt.Fprintf(deadlocks, "======================= Debug init @ %s =======================\n", time.Now().Format("2006-01-02 15:04:05")) if err != nil { panic(err) } } else { deadlock.Opts.Disable = true } } func Printf(text string, args ...interface{}) { if writer != nil { _, _ = fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] ")) _, _ = fmt.Fprintf(writer, text+"\n", args...) } } func Print(text ...interface{}) { if writer != nil { _, _ = fmt.Fprintf(writer, time.Now().Format("[2006-01-02 15:04:05] ")) _, _ = fmt.Fprintln(writer, text...) } } func PrintStack() { if writer != nil { _, _ = writer.Write(debug.Stack()) } } // Recover recovers a panic, runs the OnRecover handler and either re-panics or // shows an user-friendly message about the panic depending on whether or not // the pretty panic mode is enabled. func Recover() { if p := recover(); p != nil { if OnRecover != nil { OnRecover() } if RecoverPrettyPanic { PrettyPanic(p) } else { panic(p) } } } const Oops = ` __________ < Oh noes! > ‾‾‾\‾‾‾‾‾‾ \ ^__^ \ (XX)\_______ (__)\ )\/\ U ||----W | || || A fatal error has occurred. ` func PrettyPanic(panic interface{}) { fmt.Print(Oops) traceFile := fmt.Sprintf(filepath.Join(LogDirectory, "panic-%s.txt"), time.Now().Format("2006-01-02--15-04-05")) var buf bytes.Buffer _, _ = fmt.Fprintln(&buf, panic) buf.Write(debug.Stack()) err := ioutil.WriteFile(traceFile, buf.Bytes(), 0640) if err != nil { fmt.Println("Saving the stack trace to", traceFile, "failed:") fmt.Println("--------------------------------------------------------------------------------") fmt.Println(err) fmt.Println("--------------------------------------------------------------------------------") fmt.Println("") fmt.Println("You can file an issue at https://github.com/tulir/gomuks/issues.") fmt.Println("Please provide the file save error (above) and the stack trace of the original error (below) when filing an issue.") fmt.Println("") fmt.Println("--------------------------------------------------------------------------------") fmt.Println(panic) debug.PrintStack() fmt.Println("--------------------------------------------------------------------------------") } else { fmt.Println("The stack trace has been saved to", traceFile) fmt.Println("") fmt.Println("You can file an issue at https://github.com/tulir/gomuks/issues.") fmt.Println("Please provide the contents of that file when filing an issue.") } os.Exit(1) } gomuks-0.3.0/debug/doc.go000066400000000000000000000001431433617251100152020ustar00rootroot00000000000000// Package debug contains utilities to log debug messages and display panics nicely. package debug gomuks-0.3.0/go.mod000066400000000000000000000033621433617251100141340ustar00rootroot00000000000000module maunium.net/go/gomuks go 1.18 require ( github.com/alecthomas/chroma v0.10.0 github.com/disintegration/imaging v1.6.2 github.com/gabriel-vasile/mimetype v1.4.1 github.com/kyokomi/emoji/v2 v2.2.10 github.com/lithammer/fuzzysearch v1.1.5 github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.14 github.com/mattn/go-sqlite3 v1.14.16 github.com/rivo/uniseg v0.4.2 github.com/sasha-s/go-deadlock v0.3.1 github.com/yuin/goldmark v1.5.3 github.com/zyedidia/clipboard v1.0.4 go.etcd.io/bbolt v1.3.6 go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e go.mau.fi/mauview v0.2.1 go.mau.fi/tcell v0.4.0 golang.org/x/image v0.1.0 golang.org/x/net v0.2.0 gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 gopkg.in/vansante/go-ffprobe.v2 v2.1.1 gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.11.0 mvdan.cc/xurls/v2 v2.4.0 ) require ( github.com/dlclark/regexp2 v1.4.0 // indirect github.com/gdamore/encoding v1.0.0 // indirect github.com/gorilla/mux v1.8.0 // indirect github.com/gorilla/websocket v1.5.0 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // indirect github.com/tidwall/gjson v1.14.1 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/sjson v1.2.4 // indirect golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 // indirect golang.org/x/sys v0.2.0 // indirect golang.org/x/term v0.2.0 // indirect golang.org/x/text v0.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect maunium.net/go/maulogger/v2 v2.3.2 // indirect ) replace github.com/mattn/go-runewidth => github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246 gomuks-0.3.0/go.sum000066400000000000000000000265021433617251100141620ustar00rootroot00000000000000github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E= github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q= github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M= github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kyokomi/emoji/v2 v2.2.10 h1:1z5eMVcxFifsmEoNpdeq4UahbcicgQ4FEHuzrCVwmiI= github.com/kyokomi/emoji/v2 v2.2.10/go.mod h1:JUcn42DTdsXJo1SWanHh4HKDEyPaR5CqkmoirZZP9qE= github.com/lithammer/fuzzysearch v1.1.5 h1:Ag7aKU08wp0R9QCfF4GoGST9HbmAIeLP7xwMrOBEp1c= github.com/lithammer/fuzzysearch v1.1.5/go.mod h1:1R1LRNk7yKid1BaQkmuLQaHruxcC4HmAH30Dh61Ih1Q= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ= github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8= github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.1 h1:iymTbGkQBhveq21bEvAQ81I0LEBork8BFe1CUZXdyuo= github.com/tidwall/gjson v1.14.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.4 h1:cuiLzLnaMeBhRmEv00Lpk3tkYrcxpmbU81tAY4Dw0tc= github.com/tidwall/sjson v1.2.4/go.mod h1:098SZ494YoMWPmMO6ct4dcFnqxwj9r/gF0Etp19pSNM= github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246 h1:WjkNcgoEaoL7i9mJuH+ff/hZHkSBR1KDdvoOoLpG6vs= github.com/tulir/go-runewidth v0.0.14-0.20221113132156-dc2fc6d28246/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.5.3 h1:3HUJmBFbQW9fhQOzMgseU134xfi6hU+mjWywx5Ty+/M= github.com/yuin/goldmark v1.5.3/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zyedidia/clipboard v1.0.4 h1:r6GUQOyPtIaApRLeD56/U+2uJbXis6ANGbKWCljULEo= github.com/zyedidia/clipboard v1.0.4/go.mod h1:zykFnZUXX0ErxqvYLUFEq7QDJKId8rmh2FgD0/Y8cjA= go.etcd.io/bbolt v1.3.6 h1:/ecaJf0sk1l4l6V4awd65v2C3ILy7MSj+s/x1ADCIMU= go.etcd.io/bbolt v1.3.6/go.mod h1:qXsaaIqmgQH0T+OPdb99Bf+PKfBBQVAdyD6TY9G8XM4= go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e h1:zY4TZmHAaUhrMFJQfh02dqxDYSfnnXlw/qRoFanxZTw= go.mau.fi/cbind v0.0.0-20220415094356-e1d579b7925e/go.mod h1:9nnzlslhUo/xO+8tsQgkFqG/W+SgD+r0iTYAuglzlmA= go.mau.fi/mauview v0.2.1 h1:Sv+L3MQoo0VWuqgO/SIzhTzDcd7iqPGZgxH3au2kUGw= go.mau.fi/mauview v0.2.1/go.mod h1:aTb1VjsjFmZ5YsdMQTIHrma9Ki2O0WwkS2Er7bIgoUs= go.mau.fi/tcell v0.4.0 h1:IPFKhkzF3yZkcRYjzgYBWWiW0JWPTwEBoXlWTBT8o/4= go.mau.fi/tcell v0.4.0/go.mod h1:77zV/6KL4Zip1u9ndjswACmu/LWwZ/oe3BE188uWMrA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 h1:NUzdAbFtCJSXU20AOXgeqaUwg8Ypg4MPYmL+d+rsB5c= golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.1.0 h1:r8Oj8ZA2Xy12/b5KZYj3tuv7NG/fBz3TwQVvpJ9l8Rk= golang.org/x/image v0.1.0/go.mod h1:iyPr49SD/G/TBxYVB/9RRtGUT5eNbo2u4NamWeQcD5c= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.2.0 h1:sZfSu1wtKLGlWI4ZZayP0ck9Y73K1ynO6gqzTdBVdPU= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20200923182605-d9f96fdee20d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0 h1:ljd4t30dBnAvMZaQCevtY0xLLD0A+bRZXbgLMLU1F/A= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0 h1:z85xZCsEl7bi/KwbNADeBYoOP0++7W1ipu+aGnpwzRM= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.4.0 h1:BrVqGRd7+k1DiOgtnFvAkoQEWQvBc25ouMJM6429SFg= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2 h1:MZF6J7CV6s/h0HBkfqebrYfKCVEo5iN+wzE4QhV3Evo= gopkg.in/toast.v1 v1.0.0-20180812000517-0a84660828b2/go.mod h1:s1Sn2yZos05Qfs7NKt867Xe18emOmtsO3eAKbDaon0o= gopkg.in/vansante/go-ffprobe.v2 v2.1.1 h1:DIh5fMn+tlBvG7pXyUZdemVmLdERnf2xX6XOFF+0BBU= gopkg.in/vansante/go-ffprobe.v2 v2.1.1/go.mod h1:qF0AlAjk7Nqzqf3y333Ly+KxN3cKF2JqA3JT5ZheUGE= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= maunium.net/go/maulogger/v2 v2.3.2 h1:1XmIYmMd3PoQfp9J+PaHhpt80zpfmMqaShzUTC7FwY0= maunium.net/go/maulogger/v2 v2.3.2/go.mod h1:TYWy7wKwz/tIXTpsx8G3mZseIRiC5DoMxSZazOHy68A= maunium.net/go/mautrix v0.11.0 h1:B1FBHcvE4Mud+AC+zgNQQOw0JxSVrt40watCejhVA7w= maunium.net/go/mautrix v0.11.0/go.mod h1:K29EcHwsNg6r7fMfwvi0GHQ9o5wSjqB9+Q8RjCIQEjA= mvdan.cc/xurls/v2 v2.4.0 h1:tzxjVAj+wSBmDcF6zBB7/myTy3gX9xvi8Tyr28AuQgc= mvdan.cc/xurls/v2 v2.4.0/go.mod h1:+GEjq9uNjqs8LQfM9nVnM8rff0OQ5Iash5rzX+N1CSg= gomuks-0.3.0/gomuks.go000066400000000000000000000102051433617251100146540ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package main import ( "fmt" "os" "os/signal" "strings" "syscall" "time" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/matrix" ) // Information to find out exactly which commit gomuks was built from. // These are filled at build time with the -X linker flag. var ( Tag = "unknown" Commit = "unknown" BuildTime = "unknown" ) var ( // Version is the version number of gomuks. Changed manually when making a release. Version = "0.3.0" // VersionString is the gomuks version, plus commit information. Filled in init() using the build-time values. VersionString = "" ) func init() { if len(Tag) > 0 && Tag[0] == 'v' { Tag = Tag[1:] } if Tag != Version { suffix := "" if !strings.HasSuffix(Version, "+dev") { suffix = "+dev" } if len(Commit) > 8 { Version = fmt.Sprintf("%s%s.%s", Version, suffix, Commit[:8]) } else { Version = fmt.Sprintf("%s%s.unknown", Version, suffix) } } VersionString = fmt.Sprintf("gomuks %s (%s)", Version, BuildTime) } // Gomuks is the wrapper for everything. type Gomuks struct { ui ifc.GomuksUI matrix *matrix.Container config *config.Config stop chan bool } // NewGomuks creates a new Gomuks instance with everything initialized, // but does not start it. func NewGomuks(uiProvider ifc.UIProvider, configDir, dataDir, cacheDir, downloadDir string) *Gomuks { gmx := &Gomuks{ stop: make(chan bool, 1), } gmx.config = config.NewConfig(configDir, dataDir, cacheDir, downloadDir) gmx.ui = uiProvider(gmx) gmx.matrix = matrix.NewContainer(gmx) gmx.config.LoadAll() gmx.ui.Init() debug.OnRecover = gmx.ui.Finish return gmx } func (gmx *Gomuks) Version() string { return Version } // Save saves the active session and message history. func (gmx *Gomuks) Save() { gmx.config.SaveAll() } // StartAutosave calls Save() every minute until it receives a stop signal // on the Gomuks.stop channel. func (gmx *Gomuks) StartAutosave() { defer debug.Recover() ticker := time.NewTicker(time.Minute) for { select { case <-ticker.C: if gmx.config.AuthCache.InitialSyncDone { gmx.Save() } case val := <-gmx.stop: if val { return } } } } // Stop stops the Matrix syncer, the tview app and the autosave goroutine, // then saves everything and calls os.Exit(0). func (gmx *Gomuks) Stop(save bool) { go gmx.internalStop(save) } func (gmx *Gomuks) internalStop(save bool) { debug.Print("Disconnecting from Matrix...") gmx.matrix.Stop() debug.Print("Cleaning up UI...") gmx.ui.Stop() gmx.stop <- true if save { gmx.Save() } debug.Print("Exiting process") os.Exit(0) } // Start opens a goroutine for the autosave loop and starts the tview app. // // If the tview app returns an error, it will be passed into panic(), which // will be recovered as specified in Recover(). func (gmx *Gomuks) Start() { _ = gmx.matrix.InitClient() c := make(chan os.Signal, 1) signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c gmx.Stop(true) }() go gmx.StartAutosave() if err := gmx.ui.Start(); err != nil { panic(err) } } // Matrix returns the MatrixContainer instance. func (gmx *Gomuks) Matrix() ifc.MatrixContainer { return gmx.matrix } // Config returns the Gomuks config instance. func (gmx *Gomuks) Config() *config.Config { return gmx.config } // UI returns the Gomuks UI instance. func (gmx *Gomuks) UI() ifc.GomuksUI { return gmx.ui } gomuks-0.3.0/interface/000077500000000000000000000000001433617251100147625ustar00rootroot00000000000000gomuks-0.3.0/interface/doc.go000066400000000000000000000001521433617251100160540ustar00rootroot00000000000000// Package ifc contains interfaces to allow circular function calls without circular imports. package ifc gomuks-0.3.0/interface/gomuks.go000066400000000000000000000017421433617251100166220ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ifc import ( "maunium.net/go/gomuks/config" ) // Gomuks is the wrapper for everything. type Gomuks interface { Matrix() MatrixContainer UI() GomuksUI Config() *config.Config Version() string Start() Stop(save bool) } gomuks-0.3.0/interface/matrix.go000066400000000000000000000062511433617251100166210ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ifc import ( "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/matrix/rooms" ) type Relation struct { Type event.RelationType Event *muksevt.Event } type UploadedMediaInfo struct { *mautrix.RespMediaUpload EncryptionInfo *attachment.EncryptedFile MsgType event.MessageType Name string Info *event.FileInfo } type MatrixContainer interface { Client() *mautrix.Client Preferences() *config.UserPreferences InitClient() error Initialized() bool Start() Stop() Login(user, password string) error Logout() UIAFallback(authType mautrix.AuthType, sessionID string) error SendPreferencesToMatrix() PrepareMarkdownMessage(roomID id.RoomID, msgtype event.MessageType, text, html string, relation *Relation) *muksevt.Event PrepareMediaMessage(room *rooms.Room, path string, relation *Relation) (*muksevt.Event, error) SendEvent(evt *muksevt.Event) (id.EventID, error) Redact(roomID id.RoomID, eventID id.EventID, reason string) error SendTyping(roomID id.RoomID, typing bool) MarkRead(roomID id.RoomID, eventID id.EventID) JoinRoom(roomID id.RoomID, server string) (*rooms.Room, error) LeaveRoom(roomID id.RoomID) error CreateRoom(req *mautrix.ReqCreateRoom) (*rooms.Room, error) FetchMembers(room *rooms.Room) error GetHistory(room *rooms.Room, limit int, dbPointer uint64) ([]*muksevt.Event, uint64, error) GetEvent(room *rooms.Room, eventID id.EventID) (*muksevt.Event, error) GetRoom(roomID id.RoomID) *rooms.Room GetOrCreateRoom(roomID id.RoomID) *rooms.Room UploadMedia(path string, encrypt bool) (*UploadedMediaInfo, error) Download(uri id.ContentURI, file *attachment.EncryptedFile) ([]byte, error) DownloadToDisk(uri id.ContentURI, file *attachment.EncryptedFile, target string) (string, error) GetDownloadURL(uri id.ContentURI) string GetCachePath(uri id.ContentURI) string Crypto() Crypto } type Crypto interface { Load() error FlushStore() error ProcessSyncResponse(resp *mautrix.RespSync, since string) bool ProcessInRoomVerification(evt *event.Event) error HandleMemberEvent(*event.Event) DecryptMegolmEvent(*event.Event) (*event.Event, error) EncryptMegolmEvent(id.RoomID, event.Type, interface{}) (*event.EncryptedEventContent, error) ShareGroupSession(id.RoomID, []id.UserID) error Fingerprint() string } gomuks-0.3.0/interface/ui.go000066400000000000000000000041531433617251100157310ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ifc import ( "time" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" ) type UIProvider func(gmx Gomuks) GomuksUI type GomuksUI interface { Render() HandleNewPreferences() OnLogin() OnLogout() MainView() MainView Init() Start() error Stop() Finish() } type SyncingModal interface { SetIndeterminate() SetMessage(string) SetSteps(int) Step() Close() } type MainView interface { GetRoom(roomID id.RoomID) RoomView AddRoom(room *rooms.Room) RemoveRoom(room *rooms.Room) SetRooms(rooms *rooms.RoomCache) Bump(room *rooms.Room) UpdateTags(room *rooms.Room) SetTyping(roomID id.RoomID, users []id.UserID) OpenSyncingModal() SyncingModal NotifyMessage(room *rooms.Room, message Message, should pushrules.PushActionArrayShould) } type RoomView interface { MxRoom() *rooms.Room SetCompletions(completions []string) SetTyping(users []id.UserID) UpdateUserList() AddEvent(evt *muksevt.Event) Message AddRedaction(evt *muksevt.Event) AddEdit(evt *muksevt.Event) AddReaction(evt *muksevt.Event, key string) GetEvent(eventID id.EventID) Message AddServiceMessage(message string) } type Message interface { ID() id.EventID Time() time.Time NotificationSenderName() string NotificationContent() string SetIsHighlight(highlight bool) SetID(id id.EventID) } gomuks-0.3.0/lib/000077500000000000000000000000001433617251100135705ustar00rootroot00000000000000gomuks-0.3.0/lib/ansimage/000077500000000000000000000000001433617251100153545ustar00rootroot00000000000000gomuks-0.3.0/lib/ansimage/LICENSE000066400000000000000000000405251433617251100163670ustar00rootroot00000000000000Mozilla Public License Version 2.0 ================================== 1. Definitions -------------- 1.1. "Contributor" means each individual or legal entity that creates, contributes to the creation of, or owns Covered Software. 1.2. "Contributor Version" means the combination of the Contributions of others (if any) used by a Contributor and that particular Contributor's Contribution. 1.3. "Contribution" means Covered Software of a particular Contributor. 1.4. "Covered Software" means Source Code Form to which the initial Contributor has attached the notice in Exhibit A, the Executable Form of such Source Code Form, and Modifications of such Source Code Form, in each case including portions thereof. 1.5. "Incompatible With Secondary Licenses" means (a) that the initial Contributor has attached the notice described in Exhibit B to the Covered Software; or (b) that the Covered Software was made available under the terms of version 1.1 or earlier of the License, but not also under the terms of a Secondary License. 1.6. "Executable Form" means any form of the work other than Source Code Form. 1.7. "Larger Work" means a work that combines Covered Software with other material, in a separate file or files, that is not Covered Software. 1.8. "License" means this document. 1.9. "Licensable" means having the right to grant, to the maximum extent possible, whether at the time of the initial grant or subsequently, any and all of the rights conveyed by this License. 1.10. "Modifications" means any of the following: (a) any file in Source Code Form that results from an addition to, deletion from, or modification of the contents of Covered Software; or (b) any new file in Source Code Form that contains any Covered Software. 1.11. "Patent Claims" of a Contributor means any patent claim(s), including without limitation, method, process, and apparatus claims, in any patent Licensable by such Contributor that would be infringed, but for the grant of the License, by the making, using, selling, offering for sale, having made, import, or transfer of either its Contributions or its Contributor Version. 1.12. "Secondary License" means either the GNU General Public License, Version 2.0, the GNU Lesser General Public License, Version 2.1, the GNU Affero General Public License, Version 3.0, or any later versions of those licenses. 1.13. "Source Code Form" means the form of the work preferred for making modifications. 1.14. "You" (or "Your") means an individual or a legal entity exercising rights under this License. For legal entities, "You" includes any entity that controls, is controlled by, or is under common control with You. For purposes of this definition, "control" means (a) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (b) ownership of more than fifty percent (50%) of the outstanding shares or beneficial ownership of such entity. 2. License Grants and Conditions -------------------------------- 2.1. Grants Each Contributor hereby grants You a world-wide, royalty-free, non-exclusive license: (a) under intellectual property rights (other than patent or trademark) Licensable by such Contributor to use, reproduce, make available, modify, display, perform, distribute, and otherwise exploit its Contributions, either on an unmodified basis, with Modifications, or as part of a Larger Work; and (b) under Patent Claims of such Contributor to make, use, sell, offer for sale, have made, import, and otherwise transfer either its Contributions or its Contributor Version. 2.2. Effective Date The licenses granted in Section 2.1 with respect to any Contribution become effective for each Contribution on the date the Contributor first distributes such Contribution. 2.3. Limitations on Grant Scope The licenses granted in this Section 2 are the only rights granted under this License. No additional rights or licenses will be implied from the distribution or licensing of Covered Software under this License. Notwithstanding Section 2.1(b) above, no patent license is granted by a Contributor: (a) for any code that a Contributor has removed from Covered Software; or (b) for infringements caused by: (i) Your and any other third party's modifications of Covered Software, or (ii) the combination of its Contributions with other software (except as part of its Contributor Version); or (c) under Patent Claims infringed by Covered Software in the absence of its Contributions. This License does not grant any rights in the trademarks, service marks, or logos of any Contributor (except as may be necessary to comply with the notice requirements in Section 3.4). 2.4. Subsequent Licenses No Contributor makes additional grants as a result of Your choice to distribute the Covered Software under a subsequent version of this License (see Section 10.2) or under the terms of a Secondary License (if permitted under the terms of Section 3.3). 2.5. Representation Each Contributor represents that the Contributor believes its Contributions are its original creation(s) or it has sufficient rights to grant the rights to its Contributions conveyed by this License. 2.6. Fair Use This License is not intended to limit any rights You have under applicable copyright doctrines of fair use, fair dealing, or other equivalents. 2.7. Conditions Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in Section 2.1. 3. Responsibilities ------------------- 3.1. Distribution of Source Form All distribution of Covered Software in Source Code Form, including any Modifications that You create or to which You contribute, must be under the terms of this License. You must inform recipients that the Source Code Form of the Covered Software is governed by the terms of this License, and how they can obtain a copy of this License. You may not attempt to alter or restrict the recipients' rights in the Source Code Form. 3.2. Distribution of Executable Form If You distribute Covered Software in Executable Form then: (a) such Covered Software must also be made available in Source Code Form, as described in Section 3.1, and You must inform recipients of the Executable Form how they can obtain a copy of such Source Code Form by reasonable means in a timely manner, at a charge no more than the cost of distribution to the recipient; and (b) You may distribute such Executable Form under the terms of this License, or sublicense it under different terms, provided that the license for the Executable Form does not attempt to limit or alter the recipients' rights in the Source Code Form under this License. 3.3. Distribution of a Larger Work You may create and distribute a Larger Work under terms of Your choice, provided that You also comply with the requirements of this License for the Covered Software. If the Larger Work is a combination of Covered Software with a work governed by one or more Secondary Licenses, and the Covered Software is not Incompatible With Secondary Licenses, this License permits You to additionally distribute such Covered Software under the terms of such Secondary License(s), so that the recipient of the Larger Work may, at their option, further distribute the Covered Software under the terms of either this License or such Secondary License(s). 3.4. Notices You may not remove or alter the substance of any license notices (including copyright notices, patent notices, disclaimers of warranty, or limitations of liability) contained within the Source Code Form of the Covered Software, except that You may alter any license notices to the extent required to remedy known factual inaccuracies. 3.5. Application of Additional Terms You may choose to offer, and to charge a fee for, warranty, support, indemnity or liability obligations to one or more recipients of Covered Software. However, You may do so only on Your own behalf, and not on behalf of any Contributor. You must make it absolutely clear that any such warranty, support, indemnity, or liability obligation is offered by You alone, and You hereby agree to indemnify every Contributor for any liability incurred by such Contributor as a result of warranty, support, indemnity or liability terms You offer. You may include additional disclaimers of warranty and limitations of liability specific to any jurisdiction. 4. Inability to Comply Due to Statute or Regulation --------------------------------------------------- If it is impossible for You to comply with any of the terms of this License with respect to some or all of the Covered Software due to statute, judicial order, or regulation then You must: (a) comply with the terms of this License to the maximum extent possible; and (b) describe the limitations and the code they affect. Such description must be placed in a text file included with all distributions of the Covered Software under this License. Except to the extent prohibited by statute or regulation, such description must be sufficiently detailed for a recipient of ordinary skill to be able to understand it. 5. Termination -------------- 5.1. The rights granted under this License will terminate automatically if You fail to comply with any of its terms. However, if You become compliant, then the rights granted under this License from a particular Contributor are reinstated (a) provisionally, unless and until such Contributor explicitly and finally terminates Your grants, and (b) on an ongoing basis, if such Contributor fails to notify You of the non-compliance by some reasonable means prior to 60 days after You have come back into compliance. Moreover, Your grants from a particular Contributor are reinstated on an ongoing basis if such Contributor notifies You of the non-compliance by some reasonable means, this is the first time You have received notice of non-compliance with this License from such Contributor, and You become compliant prior to 30 days after Your receipt of the notice. 5.2. If You initiate litigation against any entity by asserting a patent infringement claim (excluding declaratory judgment actions, counter-claims, and cross-claims) alleging that a Contributor Version directly or indirectly infringes any patent, then the rights granted to You by any and all Contributors for the Covered Software under Section 2.1 of this License shall terminate. 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user license agreements (excluding distributors and resellers) which have been validly granted by You or Your distributors under this License prior to termination shall survive termination. ************************************************************************ * * * 6. Disclaimer of Warranty * * ------------------------- * * * * Covered Software is provided under this License on an "as is" * * basis, without warranty of any kind, either expressed, implied, or * * statutory, including, without limitation, warranties that the * * Covered Software is free of defects, merchantable, fit for a * * particular purpose or non-infringing. The entire risk as to the * * quality and performance of the Covered Software is with You. * * Should any Covered Software prove defective in any respect, You * * (not any Contributor) assume the cost of any necessary servicing, * * repair, or correction. This disclaimer of warranty constitutes an * * essential part of this License. No use of any Covered Software is * * authorized under this License except under this disclaimer. * * * ************************************************************************ ************************************************************************ * * * 7. Limitation of Liability * * -------------------------- * * * * Under no circumstances and under no legal theory, whether tort * * (including negligence), contract, or otherwise, shall any * * Contributor, or anyone who distributes Covered Software as * * permitted above, be liable to You for any direct, indirect, * * special, incidental, or consequential damages of any character * * including, without limitation, damages for lost profits, loss of * * goodwill, work stoppage, computer failure or malfunction, or any * * and all other commercial damages or losses, even if such party * * shall have been informed of the possibility of such damages. This * * limitation of liability shall not apply to liability for death or * * personal injury resulting from such party's negligence to the * * extent applicable law prohibits such limitation. Some * * jurisdictions do not allow the exclusion or limitation of * * incidental or consequential damages, so this exclusion and * * limitation may not apply to You. * * * ************************************************************************ 8. Litigation ------------- Any litigation relating to this License may be brought only in the courts of a jurisdiction where the defendant maintains its principal place of business and such litigation shall be governed by laws of that jurisdiction, without reference to its conflict-of-law provisions. Nothing in this Section shall prevent a party's ability to bring cross-claims or counter-claims. 9. Miscellaneous ---------------- This License represents the complete agreement concerning the subject matter hereof. If any provision of this License is held to be unenforceable, such provision shall be reformed only to the extent necessary to make it enforceable. Any law or regulation which provides that the language of a contract shall be construed against the drafter shall not be used to construe this License against a Contributor. 10. Versions of the License --------------------------- 10.1. New Versions Mozilla Foundation is the license steward. Except as provided in Section 10.3, no one other than the license steward has the right to modify or publish new versions of this License. Each version will be given a distinguishing version number. 10.2. Effect of New Versions You may distribute the Covered Software under the terms of the version of the License under which You originally received the Covered Software, or under the terms of any subsequent version published by the license steward. 10.3. Modified Versions If you create software not governed by this License, and you want to create a new license for such software, you may create and use a modified version of this License if you rename the license and remove any references to the name of the license steward (except to note that such modified license differs from this License). 10.4. Distributing Source Code Form that is Incompatible With Secondary Licenses If You choose to distribute Source Code Form that is Incompatible With Secondary Licenses under the terms of this version of the License, the notice described in Exhibit B of this License must be attached. Exhibit A - Source Code Form License Notice ------------------------------------------- This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. If it is not possible or desirable to put the notice in a particular file, then You may include the notice in a location (such as a LICENSE file in a relevant directory) where a recipient would be likely to look for such a notice. You may add additional accurate notices of copyright ownership. Exhibit B - "Incompatible With Secondary Licenses" Notice --------------------------------------------------------- This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. gomuks-0.3.0/lib/ansimage/ansimage.go000066400000000000000000000210071433617251100174670ustar00rootroot00000000000000// ___ _____ ____ // / _ \/ _/ |/_/ /____ ______ _ // / ___// /_> =2") // ErrOutOfBounds happens when ANSI-pixel coordinates are out of ANSImage bounds. ErrOutOfBounds = errors.New("ANSImage: out of bounds") ) // ANSIpixel represents a pixel of an ANSImage. type ANSIpixel struct { Brightness uint8 R, G, B uint8 upper bool source *ANSImage } // ANSImage represents an image encoded in ANSI escape codes. type ANSImage struct { h, w int maxprocs int bgR uint8 bgG uint8 bgB uint8 pixmap [][]*ANSIpixel } func (ai *ANSImage) Pixmap() [][]*ANSIpixel { return ai.pixmap } // Height gets total rows of ANSImage. func (ai *ANSImage) Height() int { return ai.h } // Width gets total columns of ANSImage. func (ai *ANSImage) Width() int { return ai.w } // SetMaxProcs sets the maximum number of parallel goroutines to render the ANSImage // (user should manually sets `runtime.GOMAXPROCS(max)` before to this change takes effect). func (ai *ANSImage) SetMaxProcs(max int) { ai.maxprocs = max } // GetMaxProcs gets the maximum number of parallels goroutines to render the ANSImage. func (ai *ANSImage) GetMaxProcs() int { return ai.maxprocs } // SetAt sets ANSI-pixel color (RBG) and brightness in coordinates (y,x). func (ai *ANSImage) SetAt(y, x int, r, g, b, brightness uint8) error { if y >= 0 && y < ai.h && x >= 0 && x < ai.w { ai.pixmap[y][x].R = r ai.pixmap[y][x].G = g ai.pixmap[y][x].B = b ai.pixmap[y][x].Brightness = brightness ai.pixmap[y][x].upper = y%2 == 0 return nil } return ErrOutOfBounds } // GetAt gets ANSI-pixel in coordinates (y,x). func (ai *ANSImage) GetAt(y, x int) (*ANSIpixel, error) { if y >= 0 && y < ai.h && x >= 0 && x < ai.w { return &ANSIpixel{ R: ai.pixmap[y][x].R, G: ai.pixmap[y][x].G, B: ai.pixmap[y][x].B, Brightness: ai.pixmap[y][x].Brightness, upper: ai.pixmap[y][x].upper, source: ai.pixmap[y][x].source, }, nil } return nil, ErrOutOfBounds } // Render returns the ANSI-compatible string form of ANSImage. // (Nice info for ANSI True Colour - https://gist.github.com/XVilka/8346728) func (ai *ANSImage) Render() []tstring.TString { type renderData struct { row int render tstring.TString } rows := make([]tstring.TString, ai.h/2) for y := 0; y < ai.h; y += ai.maxprocs { ch := make(chan renderData, ai.maxprocs) for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n { go func(row, y int) { defer func() { err := recover() if err != nil { debug.Print("Panic rendering ANSImage:", err) ch <- renderData{row: row, render: tstring.NewColorTString("ERROR", tcell.ColorRed)} } }() str := make(tstring.TString, ai.w) for x := 0; x < ai.w; x++ { topPixel := ai.pixmap[y][x] topColor := tcell.NewRGBColor(int32(topPixel.R), int32(topPixel.G), int32(topPixel.B)) bottomPixel := ai.pixmap[y+1][x] bottomColor := tcell.NewRGBColor(int32(bottomPixel.R), int32(bottomPixel.G), int32(bottomPixel.B)) str[x] = tstring.Cell{ Char: '▄', Style: tcell.StyleDefault.Background(topColor).Foreground(bottomColor), } } ch <- renderData{row: row, render: str} }(row, 2*row) } for n, row := 0, y; (n <= ai.maxprocs) && (2*row+1 < ai.h); n, row = n+1, y+n { data := <-ch rows[data.row] = data.render } } return rows } // New creates a new empty ANSImage ready to draw on it. func New(h, w int, bg color.Color) (*ANSImage, error) { if h%2 != 0 { return nil, ErrHeightNonMoT } if h < 2 || w < 2 { return nil, ErrInvalidBoundsMoT } r, g, b, _ := bg.RGBA() ansimage := &ANSImage{ h: h, w: w, maxprocs: 1, bgR: uint8(r), bgG: uint8(g), bgB: uint8(b), pixmap: nil, } ansimage.pixmap = func() [][]*ANSIpixel { v := make([][]*ANSIpixel, h) for y := 0; y < h; y++ { v[y] = make([]*ANSIpixel, w) for x := 0; x < w; x++ { v[y][x] = &ANSIpixel{ R: 0, G: 0, B: 0, Brightness: 0, source: ansimage, upper: y%2 == 0, } } } return v }() return ansimage, nil } // NewFromReader creates a new ANSImage from an io.Reader. // Background color is used to fill when image has transparency or dithering mode is enabled // Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). func NewFromReader(reader io.Reader, bg color.Color) (*ANSImage, error) { img, _, err := image.Decode(reader) if err != nil { return nil, err } return createANSImage(img, bg) } // NewScaledFromReader creates a new scaled ANSImage from an io.Reader. // Background color is used to fill when image has transparency or dithering mode is enabled // Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). func NewScaledFromReader(reader io.Reader, y, x int, bg color.Color) (*ANSImage, error) { img, _, err := image.Decode(reader) if err != nil { return nil, err } img = imaging.Resize(img, x, y, imaging.Lanczos) return createANSImage(img, bg) } // NewFromFile creates a new ANSImage from a file. // Background color is used to fill when image has transparency or dithering mode is enabled // Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). func NewFromFile(name string, bg color.Color) (*ANSImage, error) { reader, err := os.Open(name) if err != nil { return nil, err } defer reader.Close() return NewFromReader(reader, bg) } // NewScaledFromFile creates a new scaled ANSImage from a file. // Background color is used to fill when image has transparency or dithering mode is enabled // Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). func NewScaledFromFile(name string, y, x int, bg color.Color) (*ANSImage, error) { reader, err := os.Open(name) if err != nil { return nil, err } defer reader.Close() return NewScaledFromReader(reader, y, x, bg) } // createANSImage loads data from an image and returns an ANSImage. // Background color is used to fill when image has transparency or dithering mode is enabled // Dithering mode is used to specify the way that ANSImage render ANSI-pixels (char/block elements). func createANSImage(img image.Image, bg color.Color) (*ANSImage, error) { var rgbaOut *image.RGBA bounds := img.Bounds() // do compositing only if background color has no transparency (thank you @disq for the idea!) // (info - http://stackoverflow.com/questions/36595687/transparent-pixel-color-go-lang-image) if _, _, _, a := bg.RGBA(); a >= 0xffff { rgbaOut = image.NewRGBA(bounds) draw.Draw(rgbaOut, bounds, image.NewUniform(bg), image.ZP, draw.Src) draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Over) } else { if v, ok := img.(*image.RGBA); ok { rgbaOut = v } else { rgbaOut = image.NewRGBA(bounds) draw.Draw(rgbaOut, bounds, img, image.ZP, draw.Src) } } yMin, xMin := bounds.Min.Y, bounds.Min.X yMax, xMax := bounds.Max.Y, bounds.Max.X // always sets an even number of ANSIPixel rows... yMax = yMax - yMax%2 // one for upper pixel and another for lower pixel --> without dithering ansimage, err := New(yMax, xMax, bg) if err != nil { return nil, err } for y := yMin; y < yMax; y++ { for x := xMin; x < xMax; x++ { v := rgbaOut.RGBAAt(x, y) if err := ansimage.SetAt(y, x, v.R, v.G, v.B, 0); err != nil { return nil, err } } } return ansimage, nil } gomuks-0.3.0/lib/ansimage/doc.go000066400000000000000000000006071433617251100164530ustar00rootroot00000000000000// Package ansimage is a simplified version of the ansimage package // in https://github.com/eliukblau/pixterm focused in rendering images // to a tcell-based TUI app. // // ___ _____ ____ // / _ \/ _/ |/_/ /____ ______ _ // / ___// /_> . package filepicker import ( "bytes" "errors" "os/exec" "strings" ) var zenity string func init() { zenity, _ = exec.LookPath("zenity") } func IsSupported() bool { return len(zenity) > 0 } func Open() (string, error) { cmd := exec.Command(zenity, "--file-selection") var output bytes.Buffer cmd.Stdout = &output err := cmd.Run() if err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { return "", nil } return "", err } return strings.TrimSpace(output.String()), nil } gomuks-0.3.0/lib/notification/000077500000000000000000000000001433617251100162565ustar00rootroot00000000000000gomuks-0.3.0/lib/notification/doc.go000066400000000000000000000001651433617251100173540ustar00rootroot00000000000000// Package notification contains a simple cross-platform desktop notification sending function. package notification gomuks-0.3.0/lib/notification/notify_darwin.go000066400000000000000000000042151433617251100214630ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package notification import ( "fmt" "os/exec" ) var terminalNotifierAvailable = false func init() { if err := exec.Command("which", "terminal-notifier").Run(); err != nil { terminalNotifierAvailable = false } terminalNotifierAvailable = true } const sendScript = `on run {notifText, notifTitle} display notification notifText with title "gomuks" subtitle notifTitle end run` func Send(title, text string, critical, sound bool) error { if terminalNotifierAvailable { args := []string{"-title", "gomuks", "-subtitle", title, "-message", text} if critical { args = append(args, "-timeout", "15") } else { args = append(args, "-timeout", "4") } if sound { args = append(args, "-sound", "default") } //if len(iconPath) > 0 { // args = append(args, "-appIcon", iconPath) //} return exec.Command("terminal-notifier", args...).Run() } cmd := exec.Command("osascript", "-", text, title) if stdin, err := cmd.StdinPipe(); err != nil { return fmt.Errorf("failed to get stdin pipe for osascript: %w", err) } else if _, err = stdin.Write([]byte(sendScript)); err != nil { return fmt.Errorf("failed to write notification script to osascript: %w", err) } else if err = cmd.Run(); err != nil { return fmt.Errorf("failed to run notification script: %w", err) } else if !cmd.ProcessState.Success() { return fmt.Errorf("notification script exited unsuccessfully") } else { return nil } } gomuks-0.3.0/lib/notification/notify_windows.go000066400000000000000000000022261433617251100216710ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package notification import ( "gopkg.in/toast.v1" ) func Send(title, text string, critical, sound bool) error { notification := toast.Notification{ AppID: "gomuks", Title: title, Message: text, Audio: toast.Silent, Duration: toast.Short, // Icon: ..., } if sound { notification.Audio = toast.IM } if critical { notification.Duration = toast.Long } return notification.Push() } gomuks-0.3.0/lib/notification/notify_xdg.go000066400000000000000000000045561433617251100207710ustar00rootroot00000000000000//go:build !windows && !darwin // gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package notification import ( "os" "os/exec" ) var notifySendPath string var audioCommand string var tryAudioCommands = []string{"ogg123", "paplay"} var soundNormal = "/usr/share/sounds/freedesktop/stereo/message-new-instant.oga" var soundCritical = "/usr/share/sounds/freedesktop/stereo/complete.oga" func getSoundPath(env, defaultPath string) string { if path, ok := os.LookupEnv(env); ok { // Sound file overriden by environment return path } else if _, err := os.Stat(defaultPath); os.IsNotExist(err) { // Sound file doesn't exist, disable it return "" } else { // Default sound file exists and wasn't overridden by environment return defaultPath } } func init() { var err error if notifySendPath, err = exec.LookPath("notify-send"); err != nil { return } for _, cmd := range tryAudioCommands { if audioCommand, err = exec.LookPath(cmd); err == nil { break } } soundNormal = getSoundPath("GOMUKS_SOUND_NORMAL", soundNormal) soundCritical = getSoundPath("GOMUKS_SOUND_CRITICAL", soundCritical) } func Send(title, text string, critical, sound bool) error { if len(notifySendPath) == 0 { return nil } args := []string{"-a", "gomuks"} if !critical { args = append(args, "-u", "low") } //if iconPath { // args = append(args, "-i", iconPath) //} args = append(args, title, text) if sound && len(audioCommand) > 0 && len(soundNormal) > 0 { audioFile := soundNormal if critical && len(soundCritical) > 0 { audioFile = soundCritical } go func() { _ = exec.Command(audioCommand, audioFile).Run() }() } return exec.Command(notifySendPath, args...).Run() } gomuks-0.3.0/lib/open/000077500000000000000000000000001433617251100145315ustar00rootroot00000000000000gomuks-0.3.0/lib/open/doc.go000066400000000000000000000002551433617251100156270ustar00rootroot00000000000000// Package open contains a simple cross-platform way to open files in the program the OS wants to use. // // Based on https://github.com/skratchdot/open-golang package open gomuks-0.3.0/lib/open/open.go000066400000000000000000000022111433617251100160150ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package open import ( "os/exec" "maunium.net/go/gomuks/debug" ) func Open(input string) error { cmd := exec.Command(Command, append(Args, input)...) err := cmd.Start() if err != nil { debug.Printf("Failed to start %s: %v", Command, err) } else { go func() { waitErr := cmd.Wait() if waitErr != nil { debug.Printf("Failed to run %s: %v", Command, err) } }() } return err } gomuks-0.3.0/lib/open/open_darwin.go000066400000000000000000000000701433617251100173620ustar00rootroot00000000000000package open const Command = "open" var Args []string gomuks-0.3.0/lib/open/open_windows.go000066400000000000000000000017351433617251100176010ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package open import ( "os" "path/filepath" ) const FileProtocolHandler = "url.dll,FileProtocolHandler" var Command = filepath.Join(os.Getenv("SYSTEMROOT"), "System32", "rundll32.exe") var Args = []string{FileProtocolHandler} gomuks-0.3.0/lib/open/open_xdg.go000066400000000000000000000001341433617251100166610ustar00rootroot00000000000000//go:build !windows && !darwin package open const Command = "xdg-open" var Args []string gomuks-0.3.0/lib/util/000077500000000000000000000000001433617251100145455ustar00rootroot00000000000000gomuks-0.3.0/lib/util/doc.go000066400000000000000000000000761433617251100156440ustar00rootroot00000000000000// Package util contains miscellaneous utilities package util gomuks-0.3.0/lib/util/lcp.go000066400000000000000000000014101433617251100156460ustar00rootroot00000000000000// Licensed under the GNU Free Documentation License 1.2 // https://www.gnu.org/licenses/old-licenses/fdl-1.2.en.html // // Source: https://rosettacode.org/wiki/Longest_common_prefix#Go package util func LongestCommonPrefix(list []string) string { // Special cases first switch len(list) { case 0: return "" case 1: return list[0] } // LCP of min and max (lexigraphically) // is the LCP of the whole set. min, max := list[0], list[0] for _, s := range list[1:] { switch { case s < min: min = s case s > max: max = s } } for i := 0; i < len(min) && i < len(max); i++ { if min[i] != max[i] { return min[:i] } } // In the case where lengths are not equal but all bytes // are equal, min is the answer ("foo" < "foobar"). return min } gomuks-0.3.0/main.go000066400000000000000000000103201433617251100142710ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package main import ( "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "time" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/ui" ) var MainUIProvider ifc.UIProvider = ui.NewGomuksUI func main() { debugDir := os.Getenv("DEBUG_DIR") if len(debugDir) > 0 { debug.LogDirectory = debugDir } debugLevel := strings.ToLower(os.Getenv("DEBUG")) if debugLevel != "0" && debugLevel != "f" && debugLevel != "false" { debug.WriteLogs = true debug.RecoverPrettyPanic = true } if debugLevel == "1" || debugLevel == "t" || debugLevel == "true" { debug.RecoverPrettyPanic = false debug.DeadlockDetection = true } debug.Initialize() defer debug.Recover() var configDir, dataDir, cacheDir, downloadDir string var err error configDir, err = UserConfigDir() if err != nil { _, _ = fmt.Fprintln(os.Stderr, "Failed to get config directory:", err) os.Exit(3) } dataDir, err = UserDataDir() if err != nil { _, _ = fmt.Fprintln(os.Stderr, "Failed to get data directory:", err) os.Exit(3) } cacheDir, err = UserCacheDir() if err != nil { _, _ = fmt.Fprintln(os.Stderr, "Failed to get cache directory:", err) os.Exit(3) } downloadDir, err = UserDownloadDir() if err != nil { _, _ = fmt.Fprintln(os.Stderr, "Failed to get download directory:", err) os.Exit(3) } debug.Print("Config directory:", configDir) debug.Print("Data directory:", dataDir) debug.Print("Cache directory:", cacheDir) debug.Print("Download directory:", downloadDir) gmx := NewGomuks(MainUIProvider, configDir, dataDir, cacheDir, downloadDir) if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-v") { fmt.Println(VersionString) os.Exit(0) } gmx.Start() // We use os.Exit() everywhere, so exiting by returning from Start() shouldn't happen. time.Sleep(5 * time.Second) fmt.Println("Unexpected exit by return from gmx.Start().") os.Exit(2) } func getRootDir(subdir string) string { rootDir := os.Getenv("GOMUKS_ROOT") if rootDir == "" { return "" } return filepath.Join(rootDir, subdir) } func UserCacheDir() (dir string, err error) { dir = os.Getenv("GOMUKS_CACHE_HOME") if dir == "" { dir = getRootDir("cache") } if dir == "" { dir, err = os.UserCacheDir() dir = filepath.Join(dir, "gomuks") } return } func UserDataDir() (dir string, err error) { dir = os.Getenv("GOMUKS_DATA_HOME") if dir != "" { return } if runtime.GOOS == "windows" || runtime.GOOS == "darwin" { return UserConfigDir() } dir = getRootDir("data") if dir == "" { dir = os.Getenv("XDG_DATA_HOME") } if dir == "" { dir = os.Getenv("HOME") if dir == "" { return "", errors.New("neither $XDG_DATA_HOME nor $HOME are defined") } dir = filepath.Join(dir, ".local", "share") } dir = filepath.Join(dir, "gomuks") return } func getXDGUserDir(name string) (dir string, err error) { cmd := exec.Command("xdg-user-dir", name) var out strings.Builder cmd.Stdout = &out err = cmd.Run() dir = strings.TrimSpace(out.String()) return } func UserDownloadDir() (dir string, err error) { dir = os.Getenv("GOMUKS_DOWNLOAD_HOME") if dir != "" { return } dir, _ = getXDGUserDir("DOWNLOAD") if dir != "" { return } dir, err = os.UserHomeDir() dir = filepath.Join(dir, "Downloads") return } func UserConfigDir() (dir string, err error) { dir = os.Getenv("GOMUKS_CONFIG_HOME") if dir == "" { dir = getRootDir("config") } if dir == "" { dir, err = os.UserConfigDir() dir = filepath.Join(dir, "gomuks") } return } gomuks-0.3.0/matrix/000077500000000000000000000000001433617251100143265ustar00rootroot00000000000000gomuks-0.3.0/matrix/crypto.go000066400000000000000000000062451433617251100162040ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . //go:build cgo package matrix import ( "database/sql" "fmt" "os" "path/filepath" _ "github.com/mattn/go-sqlite3" "maunium.net/go/mautrix/crypto" "maunium.net/go/gomuks/debug" ) type cryptoLogger struct { prefix string } func (c cryptoLogger) Error(message string, args ...interface{}) { debug.Printf(fmt.Sprintf("[%s/Error] %s", c.prefix, message), args...) } func (c cryptoLogger) Warn(message string, args ...interface{}) { debug.Printf(fmt.Sprintf("[%s/Warn] %s", c.prefix, message), args...) } func (c cryptoLogger) Debug(message string, args ...interface{}) { debug.Printf(fmt.Sprintf("[%s/Debug] %s", c.prefix, message), args...) } func (c cryptoLogger) Trace(message string, args ...interface{}) { debug.Printf(fmt.Sprintf("[%s/Trace] %s", c.prefix, message), args...) } func isBadEncryptError(err error) bool { return err != crypto.SessionExpired && err != crypto.SessionNotShared && err != crypto.NoGroupSession } func (c *Container) initCrypto() error { var cryptoStore crypto.Store var err error legacyStorePath := filepath.Join(c.config.DataDir, "crypto.gob") if _, err = os.Stat(legacyStorePath); err == nil { debug.Printf("Using legacy crypto store as %s exists", legacyStorePath) cryptoStore, err = crypto.NewGobStore(legacyStorePath) if err != nil { return fmt.Errorf("file open: %w", err) } } else { debug.Printf("Using SQLite crypto store") newStorePath := filepath.Join(c.config.DataDir, "crypto.db") db, err := sql.Open("sqlite3", newStorePath) if err != nil { return fmt.Errorf("sql open: %w", err) } accID := fmt.Sprintf("%s/%s", c.config.UserID.String(), c.config.DeviceID) sqlStore := crypto.NewSQLCryptoStore(db, "sqlite3", accID, c.config.DeviceID, []byte("fi.mau.gomuks"), cryptoLogger{"Crypto/DB"}) err = sqlStore.CreateTables() if err != nil { return fmt.Errorf("create table: %w", err) } cryptoStore = sqlStore } crypt := crypto.NewOlmMachine(c.client, cryptoLogger{"Crypto"}, cryptoStore, c.config.Rooms) crypt.AllowUnverifiedDevices = !c.config.SendToVerifiedOnly c.crypto = crypt err = c.crypto.Load() if err != nil { return fmt.Errorf("failed to create olm machine: %w", err) } return nil } func (c *Container) cryptoOnLogin() { sqlStore, ok := c.crypto.(*crypto.OlmMachine).CryptoStore.(*crypto.SQLCryptoStore) if !ok { return } sqlStore.DeviceID = c.config.DeviceID sqlStore.AccountID = fmt.Sprintf("%s/%s", c.config.UserID.String(), c.config.DeviceID) } gomuks-0.3.0/matrix/doc.go000066400000000000000000000001341433617251100154200ustar00rootroot00000000000000// Package matrix contains wrappers for mautrix for use by the UI of gomuks. package matrix gomuks-0.3.0/matrix/history.go000066400000000000000000000205401433617251100163570ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package matrix import ( "bytes" "compress/gzip" "encoding/binary" "encoding/gob" "errors" sync "github.com/sasha-s/go-deadlock" bolt "go.etcd.io/bbolt" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/matrix/rooms" ) type HistoryManager struct { sync.Mutex db *bolt.DB historyEndPtr map[*rooms.Room]uint64 } var bucketRoomStreams = []byte("room_streams") var bucketRoomEventIDs = []byte("room_event_ids") var bucketStreamPointers = []byte("room_stream_pointers") const halfUint64 = ^uint64(0) >> 1 func NewHistoryManager(dbPath string) (*HistoryManager, error) { hm := &HistoryManager{ historyEndPtr: make(map[*rooms.Room]uint64), } db, err := bolt.Open(dbPath, 0600, &bolt.Options{ Timeout: 1, NoGrowSync: false, FreelistType: bolt.FreelistArrayType, }) if err != nil { return nil, err } err = db.Update(func(tx *bolt.Tx) error { _, err = tx.CreateBucketIfNotExists(bucketRoomStreams) if err != nil { return err } _, err = tx.CreateBucketIfNotExists(bucketRoomEventIDs) if err != nil { return err } _, err = tx.CreateBucketIfNotExists(bucketStreamPointers) if err != nil { return err } return nil }) if err != nil { return nil, err } hm.db = db return hm, nil } func (hm *HistoryManager) Close() error { return hm.db.Close() } var ( EventNotFoundError = errors.New("event not found") RoomNotFoundError = errors.New("room not found") ) func (hm *HistoryManager) getStreamIndex(tx *bolt.Tx, roomID []byte, eventID []byte) (*bolt.Bucket, []byte, error) { eventIDs := tx.Bucket(bucketRoomEventIDs).Bucket(roomID) if eventIDs == nil { return nil, nil, RoomNotFoundError } index := eventIDs.Get(eventID) if index == nil { return nil, nil, EventNotFoundError } stream := tx.Bucket(bucketRoomStreams).Bucket(roomID) return stream, index, nil } func (hm *HistoryManager) getEvent(tx *bolt.Tx, stream *bolt.Bucket, index []byte) (*muksevt.Event, error) { eventData := stream.Get(index) if eventData == nil || len(eventData) == 0 { return nil, EventNotFoundError } return unmarshalEvent(eventData) } func (hm *HistoryManager) Get(room *rooms.Room, eventID id.EventID) (evt *muksevt.Event, err error) { err = hm.db.View(func(tx *bolt.Tx) error { if stream, index, err := hm.getStreamIndex(tx, []byte(room.ID), []byte(eventID)); err != nil { return err } else if evt, err = hm.getEvent(tx, stream, index); err != nil { return err } return nil }) return } func (hm *HistoryManager) Update(room *rooms.Room, eventID id.EventID, update func(evt *muksevt.Event) error) error { return hm.db.Update(func(tx *bolt.Tx) error { if stream, index, err := hm.getStreamIndex(tx, []byte(room.ID), []byte(eventID)); err != nil { return err } else if evt, err := hm.getEvent(tx, stream, index); err != nil { return err } else if err = update(evt); err != nil { return err } else if eventData, err := marshalEvent(evt); err != nil { return err } else if err := stream.Put(index, eventData); err != nil { return err } return nil }) } func (hm *HistoryManager) Append(room *rooms.Room, events []*event.Event) ([]*muksevt.Event, error) { muksEvts, _, err := hm.store(room, events, true) return muksEvts, err } func (hm *HistoryManager) Prepend(room *rooms.Room, events []*event.Event) ([]*muksevt.Event, uint64, error) { return hm.store(room, events, false) } func (hm *HistoryManager) store(room *rooms.Room, events []*event.Event, append bool) (newEvents []*muksevt.Event, newPtrStart uint64, err error) { hm.Lock() defer hm.Unlock() newEvents = make([]*muksevt.Event, len(events)) err = hm.db.Update(func(tx *bolt.Tx) error { streamPointers := tx.Bucket(bucketStreamPointers) rid := []byte(room.ID) stream, err := tx.Bucket(bucketRoomStreams).CreateBucketIfNotExists(rid) if err != nil { return err } eventIDs, err := tx.Bucket(bucketRoomEventIDs).CreateBucketIfNotExists(rid) if err != nil { return err } if stream.Sequence() < halfUint64 { // The sequence counter (i.e. the future) the part after 2^63, i.e. the second half of uint64 // We set it to -1 because NextSequence will increment it by one. err = stream.SetSequence(halfUint64 - 1) if err != nil { return err } } if append { ptrStart, err := stream.NextSequence() if err != nil { return err } for i, evt := range events { newEvents[i] = muksevt.Wrap(evt) if err := put(stream, eventIDs, newEvents[i], ptrStart+uint64(i)); err != nil { return err } } err = stream.SetSequence(ptrStart + uint64(len(events)) - 1) if err != nil { return err } } else { ptrStart, ok := hm.historyEndPtr[room] if !ok { ptrStartRaw := streamPointers.Get(rid) if ptrStartRaw != nil { ptrStart = btoi(ptrStartRaw) } else { ptrStart = halfUint64 - 1 } } eventCount := uint64(len(events)) for i, evt := range events { newEvents[i] = muksevt.Wrap(evt) if err := put(stream, eventIDs, newEvents[i], -ptrStart-uint64(i)); err != nil { return err } } hm.historyEndPtr[room] = ptrStart + eventCount // TODO this is not the correct value for newPtrStart, figure out what the f*ck is going on here newPtrStart = ptrStart + eventCount err := streamPointers.Put(rid, itob(ptrStart+eventCount)) if err != nil { return err } } return nil }) return } func (hm *HistoryManager) Load(room *rooms.Room, num int, ptrStart uint64) (events []*muksevt.Event, newPtrStart uint64, err error) { hm.Lock() defer hm.Unlock() err = hm.db.View(func(tx *bolt.Tx) error { stream := tx.Bucket(bucketRoomStreams).Bucket([]byte(room.ID)) if stream == nil { return nil } if ptrStart == 0 { ptrStart = stream.Sequence() + 1 } c := stream.Cursor() k, v := c.Seek(itob(ptrStart - uint64(num))) ptrStartFound := btoi(k) if k == nil || ptrStartFound >= ptrStart { return nil } newPtrStart = ptrStartFound for ; k != nil && btoi(k) < ptrStart; k, v = c.Next() { evt, parseError := unmarshalEvent(v) if parseError != nil { return parseError } events = append(events, evt) } return nil }) // Reverse array because we read/append the history in reverse order. i := 0 j := len(events) - 1 for i < j { events[i], events[j] = events[j], events[i] i++ j-- } return } func itob(v uint64) []byte { b := make([]byte, 8) binary.BigEndian.PutUint64(b, v) return b } func btoi(b []byte) uint64 { return binary.BigEndian.Uint64(b) } func stripRaw(evt *muksevt.Event) { evtCopy := *evt.Event evtCopy.Content = event.Content{ Parsed: evt.Content.Parsed, } evt.Event = &evtCopy } func marshalEvent(evt *muksevt.Event) ([]byte, error) { stripRaw(evt) var buf bytes.Buffer enc, _ := gzip.NewWriterLevel(&buf, gzip.BestSpeed) if err := gob.NewEncoder(enc).Encode(evt); err != nil { _ = enc.Close() return nil, err } else if err := enc.Close(); err != nil { return nil, err } return buf.Bytes(), nil } func unmarshalEvent(data []byte) (*muksevt.Event, error) { evt := &muksevt.Event{} if cmpReader, err := gzip.NewReader(bytes.NewReader(data)); err != nil { return nil, err } else if err := gob.NewDecoder(cmpReader).Decode(evt); err != nil { _ = cmpReader.Close() return nil, err } else if err := cmpReader.Close(); err != nil { return nil, err } return evt, nil } func put(streams, eventIDs *bolt.Bucket, evt *muksevt.Event, key uint64) error { data, err := marshalEvent(evt) if err != nil { return err } keyBytes := itob(key) if err = streams.Put(keyBytes, data); err != nil { return err } if err = eventIDs.Put([]byte(evt.ID), keyBytes); err != nil { return err } return nil } gomuks-0.3.0/matrix/matrix.go000066400000000000000000001103211433617251100161570ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package matrix import ( "context" "crypto/tls" "encoding/gob" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" "os" "path" "path/filepath" "reflect" "runtime" dbg "runtime/debug" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/lib/open" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/matrix/rooms" ) // Container is a wrapper for a mautrix Client and some other stuff. // // It is used for all Matrix calls from the UI and Matrix event handlers. type Container struct { client *mautrix.Client crypto ifc.Crypto syncer *GomuksSyncer gmx ifc.Gomuks ui ifc.GomuksUI config *config.Config history *HistoryManager running bool stop chan bool typing int64 } // NewContainer creates a new Container for the given Gomuks instance. func NewContainer(gmx ifc.Gomuks) *Container { c := &Container{ config: gmx.Config(), ui: gmx.UI(), gmx: gmx, } return c } // Client returns the underlying mautrix Client. func (c *Container) Client() *mautrix.Client { return c.client } type mxLogger struct{} func (log mxLogger) Debugfln(message string, args ...interface{}) { debug.Printf("[Matrix] "+message, args...) } func (c *Container) Crypto() ifc.Crypto { return c.crypto } // InitClient initializes the mautrix client and connects to the homeserver specified in the config. func (c *Container) InitClient() error { if len(c.config.HS) == 0 { return fmt.Errorf("no homeserver entered") } if c.client != nil { c.Stop() c.client = nil c.crypto = nil } var mxid id.UserID var accessToken string if len(c.config.AccessToken) > 0 { accessToken = c.config.AccessToken mxid = c.config.UserID } var err error c.client, err = mautrix.NewClient(c.config.HS, mxid, accessToken) if err != nil { return fmt.Errorf("failed to create mautrix client: %w", err) } c.client.UserAgent = fmt.Sprintf("gomuks/%s %s", c.gmx.Version(), mautrix.DefaultUserAgent) c.client.Logger = mxLogger{} c.client.DeviceID = c.config.DeviceID err = c.initCrypto() if err != nil { return fmt.Errorf("failed to initialize crypto: %w", err) } if c.history == nil { c.history, err = NewHistoryManager(c.config.HistoryPath) if err != nil { return fmt.Errorf("failed to initialize history: %w", err) } } allowInsecure := len(os.Getenv("GOMUKS_ALLOW_INSECURE_CONNECTIONS")) > 0 if allowInsecure { c.client.Client = &http.Client{ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, } } c.stop = make(chan bool, 1) if len(accessToken) > 0 { go c.Start() } return nil } // Initialized returns whether or not the mautrix client is initialized (see InitClient()) func (c *Container) Initialized() bool { return c.client != nil } func (c *Container) PasswordLogin(user, password string) error { resp, err := c.client.Login(&mautrix.ReqLogin{ Type: "m.login.password", Identifier: mautrix.UserIdentifier{ Type: "m.id.user", User: user, }, Password: password, InitialDeviceDisplayName: "gomuks", StoreCredentials: true, StoreHomeserverURL: true, }) if err != nil { return err } c.finishLogin(resp) return nil } func (c *Container) finishLogin(resp *mautrix.RespLogin) { c.config.UserID = resp.UserID c.config.DeviceID = resp.DeviceID c.config.AccessToken = resp.AccessToken if resp.WellKnown != nil && len(resp.WellKnown.Homeserver.BaseURL) > 0 { c.config.HS = resp.WellKnown.Homeserver.BaseURL } c.config.Save() go c.Start() } func respondHTML(w http.ResponseWriter, status int, message string) { w.Header().Add("Content-Type", "text/html") w.WriteHeader(status) _, _ = w.Write([]byte(fmt.Sprintf(` gomuks single-sign on

%s

`, message))) } func (c *Container) SingleSignOn() error { loginURL := c.client.BuildURLWithQuery(mautrix.ClientURLPath{"v3", "login", "sso", "redirect"}, map[string]string{ "redirectUrl": "http://localhost:29325", }) err := open.Open(loginURL) if err != nil { return err } errChan := make(chan error, 1) server := &http.Server{Addr: ":29325"} server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { loginToken := r.URL.Query().Get("loginToken") if len(loginToken) == 0 { respondHTML(w, http.StatusBadRequest, "Missing loginToken parameter") return } resp, err := c.client.Login(&mautrix.ReqLogin{ Type: "m.login.token", Token: loginToken, InitialDeviceDisplayName: "gomuks", StoreCredentials: true, StoreHomeserverURL: true, }) if err != nil { respondHTML(w, http.StatusForbidden, err.Error()) errChan <- err return } respondHTML(w, http.StatusOK, fmt.Sprintf("Successfully logged in as %s", resp.UserID)) c.finishLogin(resp) go func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err = server.Shutdown(ctx) if err != nil { debug.Printf("Failed to shut down SSO server: %v\n", err) } errChan <- err }() }) err = server.ListenAndServe() if err != nil { return err } err = <-errChan return err } // Login sends a password login request with the given username and password. func (c *Container) Login(user, password string) error { resp, err := c.client.GetLoginFlows() if err != nil { return err } ssoSkippedBecausePassword := false for _, flow := range resp.Flows { if flow.Type == "m.login.password" { return c.PasswordLogin(user, password) } else if flow.Type == "m.login.sso" { if len(password) == 0 { return c.SingleSignOn() } else { ssoSkippedBecausePassword = true } } } if ssoSkippedBecausePassword { return fmt.Errorf("password login is not supported\nleave the password field blank to use SSO") } return fmt.Errorf("no supported login flows") } // Logout revokes the access token, stops the syncer and calls the OnLogout() method of the UI. func (c *Container) Logout() { c.client.Logout() c.Stop() c.config.DeleteSession() c.client = nil c.crypto = nil c.ui.OnLogout() } // Stop stops the Matrix syncer. func (c *Container) Stop() { if c.running { debug.Print("Stopping Matrix container...") select { case c.stop <- true: default: } c.client.StopSync() debug.Print("Closing history manager...") err := c.history.Close() if err != nil { debug.Print("Error closing history manager:", err) } c.history = nil if c.crypto != nil { debug.Print("Flushing crypto store") err = c.crypto.FlushStore() if err != nil { debug.Print("Error flushing crypto store:", err) } } } } // UpdatePushRules fetches the push notification rules from the server and stores them in the current Session object. func (c *Container) UpdatePushRules() { debug.Print("Updating push rules...") resp, err := c.client.GetPushRules() if err != nil { debug.Print("Failed to fetch push rules:", err) c.config.PushRules = &pushrules.PushRuleset{} } else { c.config.PushRules = resp } c.config.SavePushRules() } // PushRules returns the push notification rules. If no push rules are cached, UpdatePushRules() will be called first. func (c *Container) PushRules() *pushrules.PushRuleset { if c.config.PushRules == nil { c.UpdatePushRules() } return c.config.PushRules } var AccountDataGomuksPreferences = event.Type{ Type: "net.maunium.gomuks.preferences", Class: event.AccountDataEventType, } func init() { event.TypeMap[AccountDataGomuksPreferences] = reflect.TypeOf(config.UserPreferences{}) gob.Register(&config.UserPreferences{}) } type StubSyncingModal struct{} func (s StubSyncingModal) SetIndeterminate() {} func (s StubSyncingModal) SetMessage(s2 string) {} func (s StubSyncingModal) SetSteps(i int) {} func (s StubSyncingModal) Step() {} func (s StubSyncingModal) Close() {} // OnLogin initializes the syncer and updates the room list. func (c *Container) OnLogin() { c.cryptoOnLogin() c.ui.OnLogin() c.client.Store = c.config debug.Print("Initializing syncer") c.syncer = NewGomuksSyncer(c.config.Rooms) if c.crypto != nil { c.syncer.OnSync(c.crypto.ProcessSyncResponse) c.syncer.OnEventType(event.StateMember, func(source mautrix.EventSource, evt *event.Event) { // Don't spam the crypto module with member events of an initial sync // TODO invalidate all group sessions when clearing cache? if c.config.AuthCache.InitialSyncDone { c.crypto.HandleMemberEvent(evt) } }) c.syncer.OnEventType(event.EventEncrypted, c.HandleEncrypted) } else { c.syncer.OnEventType(event.EventEncrypted, c.HandleEncryptedUnsupported) } c.syncer.OnEventType(event.EventMessage, c.HandleMessage) c.syncer.OnEventType(event.EventSticker, c.HandleMessage) c.syncer.OnEventType(event.EventReaction, c.HandleMessage) c.syncer.OnEventType(event.EventRedaction, c.HandleRedaction) c.syncer.OnEventType(event.StateAliases, c.HandleMessage) c.syncer.OnEventType(event.StateCanonicalAlias, c.HandleMessage) c.syncer.OnEventType(event.StateTopic, c.HandleMessage) c.syncer.OnEventType(event.StateRoomName, c.HandleMessage) c.syncer.OnEventType(event.StateMember, c.HandleMembership) c.syncer.OnEventType(event.EphemeralEventReceipt, c.HandleReadReceipt) c.syncer.OnEventType(event.EphemeralEventTyping, c.HandleTyping) c.syncer.OnEventType(event.AccountDataDirectChats, c.HandleDirectChatInfo) c.syncer.OnEventType(event.AccountDataPushRules, c.HandlePushRules) c.syncer.OnEventType(event.AccountDataRoomTags, c.HandleTag) c.syncer.OnEventType(AccountDataGomuksPreferences, c.HandlePreferences) if len(c.config.AuthCache.NextBatch) == 0 { c.syncer.Progress = c.ui.MainView().OpenSyncingModal() c.syncer.Progress.SetMessage("Waiting for /sync response from server") c.syncer.Progress.SetIndeterminate() c.syncer.FirstDoneCallback = func() { c.syncer.Progress.Close() c.syncer.Progress = StubSyncingModal{} c.syncer.FirstDoneCallback = nil } } c.syncer.InitDoneCallback = func() { debug.Print("Initial sync done") c.config.AuthCache.InitialSyncDone = true debug.Print("Updating title caches") for _, room := range c.config.Rooms.Map { room.GetTitle() } debug.Print("Cleaning cached rooms from memory") c.config.Rooms.ForceClean() debug.Print("Saving all data") c.config.SaveAll() debug.Print("Adding rooms to UI") c.ui.MainView().SetRooms(c.config.Rooms) c.ui.Render() // The initial sync can be a bit heavy, so we force run the GC here // after cleaning up rooms from memory above. debug.Print("Running GC") runtime.GC() dbg.FreeOSMemory() } c.client.Syncer = c.syncer debug.Print("Setting existing rooms") c.ui.MainView().SetRooms(c.config.Rooms) debug.Print("OnLogin() done.") } // Start moves the UI to the main view, calls OnLogin() and runs the syncer forever until stopped with Stop() func (c *Container) Start() { defer debug.Recover() c.OnLogin() if c.client == nil { return } debug.Print("Starting sync...") c.running = true c.client.StreamSyncMinAge = 30 * time.Minute for { select { case <-c.stop: debug.Print("Stopping sync...") c.running = false return default: if err := c.client.Sync(); err != nil { if errors.Is(err, mautrix.MUnknownToken) { debug.Print("Sync() errored with ", err, " -> logging out") // TODO support soft logout c.Logout() } else { debug.Print("Sync() errored", err) } } else { debug.Print("Sync() returned without error") } } } } func (c *Container) HandlePreferences(source mautrix.EventSource, evt *event.Event) { if source&mautrix.EventSourceAccountData == 0 { return } orig := c.config.Preferences err := json.Unmarshal(evt.Content.VeryRaw, &c.config.Preferences) if err != nil { debug.Print("Failed to parse updated preferences:", err) return } debug.Printf("Updated preferences: %#v -> %#v", orig, c.config.Preferences) if c.config.AuthCache.InitialSyncDone { c.ui.HandleNewPreferences() } } func (c *Container) Preferences() *config.UserPreferences { return &c.config.Preferences } func (c *Container) SendPreferencesToMatrix() { defer debug.Recover() debug.Printf("Sending updated preferences: %#v", c.config.Preferences) err := c.client.SetAccountData(AccountDataGomuksPreferences.Type, &c.config.Preferences) if err != nil { debug.Print("Failed to update preferences:", err) } } func (c *Container) HandleRedaction(source mautrix.EventSource, evt *event.Event) { room := c.GetOrCreateRoom(evt.RoomID) var redactedEvt *muksevt.Event err := c.history.Update(room, evt.Redacts, func(redacted *muksevt.Event) error { redacted.Unsigned.RedactedBecause = evt redactedEvt = redacted return nil }) if err != nil { debug.Print("Failed to mark", evt.Redacts, "as redacted:", err) return } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { return } roomView := c.ui.MainView().GetRoom(evt.RoomID) if roomView == nil { debug.Printf("Failed to handle event %v: No room view found.", evt) return } roomView.AddRedaction(redactedEvt) if c.syncer.FirstSyncDone { c.ui.Render() } } var ErrCantEditOthersMessage = errors.New("can't edit message sent by someone else") func (c *Container) HandleEdit(room *rooms.Room, editsID id.EventID, editEvent *muksevt.Event) { var origEvt *muksevt.Event err := c.history.Update(room, editsID, func(evt *muksevt.Event) error { if editEvent.Sender != evt.Sender { return ErrCantEditOthersMessage } evt.Gomuks.Edits = append(evt.Gomuks.Edits, editEvent) origEvt = evt return nil }) if err == ErrCantEditOthersMessage { debug.Printf("Ignoring edit %s of %s by %s in %s: original event was sent by someone else", editEvent.ID, editsID, editEvent.Sender, editEvent.RoomID) return } else if err != nil { debug.Print("Failed to store edit in history db:", err) return } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { return } roomView := c.ui.MainView().GetRoom(editEvent.RoomID) if roomView == nil { debug.Printf("Failed to handle edit event %v: No room view found.", editEvent) return } roomView.AddEdit(origEvt) if c.syncer.FirstSyncDone { c.ui.Render() } } func (c *Container) HandleReaction(room *rooms.Room, reactsTo id.EventID, reactEvent *muksevt.Event) { rel := reactEvent.Content.AsReaction().RelatesTo var origEvt *muksevt.Event err := c.history.Update(room, reactsTo, func(evt *muksevt.Event) error { if evt.Unsigned.Relations.Annotations.Map == nil { evt.Unsigned.Relations.Annotations.Map = make(map[string]int) } val, _ := evt.Unsigned.Relations.Annotations.Map[rel.Key] evt.Unsigned.Relations.Annotations.Map[rel.Key] = val + 1 origEvt = evt return nil }) if err != nil { debug.Print("Failed to store reaction in history db:", err) return } else if !c.config.AuthCache.InitialSyncDone || !room.Loaded() { return } roomView := c.ui.MainView().GetRoom(reactEvent.RoomID) if roomView == nil { debug.Printf("Failed to handle edit event %v: No room view found.", reactEvent) return } roomView.AddReaction(origEvt, rel.Key) if c.syncer.FirstSyncDone { c.ui.Render() } } func (c *Container) HandleEncryptedUnsupported(source mautrix.EventSource, mxEvent *event.Event) { mxEvent.Type = muksevt.EventEncryptionUnsupported origContent, _ := mxEvent.Content.Parsed.(*event.EncryptedEventContent) mxEvent.Content.Parsed = muksevt.EncryptionUnsupportedContent{Original: origContent} c.HandleMessage(source, mxEvent) } func (c *Container) HandleEncrypted(source mautrix.EventSource, mxEvent *event.Event) { evt, err := c.crypto.DecryptMegolmEvent(mxEvent) if err != nil { debug.Printf("Failed to decrypt event %s: %v", mxEvent.ID, err) mxEvent.Type = muksevt.EventBadEncrypted origContent, _ := mxEvent.Content.Parsed.(*event.EncryptedEventContent) mxEvent.Content.Parsed = &muksevt.BadEncryptedContent{ Original: origContent, Reason: err.Error(), } c.HandleMessage(source, mxEvent) return } if evt.Type.IsInRoomVerification() { err := c.crypto.ProcessInRoomVerification(evt) if err != nil { debug.Printf("[Crypto/Error] Failed to process in-room verification event %s of type %s: %v", evt.ID, evt.Type.String(), err) } else { debug.Printf("[Crypto/Debug] Processed in-room verification event %s of type %s", evt.ID, evt.Type.String()) } } else { c.HandleMessage(source, evt) } } // HandleMessage is the event handler for the m.room.message timeline event. func (c *Container) HandleMessage(source mautrix.EventSource, mxEvent *event.Event) { room := c.GetOrCreateRoom(mxEvent.RoomID) if source&mautrix.EventSourceLeave != 0 { room.HasLeft = true return } else if source&mautrix.EventSourceState != 0 { return } relatable, ok := mxEvent.Content.Parsed.(event.Relatable) if ok { rel := relatable.GetRelatesTo() if editID := rel.GetReplaceID(); len(editID) > 0 { c.HandleEdit(room, editID, muksevt.Wrap(mxEvent)) return } else if reactionID := rel.GetAnnotationID(); mxEvent.Type == event.EventReaction && len(reactionID) > 0 { c.HandleReaction(room, reactionID, muksevt.Wrap(mxEvent)) return } } events, err := c.history.Append(room, []*event.Event{mxEvent}) if err != nil { debug.Printf("Failed to add event %s to history: %v", mxEvent.ID, err) } evt := events[0] if !c.config.AuthCache.InitialSyncDone { room.LastReceivedMessage = time.Unix(evt.Timestamp/1000, evt.Timestamp%1000*1000) return } mainView := c.ui.MainView() roomView := mainView.GetRoom(evt.RoomID) if roomView == nil { debug.Printf("Failed to handle event %v: No room view found.", evt) return } if !room.Loaded() { pushRules := c.PushRules().GetActions(room, evt.Event).Should() shouldNotify := pushRules.Notify || !pushRules.NotifySpecified if !shouldNotify { room.LastReceivedMessage = time.Unix(evt.Timestamp/1000, evt.Timestamp%1000*1000) room.AddUnread(evt.ID, shouldNotify, pushRules.Highlight) mainView.Bump(room) return } } message := roomView.AddEvent(evt) if message != nil { roomView.MxRoom().LastReceivedMessage = message.Time() if c.syncer.FirstSyncDone && evt.Sender != c.config.UserID { pushRules := c.PushRules().GetActions(roomView.MxRoom(), evt.Event).Should() mainView.NotifyMessage(roomView.MxRoom(), message, pushRules) c.ui.Render() } } else { debug.Printf("Parsing event %s type %s %v from %s in %s failed (ParseEvent() returned nil).", evt.ID, evt.Type.Repr(), evt.Content.Raw, evt.Sender, evt.RoomID) } } // HandleMembership is the event handler for the m.room.member state event. func (c *Container) HandleMembership(source mautrix.EventSource, evt *event.Event) { isLeave := source&mautrix.EventSourceLeave != 0 isTimeline := source&mautrix.EventSourceTimeline != 0 if isLeave { c.GetOrCreateRoom(evt.RoomID).HasLeft = true } isNonTimelineLeave := isLeave && !isTimeline if !c.config.AuthCache.InitialSyncDone && isNonTimelineLeave { return } else if evt.StateKey != nil && id.UserID(*evt.StateKey) == c.config.UserID { c.processOwnMembershipChange(evt) } else if !isTimeline && (!c.config.AuthCache.InitialSyncDone || isLeave) { // We don't care about other users' membership events in the initial sync or chats we've left. return } c.HandleMessage(source, evt) } func (c *Container) processOwnMembershipChange(evt *event.Event) { membership := evt.Content.AsMember().Membership prevMembership := event.MembershipLeave if evt.Unsigned.PrevContent != nil { prevMembership = evt.Unsigned.PrevContent.AsMember().Membership } debug.Printf("Processing own membership change: %s->%s in %s", prevMembership, membership, evt.RoomID) if membership == prevMembership { return } room := c.GetRoom(evt.RoomID) switch membership { case "join": room.HasLeft = false if c.config.AuthCache.InitialSyncDone { c.ui.MainView().UpdateTags(room) } fallthrough case "invite": if c.config.AuthCache.InitialSyncDone { c.ui.MainView().AddRoom(room) } case "leave": case "ban": if c.config.AuthCache.InitialSyncDone { c.ui.MainView().RemoveRoom(room) } room.HasLeft = true room.Unload() default: return } c.ui.Render() } func (c *Container) parseReadReceipt(evt *event.Event) (largestTimestampEvent id.EventID) { var largestTimestamp int64 for eventID, receipts := range *evt.Content.AsReceipt() { myInfo, ok := receipts.Read[c.config.UserID] if !ok { continue } if myInfo.Timestamp > largestTimestamp { largestTimestamp = myInfo.Timestamp largestTimestampEvent = eventID } } return } func (c *Container) HandleReadReceipt(source mautrix.EventSource, evt *event.Event) { if source&mautrix.EventSourceLeave != 0 { return } lastReadEvent := c.parseReadReceipt(evt) if len(lastReadEvent) == 0 { return } room := c.GetRoom(evt.RoomID) if room != nil { room.MarkRead(lastReadEvent) if c.config.AuthCache.InitialSyncDone { c.ui.Render() } } } func (c *Container) parseDirectChatInfo(evt *event.Event) map[*rooms.Room]id.UserID { directChats := make(map[*rooms.Room]id.UserID) for userID, roomIDList := range *evt.Content.AsDirectChats() { for _, roomID := range roomIDList { // TODO we shouldn't create direct chat rooms that we aren't in room := c.GetOrCreateRoom(roomID) if room != nil && !room.HasLeft { directChats[room] = userID } } } return directChats } func (c *Container) HandleDirectChatInfo(_ mautrix.EventSource, evt *event.Event) { directChats := c.parseDirectChatInfo(evt) for _, room := range c.config.Rooms.Map { userID, isDirect := directChats[room] if isDirect != room.IsDirect { room.IsDirect = isDirect room.OtherUser = userID if c.config.AuthCache.InitialSyncDone { c.ui.MainView().UpdateTags(room) } } } } // HandlePushRules is the event handler for the m.push_rules account data event. func (c *Container) HandlePushRules(_ mautrix.EventSource, evt *event.Event) { debug.Print("Received updated push rules") var err error c.config.PushRules, err = pushrules.EventToPushRules(evt) if err != nil { debug.Print("Failed to convert event to push rules:", err) return } c.config.SavePushRules() } // HandleTag is the event handler for the m.tag account data event. func (c *Container) HandleTag(_ mautrix.EventSource, evt *event.Event) { room := c.GetOrCreateRoom(evt.RoomID) tags := evt.Content.AsTag().Tags newTags := make([]rooms.RoomTag, len(tags)) index := 0 for tag, info := range tags { order := json.Number("0.5") if len(info.Order) > 0 { order = info.Order } newTags[index] = rooms.RoomTag{ Tag: tag, Order: order, } index++ } room.RawTags = newTags if c.config.AuthCache.InitialSyncDone { mainView := c.ui.MainView() mainView.UpdateTags(room) } } // HandleTyping is the event handler for the m.typing event. func (c *Container) HandleTyping(_ mautrix.EventSource, evt *event.Event) { if !c.config.AuthCache.InitialSyncDone { return } c.ui.MainView().SetTyping(evt.RoomID, evt.Content.AsTyping().UserIDs) } func (c *Container) MarkRead(roomID id.RoomID, eventID id.EventID) { go func() { defer debug.Recover() err := c.client.MarkRead(roomID, eventID) if err != nil { debug.Printf("Failed to mark %s in %s as read: %v", eventID, roomID, err) } }() } func (c *Container) PrepareMediaMessage(room *rooms.Room, path string, rel *ifc.Relation) (*muksevt.Event, error) { resp, err := c.UploadMedia(path, room.Encrypted) if err != nil { return nil, err } content := event.MessageEventContent{ MsgType: resp.MsgType, Body: resp.Name, Info: resp.Info, } if resp.EncryptionInfo != nil { content.File = &event.EncryptedFileInfo{ EncryptedFile: *resp.EncryptionInfo, URL: resp.ContentURI.CUString(), } } else { content.URL = resp.ContentURI.CUString() } return c.prepareEvent(room.ID, &content, rel), nil } func (c *Container) PrepareMarkdownMessage(roomID id.RoomID, msgtype event.MessageType, text, html string, rel *ifc.Relation) *muksevt.Event { var content event.MessageEventContent if html != "" { content = event.MessageEventContent{ FormattedBody: html, Format: event.FormatHTML, Body: text, MsgType: msgtype, } } else { content = format.RenderMarkdown(text, !c.config.Preferences.DisableMarkdown, !c.config.Preferences.DisableHTML) content.MsgType = msgtype } return c.prepareEvent(roomID, &content, rel) } func (c *Container) prepareEvent(roomID id.RoomID, content *event.MessageEventContent, rel *ifc.Relation) *muksevt.Event { if rel != nil && rel.Type == event.RelReplace { contentCopy := *content content.NewContent = &contentCopy content.Body = "* " + content.Body if len(content.FormattedBody) > 0 { content.FormattedBody = "* " + content.FormattedBody } content.RelatesTo = &event.RelatesTo{ Type: event.RelReplace, EventID: rel.Event.ID, } } else if rel != nil && rel.Type == event.RelReply { content.SetReply(rel.Event.Event) } txnID := c.client.TxnID() localEcho := muksevt.Wrap(&event.Event{ ID: id.EventID(txnID), Sender: c.config.UserID, Type: event.EventMessage, Timestamp: time.Now().UnixNano() / 1e6, RoomID: roomID, Content: event.Content{Parsed: content}, Unsigned: event.Unsigned{TransactionID: txnID}, }) localEcho.Gomuks.OutgoingState = muksevt.StateLocalEcho if rel != nil && rel.Type == event.RelReplace { localEcho.ID = rel.Event.ID localEcho.Gomuks.Edits = []*muksevt.Event{localEcho} } return localEcho } func (c *Container) Redact(roomID id.RoomID, eventID id.EventID, reason string) error { defer debug.Recover() _, err := c.client.RedactEvent(roomID, eventID, mautrix.ReqRedact{Reason: reason}) return err } // SendMessage sends the given event. func (c *Container) SendEvent(evt *muksevt.Event) (id.EventID, error) { defer debug.Recover() _, _ = c.client.UserTyping(evt.RoomID, false, 0) c.typing = 0 room := c.GetRoom(evt.RoomID) if room != nil && room.Encrypted && c.crypto != nil && evt.Type != event.EventReaction { encrypted, err := c.crypto.EncryptMegolmEvent(evt.RoomID, evt.Type, &evt.Content) if err != nil { if isBadEncryptError(err) { return "", err } debug.Print("Got", err, "while trying to encrypt message, sharing group session and trying again...") err = c.crypto.ShareGroupSession(room.ID, room.GetMemberList()) if err != nil { return "", err } encrypted, err = c.crypto.EncryptMegolmEvent(evt.RoomID, evt.Type, &evt.Content) if err != nil { return "", err } } evt.Type = event.EventEncrypted evt.Content = event.Content{Parsed: encrypted} } resp, err := c.client.SendMessageEvent(evt.RoomID, evt.Type, &evt.Content, mautrix.ReqSendEvent{TransactionID: evt.Unsigned.TransactionID}) if err != nil { return "", err } return resp.EventID, nil } func (c *Container) UploadMedia(path string, encrypt bool) (*ifc.UploadedMediaInfo, error) { var err error path, err = filepath.Abs(path) if err != nil { return nil, fmt.Errorf("failed to get absolute path: %w", err) } msgtype, info, err := getMediaInfo(path) if err != nil { return nil, err } file, err := os.Open(path) if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) } stat, err := file.Stat() if err != nil { return nil, fmt.Errorf("failed to get file info: %w", err) } uploadFileName := stat.Name() uploadMimeType := info.MimeType var content io.Reader var encryptionInfo *attachment.EncryptedFile if encrypt { uploadMimeType = "application/octet-stream" uploadFileName = "" encryptionInfo = attachment.NewEncryptedFile() content = encryptionInfo.EncryptStream(file) } else { content = file } resp, err := c.client.UploadMedia(mautrix.ReqUploadMedia{ Content: content, ContentLength: stat.Size(), ContentType: uploadMimeType, FileName: uploadFileName, }) if err != nil { return nil, err } return &ifc.UploadedMediaInfo{ RespMediaUpload: resp, EncryptionInfo: encryptionInfo, Name: stat.Name(), MsgType: msgtype, Info: &info, }, nil } func (c *Container) sendTypingAsync(roomID id.RoomID, typing bool, timeout int64) { defer debug.Recover() _, _ = c.client.UserTyping(roomID, typing, timeout) } // SendTyping sets whether or not the user is typing in the given room. func (c *Container) SendTyping(roomID id.RoomID, typing bool) { ts := time.Now().Unix() if (c.typing > ts && typing) || (c.typing == 0 && !typing) { return } if typing { go c.sendTypingAsync(roomID, true, 20000) c.typing = ts + 15 } else { go c.sendTypingAsync(roomID, false, 0) c.typing = 0 } } // CreateRoom attempts to create a new room and join the user. func (c *Container) CreateRoom(req *mautrix.ReqCreateRoom) (*rooms.Room, error) { resp, err := c.client.CreateRoom(req) if err != nil { return nil, err } room := c.GetOrCreateRoom(resp.RoomID) return room, nil } // JoinRoom makes the current user try to join the given room. func (c *Container) JoinRoom(roomID id.RoomID, server string) (*rooms.Room, error) { resp, err := c.client.JoinRoom(string(roomID), server, nil) if err != nil { return nil, err } room := c.GetOrCreateRoom(resp.RoomID) room.HasLeft = false return room, nil } // LeaveRoom makes the current user leave the given room. func (c *Container) LeaveRoom(roomID id.RoomID) error { _, err := c.client.LeaveRoom(roomID) if err != nil { return err } node := c.GetOrCreateRoom(roomID) node.HasLeft = true node.Unload() return nil } func (c *Container) FetchMembers(room *rooms.Room) error { debug.Print("Fetching member list for", room.ID) members, err := c.client.Members(room.ID, mautrix.ReqMembers{At: room.LastPrevBatch}) if err != nil { return err } debug.Printf("Fetched %d members for %s", len(members.Chunk), room.ID) for _, evt := range members.Chunk { err := evt.Content.ParseRaw(evt.Type) if err != nil { debug.Printf("Failed to parse member event of %s: %v", evt.GetStateKey(), err) continue } room.UpdateState(evt) } room.MembersFetched = true return nil } // GetHistory fetches room history. func (c *Container) GetHistory(room *rooms.Room, limit int, dbPointer uint64) ([]*muksevt.Event, uint64, error) { events, newDBPointer, err := c.history.Load(room, limit, dbPointer) if err != nil { return nil, dbPointer, err } if len(events) > 0 { debug.Printf("Loaded %d events for %s from local cache", len(events), room.ID) return events, newDBPointer, nil } resp, err := c.client.Messages(room.ID, room.PrevBatch, "", 'b', nil, limit) if err != nil { return nil, dbPointer, err } debug.Printf("Loaded %d events for %s from server from %s to %s", len(resp.Chunk), room.ID, resp.Start, resp.End) for i, evt := range resp.Chunk { err := evt.Content.ParseRaw(evt.Type) if err != nil { debug.Printf("Failed to unmarshal content of event %s (type %s) by %s in %s: %v\n%s", evt.ID, evt.Type.Repr(), evt.Sender, evt.RoomID, err, string(evt.Content.VeryRaw)) } if evt.Type == event.EventEncrypted { if c.crypto == nil { evt.Type = muksevt.EventEncryptionUnsupported origContent, _ := evt.Content.Parsed.(*event.EncryptedEventContent) evt.Content.Parsed = muksevt.EncryptionUnsupportedContent{Original: origContent} } else { decrypted, err := c.crypto.DecryptMegolmEvent(evt) if err != nil { debug.Printf("Failed to decrypt event %s: %v", evt.ID, err) evt.Type = muksevt.EventBadEncrypted origContent, _ := evt.Content.Parsed.(*event.EncryptedEventContent) evt.Content.Parsed = &muksevt.BadEncryptedContent{ Original: origContent, Reason: err.Error(), } } else { resp.Chunk[i] = decrypted } } } } for _, evt := range resp.State { room.UpdateState(evt) } room.PrevBatch = resp.End c.config.Rooms.Put(room) if len(resp.Chunk) == 0 { return []*muksevt.Event{}, dbPointer, nil } // TODO newDBPointer isn't accurate in this case yet, fix later events, newDBPointer, err = c.history.Prepend(room, resp.Chunk) if err != nil { return nil, dbPointer, err } return events, dbPointer, nil } func (c *Container) GetEvent(room *rooms.Room, eventID id.EventID) (*muksevt.Event, error) { evt, err := c.history.Get(room, eventID) if err != nil && err != EventNotFoundError { debug.Printf("Failed to get event %s from local cache: %v", eventID, err) } else if evt != nil { debug.Printf("Found event %s in local cache", eventID) return evt, err } mxEvent, err := c.client.GetEvent(room.ID, eventID) if err != nil { return nil, err } err = mxEvent.Content.ParseRaw(mxEvent.Type) if err != nil { return nil, err } debug.Printf("Loaded event %s from server", eventID) return muksevt.Wrap(mxEvent), nil } // GetOrCreateRoom gets the room instance stored in the session. func (c *Container) GetOrCreateRoom(roomID id.RoomID) *rooms.Room { return c.config.Rooms.GetOrCreate(roomID) } // GetRoom gets the room instance stored in the session. func (c *Container) GetRoom(roomID id.RoomID) *rooms.Room { return c.config.Rooms.Get(roomID) } func cp(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() out, err := os.Create(dst) if err != nil { return err } defer out.Close() _, err = io.Copy(out, in) if err != nil { return err } return out.Close() } func (c *Container) DownloadToDisk(uri id.ContentURI, file *attachment.EncryptedFile, target string) (fullPath string, err error) { cachePath := c.GetCachePath(uri) if target == "" { fullPath = cachePath } else if !path.IsAbs(target) { fullPath = path.Join(c.config.DownloadDir, target) } else { fullPath = target } if _, statErr := os.Stat(cachePath); os.IsNotExist(statErr) { var body io.ReadCloser body, err = c.client.Download(uri) if err != nil { return } var data []byte data, err = ioutil.ReadAll(body) _ = body.Close() if err != nil { return } if file != nil { err = file.DecryptInPlace(data) if err != nil { return } } err = ioutil.WriteFile(cachePath, data, 0600) if err != nil { return } } if fullPath != cachePath { err = os.MkdirAll(path.Dir(fullPath), 0700) if err != nil { return } err = cp(cachePath, fullPath) } return } // Download fetches the given Matrix content (mxc) URL and returns the data, homeserver, file ID and potential errors. // // The file will be either read from the media cache (if found) or downloaded from the server. func (c *Container) Download(uri id.ContentURI, file *attachment.EncryptedFile) (data []byte, err error) { cacheFile := c.GetCachePath(uri) var info os.FileInfo if info, err = os.Stat(cacheFile); err == nil && !info.IsDir() { data, err = ioutil.ReadFile(cacheFile) if err == nil { return } } data, err = c.download(uri, file, cacheFile) return } func (c *Container) GetDownloadURL(uri id.ContentURI) string { return c.client.GetDownloadURL(uri) } func (c *Container) download(uri id.ContentURI, file *attachment.EncryptedFile, cacheFile string) (data []byte, err error) { var body io.ReadCloser body, err = c.client.Download(uri) if err != nil { return } data, err = ioutil.ReadAll(body) _ = body.Close() if err != nil { return } if file != nil { err = file.DecryptInPlace(data) if err != nil { return } } err = ioutil.WriteFile(cacheFile, data, 0600) return } // GetCachePath gets the path to the cached version of the given homeserver:fileID combination. // The file may or may not exist, use Download() to ensure it has been cached. func (c *Container) GetCachePath(uri id.ContentURI) string { dir := filepath.Join(c.config.MediaDir, uri.Homeserver) err := os.MkdirAll(dir, 0700) if err != nil { return "" } return filepath.Join(dir, uri.FileID) } gomuks-0.3.0/matrix/mediainfo.go000066400000000000000000000055131433617251100166140ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package matrix import ( "context" "fmt" "image" "os" "strings" "time" "github.com/gabriel-vasile/mimetype" "gopkg.in/vansante/go-ffprobe.v2" "maunium.net/go/mautrix/event" "maunium.net/go/gomuks/debug" ) func getImageInfo(path string) (event.FileInfo, error) { var info event.FileInfo file, err := os.Open(path) if err != nil { return info, fmt.Errorf("failed to open image to get info: %w", err) } cfg, _, err := image.DecodeConfig(file) if err != nil { return info, fmt.Errorf("failed to get image info: %w", err) } info.Width = cfg.Width info.Height = cfg.Height return info, nil } func getFFProbeInfo(mimeClass, path string) (msgtype event.MessageType, info event.FileInfo, err error) { ctx, cancelFn := context.WithTimeout(context.Background(), 5*time.Second) defer cancelFn() var probedInfo *ffprobe.ProbeData probedInfo, err = ffprobe.ProbeURL(ctx, path) if err != nil { err = fmt.Errorf("failed to get %s info with ffprobe: %w", mimeClass, err) return } if mimeClass == "audio" { msgtype = event.MsgAudio stream := probedInfo.FirstAudioStream() if stream != nil { info.Duration = int(stream.DurationTs) } } else { msgtype = event.MsgVideo stream := probedInfo.FirstVideoStream() if stream != nil { info.Duration = int(stream.DurationTs) info.Width = stream.Width info.Height = stream.Height } } return } func getMediaInfo(path string) (msgtype event.MessageType, info event.FileInfo, err error) { var mime *mimetype.MIME mime, err = mimetype.DetectFile(path) if err != nil { err = fmt.Errorf("failed to get content type: %w", err) return } mimeClass := strings.SplitN(mime.String(), "/", 2)[0] switch mimeClass { case "image": msgtype = event.MsgImage info, err = getImageInfo(path) if err != nil { debug.Printf("Failed to get image info for %s: %v", err) err = nil } case "audio", "video": msgtype, info, err = getFFProbeInfo(mimeClass, path) if err != nil { debug.Printf("Failed to get ffprobe info for %s: %v", err) err = nil } default: msgtype = event.MsgFile } info.MimeType = mime.String() return } gomuks-0.3.0/matrix/muksevt/000077500000000000000000000000001433617251100160245ustar00rootroot00000000000000gomuks-0.3.0/matrix/muksevt/content.go000066400000000000000000000030321433617251100200230ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package muksevt import ( "encoding/gob" "reflect" "maunium.net/go/mautrix/event" ) var EventBadEncrypted = event.Type{Type: "net.maunium.gomuks.bad_encrypted", Class: event.MessageEventType} var EventEncryptionUnsupported = event.Type{Type: "net.maunium.gomuks.encryption_unsupported", Class: event.MessageEventType} type BadEncryptedContent struct { Original *event.EncryptedEventContent `json:"-"` Reason string `json:"-"` } type EncryptionUnsupportedContent struct { Original *event.EncryptedEventContent `json:"-"` } func init() { gob.Register(&BadEncryptedContent{}) gob.Register(&EncryptionUnsupportedContent{}) event.TypeMap[EventBadEncrypted] = reflect.TypeOf(&BadEncryptedContent{}) event.TypeMap[EventEncryptionUnsupported] = reflect.TypeOf(&EncryptionUnsupportedContent{}) } gomuks-0.3.0/matrix/muksevt/event.go000066400000000000000000000025321433617251100174760ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package muksevt import ( "maunium.net/go/mautrix/event" ) type Event struct { *event.Event Gomuks GomuksContent `json:"-"` } func (evt *Event) SomewhatDangerousCopy() *Event { base := *evt.Event content := *base.Content.Parsed.(*event.MessageEventContent) evt.Content.Parsed = &content return &Event{ Event: &base, Gomuks: evt.Gomuks, } } func Wrap(event *event.Event) *Event { return &Event{Event: event} } type OutgoingState int const ( StateDefault OutgoingState = iota StateLocalEcho StateSendFail ) type GomuksContent struct { OutgoingState OutgoingState Edits []*Event } gomuks-0.3.0/matrix/nocrypto.go000066400000000000000000000004351433617251100165340ustar00rootroot00000000000000// This contains no-op stubs of the methods in crypto.go for non-cgo builds with crypto disabled. //go:build !cgo package matrix func isBadEncryptError(err error) bool { return false } func (c *Container) initCrypto() error { return nil } func (c *Container) cryptoOnLogin() {} gomuks-0.3.0/matrix/rooms/000077500000000000000000000000001433617251100154655ustar00rootroot00000000000000gomuks-0.3.0/matrix/rooms/doc.go000066400000000000000000000001571433617251100165640ustar00rootroot00000000000000// Package rooms contains a representation for Matrix rooms and utilities to parse state events. package rooms gomuks-0.3.0/matrix/rooms/room.go000066400000000000000000000461161433617251100170000ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package rooms import ( "compress/gzip" "encoding/gob" "encoding/json" "fmt" "os" "time" sync "github.com/sasha-s/go-deadlock" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/debug" ) func init() { gob.Register(map[string]interface{}{}) gob.Register([]interface{}{}) } type RoomNameSource int const ( UnknownRoomName RoomNameSource = iota MemberRoomName CanonicalAliasRoomName ExplicitRoomName ) // RoomTag is a tag given to a specific room. type RoomTag struct { // The name of the tag. Tag string // The order of the tag. Order json.Number } type UnreadMessage struct { EventID id.EventID Counted bool Highlight bool } type Member struct { event.MemberEventContent // The user who sent the membership event Sender id.UserID `json:"-"` } // Room represents a single Matrix room. type Room struct { // The room ID. ID id.RoomID // Whether or not the user has left the room. HasLeft bool // Whether or not the room is encrypted. Encrypted bool // The first batch of events that has been fetched for this room. // Used for fetching additional history. PrevBatch string // The last_batch field from the most recent sync. Used for fetching member lists. LastPrevBatch string // The MXID of the user whose session this room was created for. SessionUserID id.UserID SessionMember *Member // The number of unread messages that were notified about. UnreadMessages []UnreadMessage unreadCountCache *int highlightCache *bool lastMarkedRead id.EventID // Whether or not this room is marked as a direct chat. IsDirect bool OtherUser id.UserID // List of tags given to this room. RawTags []RoomTag // Timestamp of previously received actual message. LastReceivedMessage time.Time // The lazy loading summary for this room. Summary mautrix.LazyLoadSummary // Whether or not the members for this room have been fetched from the server. MembersFetched bool // Room state cache. state map[event.Type]map[string]*event.Event // MXID -> Member cache calculated from membership events. memberCache map[id.UserID]*Member exMemberCache map[id.UserID]*Member // The first two non-SessionUserID members in the room. Calculated at // the same time as memberCache. firstMemberCache *Member secondMemberCache *Member // The name of the room. Calculated from the state event name, // canonical_alias or alias or the member cache. NameCache string // The event type from which the name cache was calculated from. nameCacheSource RoomNameSource // The topic of the room. Directly fetched from the m.room.topic state event. topicCache string // The canonical alias of the room. Directly fetched from the m.room.canonical_alias state event. CanonicalAliasCache id.RoomAlias // Whether or not the room has been tombstoned. replacedCache bool // The room ID that replaced this room. replacedByCache *id.RoomID // Path for state store file. path string // Room cache object cache *RoomCache // Lock for state and other room stuff. lock sync.RWMutex // Pre/post un/load hooks preUnload func() bool preLoad func() bool postUnload func() postLoad func() // Whether or not the room state has changed changed bool // Room state cache linked list. prev *Room next *Room touch int64 } func debugPrintError(fn func() error, message string) { if err := fn(); err != nil { debug.Printf("%s: %v", message, err) } } func (room *Room) Loaded() bool { return room.state != nil } func (room *Room) Load() { room.cache.TouchNode(room) if room.Loaded() { return } if room.preLoad != nil && !room.preLoad() { return } room.lock.Lock() room.load() room.lock.Unlock() if room.postLoad != nil { room.postLoad() } } func (room *Room) load() { if room.Loaded() { return } debug.Print("Loading state for room", room.ID, "from disk") room.state = make(map[event.Type]map[string]*event.Event) file, err := os.OpenFile(room.path, os.O_RDONLY, 0600) if err != nil { if !os.IsNotExist(err) { debug.Print("Failed to open room state file for reading:", err) } else { debug.Print("Room state file for", room.ID, "does not exist") } return } defer debugPrintError(file.Close, "Failed to close room state file after reading") cmpReader, err := gzip.NewReader(file) if err != nil { debug.Print("Failed to open room state gzip reader:", err) return } defer debugPrintError(cmpReader.Close, "Failed to close room state gzip reader") dec := gob.NewDecoder(cmpReader) if err = dec.Decode(&room.state); err != nil { debug.Print("Failed to decode room state:", err) } room.changed = false } func (room *Room) Touch() { room.cache.TouchNode(room) } func (room *Room) Unload() bool { if room.preUnload != nil && !room.preUnload() { return false } debug.Print("Unloading", room.ID) room.Save() room.state = nil room.memberCache = nil room.exMemberCache = nil room.firstMemberCache = nil room.secondMemberCache = nil if room.postUnload != nil { room.postUnload() } return true } func (room *Room) SetPreUnload(fn func() bool) { room.preUnload = fn } func (room *Room) SetPreLoad(fn func() bool) { room.preLoad = fn } func (room *Room) SetPostUnload(fn func()) { room.postUnload = fn } func (room *Room) SetPostLoad(fn func()) { room.postLoad = fn } func (room *Room) Save() { if !room.Loaded() { debug.Print("Failed to save room", room.ID, "state: room not loaded") return } if !room.changed { debug.Print("Not saving", room.ID, "as state hasn't changed") return } debug.Print("Saving state for room", room.ID, "to disk") file, err := os.OpenFile(room.path, os.O_WRONLY|os.O_CREATE, 0600) if err != nil { debug.Print("Failed to open room state file for writing:", err) return } defer debugPrintError(file.Close, "Failed to close room state file after writing") cmpWriter := gzip.NewWriter(file) defer debugPrintError(cmpWriter.Close, "Failed to close room state gzip writer") enc := gob.NewEncoder(cmpWriter) room.lock.RLock() defer room.lock.RUnlock() if err := enc.Encode(&room.state); err != nil { debug.Print("Failed to encode room state:", err) } } // MarkRead clears the new message statuses on this room. func (room *Room) MarkRead(eventID id.EventID) bool { room.lock.Lock() defer room.lock.Unlock() if room.lastMarkedRead == eventID { return false } room.lastMarkedRead = eventID readToIndex := -1 for index, unreadMessage := range room.UnreadMessages { if unreadMessage.EventID == eventID { readToIndex = index } } if readToIndex >= 0 { room.UnreadMessages = room.UnreadMessages[readToIndex+1:] room.highlightCache = nil room.unreadCountCache = nil } return true } func (room *Room) UnreadCount() int { room.lock.Lock() defer room.lock.Unlock() if room.unreadCountCache == nil { room.unreadCountCache = new(int) for _, unreadMessage := range room.UnreadMessages { if unreadMessage.Counted { *room.unreadCountCache++ } } } return *room.unreadCountCache } func (room *Room) Highlighted() bool { room.lock.Lock() defer room.lock.Unlock() if room.highlightCache == nil { room.highlightCache = new(bool) for _, unreadMessage := range room.UnreadMessages { if unreadMessage.Highlight { *room.highlightCache = true break } } } return *room.highlightCache } func (room *Room) HasNewMessages() bool { return len(room.UnreadMessages) > 0 } func (room *Room) AddUnread(eventID id.EventID, counted, highlight bool) { room.lock.Lock() defer room.lock.Unlock() room.UnreadMessages = append(room.UnreadMessages, UnreadMessage{ EventID: eventID, Counted: counted, Highlight: highlight, }) if counted { if room.unreadCountCache == nil { room.unreadCountCache = new(int) } *room.unreadCountCache++ } if highlight { if room.highlightCache == nil { room.highlightCache = new(bool) } *room.highlightCache = true } } var ( tagDirect = RoomTag{"net.maunium.gomuks.fake.direct", "0.5"} tagInvite = RoomTag{"net.maunium.gomuks.fake.invite", "0.5"} tagDefault = RoomTag{"", "0.5"} tagLeave = RoomTag{"net.maunium.gomuks.fake.leave", "0.5"} ) func (room *Room) Tags() []RoomTag { room.lock.RLock() defer room.lock.RUnlock() if len(room.RawTags) == 0 { if room.IsDirect { return []RoomTag{tagDirect} } else if room.SessionMember != nil && room.SessionMember.Membership == event.MembershipInvite { return []RoomTag{tagInvite} } else if room.SessionMember != nil && room.SessionMember.Membership != event.MembershipJoin { return []RoomTag{tagLeave} } return []RoomTag{tagDefault} } return room.RawTags } func (room *Room) UpdateSummary(summary mautrix.LazyLoadSummary) { if summary.JoinedMemberCount != nil { room.Summary.JoinedMemberCount = summary.JoinedMemberCount } if summary.InvitedMemberCount != nil { room.Summary.InvitedMemberCount = summary.InvitedMemberCount } if summary.Heroes != nil { room.Summary.Heroes = summary.Heroes } if room.nameCacheSource <= MemberRoomName { room.NameCache = "" } } // UpdateState updates the room's current state with the given Event. This will clobber events based // on the type/state_key combination. func (room *Room) UpdateState(evt *event.Event) { if evt.StateKey == nil { panic("Tried to UpdateState() event with no state key.") } room.Load() room.lock.Lock() defer room.lock.Unlock() room.changed = true _, exists := room.state[evt.Type] if !exists { room.state[evt.Type] = make(map[string]*event.Event) } switch content := evt.Content.Parsed.(type) { case *event.RoomNameEventContent: room.NameCache = content.Name room.nameCacheSource = ExplicitRoomName case *event.CanonicalAliasEventContent: if room.nameCacheSource <= CanonicalAliasRoomName { room.NameCache = string(content.Alias) room.nameCacheSource = CanonicalAliasRoomName } room.CanonicalAliasCache = content.Alias case *event.MemberEventContent: if room.nameCacheSource <= MemberRoomName { room.NameCache = "" } room.updateMemberState(id.UserID(evt.GetStateKey()), evt.Sender, content) case *event.TopicEventContent: room.topicCache = content.Topic case *event.EncryptionEventContent: if content.Algorithm == id.AlgorithmMegolmV1 { room.Encrypted = true } } if evt.Type != event.StateMember { debug.Printf("Updating state %s#%s for %s", evt.Type.String(), evt.GetStateKey(), room.ID) } room.state[evt.Type][*evt.StateKey] = evt } func (room *Room) updateMemberState(userID, sender id.UserID, content *event.MemberEventContent) { if userID == room.SessionUserID { debug.Print("Updating session user state:", content) room.SessionMember = room.eventToMember(userID, sender, content) } if room.memberCache != nil { member := room.eventToMember(userID, sender, content) if member.Membership.IsInviteOrJoin() { existingMember, ok := room.memberCache[userID] if ok { *existingMember = *member } else { delete(room.exMemberCache, userID) room.memberCache[userID] = member room.updateNthMemberCache(userID, member) } } else { existingExMember, ok := room.exMemberCache[userID] if ok { *existingExMember = *member } else { delete(room.memberCache, userID) room.exMemberCache[userID] = member } } } } // GetStateEvent returns the state event for the given type/state_key combo, or nil. func (room *Room) GetStateEvent(eventType event.Type, stateKey string) *event.Event { room.Load() room.lock.RLock() defer room.lock.RUnlock() stateEventMap, _ := room.state[eventType] evt, _ := stateEventMap[stateKey] return evt } // getStateEvents returns the state events for the given type. func (room *Room) getStateEvents(eventType event.Type) map[string]*event.Event { stateEventMap, _ := room.state[eventType] return stateEventMap } // GetTopic returns the topic of the room. func (room *Room) GetTopic() string { if len(room.topicCache) == 0 { topicEvt := room.GetStateEvent(event.StateTopic, "") if topicEvt != nil { room.topicCache = topicEvt.Content.AsTopic().Topic } } return room.topicCache } func (room *Room) GetCanonicalAlias() id.RoomAlias { if len(room.CanonicalAliasCache) == 0 { canonicalAliasEvt := room.GetStateEvent(event.StateCanonicalAlias, "") if canonicalAliasEvt != nil { room.CanonicalAliasCache = canonicalAliasEvt.Content.AsCanonicalAlias().Alias } else { room.CanonicalAliasCache = "-" } } if room.CanonicalAliasCache == "-" { return "" } return room.CanonicalAliasCache } // updateNameFromNameEvent updates the room display name to be the name set in the name event. func (room *Room) updateNameFromNameEvent() { nameEvt := room.GetStateEvent(event.StateRoomName, "") if nameEvt != nil { room.NameCache = nameEvt.Content.AsRoomName().Name } } // updateNameFromMembers updates the room display name based on the members in this room. // // The room name depends on the number of users: // // Less than two users -> "Empty room" // Exactly two users -> The display name of the other user. // More than two users -> The display name of one of the other users, followed // by "and X others", where X is the number of users // excluding the local user and the named user. func (room *Room) updateNameFromMembers() { members := room.GetMembers() if len(members) <= 1 { room.NameCache = "Empty room" } else if room.firstMemberCache == nil { room.NameCache = "Room" } else if len(members) == 2 { room.NameCache = room.firstMemberCache.Displayname } else if len(members) == 3 && room.secondMemberCache != nil { room.NameCache = fmt.Sprintf("%s and %s", room.firstMemberCache.Displayname, room.secondMemberCache.Displayname) } else { members := room.firstMemberCache.Displayname count := len(members) - 2 if room.secondMemberCache != nil { members += ", " + room.secondMemberCache.Displayname count-- } room.NameCache = fmt.Sprintf("%s and %d others", members, count) } } // updateNameCache updates the room display name based on the room state in the order // specified in spec section 11.2.2.5. func (room *Room) updateNameCache() { if len(room.NameCache) == 0 { room.updateNameFromNameEvent() room.nameCacheSource = ExplicitRoomName } if len(room.NameCache) == 0 { room.NameCache = string(room.GetCanonicalAlias()) room.nameCacheSource = CanonicalAliasRoomName } if len(room.NameCache) == 0 { room.updateNameFromMembers() room.nameCacheSource = MemberRoomName } } // GetTitle returns the display name of the room. // // The display name is returned from the cache. // If the cache is empty, it is updated first. func (room *Room) GetTitle() string { room.updateNameCache() return room.NameCache } func (room *Room) IsReplaced() bool { if room.replacedByCache == nil { evt := room.GetStateEvent(event.StateTombstone, "") var replacement id.RoomID if evt != nil { content, ok := evt.Content.Parsed.(*event.TombstoneEventContent) if ok { replacement = content.ReplacementRoom } } room.replacedCache = evt != nil room.replacedByCache = &replacement } return room.replacedCache } func (room *Room) ReplacedBy() id.RoomID { if room.replacedByCache == nil { room.IsReplaced() } return *room.replacedByCache } func (room *Room) eventToMember(userID, sender id.UserID, member *event.MemberEventContent) *Member { if len(member.Displayname) == 0 { member.Displayname = string(userID) } return &Member{ MemberEventContent: *member, Sender: sender, } } func (room *Room) updateNthMemberCache(userID id.UserID, member *Member) { if userID != room.SessionUserID { if room.firstMemberCache == nil { room.firstMemberCache = member } else if room.secondMemberCache == nil { room.secondMemberCache = member } } } // createMemberCache caches all member events into a easily processable MXID -> *Member map. func (room *Room) createMemberCache() map[id.UserID]*Member { if len(room.memberCache) > 0 { return room.memberCache } cache := make(map[id.UserID]*Member) exCache := make(map[id.UserID]*Member) room.lock.RLock() memberEvents := room.getStateEvents(event.StateMember) room.firstMemberCache = nil room.secondMemberCache = nil if memberEvents != nil { for userIDStr, evt := range memberEvents { userID := id.UserID(userIDStr) member := room.eventToMember(userID, evt.Sender, evt.Content.AsMember()) if member.Membership.IsInviteOrJoin() { cache[userID] = member room.updateNthMemberCache(userID, member) } else { exCache[userID] = member } if userID == room.SessionUserID { room.SessionMember = member } } } if len(room.Summary.Heroes) > 1 { room.firstMemberCache, _ = cache[room.Summary.Heroes[0]] } if len(room.Summary.Heroes) > 2 { room.secondMemberCache, _ = cache[room.Summary.Heroes[1]] } room.lock.RUnlock() room.lock.Lock() room.memberCache = cache room.exMemberCache = exCache room.lock.Unlock() return cache } // GetMembers returns the members in this room. // // The members are returned from the cache. // If the cache is empty, it is updated first. func (room *Room) GetMembers() map[id.UserID]*Member { room.Load() room.createMemberCache() return room.memberCache } func (room *Room) GetMemberList() []id.UserID { members := room.GetMembers() memberList := make([]id.UserID, len(members)) index := 0 for userID, _ := range members { memberList[index] = userID index++ } return memberList } // GetMember returns the member with the given MXID. // If the member doesn't exist, nil is returned. func (room *Room) GetMember(userID id.UserID) *Member { if userID == room.SessionUserID && room.SessionMember != nil { return room.SessionMember } room.Load() room.createMemberCache() room.lock.RLock() member, ok := room.memberCache[userID] if ok { room.lock.RUnlock() return member } exMember, ok := room.exMemberCache[userID] if ok { room.lock.RUnlock() return exMember } room.lock.RUnlock() return nil } func (room *Room) GetMemberCount() int { if room.memberCache == nil && room.Summary.JoinedMemberCount != nil { return *room.Summary.JoinedMemberCount } return len(room.GetMembers()) } // GetSessionOwner returns the ID of the user whose session this room was created for. func (room *Room) GetOwnDisplayname() string { member := room.GetMember(room.SessionUserID) if member != nil { return member.Displayname } return "" } // NewRoom creates a new Room with the given ID func NewRoom(roomID id.RoomID, cache *RoomCache) *Room { return &Room{ ID: roomID, state: make(map[event.Type]map[string]*event.Event), path: cache.roomPath(roomID), cache: cache, SessionUserID: cache.getOwner(), } } gomuks-0.3.0/matrix/rooms/roomcache.go000066400000000000000000000204401433617251100177540ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package rooms import ( "compress/gzip" "encoding/gob" "fmt" "os" "path/filepath" "strings" "time" sync "github.com/sasha-s/go-deadlock" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/debug" ) // RoomCache contains room state info in a hashmap and linked list. type RoomCache struct { sync.Mutex listPath string directory string maxSize int maxAge int64 getOwner func() id.UserID noUnload bool Map map[id.RoomID]*Room head *Room tail *Room size int } func NewRoomCache(listPath, directory string, maxSize int, maxAge int64, getOwner func() id.UserID) *RoomCache { return &RoomCache{ listPath: listPath, directory: directory, maxSize: maxSize, maxAge: maxAge, getOwner: getOwner, Map: make(map[id.RoomID]*Room), } } func (cache *RoomCache) DisableUnloading() { cache.noUnload = true } func (cache *RoomCache) EnableUnloading() { cache.noUnload = false } func (cache *RoomCache) IsEncrypted(roomID id.RoomID) bool { room := cache.Get(roomID) return room != nil && room.Encrypted } func (cache *RoomCache) GetEncryptionEvent(roomID id.RoomID) *event.EncryptionEventContent { room := cache.Get(roomID) evt := room.GetStateEvent(event.StateEncryption, "") if evt == nil { return nil } content, ok := evt.Content.Parsed.(*event.EncryptionEventContent) if !ok { return nil } return content } func (cache *RoomCache) FindSharedRooms(userID id.UserID) (shared []id.RoomID) { // FIXME this disables unloading so TouchNode wouldn't try to double-lock cache.DisableUnloading() cache.Lock() for _, room := range cache.Map { if !room.Encrypted { continue } member, ok := room.GetMembers()[userID] if ok && member.Membership == event.MembershipJoin { shared = append(shared, room.ID) } } cache.Unlock() cache.EnableUnloading() return } func (cache *RoomCache) LoadList() error { cache.Lock() defer cache.Unlock() // Open room list file file, err := os.OpenFile(cache.listPath, os.O_RDONLY, 0600) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("failed to open room list file for reading: %w", err) } defer debugPrintError(file.Close, "Failed to close room list file after reading") // Open gzip reader for room list file cmpReader, err := gzip.NewReader(file) if err != nil { return fmt.Errorf("failed to read gzip room list: %w", err) } defer debugPrintError(cmpReader.Close, "Failed to close room list gzip reader") // Open gob decoder for gzip reader dec := gob.NewDecoder(cmpReader) // Read number of items in list var size int err = dec.Decode(&size) if err != nil { return fmt.Errorf("failed to read size of room list: %w", err) } // Read list cache.Map = make(map[id.RoomID]*Room, size) for i := 0; i < size; i++ { room := &Room{} err = dec.Decode(room) if err != nil { debug.Printf("Failed to decode %dth room list entry: %v", i+1, err) continue } room.path = cache.roomPath(room.ID) room.cache = cache cache.Map[room.ID] = room } return nil } func (cache *RoomCache) SaveLoadedRooms() { cache.Lock() cache.clean(false) for node := cache.head; node != nil; node = node.prev { node.Save() } cache.Unlock() } func (cache *RoomCache) SaveList() error { cache.Lock() defer cache.Unlock() debug.Print("Saving room list...") // Open room list file file, err := os.OpenFile(cache.listPath, os.O_WRONLY|os.O_CREATE, 0600) if err != nil { return fmt.Errorf("failed to open room list file for writing: %w", err) } defer debugPrintError(file.Close, "Failed to close room list file after writing") // Open gzip writer for room list file cmpWriter := gzip.NewWriter(file) defer debugPrintError(cmpWriter.Close, "Failed to close room list gzip writer") // Open gob encoder for gzip writer enc := gob.NewEncoder(cmpWriter) // Write number of items in list err = enc.Encode(len(cache.Map)) if err != nil { return fmt.Errorf("failed to write size of room list: %w", err) } // Write list for _, node := range cache.Map { err = enc.Encode(node) if err != nil { debug.Printf("Failed to encode room list entry of %s: %v", node.ID, err) } } debug.Print("Room list saved to", cache.listPath, len(cache.Map), cache.size) return nil } func (cache *RoomCache) Touch(roomID id.RoomID) { cache.Lock() node, ok := cache.Map[roomID] if !ok || node == nil { cache.Unlock() return } cache.touch(node) cache.Unlock() } func (cache *RoomCache) TouchNode(node *Room) { if cache.noUnload || node.touch+2 > time.Now().Unix() { return } cache.Lock() cache.touch(node) cache.Unlock() } func (cache *RoomCache) touch(node *Room) { if node == cache.head { return } debug.Print("Touching", node.ID) cache.llPop(node) cache.llPush(node) node.touch = time.Now().Unix() } func (cache *RoomCache) Get(roomID id.RoomID) *Room { cache.Lock() node := cache.get(roomID) cache.Unlock() return node } func (cache *RoomCache) GetOrCreate(roomID id.RoomID) *Room { cache.Lock() node := cache.get(roomID) if node == nil { node = cache.newRoom(roomID) cache.llPush(node) } cache.Unlock() return node } func (cache *RoomCache) get(roomID id.RoomID) *Room { node, ok := cache.Map[roomID] if ok && node != nil { return node } return nil } func (cache *RoomCache) Put(room *Room) { cache.Lock() node := cache.get(room.ID) if node != nil { cache.touch(node) } else { cache.Map[room.ID] = room if room.Loaded() { cache.llPush(room) } node = room } cache.Unlock() node.Save() } func (cache *RoomCache) roomPath(roomID id.RoomID) string { escapedRoomID := strings.ReplaceAll(strings.ReplaceAll(string(roomID), "%", "%25"), "/", "%2F") return filepath.Join(cache.directory, escapedRoomID+".gob.gz") } func (cache *RoomCache) Load(roomID id.RoomID) *Room { cache.Lock() defer cache.Unlock() node, ok := cache.Map[roomID] if ok { return node } node = NewRoom(roomID, cache) node.Load() return node } func (cache *RoomCache) llPop(node *Room) { if node.prev == nil && node.next == nil { return } if node.prev != nil { node.prev.next = node.next } if node.next != nil { node.next.prev = node.prev } if node == cache.tail { cache.tail = node.next } if node == cache.head { cache.head = node.prev } node.next = nil node.prev = nil cache.size-- } func (cache *RoomCache) llPush(node *Room) { if node.next != nil || node.prev != nil { debug.PrintStack() debug.Print("Tried to llPush node that is already in stack") return } if node == cache.head { return } if cache.head != nil { cache.head.next = node } node.prev = cache.head node.next = nil cache.head = node if cache.tail == nil { cache.tail = node } cache.size++ cache.clean(false) } func (cache *RoomCache) ForceClean() { cache.Lock() cache.clean(true) cache.Unlock() } func (cache *RoomCache) clean(force bool) { if cache.noUnload && !force { return } origSize := cache.size maxTS := time.Now().Unix() - cache.maxAge for cache.size > cache.maxSize { if cache.tail.touch > maxTS && !force { break } ok := cache.tail.Unload() node := cache.tail cache.llPop(node) if !ok { debug.Print("Unload returned false, pushing node back") cache.llPush(node) } } if cleaned := origSize - cache.size; cleaned > 0 { debug.Print("Cleaned", cleaned, "rooms") } } func (cache *RoomCache) Unload(node *Room) { cache.Lock() defer cache.Unlock() cache.llPop(node) ok := node.Unload() if !ok { debug.Print("Unload returned false, pushing node back") cache.llPush(node) } } func (cache *RoomCache) newRoom(roomID id.RoomID) *Room { node := NewRoom(roomID, cache) cache.Map[node.ID] = node return node } gomuks-0.3.0/matrix/sync.go000066400000000000000000000210121433617251100156250ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . // Based on https://github.com/matrix-org/mautrix/blob/master/sync.go package matrix import ( "sync" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/matrix/rooms" ) type GomuksSyncer struct { rooms *rooms.RoomCache globalListeners []mautrix.SyncHandler listeners map[event.Type][]mautrix.EventHandler // event type to listeners array FirstSyncDone bool InitDoneCallback func() FirstDoneCallback func() Progress ifc.SyncingModal } // NewGomuksSyncer returns an instantiated GomuksSyncer func NewGomuksSyncer(rooms *rooms.RoomCache) *GomuksSyncer { return &GomuksSyncer{ rooms: rooms, globalListeners: []mautrix.SyncHandler{}, listeners: make(map[event.Type][]mautrix.EventHandler), FirstSyncDone: false, Progress: StubSyncingModal{}, } } // ProcessResponse processes a Matrix sync response. func (s *GomuksSyncer) ProcessResponse(res *mautrix.RespSync, since string) (err error) { if since == "" { s.rooms.DisableUnloading() } debug.Print("Received sync response") s.Progress.SetMessage("Processing sync response") steps := len(res.Rooms.Join) + len(res.Rooms.Invite) + len(res.Rooms.Leave) s.Progress.SetSteps(steps + 2 + len(s.globalListeners)) wait := &sync.WaitGroup{} callback := func() { wait.Done() s.Progress.Step() } wait.Add(len(s.globalListeners)) s.notifyGlobalListeners(res, since, callback) wait.Wait() s.processSyncEvents(nil, res.Presence.Events, mautrix.EventSourcePresence) s.Progress.Step() s.processSyncEvents(nil, res.AccountData.Events, mautrix.EventSourceAccountData) s.Progress.Step() wait.Add(steps) for roomID, roomData := range res.Rooms.Join { go s.processJoinedRoom(roomID, roomData, callback) } for roomID, roomData := range res.Rooms.Invite { go s.processInvitedRoom(roomID, roomData, callback) } for roomID, roomData := range res.Rooms.Leave { go s.processLeftRoom(roomID, roomData, callback) } wait.Wait() s.Progress.SetMessage("Finishing sync") if since == "" && s.InitDoneCallback != nil { s.InitDoneCallback() s.rooms.EnableUnloading() } if !s.FirstSyncDone && s.FirstDoneCallback != nil { s.FirstDoneCallback() } s.FirstSyncDone = true return } func (s *GomuksSyncer) notifyGlobalListeners(res *mautrix.RespSync, since string, callback func()) { for _, listener := range s.globalListeners { go func(listener mautrix.SyncHandler) { listener(res, since) callback() }(listener) } } func (s *GomuksSyncer) processJoinedRoom(roomID id.RoomID, roomData mautrix.SyncJoinedRoom, callback func()) { defer debug.Recover() room := s.rooms.GetOrCreate(roomID) room.UpdateSummary(roomData.Summary) s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceJoin|mautrix.EventSourceState) s.processSyncEvents(room, roomData.Timeline.Events, mautrix.EventSourceJoin|mautrix.EventSourceTimeline) s.processSyncEvents(room, roomData.Ephemeral.Events, mautrix.EventSourceJoin|mautrix.EventSourceEphemeral) s.processSyncEvents(room, roomData.AccountData.Events, mautrix.EventSourceJoin|mautrix.EventSourceAccountData) if len(room.PrevBatch) == 0 { room.PrevBatch = roomData.Timeline.PrevBatch } room.LastPrevBatch = roomData.Timeline.PrevBatch callback() } func (s *GomuksSyncer) processInvitedRoom(roomID id.RoomID, roomData mautrix.SyncInvitedRoom, callback func()) { defer debug.Recover() room := s.rooms.GetOrCreate(roomID) room.UpdateSummary(roomData.Summary) s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceInvite|mautrix.EventSourceState) callback() } func (s *GomuksSyncer) processLeftRoom(roomID id.RoomID, roomData mautrix.SyncLeftRoom, callback func()) { defer debug.Recover() room := s.rooms.GetOrCreate(roomID) room.HasLeft = true room.UpdateSummary(roomData.Summary) s.processSyncEvents(room, roomData.State.Events, mautrix.EventSourceLeave|mautrix.EventSourceState) s.processSyncEvents(room, roomData.Timeline.Events, mautrix.EventSourceLeave|mautrix.EventSourceTimeline) if len(room.PrevBatch) == 0 { room.PrevBatch = roomData.Timeline.PrevBatch } room.LastPrevBatch = roomData.Timeline.PrevBatch callback() } func (s *GomuksSyncer) processSyncEvents(room *rooms.Room, events []*event.Event, source mautrix.EventSource) { for _, evt := range events { s.processSyncEvent(room, evt, source) } } func (s *GomuksSyncer) processSyncEvent(room *rooms.Room, evt *event.Event, source mautrix.EventSource) { if room != nil { evt.RoomID = room.ID } // Ensure the type class is correct. It's safe to mutate since it's not a pointer. // Listeners are keyed by type structs, which means only the correct class will pass. switch { case evt.StateKey != nil: evt.Type.Class = event.StateEventType case source == mautrix.EventSourcePresence, source&mautrix.EventSourceEphemeral != 0: evt.Type.Class = event.EphemeralEventType case source&mautrix.EventSourceAccountData != 0: evt.Type.Class = event.AccountDataEventType case source == mautrix.EventSourceToDevice: evt.Type.Class = event.ToDeviceEventType default: evt.Type.Class = event.MessageEventType } err := evt.Content.ParseRaw(evt.Type) if err != nil { debug.Printf("Failed to unmarshal content of event %s (type %s) by %s in %s: %v\n%s", evt.ID, evt.Type.Repr(), evt.Sender, evt.RoomID, err, string(evt.Content.VeryRaw)) // TODO might be good to let these pass to allow handling invalid events too return } if room != nil && evt.Type.IsState() { room.UpdateState(evt) } s.notifyListeners(source, evt) } // OnEventType allows callers to be notified when there are new events for the given event type. // There are no duplicate checks. func (s *GomuksSyncer) OnEventType(eventType event.Type, callback mautrix.EventHandler) { _, exists := s.listeners[eventType] if !exists { s.listeners[eventType] = []mautrix.EventHandler{} } s.listeners[eventType] = append(s.listeners[eventType], callback) } func (s *GomuksSyncer) OnSync(callback mautrix.SyncHandler) { s.globalListeners = append(s.globalListeners, callback) } func (s *GomuksSyncer) notifyListeners(source mautrix.EventSource, evt *event.Event) { listeners, exists := s.listeners[evt.Type] if !exists { return } for _, fn := range listeners { fn(source, evt) } } // OnFailedSync always returns a 10 second wait period between failed /syncs, never a fatal error. func (s *GomuksSyncer) OnFailedSync(res *mautrix.RespSync, err error) (time.Duration, error) { debug.Printf("Sync failed: %v", err) return 10 * time.Second, nil } // GetFilterJSON returns a filter with a timeline limit of 50. func (s *GomuksSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter { stateEvents := []event.Type{ event.StateMember, event.StateRoomName, event.StateTopic, event.StateCanonicalAlias, event.StatePowerLevels, event.StateTombstone, event.StateEncryption, } messageEvents := []event.Type{ event.EventMessage, event.EventRedaction, event.EventEncrypted, event.EventSticker, event.EventReaction, } return &mautrix.Filter{ Room: mautrix.RoomFilter{ IncludeLeave: false, State: mautrix.FilterPart{ LazyLoadMembers: true, Types: stateEvents, }, Timeline: mautrix.FilterPart{ LazyLoadMembers: true, Types: append(messageEvents, stateEvents...), Limit: 50, }, Ephemeral: mautrix.FilterPart{ Types: []event.Type{event.EphemeralEventTyping, event.EphemeralEventReceipt}, }, AccountData: mautrix.FilterPart{ Types: []event.Type{event.AccountDataRoomTags}, }, }, AccountData: mautrix.FilterPart{ Types: []event.Type{event.AccountDataPushRules, event.AccountDataDirectChats, AccountDataGomuksPreferences}, }, Presence: mautrix.FilterPart{ NotTypes: []event.Type{event.NewEventType("*")}, }, } } gomuks-0.3.0/matrix/uia-fallback.go000066400000000000000000000061231433617251100171720ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package matrix import ( "context" "errors" "net/http" "net/url" "time" "maunium.net/go/mautrix" "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/lib/open" ) const uiaFallbackPage = ` gomuks user-interactive auth

Please complete the login in the popup window

Keep this page open while logging in, it will close automatically after the login finishes.

` func (c *Container) UIAFallback(loginType mautrix.AuthType, sessionID string) error { errChan := make(chan error, 1) server := &http.Server{Addr: ":29325"} server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { w.Header().Add("Content-Type", "text/html") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(uiaFallbackPage)) } else if r.Method == "POST" || r.Method == "DELETE" { w.Header().Add("Content-Type", "text/html") w.WriteHeader(http.StatusOK) go func() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := server.Shutdown(ctx) if err != nil { debug.Printf("Failed to shut down SSO server: %v\n", err) } if r.Method == "DELETE" { errChan <- errors.New("login cancelled") } else { errChan <- nil } }() } else { w.WriteHeader(http.StatusMethodNotAllowed) } }) go server.ListenAndServe() defer server.Close() authURL := c.client.BuildURLWithQuery(mautrix.ClientURLPath{"v3", "auth", loginType, "fallback", "web"}, map[string]string{ "session": sessionID, }) link := url.URL{ Scheme: "http", Host: "localhost:29325", Path: "/", Fragment: authURL, } err := open.Open(link.String()) if err != nil { return err } err = <-errChan return err } gomuks-0.3.0/ui/000077500000000000000000000000001433617251100134375ustar00rootroot00000000000000gomuks-0.3.0/ui/autocomplete.go000066400000000000000000000037721433617251100165000ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "fmt" "io/ioutil" "path/filepath" "strings" ) func autocompleteFile(cmd *CommandAutocomplete) (completions []string, newText string) { inputPath, err := filepath.Abs(cmd.RawArgs) if err != nil { return } var searchNamePrefix, searchDir string if strings.HasSuffix(cmd.RawArgs, "/") { searchDir = inputPath } else { searchNamePrefix = filepath.Base(inputPath) searchDir = filepath.Dir(inputPath) } files, err := ioutil.ReadDir(searchDir) if err != nil { return } for _, file := range files { name := file.Name() if !strings.HasPrefix(name, searchNamePrefix) || (name[0] == '.' && searchNamePrefix == "") { continue } fullPath := filepath.Join(searchDir, name) if file.IsDir() { fullPath += "/" } completions = append(completions, fullPath) } if len(completions) == 1 { newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0]) } return } func autocompleteToggle(cmd *CommandAutocomplete) (completions []string, newText string) { completions = make([]string, 0, len(toggleMsg)) for k := range toggleMsg { if strings.HasPrefix(k, cmd.RawArgs) { completions = append(completions, k) } } if len(completions) == 1 { newText = fmt.Sprintf("/%s %s", cmd.OrigCommand, completions[0]) } return } gomuks-0.3.0/ui/command-processor.go000066400000000000000000000162671433617251100174350ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "fmt" "strings" "github.com/mattn/go-runewidth" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" ) type gomuksPointerContainer struct { MainView *MainView UI *GomuksUI Matrix ifc.MatrixContainer Config *config.Config Gomuks ifc.Gomuks } type Command struct { gomuksPointerContainer Handler *CommandProcessor Room *RoomView Command string OrigCommand string Args []string RawArgs string OrigText string } type CommandAutocomplete Command func (cmd *Command) Reply(message string, args ...interface{}) { cmd.Room.AddServiceMessage(fmt.Sprintf(message, args...)) cmd.UI.Render() } type Alias struct { NewCommand string } func (alias *Alias) Process(cmd *Command) *Command { cmd.Command = alias.NewCommand return cmd } type CommandHandler func(cmd *Command) type CommandAutocompleter func(cmd *CommandAutocomplete) (completions []string, newText string) type CommandProcessor struct { gomuksPointerContainer aliases map[string]*Alias commands map[string]CommandHandler autocompleters map[string]CommandAutocompleter } func NewCommandProcessor(parent *MainView) *CommandProcessor { return &CommandProcessor{ gomuksPointerContainer: gomuksPointerContainer{ MainView: parent, UI: parent.parent, Matrix: parent.matrix, Config: parent.config, Gomuks: parent.gmx, }, aliases: map[string]*Alias{ "part": {"leave"}, "send": {"sendevent"}, "msend": {"msendevent"}, "state": {"setstate"}, "mstate": {"msetstate"}, "rb": {"rainbow"}, "rbme": {"rainbowme"}, "rbn": {"rainbownotice"}, "myroomnick": {"roomnick"}, "createroom": {"create"}, "dm": {"pm"}, "query": {"pm"}, "r": {"reply"}, "delete": {"redact"}, "remove": {"redact"}, "rm": {"redact"}, "del": {"redact"}, "e": {"edit"}, "dl": {"download"}, "o": {"open"}, "4s": {"ssss"}, "s4": {"ssss"}, "cs": {"cross-signing"}, }, autocompleters: map[string]CommandAutocompleter{ "devices": autocompleteUser, "device": autocompleteDevice, "verify": autocompleteUser, "verify-device": autocompleteDevice, "unverify": autocompleteDevice, "blacklist": autocompleteDevice, "upload": autocompleteFile, "download": autocompleteFile, "open": autocompleteFile, "import": autocompleteFile, "export": autocompleteFile, "export-room": autocompleteFile, "toggle": autocompleteToggle, }, commands: map[string]CommandHandler{ "unknown-command": cmdUnknownCommand, "id": cmdID, "help": cmdHelp, "me": cmdMe, "quit": cmdQuit, "clearcache": cmdClearCache, "leave": cmdLeave, "create": cmdCreateRoom, "pm": cmdPrivateMessage, "join": cmdJoin, "kick": cmdKick, "ban": cmdBan, "unban": cmdUnban, "toggle": cmdToggle, "logout": cmdLogout, "accept": cmdAccept, "reject": cmdReject, "reply": cmdReply, "redact": cmdRedact, "react": cmdReact, "edit": cmdEdit, "external": cmdExternalEditor, "download": cmdDownload, "upload": cmdUpload, "open": cmdOpen, "copy": cmdCopy, "sendevent": cmdSendEvent, "msendevent": cmdMSendEvent, "setstate": cmdSetState, "msetstate": cmdMSetState, "roomnick": cmdRoomNick, "rainbow": cmdRainbow, "rainbowme": cmdRainbowMe, "notice": cmdNotice, "alias": cmdAlias, "tags": cmdTags, "tag": cmdTag, "untag": cmdUntag, "invite": cmdInvite, "hprof": cmdHeapProfile, "cprof": cmdCPUProfile, "trace": cmdTrace, "panic": func(cmd *Command) { panic("hello world") }, "rainbownotice": cmdRainbowNotice, "fingerprint": cmdFingerprint, "devices": cmdDevices, "verify-device": cmdVerifyDevice, "verify": cmdVerify, "device": cmdDevice, "unverify": cmdUnverify, "blacklist": cmdBlacklist, "reset-session": cmdResetSession, "import": cmdImportKeys, "export": cmdExportKeys, "export-room": cmdExportRoomKeys, "ssss": cmdSSSS, "cross-signing": cmdCrossSigning, }, } } func (ch *CommandProcessor) ParseCommand(roomView *RoomView, text string) *Command { if text[0] != '/' || len(text) < 2 { return nil } text = text[1:] split := strings.Fields(text) command := split[0] args := split[1:] var rawArgs string if len(text) > len(command)+1 { rawArgs = text[len(command)+1:] } return &Command{ gomuksPointerContainer: ch.gomuksPointerContainer, Handler: ch, Room: roomView, Command: strings.ToLower(command), OrigCommand: command, Args: args, RawArgs: rawArgs, OrigText: text, } } func (ch *CommandProcessor) Autocomplete(roomView *RoomView, text string, cursorOffset int) ([]string, string, bool) { var completions []string if cursorOffset != runewidth.StringWidth(text) { return completions, text, false } var cmd *Command if cmd = ch.ParseCommand(roomView, text); cmd == nil { return completions, text, false } else if alias, ok := ch.aliases[cmd.Command]; ok { cmd = alias.Process(cmd) } handler, ok := ch.autocompleters[cmd.Command] if ok { var newText string completions, newText = handler((*CommandAutocomplete)(cmd)) if newText != "" { text = newText } } return completions, text, ok } func (ch *CommandProcessor) AutocompleteCommand(word string) (completions []string) { if word[0] != '/' { return } word = word[1:] for alias := range ch.aliases { if alias == word { return []string{"/" + alias} } if strings.HasPrefix(alias, word) { completions = append(completions, "/"+alias) } } for command := range ch.commands { if command == word { return []string{"/" + command} } if strings.HasPrefix(command, word) { completions = append(completions, "/"+command) } } return } func (ch *CommandProcessor) HandleCommand(cmd *Command) { defer debug.Recover() if cmd == nil { return } if alias, ok := ch.aliases[cmd.Command]; ok { cmd = alias.Process(cmd) } if cmd == nil { return } if handler, ok := ch.commands[cmd.Command]; ok { handler(cmd) return } cmdUnknownCommand(cmd) } gomuks-0.3.0/ui/commands.go000066400000000000000000000576001433617251100155770ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "bytes" "encoding/json" "errors" "fmt" "io" "math" "os" "os/exec" "path/filepath" "regexp" "runtime" dbg "runtime/debug" "runtime/pprof" "runtime/trace" "strconv" "strings" "time" "unicode" "github.com/lucasb-eyer/go-colorful" "github.com/yuin/goldmark" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/lib/filepicker" ) func cmdMe(cmd *Command) { text := strings.Join(cmd.Args, " ") go cmd.Room.SendMessage(event.MsgEmote, text) } // GradientTable from https://github.com/lucasb-eyer/go-colorful/blob/master/doc/gradientgen/gradientgen.go type GradientTable []struct { Col colorful.Color Pos float64 } func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color { for i := 0; i < len(gt)-1; i++ { c1 := gt[i] c2 := gt[i+1] if c1.Pos <= t && t <= c2.Pos { t := (t - c1.Pos) / (c2.Pos - c1.Pos) return c1.Col.BlendHcl(c2.Col, t).Clamped() } } return gt[len(gt)-1].Col } var rainbow = GradientTable{ {colorful.LinearRgb(1, 0, 0), 0 / 11.0}, {colorful.LinearRgb(1, 0.5, 0), 1 / 11.0}, {colorful.LinearRgb(1, 1, 0), 2 / 11.0}, {colorful.LinearRgb(0.5, 1, 0), 3 / 11.0}, {colorful.LinearRgb(0, 1, 0), 4 / 11.0}, {colorful.LinearRgb(0, 1, 0.5), 5 / 11.0}, {colorful.LinearRgb(0, 1, 1), 6 / 11.0}, {colorful.LinearRgb(0, 0.5, 1), 7 / 11.0}, {colorful.LinearRgb(0, 0, 1), 8 / 11.0}, {colorful.LinearRgb(0.5, 0, 1), 9 / 11.0}, {colorful.LinearRgb(1, 0, 1), 10 / 11.0}, {colorful.LinearRgb(1, 0, 0.5), 11 / 11.0}, } var rainbowMark = goldmark.New(format.Extensions, format.HTMLOptions, goldmark.WithExtensions(ExtensionRainbow)) // TODO this command definitely belongs in a plugin once we have a plugin system. func makeRainbow(cmd *Command, msgtype event.MessageType) { text := strings.Join(cmd.Args, " ") var buf strings.Builder _ = rainbowMark.Convert([]byte(text), &buf) htmlBody := strings.TrimRight(buf.String(), "\n") htmlBody = format.AntiParagraphRegex.ReplaceAllString(htmlBody, "$1") text = format.HTMLToText(htmlBody) count := strings.Count(htmlBody, defaultRB.ColorID) i := -1 htmlBody = regexp.MustCompile(defaultRB.ColorID).ReplaceAllStringFunc(htmlBody, func(match string) string { i++ return rainbow.GetInterpolatedColorFor(float64(i) / float64(count)).Hex() }) go cmd.Room.SendMessageHTML(msgtype, text, htmlBody) } func cmdRainbow(cmd *Command) { makeRainbow(cmd, event.MsgText) } func cmdRainbowMe(cmd *Command) { makeRainbow(cmd, event.MsgEmote) } func cmdRainbowNotice(cmd *Command) { makeRainbow(cmd, event.MsgNotice) } func cmdNotice(cmd *Command) { go cmd.Room.SendMessage(event.MsgNotice, strings.Join(cmd.Args, " ")) } func cmdAccept(cmd *Command) { room := cmd.Room.MxRoom() if room.SessionMember.Membership != "invite" { cmd.Reply("/accept can only be used in rooms you're invited to") return } _, server, _ := room.SessionMember.Sender.Parse() _, err := cmd.Matrix.JoinRoom(room.ID, server) if err != nil { cmd.Reply("Failed to accept invite: %v", err) } else { cmd.Reply("Successfully accepted invite") } cmd.MainView.UpdateTags(room) go cmd.MainView.LoadHistory(room.ID) } func cmdReject(cmd *Command) { room := cmd.Room.MxRoom() if room.SessionMember.Membership != "invite" { cmd.Reply("/reject can only be used in rooms you're invited to") return } err := cmd.Matrix.LeaveRoom(room.ID) if err != nil { cmd.Reply("Failed to reject invite: %v", err) } else { cmd.Reply("Successfully rejected invite") } cmd.MainView.RemoveRoom(room) } func cmdID(cmd *Command) { cmd.Reply("The internal ID of this room is %s", cmd.Room.MxRoom().ID) } type SelectReason string const ( SelectReply SelectReason = "reply to" SelectReact = "react to" SelectRedact = "redact" SelectEdit = "edit" SelectDownload = "download" SelectOpen = "open" SelectCopy = "copy" ) func cmdReply(cmd *Command) { cmd.Room.StartSelecting(SelectReply, strings.Join(cmd.Args, " ")) } func cmdEdit(cmd *Command) { cmd.Room.StartSelecting(SelectEdit, "") } func findEditorExecutable() (string, string, error) { if editor := os.Getenv("VISUAL"); len(editor) > 0 { if path, err := exec.LookPath(editor); err != nil { return "", "", fmt.Errorf("$VISUAL ('%s') not found in $PATH", editor) } else { return editor, path, nil } } else if editor = os.Getenv("EDITOR"); len(editor) > 0 { if path, err := exec.LookPath(editor); err != nil { return "", "", fmt.Errorf("$EDITOR ('%s') not found in $PATH", editor) } else { return editor, path, nil } } else if path, _ := exec.LookPath("nano"); len(path) > 0 { return "nano", path, nil } else if path, _ = exec.LookPath("vi"); len(path) > 0 { return "vi", path, nil } else { return "", "", fmt.Errorf("$VISUAL and $EDITOR not set, nano and vi not found in $PATH") } } func cmdExternalEditor(cmd *Command) { var file *os.File defer func() { if file != nil { _ = file.Close() _ = os.Remove(file.Name()) } }() fileExtension := "md" if cmd.Config.Preferences.DisableMarkdown { if cmd.Config.Preferences.DisableHTML { fileExtension = "txt" } else { fileExtension = "html" } } if editorName, executablePath, err := findEditorExecutable(); err != nil { cmd.Reply("Couldn't find editor to use: %v", err) return } else if file, err = os.CreateTemp("", fmt.Sprintf("gomuks-draft-*.%s", fileExtension)); err != nil { cmd.Reply("Failed to create temp file: %v", err) return } else if _, err = file.WriteString(cmd.RawArgs); err != nil { cmd.Reply("Failed to write to temp file: %v", err) } else if err = file.Close(); err != nil { cmd.Reply("Failed to close temp file: %v", err) } else if err = cmd.UI.RunExternal(executablePath, file.Name()); err != nil { var exitErr *exec.ExitError if isExit := errors.As(err, &exitErr); isExit { cmd.Reply("%s exited with non-zero status %d", editorName, exitErr.ExitCode()) } else { cmd.Reply("Failed to run %s: %v", editorName, err) } } else if data, err := os.ReadFile(file.Name()); err != nil { cmd.Reply("Failed to read temp file: %v", err) } else if len(bytes.TrimSpace(data)) > 0 { cmd.Room.InputSubmit(string(data)) } else { cmd.Reply("Temp file was blank, sending cancelled") if cmd.Room.editing != nil { cmd.Room.SetEditing(nil) } } } func cmdRedact(cmd *Command) { cmd.Room.StartSelecting(SelectRedact, strings.Join(cmd.Args, " ")) } func cmdDownload(cmd *Command) { cmd.Room.StartSelecting(SelectDownload, strings.Join(cmd.Args, " ")) } func cmdUpload(cmd *Command) { var path string var err error if len(cmd.Args) == 0 { if filepicker.IsSupported() { path, err = filepicker.Open() if err != nil { cmd.Reply("Failed to open file picker: %v", err) return } else if len(path) == 0 { cmd.Reply("File picking cancelled") return } } else { cmd.Reply("Usage: /upload ") return } } else { path, err = filepath.Abs(cmd.RawArgs) if err != nil { cmd.Reply("Failed to get absolute path: %v", err) return } } go cmd.Room.SendMessageMedia(path) } func cmdOpen(cmd *Command) { cmd.Room.StartSelecting(SelectOpen, strings.Join(cmd.Args, " ")) } func cmdCopy(cmd *Command) { register := strings.Join(cmd.Args, " ") if len(register) == 0 { register = "clipboard" } if register == "clipboard" || register == "primary" { cmd.Room.StartSelecting(SelectCopy, register) } else { cmd.Reply("Usage: /copy [register], where register is either \"clipboard\" or \"primary\". Defaults to \"clipboard\".") } } func cmdReact(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply("Usage: /react ") return } cmd.Room.StartSelecting(SelectReact, strings.Join(cmd.Args, " ")) } func readRoomAlias(cmd *Command) (alias id.RoomAlias, err error) { param := strings.Join(cmd.Args[1:], " ") if strings.ContainsRune(param, ':') { if param[0] != '#' { return "", errors.New("full aliases must start with #") } alias = id.RoomAlias(param) } else { _, homeserver, _ := cmd.Matrix.Client().UserID.Parse() alias = id.NewRoomAlias(param, homeserver) } return } func cmdAlias(cmd *Command) { if len(cmd.Args) < 2 { cmd.Reply("Usage: /alias ") return } alias, err := readRoomAlias(cmd) if err != nil { cmd.Reply(err.Error()) return } subcmd := strings.ToLower(cmd.Args[0]) switch subcmd { case "add", "create": cmdAddAlias(cmd, alias) case "remove", "delete", "del", "rm": cmdRemoveAlias(cmd, alias) case "resolve", "get": cmdResolveAlias(cmd, alias) default: cmd.Reply("Usage: /alias ") } } func niceError(err error) string { httpErr, ok := err.(mautrix.HTTPError) if ok && httpErr.RespError != nil { return httpErr.RespError.Error() } return err.Error() } func cmdAddAlias(cmd *Command, alias id.RoomAlias) { _, err := cmd.Matrix.Client().CreateAlias(alias, cmd.Room.MxRoom().ID) if err != nil { cmd.Reply("Failed to create alias: %v", niceError(err)) } else { cmd.Reply("Created alias %s", alias) } } func cmdRemoveAlias(cmd *Command, alias id.RoomAlias) { _, err := cmd.Matrix.Client().DeleteAlias(alias) if err != nil { cmd.Reply("Failed to delete alias: %v", niceError(err)) } else { cmd.Reply("Deleted alias %s", alias) } } func cmdResolveAlias(cmd *Command, alias id.RoomAlias) { resp, err := cmd.Matrix.Client().ResolveAlias(alias) if err != nil { cmd.Reply("Failed to resolve alias: %v", niceError(err)) } else { roomIDText := string(resp.RoomID) if resp.RoomID == cmd.Room.MxRoom().ID { roomIDText += " (this room)" } cmd.Reply("Alias %s points to room %s\nThere are %d servers in the room.", alias, roomIDText, len(resp.Servers)) } } func cmdTags(cmd *Command) { tags := cmd.Room.MxRoom().RawTags if len(cmd.Args) > 0 && cmd.Args[0] == "--internal" { tags = cmd.Room.MxRoom().Tags() } if len(tags) == 0 { if cmd.Room.MxRoom().IsDirect { cmd.Reply("This room has no tags, but it's marked as a direct chat.") } else { cmd.Reply("This room has no tags.") } return } var resp strings.Builder resp.WriteString("Tags in this room:\n") for _, tag := range tags { if tag.Order != "" { _, _ = fmt.Fprintf(&resp, "%s (order: %s)\n", tag.Tag, tag.Order) } else { _, _ = fmt.Fprintf(&resp, "%s (no order)\n", tag.Tag) } } cmd.Reply(strings.TrimSpace(resp.String())) } func cmdTag(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply("Usage: /tag [order]") return } order := math.NaN() if len(cmd.Args) > 1 { var err error order, err = strconv.ParseFloat(cmd.Args[1], 64) if err != nil { cmd.Reply("%s is not a valid order: %v", cmd.Args[1], err) return } } var err error if len(cmd.Args) > 2 && cmd.Args[2] == "--reset" { tags := event.Tags{ cmd.Args[0]: {Order: json.Number(fmt.Sprintf("%f", order))}, } for _, tag := range cmd.Room.MxRoom().RawTags { tags[tag.Tag] = event.Tag{Order: tag.Order} } err = cmd.Matrix.Client().SetTags(cmd.Room.MxRoom().ID, tags) } else { err = cmd.Matrix.Client().AddTag(cmd.Room.MxRoom().ID, cmd.Args[0], order) } if err != nil { cmd.Reply("Failed to add tag: %v", err) } } func cmdUntag(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply("Usage: /untag ") return } err := cmd.Matrix.Client().RemoveTag(cmd.Room.MxRoom().ID, cmd.Args[0]) if err != nil { cmd.Reply("Failed to remove tag: %v", err) } } func cmdRoomNick(cmd *Command) { room := cmd.Room.MxRoom() member := room.GetMember(room.SessionUserID) member.Displayname = strings.Join(cmd.Args, " ") _, err := cmd.Matrix.Client().SendStateEvent(room.ID, event.StateMember, string(room.SessionUserID), member) if err != nil { cmd.Reply("Failed to set room nick: %v", err) } } func cmdFingerprint(cmd *Command) { c := cmd.Matrix.Crypto() if c == nil { cmd.Reply("Encryption support is not enabled") } else { cmd.Reply("Device ID: %s\nFingerprint: %s", cmd.Matrix.Client().DeviceID, c.Fingerprint()) } } func cmdHeapProfile(cmd *Command) { if len(cmd.Args) == 0 || cmd.Args[0] != "nogc" { runtime.GC() dbg.FreeOSMemory() } memProfile, err := os.Create("gomuks.heap.prof") if err != nil { debug.Print("Failed to open gomuks.heap.prof:", err) return } defer func() { err := memProfile.Close() if err != nil { debug.Print("Failed to close gomuks.heap.prof:", err) } }() if err := pprof.WriteHeapProfile(memProfile); err != nil { debug.Print("Heap profile error:", err) } } func runTimedProfile(cmd *Command, start func(writer io.Writer) error, stop func(), task, file string) { if len(cmd.Args) == 0 { cmd.Reply("Usage: /%s ", cmd.Command) } else if dur, err := strconv.Atoi(cmd.Args[0]); err != nil || dur < 0 { cmd.Reply("Usage: /%s ", cmd.Command) } else if cpuProfile, err := os.Create(file); err != nil { debug.Printf("Failed to open %s: %v", file, err) } else if err = start(cpuProfile); err != nil { _ = cpuProfile.Close() debug.Print(task, "error:", err) } else { cmd.Reply("Started %s for %d seconds", task, dur) go func() { time.Sleep(time.Duration(dur) * time.Second) stop() cmd.Reply("%s finished.", task) err := cpuProfile.Close() if err != nil { debug.Print("Failed to close gomuks.cpu.prof:", err) } }() } } func cmdCPUProfile(cmd *Command) { runTimedProfile(cmd, pprof.StartCPUProfile, pprof.StopCPUProfile, "CPU profiling", "gomuks.cpu.prof") } func cmdTrace(cmd *Command) { runTimedProfile(cmd, trace.Start, trace.Stop, "Call tracing", "gomuks.trace") } func cmdQuit(cmd *Command) { cmd.Gomuks.Stop(true) } func cmdClearCache(cmd *Command) { cmd.Config.Clear() cmd.Gomuks.Stop(false) } func cmdUnknownCommand(cmd *Command) { cmd.Reply(`Unknown command "/%s". Try "/help" for help.`, cmd.Command) } func cmdHelp(cmd *Command) { view := cmd.MainView view.ShowModal(NewHelpModal(view)) } func cmdLeave(cmd *Command) { err := cmd.Matrix.LeaveRoom(cmd.Room.MxRoom().ID) debug.Print("Leave room error:", err) if err == nil { cmd.MainView.RemoveRoom(cmd.Room.MxRoom()) } } func cmdInvite(cmd *Command) { if len(cmd.Args) != 1 { cmd.Reply("Usage: /invite ") return } _, err := cmd.Matrix.Client().InviteUser(cmd.Room.MxRoom().ID, &mautrix.ReqInviteUser{UserID: id.UserID(cmd.Args[0])}) if err != nil { debug.Print("Error in invite call:", err) cmd.Reply("Failed to invite user: %v", err) } } func cmdBan(cmd *Command) { if len(cmd.Args) < 1 { cmd.Reply("Usage: /ban [reason]") return } reason := "you are the weakest link, goodbye!" if len(cmd.Args) >= 2 { reason = strings.Join(cmd.Args[1:], " ") } _, err := cmd.Matrix.Client().BanUser(cmd.Room.MxRoom().ID, &mautrix.ReqBanUser{Reason: reason, UserID: id.UserID(cmd.Args[0])}) if err != nil { debug.Print("Error in ban call:", err) cmd.Reply("Failed to ban user: %v", err) } } func cmdUnban(cmd *Command) { if len(cmd.Args) != 1 { cmd.Reply("Usage: /unban ") return } _, err := cmd.Matrix.Client().UnbanUser(cmd.Room.MxRoom().ID, &mautrix.ReqUnbanUser{UserID: id.UserID(cmd.Args[0])}) if err != nil { debug.Print("Error in unban call:", err) cmd.Reply("Failed to unban user: %v", err) } } func cmdKick(cmd *Command) { if len(cmd.Args) < 1 { cmd.Reply("Usage: /kick [reason]") return } reason := "you are the weakest link, goodbye!" if len(cmd.Args) >= 2 { reason = strings.Join(cmd.Args[1:], " ") } _, err := cmd.Matrix.Client().KickUser(cmd.Room.MxRoom().ID, &mautrix.ReqKickUser{Reason: reason, UserID: id.UserID(cmd.Args[0])}) if err != nil { debug.Print("Error in kick call:", err) debug.Print("Failed to kick user:", err) } } func cmdCreateRoom(cmd *Command) { req := &mautrix.ReqCreateRoom{} if len(cmd.Args) > 0 { req.Name = strings.Join(cmd.Args, " ") } room, err := cmd.Matrix.CreateRoom(req) if err != nil { cmd.Reply("Failed to create room: %v", err) return } cmd.MainView.SwitchRoom("", room) } func cmdPrivateMessage(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply("Usage: /pm [more user ids...]") } invites := make([]id.UserID, len(cmd.Args)) for i, userID := range cmd.Args { invites[i] = id.UserID(userID) _, _, err := invites[i].Parse() if err != nil { cmd.Reply("%s isn't a valid user ID", userID) return } } req := &mautrix.ReqCreateRoom{ Preset: "trusted_private_chat", Invite: invites, IsDirect: true, } room, err := cmd.Matrix.CreateRoom(req) if err != nil { cmd.Reply("Failed to create room: %v", err) return } cmd.MainView.SwitchRoom("", room) } func cmdJoin(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply("Usage: /join ") return } identifer := id.RoomID(cmd.Args[0]) server := "" if len(cmd.Args) > 1 { server = cmd.Args[1] } room, err := cmd.Matrix.JoinRoom(identifer, server) debug.Print("Join room error:", err) if err == nil { cmd.MainView.AddRoom(room) } } func cmdMSendEvent(cmd *Command) { if len(cmd.Args) < 2 { cmd.Reply("Usage: /msend ") return } cmd.Args = append([]string{string(cmd.Room.MxRoom().ID)}, cmd.Args...) cmdSendEvent(cmd) } func cmdSendEvent(cmd *Command) { if len(cmd.Args) < 3 { cmd.Reply("Usage: /send ") return } roomID := id.RoomID(cmd.Args[0]) eventType := event.NewEventType(cmd.Args[1]) rawContent := strings.Join(cmd.Args[2:], " ") var content interface{} err := json.Unmarshal([]byte(rawContent), &content) debug.Print(err) if err != nil { cmd.Reply("Failed to parse content: %v", err) return } debug.Print("Sending event to", roomID, eventType, content) resp, err := cmd.Matrix.Client().SendMessageEvent(roomID, eventType, content) debug.Print(resp, err) if err != nil { cmd.Reply("Error from server: %v", err) } else { cmd.Reply("Event sent, ID: %s", resp.EventID) } } func cmdMSetState(cmd *Command) { if len(cmd.Args) < 2 { cmd.Reply("Usage: /msetstate ") return } cmd.Args = append([]string{string(cmd.Room.MxRoom().ID)}, cmd.Args...) cmdSetState(cmd) } func cmdSetState(cmd *Command) { if len(cmd.Args) < 4 { cmd.Reply("Usage: /setstate ") return } roomID := id.RoomID(cmd.Args[0]) eventType := event.NewEventType(cmd.Args[1]) stateKey := cmd.Args[2] if stateKey == "-" { stateKey = "" } rawContent := strings.Join(cmd.Args[3:], " ") var content interface{} err := json.Unmarshal([]byte(rawContent), &content) if err != nil { cmd.Reply("Failed to parse content: %v", err) return } debug.Print("Sending state event to", roomID, eventType, stateKey, content) resp, err := cmd.Matrix.Client().SendStateEvent(roomID, eventType, stateKey, content) if err != nil { cmd.Reply("Error from server: %v", err) } else { cmd.Reply("State event sent, ID: %s", resp.EventID) } } type ToggleMessage interface { Name() string Format(state bool) string } type HideMessage string func (hm HideMessage) Format(state bool) string { if state { return string(hm) + " is now hidden" } else { return string(hm) + " is now visible" } } func (hm HideMessage) Name() string { return string(hm) } type SimpleToggleMessage string func (stm SimpleToggleMessage) Format(state bool) string { if state { return "Disabled " + string(stm) } else { return "Enabled " + string(stm) } } func (stm SimpleToggleMessage) Name() string { return string(unicode.ToUpper(rune(stm[0]))) + string(stm[1:]) } type InvertedToggleMessage string func (itm InvertedToggleMessage) Format(state bool) string { if state { return "Enabled " + string(itm) } else { return "Disabled " + string(itm) } } func (itm InvertedToggleMessage) Name() string { return string(unicode.ToUpper(rune(itm[0]))) + string(itm[1:]) } type NewlineKeybindMessage string func (nkm NewlineKeybindMessage) Format(state bool) string { if state { return "Now using to create new line and to send" } else { return "Now using to send and to create new line" } } func (nkm NewlineKeybindMessage) Name() string { return string(nkm) } var toggleMsg = map[string]ToggleMessage{ "rooms": HideMessage("Room list sidebar"), "users": HideMessage("User list sidebar"), "timestamps": HideMessage("message timestamps"), "baremessages": InvertedToggleMessage("bare message view"), "images": SimpleToggleMessage("image rendering"), "typingnotif": SimpleToggleMessage("typing notifications"), "emojis": SimpleToggleMessage("emoji shortcode conversion"), "html": SimpleToggleMessage("HTML input"), "markdown": SimpleToggleMessage("markdown input"), "downloads": SimpleToggleMessage("automatic downloads"), "notifications": SimpleToggleMessage("desktop notifications"), "unverified": SimpleToggleMessage("sending messages to unverified devices"), "showurls": SimpleToggleMessage("show URLs in text format"), "inlineurls": InvertedToggleMessage("use fancy terminal features to render URLs inside text"), "newline": NewlineKeybindMessage("should make a new line or send the message"), } func makeUsage() string { var buf strings.Builder buf.WriteString("Usage: /toggle \n\n") buf.WriteString("List of Things:\n") for key, value := range toggleMsg { _, _ = fmt.Fprintf(&buf, "* %s - %s\n", key, value.Name()) } return buf.String()[:buf.Len()-1] } func cmdToggle(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply(makeUsage()) return } for _, thing := range cmd.Args { var val *bool switch thing { case "rooms": val = &cmd.Config.Preferences.HideRoomList case "users": val = &cmd.Config.Preferences.HideUserList case "timestamps": val = &cmd.Config.Preferences.HideTimestamp case "baremessages": val = &cmd.Config.Preferences.BareMessageView case "images": val = &cmd.Config.Preferences.DisableImages case "typingnotif": val = &cmd.Config.Preferences.DisableTypingNotifs case "emojis": val = &cmd.Config.Preferences.DisableEmojis case "html": val = &cmd.Config.Preferences.DisableHTML case "markdown": val = &cmd.Config.Preferences.DisableMarkdown case "downloads": val = &cmd.Config.Preferences.DisableDownloads case "notifications": val = &cmd.Config.Preferences.DisableNotifications case "unverified": val = &cmd.Config.SendToVerifiedOnly case "showurls": val = &cmd.Config.Preferences.DisableShowURLs case "inlineurls": switch cmd.Config.Preferences.InlineURLMode { case "enable": cmd.Config.Preferences.InlineURLMode = "disable" cmd.Reply("Force-disabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.") default: cmd.Config.Preferences.InlineURLMode = "enable" cmd.Reply("Force-enabled using fancy terminal features to render URLs inside text. Restart gomuks to apply changes.") } continue case "newline": val = &cmd.Config.Preferences.AltEnterToSend default: cmd.Reply("Unknown toggle %s. Use /toggle without arguments for a list of togglable things.", thing) return } *val = !(*val) debug.Print(thing, *val) cmd.Reply(toggleMsg[thing].Format(*val)) if thing == "rooms" { // Update topic string to include or not include room name cmd.Room.Update() } } cmd.UI.Render() go cmd.Matrix.SendPreferencesToMatrix() } func cmdLogout(cmd *Command) { cmd.Matrix.Logout() } gomuks-0.3.0/ui/crypto-commands.go000066400000000000000000000505161433617251100171140ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . //go:build cgo package ui import ( "errors" "fmt" "io/ioutil" "path/filepath" "strings" "time" "unicode" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/crypto/ssss" "maunium.net/go/mautrix/id" ifc "maunium.net/go/gomuks/interface" ) func autocompleteDeviceUserID(cmd *CommandAutocomplete) (completions []string, newText string) { userCompletions := cmd.Room.AutocompleteUser(cmd.Args[0]) if len(userCompletions) == 1 { newText = fmt.Sprintf("/%s %s ", cmd.OrigCommand, userCompletions[0].id) } else { completions = make([]string, len(userCompletions)) for i, completion := range userCompletions { completions[i] = completion.id } } return } func autocompleteDeviceDeviceID(cmd *CommandAutocomplete) (completions []string, newText string) { mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) devices, err := mach.CryptoStore.GetDevices(id.UserID(cmd.Args[0])) if len(devices) == 0 || err != nil { return } var completedDeviceID id.DeviceID if len(cmd.Args) > 1 { existingID := strings.ToUpper(cmd.Args[1]) for _, device := range devices { deviceIDStr := string(device.DeviceID) if deviceIDStr == existingID { // We don't want to do any autocompletion if there's already a full device ID there. return []string{}, "" } else if strings.HasPrefix(strings.ToUpper(device.Name), existingID) || strings.HasPrefix(deviceIDStr, existingID) { completedDeviceID = device.DeviceID completions = append(completions, fmt.Sprintf("%s (%s)", device.DeviceID, device.Name)) } } } else { completions = make([]string, len(devices)) i := 0 for _, device := range devices { completedDeviceID = device.DeviceID completions[i] = fmt.Sprintf("%s (%s)", device.DeviceID, device.Name) i++ } } if len(completions) == 1 { newText = fmt.Sprintf("/%s %s %s ", cmd.OrigCommand, cmd.Args[0], completedDeviceID) } return } func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) { if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) { return autocompleteDeviceUserID(cmd) } return []string{}, "" } func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) { if len(cmd.Args) == 0 { return []string{}, "" } else if len(cmd.Args) == 1 && !unicode.IsSpace(rune(cmd.RawArgs[len(cmd.RawArgs)-1])) { return autocompleteDeviceUserID(cmd) } return autocompleteDeviceDeviceID(cmd) } func getDevice(cmd *Command) *crypto.DeviceIdentity { if len(cmd.Args) < 2 { cmd.Reply("Usage: /%s [fingerprint]", cmd.Command) return nil } mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) device, err := mach.GetOrFetchDevice(id.UserID(cmd.Args[0]), id.DeviceID(cmd.Args[1])) if err != nil { cmd.Reply("Failed to get device: %v", err) return nil } return device } func putDevice(cmd *Command, device *crypto.DeviceIdentity, action string) { mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) err := mach.CryptoStore.PutDevice(device.UserID, device) if err != nil { cmd.Reply("Failed to save device: %v", err) } else { cmd.Reply("Successfully %s %s/%s (%s)", action, device.UserID, device.DeviceID, device.Name) } mach.OnDevicesChanged(device.UserID) } func cmdDevices(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply("Usage: /devices ") return } userID := id.UserID(cmd.Args[0]) mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) devices, err := mach.CryptoStore.GetDevices(userID) if err != nil { cmd.Reply("Failed to get device list: %v", err) } if len(devices) == 0 { cmd.Reply("Fetching device list from server...") devices = mach.LoadDevices(userID) } if len(devices) == 0 { cmd.Reply("No devices found for %s", userID) return } var buf strings.Builder for _, device := range devices { trust := device.Trust.String() if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) { trust = "verified (transitive)" } _, _ = fmt.Fprintf(&buf, "%s (%s) - %s\n Fingerprint: %s\n", device.DeviceID, device.Name, trust, device.Fingerprint()) } resp := buf.String() cmd.Reply("%s", resp[:len(resp)-1]) } func cmdDevice(cmd *Command) { device := getDevice(cmd) if device == nil { return } deviceType := "Device" if device.Deleted { deviceType = "Deleted device" } mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) trustState := device.Trust.String() if device.Trust == crypto.TrustStateUnset && mach.IsDeviceTrusted(device) { trustState = "verified (transitive)" } cmd.Reply("%s %s of %s\nFingerprint: %s\nIdentity key: %s\nDevice name: %s\nTrust state: %s", deviceType, device.DeviceID, device.UserID, device.Fingerprint(), device.IdentityKey, device.Name, trustState) } func crossSignDevice(cmd *Command, device *crypto.DeviceIdentity) { mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) err := mach.SignOwnDevice(device) if err != nil { cmd.Reply("Failed to upload cross-signing signature: %v", err) } else { cmd.Reply("Successfully cross-signed %s (%s)", device.DeviceID, device.Name) } } func cmdVerifyDevice(cmd *Command) { device := getDevice(cmd) if device == nil { return } if device.Trust == crypto.TrustStateVerified { cmd.Reply("That device is already verified") return } if len(cmd.Args) == 2 { mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) mach.DefaultSASTimeout = 120 * time.Second modal := NewVerificationModal(cmd.MainView, device, mach.DefaultSASTimeout) cmd.MainView.ShowModal(modal) _, err := mach.NewSimpleSASVerificationWith(device, modal) if err != nil { cmd.Reply("Failed to start interactive verification: %v", err) return } } else { fingerprint := strings.Join(cmd.Args[2:], "") if string(device.SigningKey) != fingerprint { cmd.Reply("Mismatching fingerprint") return } action := "verified" if device.Trust == crypto.TrustStateBlacklisted { action = "unblacklisted and verified" } if device.UserID == cmd.Matrix.Client().UserID { crossSignDevice(cmd, device) device.Trust = crypto.TrustStateVerified putDevice(cmd, device, action) } else { putDevice(cmd, device, action) cmd.Reply("Warning: verifying individual devices of other users is not synced with cross-signing") } } } func cmdVerify(cmd *Command) { if len(cmd.Args) < 1 { cmd.Reply("Usage: /%s [--force]", cmd.OrigCommand) return } force := len(cmd.Args) >= 2 && strings.ToLower(cmd.Args[1]) == "--force" userID := id.UserID(cmd.Args[0]) room := cmd.Room.Room if !room.Encrypted { cmd.Reply("In-room verification is only supported in encrypted rooms") return } if (!room.IsDirect || room.OtherUser != userID) && !force { cmd.Reply("This doesn't seem to be a direct chat. Either switch to a direct chat with %s, "+ "or use `--force` to start the verification anyway.", userID) return } mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) if mach.CrossSigningKeys == nil && !force { cmd.Reply("Cross-signing private keys not cached. Generate or fetch cross-signing keys with `/cross-signing`, " + "or use `--force` to start the verification anyway") return } modal := NewVerificationModal(cmd.MainView, &crypto.DeviceIdentity{UserID: userID}, mach.DefaultSASTimeout) _, err := mach.NewInRoomSASVerificationWith(cmd.Room.Room.ID, userID, modal, 120*time.Second) if err != nil { cmd.Reply("Failed to start in-room verification: %v", err) return } cmd.MainView.ShowModal(modal) } func cmdUnverify(cmd *Command) { device := getDevice(cmd) if device == nil { return } if device.Trust == crypto.TrustStateUnset { cmd.Reply("That device is already not verified") return } action := "unverified" if device.Trust == crypto.TrustStateBlacklisted { action = "unblacklisted" } device.Trust = crypto.TrustStateUnset putDevice(cmd, device, action) } func cmdBlacklist(cmd *Command) { device := getDevice(cmd) if device == nil { return } if device.Trust == crypto.TrustStateBlacklisted { cmd.Reply("That device is already blacklisted") return } action := "blacklisted" if device.Trust == crypto.TrustStateVerified { action = "unverified and blacklisted" } device.Trust = crypto.TrustStateBlacklisted putDevice(cmd, device, action) } func cmdResetSession(cmd *Command) { err := cmd.Matrix.Crypto().(*crypto.OlmMachine).CryptoStore.RemoveOutboundGroupSession(cmd.Room.Room.ID) if err != nil { cmd.Reply("Failed to remove outbound group session: %v", err) } else { cmd.Reply("Removed outbound group session for this room") } } func cmdImportKeys(cmd *Command) { path, err := filepath.Abs(cmd.RawArgs) if err != nil { cmd.Reply("Failed to get absolute path: %v", err) return } data, err := ioutil.ReadFile(path) if err != nil { cmd.Reply("Failed to read %s: %v", path, err) return } passphrase, ok := cmd.MainView.AskPassword("Key import", "passphrase", "", false) if !ok { cmd.Reply("Passphrase entry cancelled") return } mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) imported, total, err := mach.ImportKeys(passphrase, data) if err != nil { cmd.Reply("Failed to import sessions: %v", err) } else { cmd.Reply("Successfully imported %d/%d sessions", imported, total) } } func exportKeys(cmd *Command, sessions []*crypto.InboundGroupSession) { path, err := filepath.Abs(cmd.RawArgs) if err != nil { cmd.Reply("Failed to get absolute path: %v", err) return } passphrase, ok := cmd.MainView.AskPassword("Key export", "passphrase", "", true) if !ok { cmd.Reply("Passphrase entry cancelled") return } export, err := crypto.ExportKeys(passphrase, sessions) if err != nil { cmd.Reply("Failed to export sessions: %v", err) } err = ioutil.WriteFile(path, export, 0400) if err != nil { cmd.Reply("Failed to write sessions to %s: %v", path, err) } else { cmd.Reply("Successfully exported %d sessions to %s", len(sessions), path) } } func cmdExportKeys(cmd *Command) { mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) sessions, err := mach.CryptoStore.GetAllGroupSessions() if err != nil { cmd.Reply("Failed to get sessions to export: %v", err) return } exportKeys(cmd, sessions) } func cmdExportRoomKeys(cmd *Command) { mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) sessions, err := mach.CryptoStore.GetGroupSessionsForRoom(cmd.Room.MxRoom().ID) if err != nil { cmd.Reply("Failed to get sessions to export: %v", err) return } exportKeys(cmd, sessions) } const ssssHelp = `Usage: /%s [...] Subcommands: * status [key ID] - Check the status of your SSSS. * generate [--set-default] - Generate a SSSS key and optionally set it as the default. * set-default - Set a SSSS key as the default.` func cmdSSSS(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply(ssssHelp, cmd.OrigCommand) return } mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) switch strings.ToLower(cmd.Args[0]) { case "status": keyID := "" if len(cmd.Args) > 1 { keyID = cmd.Args[1] } cmdS4Status(cmd, mach, keyID) case "generate": setDefault := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--set-default" cmdS4Generate(cmd, mach, setDefault) case "set-default": if len(cmd.Args) < 2 { cmd.Reply("Usage: /%s set-default ", cmd.OrigCommand) return } cmdS4SetDefault(cmd, mach, cmd.Args[1]) default: cmd.Reply(ssssHelp, cmd.OrigCommand) } } func cmdS4Status(cmd *Command, mach *crypto.OlmMachine, keyID string) { var keyData *ssss.KeyMetadata var err error if len(keyID) == 0 { keyID, keyData, err = mach.SSSS.GetDefaultKeyData() } else { keyData, err = mach.SSSS.GetKeyData(keyID) } if errors.Is(err, ssss.ErrNoDefaultKeyAccountDataEvent) { cmd.Reply("SSSS is not set up: no default key set") return } else if err != nil { cmd.Reply("Failed to get key data: %v", err) return } hasPassphrase := "no" if keyData.Passphrase != nil { hasPassphrase = fmt.Sprintf("yes (alg=%s,bits=%d,iter=%d)", keyData.Passphrase.Algorithm, keyData.Passphrase.Bits, keyData.Passphrase.Iterations) } algorithm := keyData.Algorithm if algorithm != ssss.AlgorithmAESHMACSHA2 { algorithm += " (not supported!)" } cmd.Reply("Default key is set.\n Key ID: %s\n Has passphrase: %s\n Algorithm: %s", keyID, hasPassphrase, algorithm) } func cmdS4Generate(cmd *Command, mach *crypto.OlmMachine, setDefault bool) { passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "", true) if !ok { return } key, err := ssss.NewKey(passphrase) if err != nil { cmd.Reply("Failed to generate new key: %v", err) return } err = mach.SSSS.SetKeyData(key.ID, key.Metadata) if err != nil { cmd.Reply("Failed to upload key metadata: %v", err) return } // TODO if we start persisting command replies, the recovery key needs to be moved into a popup cmd.Reply("Successfully generated key %s\nRecovery key: %s", key.ID, key.RecoveryKey()) if setDefault { err = mach.SSSS.SetDefaultKeyID(key.ID) if err != nil { cmd.Reply("Failed to set key as default: %v", err) } } else { cmd.Reply("You can use `/%s set-default %s` to set it as the default", cmd.OrigCommand, key.ID) } } func cmdS4SetDefault(cmd *Command, mach *crypto.OlmMachine, keyID string) { _, err := mach.SSSS.GetKeyData(keyID) if err != nil { if errors.Is(err, mautrix.MNotFound) { cmd.Reply("Couldn't find key data on server") } else { cmd.Reply("Failed to fetch key data: %v", err) } return } err = mach.SSSS.SetDefaultKeyID(keyID) if err != nil { cmd.Reply("Failed to set key as default: %v", err) } else { cmd.Reply("Successfully set key %s as default", keyID) } } const crossSigningHelp = `Usage: /%s [...] Subcommands: * status Check the status of your own cross-signing keys. * generate [--force] Generate and upload new cross-signing keys. This will prompt you to enter your account password. If you already have existing keys, --force is required. * self-sign Sign the current device with cached cross-signing keys. * fetch [--save-to-disk] Fetch your cross-signing keys from SSSS and decrypt them. If --save-to-disk is specified, the keys are saved to disk. * upload Upload your cross-signing keys to SSSS.` func cmdCrossSigning(cmd *Command) { if len(cmd.Args) == 0 { cmd.Reply(crossSigningHelp, cmd.OrigCommand) return } client := cmd.Matrix.Client() mach := cmd.Matrix.Crypto().(*crypto.OlmMachine) switch strings.ToLower(cmd.Args[0]) { case "status": cmdCrossSigningStatus(cmd, mach) case "generate": force := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--force" cmdCrossSigningGenerate(cmd, cmd.Matrix, mach, client, force) case "fetch": saveToDisk := len(cmd.Args) > 1 && strings.ToLower(cmd.Args[1]) == "--save-to-disk" cmdCrossSigningFetch(cmd, mach, saveToDisk) case "upload": cmdCrossSigningUpload(cmd, mach) case "self-sign": cmdCrossSigningSelfSign(cmd, mach) default: cmd.Reply(crossSigningHelp, cmd.OrigCommand) } } func cmdCrossSigningStatus(cmd *Command, mach *crypto.OlmMachine) { keys := mach.GetOwnCrossSigningPublicKeys() if keys == nil { if mach.CrossSigningKeys != nil { cmd.Reply("Cross-signing keys are cached, but not published") } else { cmd.Reply("Didn't find published cross-signing keys") } return } if mach.CrossSigningKeys != nil { cmd.Reply("Cross-signing keys are published and private keys are cached") } else { cmd.Reply("Cross-signing keys are published, but private keys are not cached") } cmd.Reply("Master key: %s", keys.MasterKey) cmd.Reply("User signing key: %s", keys.UserSigningKey) cmd.Reply("Self-signing key: %s", keys.SelfSigningKey) } func cmdCrossSigningFetch(cmd *Command, mach *crypto.OlmMachine, saveToDisk bool) { key := getSSSS(cmd, mach) if key == nil { return } err := mach.FetchCrossSigningKeysFromSSSS(key) if err != nil { cmd.Reply("Error fetching cross-signing keys: %v", err) return } if saveToDisk { cmd.Reply("Saving keys to disk is not yet implemented") } cmd.Reply("Successfully unlocked cross-signing keys") } func cmdCrossSigningGenerate(cmd *Command, container ifc.MatrixContainer, mach *crypto.OlmMachine, client *mautrix.Client, force bool) { if !force { existingKeys := mach.GetOwnCrossSigningPublicKeys() if existingKeys != nil { cmd.Reply("Found existing cross-signing keys. Use `--force` if you want to overwrite them.") return } } keys, err := mach.GenerateCrossSigningKeys() if err != nil { cmd.Reply("Failed to generate cross-signing keys: %v", err) return } err = mach.PublishCrossSigningKeys(keys, func(uia *mautrix.RespUserInteractive) interface{} { if !uia.HasSingleStageFlow(mautrix.AuthTypePassword) { for _, flow := range uia.Flows { if len(flow.Stages) != 1 { return nil } cmd.Reply("Opening browser for authentication") err := container.UIAFallback(flow.Stages[0], uia.Session) if err != nil { cmd.Reply("Authentication failed: %v", err) return nil } return &mautrix.ReqUIAuthFallback{ Session: uia.Session, User: mach.Client.UserID.String(), } } cmd.Reply("No supported authentication mechanisms found") return nil } password, ok := cmd.MainView.AskPassword("Account password", "", "correct horse battery staple", false) if !ok { return nil } return &mautrix.ReqUIAuthLogin{ BaseAuthData: mautrix.BaseAuthData{ Type: mautrix.AuthTypePassword, Session: uia.Session, }, User: mach.Client.UserID.String(), Password: password, } }) if err != nil { cmd.Reply("Failed to publish cross-signing keys: %v", err) return } cmd.Reply("Successfully generated and published cross-signing keys") err = mach.SignOwnMasterKey() if err != nil { cmd.Reply("Failed to sign master key with device key: %v", err) } } func getSSSS(cmd *Command, mach *crypto.OlmMachine) *ssss.Key { _, keyData, err := mach.SSSS.GetDefaultKeyData() if err != nil { if errors.Is(err, mautrix.MNotFound) { cmd.Reply("SSSS not set up, use `!ssss generate --set-default` first") } else { cmd.Reply("Failed to fetch default SSSS key data: %v", err) } return nil } var key *ssss.Key if keyData.Passphrase != nil && keyData.Passphrase.Algorithm == ssss.PassphraseAlgorithmPBKDF2 { passphrase, ok := cmd.MainView.AskPassword("Passphrase", "", "correct horse battery staple", false) if !ok { return nil } key, err = keyData.VerifyPassphrase(passphrase) if errors.Is(err, ssss.ErrIncorrectSSSSKey) { cmd.Reply("Incorrect passphrase") return nil } } else { recoveryKey, ok := cmd.MainView.AskPassword("Recovery key", "", "tDAK LMRH PiYE bdzi maCe xLX5 wV6P Nmfd c5mC wLef 15Fs VVSc", false) if !ok { return nil } key, err = keyData.VerifyRecoveryKey(recoveryKey) if errors.Is(err, ssss.ErrInvalidRecoveryKey) { cmd.Reply("Malformed recovery key") return nil } else if errors.Is(err, ssss.ErrIncorrectSSSSKey) { cmd.Reply("Incorrect recovery key") return nil } } // All the errors should already be handled above, this is just for backup if err != nil { cmd.Reply("Failed to get SSSS key: %v", err) return nil } return key } func cmdCrossSigningUpload(cmd *Command, mach *crypto.OlmMachine) { if mach.CrossSigningKeys == nil { cmd.Reply("Cross-signing keys not cached, use `!%s generate` first", cmd.OrigCommand) return } key := getSSSS(cmd, mach) if key == nil { return } err := mach.UploadCrossSigningKeysToSSSS(key, mach.CrossSigningKeys) if err != nil { cmd.Reply("Failed to upload keys to SSSS: %v", err) } else { cmd.Reply("Successfully uploaded cross-signing keys to SSSS") } } func cmdCrossSigningSelfSign(cmd *Command, mach *crypto.OlmMachine) { if mach.CrossSigningKeys == nil { cmd.Reply("Cross-signing keys not cached") return } err := mach.SignOwnDevice(mach.OwnIdentity()) if err != nil { cmd.Reply("Failed to self-sign: %v", err) } else { cmd.Reply("Successfully self-signed. This device is now trusted by other devices") } } gomuks-0.3.0/ui/doc.go000066400000000000000000000000661433617251100145350ustar00rootroot00000000000000// Package ui contains the main gomuks UI. package ui gomuks-0.3.0/ui/fuzzy-search-modal.go000066400000000000000000000105211433617251100175110ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "fmt" "sort" "strconv" "github.com/lithammer/fuzzysearch/fuzzy" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/matrix/rooms" ) type FuzzySearchModal struct { mauview.Component container *mauview.Box search *mauview.InputArea results *mauview.TextView matches fuzzy.Ranks selected int roomList []*rooms.Room roomTitles []string parent *MainView } func NewFuzzySearchModal(mainView *MainView, width int, height int) *FuzzySearchModal { fs := &FuzzySearchModal{ parent: mainView, } fs.InitList(mainView.rooms) fs.results = mauview.NewTextView().SetRegions(true) fs.search = mauview.NewInputArea(). SetChangedFunc(fs.changeHandler). SetTextColor(tcell.ColorWhite). SetBackgroundColor(tcell.ColorDarkCyan) fs.search.Focus() flex := mauview.NewFlex(). SetDirection(mauview.FlexRow). AddFixedComponent(fs.search, 1). AddProportionalComponent(fs.results, 1) fs.container = mauview.NewBox(flex). SetBorder(true). SetTitle("Quick Room Switcher"). SetBlurCaptureFunc(func() bool { fs.parent.HideModal() return true }) fs.Component = mauview.Center(fs.container, width, height).SetAlwaysFocusChild(true) return fs } func (fs *FuzzySearchModal) Focus() { fs.container.Focus() } func (fs *FuzzySearchModal) Blur() { fs.container.Blur() } func (fs *FuzzySearchModal) InitList(rooms map[id.RoomID]*RoomView) { for _, room := range rooms { if room.Room.IsReplaced() { //if _, ok := rooms[room.Room.ReplacedBy()]; ok continue } fs.roomList = append(fs.roomList, room.Room) fs.roomTitles = append(fs.roomTitles, room.Room.GetTitle()) } } func (fs *FuzzySearchModal) changeHandler(str string) { // Get matches and display in result box fs.matches = fuzzy.RankFindFold(str, fs.roomTitles) if len(str) > 0 && len(fs.matches) > 0 { sort.Sort(fs.matches) fs.results.Clear() for _, match := range fs.matches { fmt.Fprintf(fs.results, `["%d"]%s[""]%s`, match.OriginalIndex, match.Target, "\n") } //fs.parent.parent.Render() fs.results.Highlight(strconv.Itoa(fs.matches[0].OriginalIndex)) fs.selected = 0 fs.results.ScrollToBeginning() } else { fs.results.Clear() fs.results.Highlight() } } func (fs *FuzzySearchModal) OnKeyEvent(event mauview.KeyEvent) bool { highlights := fs.results.GetHighlights() kb := config.Keybind{ Key: event.Key(), Ch: event.Rune(), Mod: event.Modifiers(), } switch fs.parent.config.Keybindings.Modal[kb] { case "cancel": // Close room finder fs.parent.HideModal() return true case "select_next": // Cycle highlighted area to next match if len(highlights) > 0 { fs.selected = (fs.selected + 1) % len(fs.matches) fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex)) fs.results.ScrollToHighlight() } return true case "select_prev": if len(highlights) > 0 { fs.selected = (fs.selected - 1) % len(fs.matches) if fs.selected < 0 { fs.selected += len(fs.matches) } fs.results.Highlight(strconv.Itoa(fs.matches[fs.selected].OriginalIndex)) fs.results.ScrollToHighlight() } return true case "confirm": // Switch room to currently selected room if len(highlights) > 0 { debug.Print("Fuzzy Selected Room:", fs.roomList[fs.matches[fs.selected].OriginalIndex].GetTitle()) fs.parent.SwitchRoom(fs.roomList[fs.matches[fs.selected].OriginalIndex].Tags()[0].Tag, fs.roomList[fs.matches[fs.selected].OriginalIndex]) } fs.parent.HideModal() fs.results.Clear() fs.search.SetText("") return true } return fs.search.OnKeyEvent(event) } gomuks-0.3.0/ui/help-modal.go000066400000000000000000000073101433617251100160110ustar00rootroot00000000000000package ui import ( "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/gomuks/config" ) const helpText = `# General /help - Show this help dialog. /quit - Quit gomuks. /clearcache - Clear cache and quit gomuks. /logout - Log out of Matrix. /toggle - Temporary command to toggle various UI features. Run /toggle without arguments to see the list of toggles. # Media /download [path] - Downloads file from selected message. /open [path] - Download file from selected message and open it with xdg-open. /upload - Upload the file at the given path to the current room. # Sending special messages /me - Send an emote message. /notice - Send a notice (generally used for bot messages). /rainbow - Send rainbow text. /rainbowme - Send rainbow text in an emote. /reply [text] - Reply to the selected message. /react - React to the selected message. /redact [reason] - Redact the selected message. /edit - Edit the selected message. # Encryption /fingerprint - View the fingerprint of your device. /devices - View the device list of a user. /device - Show info about a specific device. /unverify - Un-verify a device. /blacklist - Blacklist a device. /verify - Verify a user with in-room verification. Probably broken. /verify-device [fingerprint] - Verify a device. If the fingerprint is not provided, interactive emoji verification will be started. /reset-session - Reset the outbound Megolm session in the current room. /import - Import encryption keys /export - Export encryption keys /export-room - Export encryption keys for the current room. /cross-signing [...] - Cross-signing commands. Somewhat experimental. Run without arguments for help. (alias: /cs) /ssss [...] - Secure Secret Storage (and Sharing) commands. Very experimental. Run without arguments for help. # Rooms /pm <...> - Create a private chat with the given user(s). /create [room name] - Create a room. /join [server] - Join a room. /accept - Accept the invite. /reject - Reject the invite. /invite - Invite the given user to the room. /roomnick - Change your per-room displayname. /tag - Add the room to . /untag - Remove the room from . /tags - List the tags the room is in. /alias - Add or remove local addresses. /leave - Leave the current room. /kick [reason] - Kick a user. /ban [reason] - Ban a user. /unban - Unban a user.` type HelpModal struct { mauview.FocusableComponent parent *MainView } func NewHelpModal(parent *MainView) *HelpModal { hm := &HelpModal{parent: parent} text := mauview.NewTextView(). SetText(helpText). SetScrollable(true). SetWrap(false). SetTextColor(tcell.ColorDefault) box := mauview.NewBox(text). SetBorder(true). SetTitle("Help"). SetBlurCaptureFunc(func() bool { hm.parent.HideModal() return true }) box.Focus() hm.FocusableComponent = mauview.FractionalCenter(box, 42, 10, 0.5, 0.5) return hm } func (hm *HelpModal) OnKeyEvent(event mauview.KeyEvent) bool { kb := config.Keybind{ Key: event.Key(), Ch: event.Rune(), Mod: event.Modifiers(), } // TODO unhardcode q if hm.parent.config.Keybindings.Modal[kb] == "cancel" || event.Rune() == 'q' { hm.parent.HideModal() return true } return hm.FocusableComponent.OnKeyEvent(event) } gomuks-0.3.0/ui/member-list.go000066400000000000000000000065171433617251100162170ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "math" "sort" "strings" "github.com/mattn/go-runewidth" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/widget" ) type MemberList struct { list roomMemberList } func NewMemberList() *MemberList { return &MemberList{} } type memberListItem struct { rooms.Member PowerLevel int Sigil rune UserID id.UserID Color tcell.Color } type roomMemberList []*memberListItem func (rml roomMemberList) Len() int { return len(rml) } func (rml roomMemberList) Less(i, j int) bool { if rml[i].PowerLevel != rml[j].PowerLevel { return rml[i].PowerLevel > rml[j].PowerLevel } return strings.Compare(strings.ToLower(rml[i].Displayname), strings.ToLower(rml[j].Displayname)) < 0 } func (rml roomMemberList) Swap(i, j int) { rml[i], rml[j] = rml[j], rml[i] } func (ml *MemberList) Update(data map[id.UserID]*rooms.Member, levels *event.PowerLevelsEventContent) *MemberList { ml.list = make(roomMemberList, len(data)) i := 0 highestLevel := math.MinInt32 count := 0 for _, level := range levels.Users { if level > highestLevel { highestLevel = level count = 1 } else if level == highestLevel { count++ } } for userID, member := range data { level := levels.GetUserLevel(userID) sigil := ' ' if level == highestLevel && count == 1 { sigil = '~' } else if level > levels.StateDefault() { sigil = '&' } else if level >= levels.Ban() { sigil = '@' } else if level >= levels.Kick() || level >= levels.Redact() { sigil = '%' } else if level > levels.UsersDefault { sigil = '+' } ml.list[i] = &memberListItem{ Member: *member, UserID: userID, PowerLevel: level, Sigil: sigil, Color: widget.GetHashColor(userID), } i++ } sort.Sort(ml.list) return ml } func (ml *MemberList) Draw(screen mauview.Screen) { width, _ := screen.Size() sigilStyle := tcell.StyleDefault.Background(tcell.ColorGreen).Foreground(tcell.ColorDefault) for y, member := range ml.list { if member.Sigil != ' ' { screen.SetCell(0, y, sigilStyle, member.Sigil) } if member.Membership == "invite" { widget.WriteLineSimpleColor(screen, member.Displayname, 2, y, member.Color) screen.SetCell(1, y, tcell.StyleDefault, '(') if sw := runewidth.StringWidth(member.Displayname); sw+2 < width { screen.SetCell(sw+2, y, tcell.StyleDefault, ')') } else { screen.SetCell(width-1, y, tcell.StyleDefault, ')') } } else { widget.WriteLineSimpleColor(screen, member.Displayname, 1, y, member.Color) } } } gomuks-0.3.0/ui/message-view.go000066400000000000000000000444221433617251100163700ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "fmt" "math" "strings" "sync/atomic" "github.com/mattn/go-runewidth" sync "github.com/sasha-s/go-deadlock" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/lib/open" "maunium.net/go/gomuks/ui/messages" "maunium.net/go/gomuks/ui/widget" ) type MessageView struct { parent *RoomView config *config.Config ScrollOffset int MaxSenderWidth int DateFormat string TimestampFormat string TimestampWidth int // Used for locking loadingMessages int32 historyLoadPtr uint64 _widestSender uint32 _prevWidestSender uint32 _width uint32 _height uint32 _prevWidth uint32 _prevHeight uint32 prevMsgCount int prevPrefs config.UserPreferences messageIDLock sync.RWMutex messageIDs map[id.EventID]*messages.UIMessage messagesLock sync.RWMutex messages []*messages.UIMessage msgBufferLock sync.RWMutex msgBuffer []*messages.UIMessage selected *messages.UIMessage initialHistoryLoaded bool } func NewMessageView(parent *RoomView) *MessageView { return &MessageView{ parent: parent, config: parent.config, MaxSenderWidth: 15, TimestampWidth: len(messages.TimeFormat), ScrollOffset: 0, messages: make([]*messages.UIMessage, 0), messageIDs: make(map[id.EventID]*messages.UIMessage), msgBuffer: make([]*messages.UIMessage, 0), _widestSender: 5, _prevWidestSender: 0, _width: 80, _prevWidth: 0, _prevHeight: 0, prevMsgCount: -1, } } func (view *MessageView) Unload() { debug.Print("Unloading message view", view.parent.Room.ID) view.messagesLock.Lock() view.msgBufferLock.Lock() view.messageIDLock.Lock() view.messageIDs = make(map[id.EventID]*messages.UIMessage) view.msgBuffer = make([]*messages.UIMessage, 0) view.messages = make([]*messages.UIMessage, 0) view.initialHistoryLoaded = false view.ScrollOffset = 0 view._widestSender = 5 view.prevMsgCount = -1 view.historyLoadPtr = 0 view.messagesLock.Unlock() view.msgBufferLock.Unlock() view.messageIDLock.Unlock() } func (view *MessageView) updateWidestSender(sender string) { if len(sender) > int(view._widestSender) { if len(sender) > view.MaxSenderWidth { atomic.StoreUint32(&view._widestSender, uint32(view.MaxSenderWidth)) } else { atomic.StoreUint32(&view._widestSender, uint32(len(sender))) } } } type MessageDirection int const ( AppendMessage MessageDirection = iota PrependMessage IgnoreMessage ) func (view *MessageView) AddMessage(ifcMessage ifc.Message, direction MessageDirection) { if ifcMessage == nil { return } message, ok := ifcMessage.(*messages.UIMessage) if !ok || message == nil { debug.Print("[Warning] Passed non-UIMessage ifc.Message object to AddMessage().") debug.PrintStack() return } var oldMsg *messages.UIMessage if oldMsg = view.getMessageByID(message.EventID); oldMsg != nil { view.replaceMessage(oldMsg, message) direction = IgnoreMessage } else if oldMsg = view.getMessageByID(id.EventID(message.TxnID)); oldMsg != nil { view.replaceMessage(oldMsg, message) view.deleteMessageID(id.EventID(message.TxnID)) direction = IgnoreMessage } view.updateWidestSender(message.Sender()) width := view.width() bare := view.config.Preferences.BareMessageView if !bare { width -= view.widestSender() + SenderMessageGap if !view.config.Preferences.HideTimestamp { width -= view.TimestampWidth + TimestampSenderGap } } message.CalculateBuffer(view.config.Preferences, width) makeDateChange := func(msg *messages.UIMessage) *messages.UIMessage { dateChange := messages.NewDateChangeMessage( fmt.Sprintf("Date changed to %s", msg.FormatDate())) dateChange.CalculateBuffer(view.config.Preferences, width) view.appendBuffer(dateChange) return dateChange } if direction == AppendMessage { if view.ScrollOffset > 0 { view.ScrollOffset += message.Height() } view.messagesLock.Lock() if len(view.messages) > 0 && !view.messages[len(view.messages)-1].SameDate(message) { view.messages = append(view.messages, makeDateChange(message), message) } else { view.messages = append(view.messages, message) } view.messagesLock.Unlock() view.appendBuffer(message) } else if direction == PrependMessage { view.messagesLock.Lock() if len(view.messages) > 0 && !view.messages[0].SameDate(message) { view.messages = append([]*messages.UIMessage{message, makeDateChange(view.messages[0])}, view.messages...) } else { view.messages = append([]*messages.UIMessage{message}, view.messages...) } view.messagesLock.Unlock() } else if oldMsg != nil { view.replaceBuffer(oldMsg, message) } else { debug.Print("Unexpected AddMessage() call: Direction is not append or prepend, but message is new.") debug.PrintStack() } if len(message.ID()) > 0 { view.setMessageID(message) } } func (view *MessageView) replaceMessage(original *messages.UIMessage, new *messages.UIMessage) { if len(new.ID()) > 0 { view.setMessageID(new) } view.messagesLock.Lock() for index, msg := range view.messages { if msg == original { view.messages[index] = new } } view.messagesLock.Unlock() } func (view *MessageView) getMessageByID(id id.EventID) *messages.UIMessage { if id == "" { return nil } view.messageIDLock.RLock() defer view.messageIDLock.RUnlock() msg, ok := view.messageIDs[id] if !ok { return nil } return msg } func (view *MessageView) deleteMessageID(id id.EventID) { if id == "" { return } view.messageIDLock.Lock() delete(view.messageIDs, id) view.messageIDLock.Unlock() } func (view *MessageView) setMessageID(message *messages.UIMessage) { if message.ID() == "" { return } view.messageIDLock.Lock() view.messageIDs[message.ID()] = message view.messageIDLock.Unlock() } func (view *MessageView) appendBuffer(message *messages.UIMessage) { view.msgBufferLock.Lock() view.appendBufferUnlocked(message) view.msgBufferLock.Unlock() } func (view *MessageView) appendBufferUnlocked(message *messages.UIMessage) { for i := 0; i < message.Height(); i++ { view.msgBuffer = append(view.msgBuffer, message) } view.prevMsgCount++ } func (view *MessageView) replaceBuffer(original *messages.UIMessage, new *messages.UIMessage) { start := -1 end := -1 view.msgBufferLock.RLock() for index, meta := range view.msgBuffer { if meta == original { if start == -1 { start = index } end = index } else if start != -1 { break } } view.msgBufferLock.RUnlock() if start == -1 { debug.Print("Called replaceBuffer() with message that was not in the buffer:", original) //debug.PrintStack() view.appendBuffer(new) return } if len(view.msgBuffer) > end { end++ } if new.Height() == 0 { new.CalculateBuffer(view.prevPrefs, view.prevWidth()) } view.msgBufferLock.Lock() if new.Height() != end-start { height := new.Height() newBuffer := make([]*messages.UIMessage, height+len(view.msgBuffer)-end) for i := 0; i < height; i++ { newBuffer[i] = new } for i := height; i < len(newBuffer); i++ { newBuffer[i] = view.msgBuffer[end+(i-height)] } view.msgBuffer = append(view.msgBuffer[0:start], newBuffer...) } else { for i := start; i < end; i++ { view.msgBuffer[i] = new } } view.msgBufferLock.Unlock() } func (view *MessageView) recalculateBuffers() { prefs := view.config.Preferences recalculateMessageBuffers := view.width() != view.prevWidth() || view.widestSender() != view.prevWidestSender() || view.prevPrefs.BareMessageView != prefs.BareMessageView || view.prevPrefs.DisableImages != prefs.DisableImages view.messagesLock.RLock() view.msgBufferLock.Lock() if recalculateMessageBuffers || len(view.messages) != view.prevMsgCount { width := view.width() if !prefs.BareMessageView { width -= view.widestSender() + SenderMessageGap if !prefs.HideTimestamp { width -= view.TimestampWidth + TimestampSenderGap } } view.msgBuffer = []*messages.UIMessage{} view.prevMsgCount = 0 for i, message := range view.messages { if message == nil { debug.Print("O.o found nil message at", i) break } if recalculateMessageBuffers { message.CalculateBuffer(prefs, width) } view.appendBufferUnlocked(message) } } view.msgBufferLock.Unlock() view.messagesLock.RUnlock() view.updatePrevSize() view.prevPrefs = prefs } func (view *MessageView) SetSelected(message *messages.UIMessage) { if view.selected != nil { view.selected.IsSelected = false } if message != nil && (view.selected == message || message.IsService) { view.selected = nil } else { view.selected = message } if view.selected != nil { view.selected.IsSelected = true } } func (view *MessageView) handleMessageClick(message *messages.UIMessage, mod tcell.ModMask) bool { if msg, ok := message.Renderer.(*messages.FileMessage); ok && mod > 0 && !msg.Thumbnail.IsEmpty() { debug.Print("Opening thumbnail", msg.ThumbnailPath()) open.Open(msg.ThumbnailPath()) // No need to re-render return false } view.SetSelected(message) view.parent.OnSelect(view.selected) return true } func (view *MessageView) handleUsernameClick(message *messages.UIMessage, prevMessage *messages.UIMessage) bool { // TODO this is needed if senders are hidden for messages from the same sender (see Draw method) //if prevMessage != nil && prevMessage.SenderName == message.SenderName { // return false //} if message.SenderName == "---" || message.SenderName == "-->" || message.SenderName == "<--" || message.Type == event.MsgEmote { return false } sender := fmt.Sprintf("[%s](https://matrix.to/#/%s)", message.SenderName, message.SenderID) cursorPos := view.parent.input.GetCursorOffset() text := view.parent.input.GetText() var buf strings.Builder if cursorPos == 0 { buf.WriteString(sender) buf.WriteRune(':') buf.WriteRune(' ') buf.WriteString(text) } else { textBefore := runewidth.Truncate(text, cursorPos, "") textAfter := text[len(textBefore):] buf.WriteString(textBefore) buf.WriteString(sender) buf.WriteRune(' ') buf.WriteString(textAfter) } newText := buf.String() view.parent.input.SetText(string(newText)) view.parent.input.SetCursorOffset(cursorPos + len(newText) - len(text)) return true } func (view *MessageView) OnMouseEvent(event mauview.MouseEvent) bool { if event.HasMotion() { return false } switch event.Buttons() { case tcell.WheelUp: if view.IsAtTop() { go view.parent.parent.LoadHistory(view.parent.Room.ID) } else { view.AddScrollOffset(WheelScrollOffsetDiff) return true } case tcell.WheelDown: view.AddScrollOffset(-WheelScrollOffsetDiff) view.parent.parent.MarkRead(view.parent) return true case tcell.Button1: x, y := event.Position() line := view.TotalHeight() - view.ScrollOffset - view.Height() + y if line < 0 || line >= view.TotalHeight() { return false } view.msgBufferLock.RLock() message := view.msgBuffer[line] var prevMessage *messages.UIMessage if y != 0 && line > 0 { prevMessage = view.msgBuffer[line-1] } view.msgBufferLock.RUnlock() usernameX := 0 if !view.config.Preferences.HideTimestamp { usernameX += view.TimestampWidth + TimestampSenderGap } messageX := usernameX + view.widestSender() + SenderMessageGap if x >= messageX { return view.handleMessageClick(message, event.Modifiers()) } else if x >= usernameX { return view.handleUsernameClick(message, prevMessage) } } return false } const PaddingAtTop = 5 func (view *MessageView) AddScrollOffset(diff int) { totalHeight := view.TotalHeight() height := view.Height() if diff >= 0 && view.ScrollOffset+diff >= totalHeight-height+PaddingAtTop { view.ScrollOffset = totalHeight - height + PaddingAtTop } else { view.ScrollOffset += diff } if view.ScrollOffset > totalHeight-height+PaddingAtTop { view.ScrollOffset = totalHeight - height + PaddingAtTop } if view.ScrollOffset < 0 { view.ScrollOffset = 0 } } func (view *MessageView) setSize(width, height int) { atomic.StoreUint32(&view._width, uint32(width)) atomic.StoreUint32(&view._height, uint32(height)) } func (view *MessageView) updatePrevSize() { atomic.StoreUint32(&view._prevWidth, atomic.LoadUint32(&view._width)) atomic.StoreUint32(&view._prevHeight, atomic.LoadUint32(&view._height)) atomic.StoreUint32(&view._prevWidestSender, atomic.LoadUint32(&view._widestSender)) } func (view *MessageView) prevHeight() int { return int(atomic.LoadUint32(&view._prevHeight)) } func (view *MessageView) prevWidth() int { return int(atomic.LoadUint32(&view._prevWidth)) } func (view *MessageView) prevWidestSender() int { return int(atomic.LoadUint32(&view._prevWidestSender)) } func (view *MessageView) widestSender() int { return int(atomic.LoadUint32(&view._widestSender)) } func (view *MessageView) Height() int { return int(atomic.LoadUint32(&view._height)) } func (view *MessageView) width() int { return int(atomic.LoadUint32(&view._width)) } func (view *MessageView) TotalHeight() int { view.msgBufferLock.RLock() defer view.msgBufferLock.RUnlock() return len(view.msgBuffer) } func (view *MessageView) IsAtTop() bool { return view.ScrollOffset >= view.TotalHeight()-view.Height()+PaddingAtTop } const ( TimestampSenderGap = 1 SenderSeparatorGap = 1 SenderMessageGap = 3 ) func getScrollbarStyle(scrollbarHere, isTop, isBottom bool) (char rune, style tcell.Style) { char = '│' style = tcell.StyleDefault if scrollbarHere { style = style.Foreground(tcell.ColorGreen) } if isTop { if scrollbarHere { char = '╥' } else { char = '┬' } } else if isBottom { if scrollbarHere { char = '╨' } else { char = '┴' } } else if scrollbarHere { char = '║' } return } func (view *MessageView) calculateScrollBar(height int) (scrollBarHeight, scrollBarPos int) { viewportHeight := float64(height) contentHeight := float64(view.TotalHeight()) scrollBarHeight = int(math.Ceil(viewportHeight / (contentHeight / viewportHeight))) scrollBarPos = height - int(math.Round(float64(view.ScrollOffset)/contentHeight*viewportHeight)) return } func (view *MessageView) getIndexOffset(screen mauview.Screen, height, messageX int) (indexOffset int) { indexOffset = view.TotalHeight() - view.ScrollOffset - height if indexOffset <= -PaddingAtTop { message := "Scroll up to load more messages." if atomic.LoadInt32(&view.loadingMessages) == 1 { message = "Loading more messages..." } widget.WriteLineSimpleColor(screen, message, messageX, 0, tcell.ColorGreen) } return } func (view *MessageView) CapturePlaintext(height int) string { var buf strings.Builder indexOffset := view.TotalHeight() - view.ScrollOffset - height var prevMessage *messages.UIMessage view.msgBufferLock.RLock() for line := 0; line < height; line++ { index := indexOffset + line if index < 0 { continue } message := view.msgBuffer[index] if message != prevMessage { var sender string if len(message.Sender()) > 0 { sender = fmt.Sprintf(" <%s>", message.Sender()) } else if message.Type == event.MsgEmote { sender = fmt.Sprintf(" * %s", message.SenderName) } fmt.Fprintf(&buf, "%s%s %s\n", message.FormatTime(), sender, message.PlainText()) prevMessage = message } } view.msgBufferLock.RUnlock() return buf.String() } func (view *MessageView) Draw(screen mauview.Screen) { view.setSize(screen.Size()) view.recalculateBuffers() height := view.Height() if view.TotalHeight() == 0 { widget.WriteLineSimple(screen, "It's quite empty in here.", 0, height) return } usernameX := 0 if !view.config.Preferences.HideTimestamp { usernameX += view.TimestampWidth + TimestampSenderGap } messageX := usernameX + view.widestSender() + SenderMessageGap bareMode := view.config.Preferences.BareMessageView if bareMode { messageX = 0 } indexOffset := view.getIndexOffset(screen, height, messageX) viewStart := 0 if indexOffset < 0 { viewStart = -indexOffset } if !bareMode { separatorX := usernameX + view.widestSender() + SenderSeparatorGap scrollBarHeight, scrollBarPos := view.calculateScrollBar(height) for line := viewStart; line < height; line++ { showScrollbar := line-viewStart >= scrollBarPos-scrollBarHeight && line-viewStart < scrollBarPos isTop := line == viewStart && view.ScrollOffset+height >= view.TotalHeight() isBottom := line == height-1 && view.ScrollOffset == 0 borderChar, borderStyle := getScrollbarStyle(showScrollbar, isTop, isBottom) screen.SetContent(separatorX, line, borderChar, nil, borderStyle) } } var prevMsg *messages.UIMessage view.msgBufferLock.RLock() for line := viewStart; line < height && indexOffset+line < len(view.msgBuffer); { index := indexOffset + line msg := view.msgBuffer[index] if msg == prevMsg { debug.Print("Unexpected re-encounter of", msg, msg.Height(), "at", line, index) line++ continue } if len(msg.FormatTime()) > 0 && !view.config.Preferences.HideTimestamp { widget.WriteLineSimpleColor(screen, msg.FormatTime(), 0, line, msg.TimestampColor()) } // TODO hiding senders might not be that nice after all, maybe an option? (disabled for now) //if !bareMode && (prevMsg == nil || meta.Sender() != prevMsg.Sender()) { widget.WriteLineColor( screen, mauview.AlignRight, msg.Sender(), usernameX, line, view.widestSender(), msg.SenderColor()) //} if msg.Edited { // TODO add better indicator for edits screen.SetCell(usernameX+view.widestSender(), line, tcell.StyleDefault.Foreground(tcell.ColorDarkRed), '*') } for i := index - 1; i >= 0 && view.msgBuffer[i] == msg; i-- { line-- } msg.Draw(mauview.NewProxyScreen(screen, messageX, line, view.width()-messageX, msg.Height())) line += msg.Height() prevMsg = msg } view.msgBufferLock.RUnlock() } gomuks-0.3.0/ui/messages/000077500000000000000000000000001433617251100152465ustar00rootroot00000000000000gomuks-0.3.0/ui/messages/base.go000066400000000000000000000245671433617251100165250ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package messages import ( "fmt" "sort" "time" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/ui/widget" ) type MessageRenderer interface { Draw(screen mauview.Screen, msg *UIMessage) NotificationContent() string PlainText() string CalculateBuffer(prefs config.UserPreferences, width int, msg *UIMessage) Height() int Clone() MessageRenderer String() string } type ReactionItem struct { Key string Count int } func (ri ReactionItem) String() string { return fmt.Sprintf("%d×%s", ri.Count, ri.Key) } type ReactionSlice []ReactionItem func (rs ReactionSlice) Len() int { return len(rs) } func (rs ReactionSlice) Less(i, j int) bool { return rs[i].Key < rs[j].Key } func (rs ReactionSlice) Swap(i, j int) { rs[i], rs[j] = rs[j], rs[i] } type UIMessage struct { EventID id.EventID TxnID string Relation event.RelatesTo Type event.MessageType SenderID id.UserID SenderName string DefaultSenderColor tcell.Color Timestamp time.Time State muksevt.OutgoingState IsHighlight bool IsService bool IsSelected bool Edited bool Event *muksevt.Event ReplyTo *UIMessage Reactions ReactionSlice Renderer MessageRenderer } func (msg *UIMessage) GetEvent() *muksevt.Event { if msg == nil { return nil } return msg.Event } const DateFormat = "January _2, 2006" const TimeFormat = "15:04:05" func newUIMessage(evt *muksevt.Event, displayname string, renderer MessageRenderer) *UIMessage { msgContent := evt.Content.AsMessage() msgtype := msgContent.MsgType if len(msgtype) == 0 { msgtype = event.MessageType(evt.Type.String()) } reactions := make(ReactionSlice, 0, len(evt.Unsigned.Relations.Annotations.Map)) for key, count := range evt.Unsigned.Relations.Annotations.Map { reactions = append(reactions, ReactionItem{ Key: key, Count: count, }) } sort.Sort(reactions) return &UIMessage{ SenderID: evt.Sender, SenderName: displayname, Timestamp: unixToTime(evt.Timestamp), DefaultSenderColor: widget.GetHashColor(evt.Sender), Type: msgtype, EventID: evt.ID, TxnID: evt.Unsigned.TransactionID, Relation: *msgContent.GetRelatesTo(), State: evt.Gomuks.OutgoingState, IsHighlight: false, IsService: false, Edited: len(evt.Gomuks.Edits) > 0, Reactions: reactions, Event: evt, Renderer: renderer, } } func (msg *UIMessage) AddReaction(key string) { found := false for i, rs := range msg.Reactions { if rs.Key == key { rs.Count++ msg.Reactions[i] = rs found = true break } } if !found { msg.Reactions = append(msg.Reactions, ReactionItem{ Key: key, Count: 1, }) } sort.Sort(msg.Reactions) } func unixToTime(unix int64) time.Time { timestamp := time.Now() if unix != 0 { timestamp = time.Unix(unix/1000, unix%1000*1000) } return timestamp } // Sender gets the string that should be displayed as the sender of this message. // // If the message is being sent, the sender is "Sending...". // If sending has failed, the sender is "Error". // If the message is an emote, the sender is blank. // In any other case, the sender is the display name of the user who sent the message. func (msg *UIMessage) Sender() string { switch msg.State { case muksevt.StateLocalEcho: return "Sending..." case muksevt.StateSendFail: return "Error" } switch msg.Type { case "m.emote": // Emotes don't show a separate sender, it's included in the buffer. return "" default: return msg.SenderName } } func (msg *UIMessage) NotificationSenderName() string { return msg.SenderName } func (msg *UIMessage) NotificationContent() string { return msg.Renderer.NotificationContent() } func (msg *UIMessage) getStateSpecificColor() tcell.Color { switch msg.State { case muksevt.StateLocalEcho: return tcell.ColorGray case muksevt.StateSendFail: return tcell.ColorRed case muksevt.StateDefault: fallthrough default: return tcell.ColorDefault } } // SenderColor returns the color the name of the sender should be shown in. // // If the message is being sent, the color is gray. // If sending has failed, the color is red. // // In any other case, the color is whatever is specified in the Message struct. // Usually that means it is the hash-based color of the sender (see ui/widget/color.go) func (msg *UIMessage) SenderColor() tcell.Color { stateColor := msg.getStateSpecificColor() switch { case stateColor != tcell.ColorDefault: return stateColor case msg.Type == "m.room.member": return widget.GetHashColor(msg.SenderName) case msg.IsService: return tcell.ColorGray default: return msg.DefaultSenderColor } } // TextColor returns the color the actual content of the message should be shown in. func (msg *UIMessage) TextColor() tcell.Color { stateColor := msg.getStateSpecificColor() switch { case stateColor != tcell.ColorDefault: return stateColor case msg.IsService, msg.Type == "m.notice": return tcell.ColorGray case msg.IsHighlight: return tcell.ColorYellow case msg.Type == "m.room.member": return tcell.ColorGreen default: return tcell.ColorDefault } } // TimestampColor returns the color the timestamp should be shown in. // // As with SenderColor(), messages being sent and messages that failed to be sent are // gray and red respectively. // // However, other messages are the default color instead of a color stored in the struct. func (msg *UIMessage) TimestampColor() tcell.Color { if msg.IsService { return tcell.ColorGray } return msg.getStateSpecificColor() } func (msg *UIMessage) ReplyHeight() int { if msg.ReplyTo != nil { return 1 + msg.ReplyTo.Height() } return 0 } func (msg *UIMessage) ReactionHeight() int { if len(msg.Reactions) > 0 { return 1 } return 0 } // Height returns the number of rows in the computed buffer (see Buffer()). func (msg *UIMessage) Height() int { return msg.ReplyHeight() + msg.Renderer.Height() + msg.ReactionHeight() } func (msg *UIMessage) Time() time.Time { return msg.Timestamp } // FormatTime returns the formatted time when the message was sent. func (msg *UIMessage) FormatTime() string { return msg.Timestamp.Format(TimeFormat) } // FormatDate returns the formatted date when the message was sent. func (msg *UIMessage) FormatDate() string { return msg.Timestamp.Format(DateFormat) } func (msg *UIMessage) SameDate(message *UIMessage) bool { year1, month1, day1 := msg.Timestamp.Date() year2, month2, day2 := message.Timestamp.Date() return day1 == day2 && month1 == month2 && year1 == year2 } func (msg *UIMessage) ID() id.EventID { if len(msg.EventID) == 0 { return id.EventID(msg.TxnID) } return msg.EventID } func (msg *UIMessage) SetID(id id.EventID) { msg.EventID = id } func (msg *UIMessage) SetIsHighlight(isHighlight bool) { msg.IsHighlight = isHighlight } func (msg *UIMessage) DrawReactions(screen mauview.Screen) { if len(msg.Reactions) == 0 { return } width, height := screen.Size() screen = mauview.NewProxyScreen(screen, 0, height-1, width, 1) x := 0 for _, reaction := range msg.Reactions { _, drawn := mauview.PrintWithStyle(screen, reaction.String(), x, 0, width-x, mauview.AlignLeft, tcell.StyleDefault.Foreground(mauview.Styles.PrimaryTextColor).Background(tcell.ColorDarkGreen)) x += drawn + 1 if x >= width { break } } } func (msg *UIMessage) Draw(screen mauview.Screen) { proxyScreen := msg.DrawReply(screen) msg.Renderer.Draw(proxyScreen, msg) msg.DrawReactions(proxyScreen) if msg.IsSelected { w, h := screen.Size() for x := 0; x < w; x++ { for y := 0; y < h; y++ { mainc, combc, style, _ := screen.GetContent(x, y) _, bg, _ := style.Decompose() if bg == tcell.ColorDefault { screen.SetContent(x, y, mainc, combc, style.Background(tcell.ColorDarkGreen)) } } } } } func (msg *UIMessage) Clone() *UIMessage { clone := *msg clone.ReplyTo = nil clone.Reactions = nil clone.Renderer = clone.Renderer.Clone() return &clone } func (msg *UIMessage) CalculateReplyBuffer(preferences config.UserPreferences, width int) { if msg.ReplyTo == nil { return } msg.ReplyTo.CalculateBuffer(preferences, width-1) } func (msg *UIMessage) CalculateBuffer(preferences config.UserPreferences, width int) { msg.Renderer.CalculateBuffer(preferences, width, msg) msg.CalculateReplyBuffer(preferences, width) } func (msg *UIMessage) DrawReply(screen mauview.Screen) mauview.Screen { if msg.ReplyTo == nil { return screen } width, height := screen.Size() replyHeight := msg.ReplyTo.Height() widget.WriteLineSimpleColor(screen, "In reply to", 1, 0, tcell.ColorGreen) widget.WriteLineSimpleColor(screen, msg.ReplyTo.SenderName, 13, 0, msg.ReplyTo.SenderColor()) for y := 0; y < 1+replyHeight; y++ { screen.SetCell(0, y, tcell.StyleDefault, '▊') } replyScreen := mauview.NewProxyScreen(screen, 1, 1, width-1, replyHeight) msg.ReplyTo.Draw(replyScreen) return mauview.NewProxyScreen(screen, 0, replyHeight+1, width, height-replyHeight-1) } func (msg *UIMessage) String() string { return fmt.Sprintf(`&messages.UIMessage{ ID="%s", TxnID="%s", Type="%s", Timestamp=%s, Sender={ID="%s", Name="%s", Color=#%X}, IsService=%t, IsHighlight=%t, Renderer=%s, }`, msg.EventID, msg.TxnID, msg.Type, msg.Timestamp.String(), msg.SenderID, msg.SenderName, msg.DefaultSenderColor.Hex(), msg.IsService, msg.IsHighlight, msg.Renderer.String()) } func (msg *UIMessage) PlainText() string { return msg.Renderer.PlainText() } gomuks-0.3.0/ui/messages/doc.go000066400000000000000000000001541433617251100163420ustar00rootroot00000000000000// Package messages contains different message types and code to generate and render them. package messages gomuks-0.3.0/ui/messages/expandedtextmessage.go000066400000000000000000000053621433617251100216450ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package messages import ( "fmt" "time" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/ui/messages/tstring" ) type ExpandedTextMessage struct { Text tstring.TString buffer []tstring.TString } // NewExpandedTextMessage creates a new ExpandedTextMessage object with the provided values and the default state. func NewExpandedTextMessage(evt *muksevt.Event, displayname string, text tstring.TString) *UIMessage { return newUIMessage(evt, displayname, &ExpandedTextMessage{ Text: text, }) } func NewServiceMessage(text string) *UIMessage { return &UIMessage{ SenderID: "*", SenderName: "*", Timestamp: time.Now(), IsService: true, Renderer: &ExpandedTextMessage{ Text: tstring.NewTString(text), }, } } func NewDateChangeMessage(text string) *UIMessage { midnight := time.Now() midnight = time.Date(midnight.Year(), midnight.Month(), midnight.Day(), 0, 0, 0, 0, midnight.Location()) return &UIMessage{ SenderID: "*", SenderName: "*", Timestamp: midnight, IsService: true, Renderer: &ExpandedTextMessage{ Text: tstring.NewColorTString(text, tcell.ColorGreen), }, } } func (msg *ExpandedTextMessage) Clone() MessageRenderer { return &ExpandedTextMessage{ Text: msg.Text.Clone(), } } func (msg *ExpandedTextMessage) NotificationContent() string { return msg.Text.String() } func (msg *ExpandedTextMessage) PlainText() string { return msg.Text.String() } func (msg *ExpandedTextMessage) String() string { return fmt.Sprintf(`&messages.ExpandedTextMessage{Text="%s"}`, msg.Text.String()) } func (msg *ExpandedTextMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { msg.buffer = calculateBufferWithText(prefs, msg.Text, width, uiMsg) } func (msg *ExpandedTextMessage) Height() int { return len(msg.buffer) } func (msg *ExpandedTextMessage) Draw(screen mauview.Screen, _ *UIMessage) { for y, line := range msg.buffer { line.Draw(screen, 0, y) } } gomuks-0.3.0/ui/messages/filemessage.go000066400000000000000000000122221433617251100200600ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package messages import ( "bytes" "fmt" "image" "image/color" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/lib/ansimage" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/ui/messages/tstring" ) type FileMessage struct { Type event.MessageType Body string URL id.ContentURI File *attachment.EncryptedFile Thumbnail id.ContentURI ThumbnailFile *attachment.EncryptedFile eventID id.EventID imageData []byte buffer []tstring.TString matrix ifc.MatrixContainer } // NewFileMessage creates a new FileMessage object with the provided values and the default state. func NewFileMessage(matrix ifc.MatrixContainer, evt *muksevt.Event, displayname string) *UIMessage { content := evt.Content.AsMessage() var file, thumbnailFile *attachment.EncryptedFile if content.File != nil { file = &content.File.EncryptedFile content.URL = content.File.URL } if content.GetInfo().ThumbnailFile != nil { thumbnailFile = &content.Info.ThumbnailFile.EncryptedFile content.Info.ThumbnailURL = content.Info.ThumbnailFile.URL } return newUIMessage(evt, displayname, &FileMessage{ Type: content.MsgType, Body: content.Body, URL: content.URL.ParseOrIgnore(), File: file, Thumbnail: content.GetInfo().ThumbnailURL.ParseOrIgnore(), ThumbnailFile: thumbnailFile, eventID: evt.ID, matrix: matrix, }) } func (msg *FileMessage) Clone() MessageRenderer { data := make([]byte, len(msg.imageData)) copy(data, msg.imageData) return &FileMessage{ Body: msg.Body, URL: msg.URL, Thumbnail: msg.Thumbnail, imageData: data, matrix: msg.matrix, } } func (msg *FileMessage) NotificationContent() string { switch msg.Type { case event.MsgImage: return "Sent an image" case event.MsgAudio: return "Sent an audio file" case event.MsgVideo: return "Sent a video" case event.MsgFile: fallthrough default: return "Sent a file" } } func (msg *FileMessage) PlainText() string { return fmt.Sprintf("%s: %s", msg.Body, msg.matrix.GetDownloadURL(msg.URL)) } func (msg *FileMessage) String() string { return fmt.Sprintf(`&messages.FileMessage{Body="%s", URL="%s", Thumbnail="%s"}`, msg.Body, msg.URL, msg.Thumbnail) } func (msg *FileMessage) DownloadPreview() { var url id.ContentURI var file *attachment.EncryptedFile if !msg.Thumbnail.IsEmpty() { url = msg.Thumbnail file = msg.ThumbnailFile } else if msg.Type == event.MsgImage && !msg.URL.IsEmpty() { msg.Thumbnail = msg.URL url = msg.URL file = msg.File } else { return } debug.Print("Loading file:", url) data, err := msg.matrix.Download(url, file) if err != nil { debug.Printf("Failed to download file %s: %v", url, err) return } debug.Print("File", url, "loaded.") msg.imageData = data } func (msg *FileMessage) ThumbnailPath() string { return msg.matrix.GetCachePath(msg.Thumbnail) } func (msg *FileMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { if width < 2 { return } if prefs.BareMessageView || prefs.DisableImages || len(msg.imageData) == 0 { url := msg.matrix.GetDownloadURL(msg.URL) var urlTString tstring.TString if prefs.EnableInlineURLs() { urlTString = tstring.NewStyleTString(url, tcell.StyleDefault.Url(url).UrlId(msg.eventID.String())) } else { urlTString = tstring.NewTString(url) } text := tstring.NewTString(msg.Body). Append(": "). AppendTString(urlTString) msg.buffer = calculateBufferWithText(prefs, text, width, uiMsg) return } img, _, err := image.DecodeConfig(bytes.NewReader(msg.imageData)) if err != nil { debug.Print("File could not be decoded:", err) } imgWidth := img.Width if img.Width > width { imgWidth = width / 3 } ansFile, err := ansimage.NewScaledFromReader(bytes.NewReader(msg.imageData), 0, imgWidth, color.Black) if err != nil { msg.buffer = []tstring.TString{tstring.NewColorTString("Failed to display image", tcell.ColorRed)} debug.Print("Failed to display image:", err) return } msg.buffer = ansFile.Render() } func (msg *FileMessage) Height() int { return len(msg.buffer) } func (msg *FileMessage) Draw(screen mauview.Screen, _ *UIMessage) { for y, line := range msg.buffer { line.Draw(screen, 0, y) } } gomuks-0.3.0/ui/messages/html/000077500000000000000000000000001433617251100162125ustar00rootroot00000000000000gomuks-0.3.0/ui/messages/html/base.go000066400000000000000000000052051433617251100174550ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "fmt" "go.mau.fi/mauview" "go.mau.fi/tcell" ) type BaseEntity struct { // The HTML tag of this entity. Tag string // Style for this entity. Style tcell.Style // Whether or not this is a block-type entity. Block bool // Height to use for entity if both text and children are empty. DefaultHeight int prevWidth int startX int height int } // AdjustStyle changes the style of this text entity. func (be *BaseEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { be.Style = fn(be.Style) return be } func (be *BaseEntity) IsEmpty() bool { return false } // IsBlock returns whether or not this is a block-type entity. func (be *BaseEntity) IsBlock() bool { return be.Block } // GetTag returns the HTML tag of this entity. func (be *BaseEntity) GetTag() string { return be.Tag } // Height returns the render height of this entity. func (be *BaseEntity) Height() int { return be.height } func (be *BaseEntity) getStartX() int { return be.startX } // Clone creates a copy of this base entity. func (be *BaseEntity) Clone() Entity { return &BaseEntity{ Tag: be.Tag, Style: be.Style, Block: be.Block, DefaultHeight: be.DefaultHeight, } } func (be *BaseEntity) PlainText() string { return "" } // String returns a textual representation of this BaseEntity struct. func (be *BaseEntity) String() string { return fmt.Sprintf(`&html.BaseEntity{Tag="%s", Style=%#v, Block=%t, startX=%d, height=%d}`, be.Tag, be.Style, be.Block, be.startX, be.height) } // CalculateBuffer prepares this entity for rendering with the given parameters. func (be *BaseEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { be.height = be.DefaultHeight be.startX = startX if be.Block { be.startX = 0 } return be.startX } func (be *BaseEntity) Draw(screen mauview.Screen, ctx DrawContext) { panic("Called Draw() of BaseEntity") } gomuks-0.3.0/ui/messages/html/blockquote.go000066400000000000000000000044321433617251100207140ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "fmt" "strings" "go.mau.fi/mauview" ) type BlockquoteEntity struct { *ContainerEntity } const BlockQuoteChar = '>' func NewBlockquoteEntity(children []Entity) *BlockquoteEntity { return &BlockquoteEntity{&ContainerEntity{ BaseEntity: &BaseEntity{ Tag: "blockquote", Block: true, }, Children: children, Indent: 2, }} } func (be *BlockquoteEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) return be } func (be *BlockquoteEntity) Clone() Entity { return &BlockquoteEntity{ContainerEntity: be.ContainerEntity.Clone().(*ContainerEntity)} } func (be *BlockquoteEntity) Draw(screen mauview.Screen, ctx DrawContext) { be.ContainerEntity.Draw(screen, ctx) for y := 0; y < be.height; y++ { screen.SetContent(0, y, BlockQuoteChar, nil, be.Style) } } func (be *BlockquoteEntity) PlainText() string { if len(be.Children) == 0 { return "" } var buf strings.Builder newlined := false for i, child := range be.Children { if i != 0 && child.IsBlock() && !newlined { buf.WriteRune('\n') } newlined = false for i, row := range strings.Split(child.PlainText(), "\n") { if i != 0 { buf.WriteRune('\n') } buf.WriteRune('>') buf.WriteRune(' ') buf.WriteString(row) } if child.IsBlock() { buf.WriteRune('\n') newlined = true } } return strings.TrimSpace(buf.String()) } func (be *BlockquoteEntity) String() string { return fmt.Sprintf("&html.BlockquoteEntity{%s},\n", be.BaseEntity) } gomuks-0.3.0/ui/messages/html/break.go000066400000000000000000000027371433617251100176360ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "go.mau.fi/mauview" ) type BreakEntity struct { *BaseEntity } func NewBreakEntity() *BreakEntity { return &BreakEntity{&BaseEntity{ Tag: "br", Block: true, }} } // AdjustStyle changes the style of this text entity. func (be *BreakEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { be.BaseEntity = be.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) return be } func (be *BreakEntity) Clone() Entity { return NewBreakEntity() } func (be *BreakEntity) PlainText() string { return "\n" } func (be *BreakEntity) String() string { return "&html.BreakEntity{},\n" } func (be *BreakEntity) Draw(screen mauview.Screen, ctx DrawContext) { // No-op, the logic happens in containers } gomuks-0.3.0/ui/messages/html/codeblock.go000066400000000000000000000032411433617251100204660ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "go.mau.fi/mauview" "go.mau.fi/tcell" ) type CodeBlockEntity struct { *ContainerEntity Background tcell.Style } func NewCodeBlockEntity(children []Entity, background tcell.Style) *CodeBlockEntity { return &CodeBlockEntity{ ContainerEntity: &ContainerEntity{ BaseEntity: &BaseEntity{ Tag: "pre", Block: true, }, Children: children, }, Background: background, } } func (ce *CodeBlockEntity) Clone() Entity { return &CodeBlockEntity{ ContainerEntity: ce.ContainerEntity.Clone().(*ContainerEntity), Background: ce.Background, } } func (ce *CodeBlockEntity) Draw(screen mauview.Screen, ctx DrawContext) { screen.Fill(' ', ce.Background) ce.ContainerEntity.Draw(screen, ctx) } func (ce *CodeBlockEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { if reason != AdjustStyleReasonNormal { ce.ContainerEntity.AdjustStyle(fn, reason) } return ce } gomuks-0.3.0/ui/messages/html/colormap.go000066400000000000000000000247661433617251100203740ustar00rootroot00000000000000// From https://github.com/golang/image/blob/master/colornames/colornames.go package html import ( "image/color" ) var colorMap = map[string]color.RGBA{ "aliceblue": {0xf0, 0xf8, 0xff, 0xff}, // rgb(240, 248, 255) "antiquewhite": {0xfa, 0xeb, 0xd7, 0xff}, // rgb(250, 235, 215) "aqua": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255) "aquamarine": {0x7f, 0xff, 0xd4, 0xff}, // rgb(127, 255, 212) "azure": {0xf0, 0xff, 0xff, 0xff}, // rgb(240, 255, 255) "beige": {0xf5, 0xf5, 0xdc, 0xff}, // rgb(245, 245, 220) "bisque": {0xff, 0xe4, 0xc4, 0xff}, // rgb(255, 228, 196) "black": {0x00, 0x00, 0x00, 0xff}, // rgb(0, 0, 0) "blanchedalmond": {0xff, 0xeb, 0xcd, 0xff}, // rgb(255, 235, 205) "blue": {0x00, 0x00, 0xff, 0xff}, // rgb(0, 0, 255) "blueviolet": {0x8a, 0x2b, 0xe2, 0xff}, // rgb(138, 43, 226) "brown": {0xa5, 0x2a, 0x2a, 0xff}, // rgb(165, 42, 42) "burlywood": {0xde, 0xb8, 0x87, 0xff}, // rgb(222, 184, 135) "cadetblue": {0x5f, 0x9e, 0xa0, 0xff}, // rgb(95, 158, 160) "chartreuse": {0x7f, 0xff, 0x00, 0xff}, // rgb(127, 255, 0) "chocolate": {0xd2, 0x69, 0x1e, 0xff}, // rgb(210, 105, 30) "coral": {0xff, 0x7f, 0x50, 0xff}, // rgb(255, 127, 80) "cornflowerblue": {0x64, 0x95, 0xed, 0xff}, // rgb(100, 149, 237) "cornsilk": {0xff, 0xf8, 0xdc, 0xff}, // rgb(255, 248, 220) "crimson": {0xdc, 0x14, 0x3c, 0xff}, // rgb(220, 20, 60) "cyan": {0x00, 0xff, 0xff, 0xff}, // rgb(0, 255, 255) "darkblue": {0x00, 0x00, 0x8b, 0xff}, // rgb(0, 0, 139) "darkcyan": {0x00, 0x8b, 0x8b, 0xff}, // rgb(0, 139, 139) "darkgoldenrod": {0xb8, 0x86, 0x0b, 0xff}, // rgb(184, 134, 11) "darkgray": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169) "darkgreen": {0x00, 0x64, 0x00, 0xff}, // rgb(0, 100, 0) "darkgrey": {0xa9, 0xa9, 0xa9, 0xff}, // rgb(169, 169, 169) "darkkhaki": {0xbd, 0xb7, 0x6b, 0xff}, // rgb(189, 183, 107) "darkmagenta": {0x8b, 0x00, 0x8b, 0xff}, // rgb(139, 0, 139) "darkolivegreen": {0x55, 0x6b, 0x2f, 0xff}, // rgb(85, 107, 47) "darkorange": {0xff, 0x8c, 0x00, 0xff}, // rgb(255, 140, 0) "darkorchid": {0x99, 0x32, 0xcc, 0xff}, // rgb(153, 50, 204) "darkred": {0x8b, 0x00, 0x00, 0xff}, // rgb(139, 0, 0) "darksalmon": {0xe9, 0x96, 0x7a, 0xff}, // rgb(233, 150, 122) "darkseagreen": {0x8f, 0xbc, 0x8f, 0xff}, // rgb(143, 188, 143) "darkslateblue": {0x48, 0x3d, 0x8b, 0xff}, // rgb(72, 61, 139) "darkslategray": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79) "darkslategrey": {0x2f, 0x4f, 0x4f, 0xff}, // rgb(47, 79, 79) "darkturquoise": {0x00, 0xce, 0xd1, 0xff}, // rgb(0, 206, 209) "darkviolet": {0x94, 0x00, 0xd3, 0xff}, // rgb(148, 0, 211) "deeppink": {0xff, 0x14, 0x93, 0xff}, // rgb(255, 20, 147) "deepskyblue": {0x00, 0xbf, 0xff, 0xff}, // rgb(0, 191, 255) "dimgray": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105) "dimgrey": {0x69, 0x69, 0x69, 0xff}, // rgb(105, 105, 105) "dodgerblue": {0x1e, 0x90, 0xff, 0xff}, // rgb(30, 144, 255) "firebrick": {0xb2, 0x22, 0x22, 0xff}, // rgb(178, 34, 34) "floralwhite": {0xff, 0xfa, 0xf0, 0xff}, // rgb(255, 250, 240) "forestgreen": {0x22, 0x8b, 0x22, 0xff}, // rgb(34, 139, 34) "fuchsia": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255) "gainsboro": {0xdc, 0xdc, 0xdc, 0xff}, // rgb(220, 220, 220) "ghostwhite": {0xf8, 0xf8, 0xff, 0xff}, // rgb(248, 248, 255) "gold": {0xff, 0xd7, 0x00, 0xff}, // rgb(255, 215, 0) "goldenrod": {0xda, 0xa5, 0x20, 0xff}, // rgb(218, 165, 32) "gray": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128) "green": {0x00, 0x80, 0x00, 0xff}, // rgb(0, 128, 0) "greenyellow": {0xad, 0xff, 0x2f, 0xff}, // rgb(173, 255, 47) "grey": {0x80, 0x80, 0x80, 0xff}, // rgb(128, 128, 128) "honeydew": {0xf0, 0xff, 0xf0, 0xff}, // rgb(240, 255, 240) "hotpink": {0xff, 0x69, 0xb4, 0xff}, // rgb(255, 105, 180) "indianred": {0xcd, 0x5c, 0x5c, 0xff}, // rgb(205, 92, 92) "indigo": {0x4b, 0x00, 0x82, 0xff}, // rgb(75, 0, 130) "ivory": {0xff, 0xff, 0xf0, 0xff}, // rgb(255, 255, 240) "khaki": {0xf0, 0xe6, 0x8c, 0xff}, // rgb(240, 230, 140) "lavender": {0xe6, 0xe6, 0xfa, 0xff}, // rgb(230, 230, 250) "lavenderblush": {0xff, 0xf0, 0xf5, 0xff}, // rgb(255, 240, 245) "lawngreen": {0x7c, 0xfc, 0x00, 0xff}, // rgb(124, 252, 0) "lemonchiffon": {0xff, 0xfa, 0xcd, 0xff}, // rgb(255, 250, 205) "lightblue": {0xad, 0xd8, 0xe6, 0xff}, // rgb(173, 216, 230) "lightcoral": {0xf0, 0x80, 0x80, 0xff}, // rgb(240, 128, 128) "lightcyan": {0xe0, 0xff, 0xff, 0xff}, // rgb(224, 255, 255) "lightgoldenrodyellow": {0xfa, 0xfa, 0xd2, 0xff}, // rgb(250, 250, 210) "lightgray": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211) "lightgreen": {0x90, 0xee, 0x90, 0xff}, // rgb(144, 238, 144) "lightgrey": {0xd3, 0xd3, 0xd3, 0xff}, // rgb(211, 211, 211) "lightpink": {0xff, 0xb6, 0xc1, 0xff}, // rgb(255, 182, 193) "lightsalmon": {0xff, 0xa0, 0x7a, 0xff}, // rgb(255, 160, 122) "lightseagreen": {0x20, 0xb2, 0xaa, 0xff}, // rgb(32, 178, 170) "lightskyblue": {0x87, 0xce, 0xfa, 0xff}, // rgb(135, 206, 250) "lightslategray": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153) "lightslategrey": {0x77, 0x88, 0x99, 0xff}, // rgb(119, 136, 153) "lightsteelblue": {0xb0, 0xc4, 0xde, 0xff}, // rgb(176, 196, 222) "lightyellow": {0xff, 0xff, 0xe0, 0xff}, // rgb(255, 255, 224) "lime": {0x00, 0xff, 0x00, 0xff}, // rgb(0, 255, 0) "limegreen": {0x32, 0xcd, 0x32, 0xff}, // rgb(50, 205, 50) "linen": {0xfa, 0xf0, 0xe6, 0xff}, // rgb(250, 240, 230) "magenta": {0xff, 0x00, 0xff, 0xff}, // rgb(255, 0, 255) "maroon": {0x80, 0x00, 0x00, 0xff}, // rgb(128, 0, 0) "mediumaquamarine": {0x66, 0xcd, 0xaa, 0xff}, // rgb(102, 205, 170) "mediumblue": {0x00, 0x00, 0xcd, 0xff}, // rgb(0, 0, 205) "mediumorchid": {0xba, 0x55, 0xd3, 0xff}, // rgb(186, 85, 211) "mediumpurple": {0x93, 0x70, 0xdb, 0xff}, // rgb(147, 112, 219) "mediumseagreen": {0x3c, 0xb3, 0x71, 0xff}, // rgb(60, 179, 113) "mediumslateblue": {0x7b, 0x68, 0xee, 0xff}, // rgb(123, 104, 238) "mediumspringgreen": {0x00, 0xfa, 0x9a, 0xff}, // rgb(0, 250, 154) "mediumturquoise": {0x48, 0xd1, 0xcc, 0xff}, // rgb(72, 209, 204) "mediumvioletred": {0xc7, 0x15, 0x85, 0xff}, // rgb(199, 21, 133) "midnightblue": {0x19, 0x19, 0x70, 0xff}, // rgb(25, 25, 112) "mintcream": {0xf5, 0xff, 0xfa, 0xff}, // rgb(245, 255, 250) "mistyrose": {0xff, 0xe4, 0xe1, 0xff}, // rgb(255, 228, 225) "moccasin": {0xff, 0xe4, 0xb5, 0xff}, // rgb(255, 228, 181) "navajowhite": {0xff, 0xde, 0xad, 0xff}, // rgb(255, 222, 173) "navy": {0x00, 0x00, 0x80, 0xff}, // rgb(0, 0, 128) "oldlace": {0xfd, 0xf5, 0xe6, 0xff}, // rgb(253, 245, 230) "olive": {0x80, 0x80, 0x00, 0xff}, // rgb(128, 128, 0) "olivedrab": {0x6b, 0x8e, 0x23, 0xff}, // rgb(107, 142, 35) "orange": {0xff, 0xa5, 0x00, 0xff}, // rgb(255, 165, 0) "orangered": {0xff, 0x45, 0x00, 0xff}, // rgb(255, 69, 0) "orchid": {0xda, 0x70, 0xd6, 0xff}, // rgb(218, 112, 214) "palegoldenrod": {0xee, 0xe8, 0xaa, 0xff}, // rgb(238, 232, 170) "palegreen": {0x98, 0xfb, 0x98, 0xff}, // rgb(152, 251, 152) "paleturquoise": {0xaf, 0xee, 0xee, 0xff}, // rgb(175, 238, 238) "palevioletred": {0xdb, 0x70, 0x93, 0xff}, // rgb(219, 112, 147) "papayawhip": {0xff, 0xef, 0xd5, 0xff}, // rgb(255, 239, 213) "peachpuff": {0xff, 0xda, 0xb9, 0xff}, // rgb(255, 218, 185) "peru": {0xcd, 0x85, 0x3f, 0xff}, // rgb(205, 133, 63) "pink": {0xff, 0xc0, 0xcb, 0xff}, // rgb(255, 192, 203) "plum": {0xdd, 0xa0, 0xdd, 0xff}, // rgb(221, 160, 221) "powderblue": {0xb0, 0xe0, 0xe6, 0xff}, // rgb(176, 224, 230) "purple": {0x80, 0x00, 0x80, 0xff}, // rgb(128, 0, 128) "red": {0xff, 0x00, 0x00, 0xff}, // rgb(255, 0, 0) "rosybrown": {0xbc, 0x8f, 0x8f, 0xff}, // rgb(188, 143, 143) "royalblue": {0x41, 0x69, 0xe1, 0xff}, // rgb(65, 105, 225) "saddlebrown": {0x8b, 0x45, 0x13, 0xff}, // rgb(139, 69, 19) "salmon": {0xfa, 0x80, 0x72, 0xff}, // rgb(250, 128, 114) "sandybrown": {0xf4, 0xa4, 0x60, 0xff}, // rgb(244, 164, 96) "seagreen": {0x2e, 0x8b, 0x57, 0xff}, // rgb(46, 139, 87) "seashell": {0xff, 0xf5, 0xee, 0xff}, // rgb(255, 245, 238) "sienna": {0xa0, 0x52, 0x2d, 0xff}, // rgb(160, 82, 45) "silver": {0xc0, 0xc0, 0xc0, 0xff}, // rgb(192, 192, 192) "skyblue": {0x87, 0xce, 0xeb, 0xff}, // rgb(135, 206, 235) "slateblue": {0x6a, 0x5a, 0xcd, 0xff}, // rgb(106, 90, 205) "slategray": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144) "slategrey": {0x70, 0x80, 0x90, 0xff}, // rgb(112, 128, 144) "snow": {0xff, 0xfa, 0xfa, 0xff}, // rgb(255, 250, 250) "springgreen": {0x00, 0xff, 0x7f, 0xff}, // rgb(0, 255, 127) "steelblue": {0x46, 0x82, 0xb4, 0xff}, // rgb(70, 130, 180) "tan": {0xd2, 0xb4, 0x8c, 0xff}, // rgb(210, 180, 140) "teal": {0x00, 0x80, 0x80, 0xff}, // rgb(0, 128, 128) "thistle": {0xd8, 0xbf, 0xd8, 0xff}, // rgb(216, 191, 216) "tomato": {0xff, 0x63, 0x47, 0xff}, // rgb(255, 99, 71) "turquoise": {0x40, 0xe0, 0xd0, 0xff}, // rgb(64, 224, 208) "violet": {0xee, 0x82, 0xee, 0xff}, // rgb(238, 130, 238) "wheat": {0xf5, 0xde, 0xb3, 0xff}, // rgb(245, 222, 179) "white": {0xff, 0xff, 0xff, 0xff}, // rgb(255, 255, 255) "whitesmoke": {0xf5, 0xf5, 0xf5, 0xff}, // rgb(245, 245, 245) "yellow": {0xff, 0xff, 0x00, 0xff}, // rgb(255, 255, 0) "yellowgreen": {0x9a, 0xcd, 0x32, 0xff}, // rgb(154, 205, 50) } gomuks-0.3.0/ui/messages/html/container.go000066400000000000000000000103601433617251100205230ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "fmt" "strings" "go.mau.fi/mauview" ) type ContainerEntity struct { *BaseEntity // The children of this container entity. Children []Entity // Number of cells to indent children. Indent int } func (ce *ContainerEntity) IsEmpty() bool { return len(ce.Children) == 0 } // PlainText returns the plaintext content in this entity and all its children. func (ce *ContainerEntity) PlainText() string { if len(ce.Children) == 0 { return "" } var buf strings.Builder newlined := false for _, child := range ce.Children { text := child.PlainText() if !strings.HasPrefix(text, "\n") && child.IsBlock() && !newlined { buf.WriteRune('\n') } newlined = false buf.WriteString(text) if child.IsBlock() { if !strings.HasSuffix(text, "\n") { buf.WriteRune('\n') } newlined = true } } return strings.TrimSpace(buf.String()) } // AdjustStyle recursively changes the style of this entity and all its children. func (ce *ContainerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { for _, child := range ce.Children { child.AdjustStyle(fn, reason) } ce.Style = fn(ce.Style) return ce } // Clone creates a deep copy of this base entity. func (ce *ContainerEntity) Clone() Entity { children := make([]Entity, len(ce.Children)) for i, child := range ce.Children { children[i] = child.Clone() } return &ContainerEntity{ BaseEntity: ce.BaseEntity.Clone().(*BaseEntity), Children: children, Indent: ce.Indent, } } // String returns a textual representation of this BaseEntity struct. func (ce *ContainerEntity) String() string { if len(ce.Children) == 0 { return fmt.Sprintf(`&html.ContainerEntity{Base=%s, Indent=%d, Children=[]}`, ce.BaseEntity, ce.Indent) } var buf strings.Builder _, _ = fmt.Fprintf(&buf, `&html.ContainerEntity{Base=%s, Indent=%d, Children=[`, ce.BaseEntity, ce.Indent) for _, child := range ce.Children { buf.WriteString("\n ") buf.WriteString(strings.Join(strings.Split(strings.TrimRight(child.String(), "\n"), "\n"), "\n ")) } buf.WriteString("\n]},") return buf.String() } // Draw draws this entity onto the given mauview Screen. func (ce *ContainerEntity) Draw(screen mauview.Screen, ctx DrawContext) { if len(ce.Children) == 0 { return } width, _ := screen.Size() prevBreak := false proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: ce.Indent, Width: width - ce.Indent, Style: ce.Style} for i, entity := range ce.Children { if i != 0 && entity.getStartX() == 0 { proxyScreen.OffsetY++ } proxyScreen.Height = entity.Height() entity.Draw(proxyScreen, ctx) proxyScreen.SetStyle(ce.Style) proxyScreen.OffsetY += entity.Height() - 1 _, isBreak := entity.(*BreakEntity) if prevBreak && isBreak { proxyScreen.OffsetY++ } prevBreak = isBreak } } // CalculateBuffer prepares this entity and all its children for rendering with the given parameters func (ce *ContainerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { ce.BaseEntity.CalculateBuffer(width, startX, ctx) if len(ce.Children) > 0 { ce.height = 0 childStartX := ce.startX prevBreak := false for _, entity := range ce.Children { if entity.IsBlock() || childStartX == 0 || ce.height == 0 { ce.height++ } childStartX = entity.CalculateBuffer(width-ce.Indent, childStartX, ctx) ce.height += entity.Height() - 1 _, isBreak := entity.(*BreakEntity) if prevBreak && isBreak { ce.height++ } prevBreak = isBreak } if !ce.Block { return childStartX } } return ce.startX } gomuks-0.3.0/ui/messages/html/entity.go000066400000000000000000000040041433617251100200530ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "go.mau.fi/mauview" "go.mau.fi/tcell" ) // AdjustStyleFunc is a lambda function type to edit an existing tcell Style. type AdjustStyleFunc func(tcell.Style) tcell.Style type AdjustStyleReason int const ( AdjustStyleReasonNormal AdjustStyleReason = iota AdjustStyleReasonHideSpoiler ) type DrawContext struct { IsSelected bool BareMessages bool } type Entity interface { // AdjustStyle recursively changes the style of the entity and all its children. AdjustStyle(AdjustStyleFunc, AdjustStyleReason) Entity // Draw draws the entity onto the given mauview Screen. Draw(screen mauview.Screen, ctx DrawContext) // IsBlock returns whether or not it's a block-type entity. IsBlock() bool // GetTag returns the HTML tag of the entity. GetTag() string // PlainText returns the plaintext content in the entity and all its children. PlainText() string // String returns a string representation of the entity struct. String() string // Clone creates a deep copy of the entity. Clone() Entity // Height returns the render height of the entity. Height() int // CalculateBuffer prepares the entity and all its children for rendering with the given parameters CalculateBuffer(width, startX int, ctx DrawContext) int getStartX() int IsEmpty() bool } gomuks-0.3.0/ui/messages/html/horizontalline.go000066400000000000000000000033251433617251100216050ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "strings" "go.mau.fi/mauview" ) type HorizontalLineEntity struct { *BaseEntity } const HorizontalLineChar = '━' func NewHorizontalLineEntity() *HorizontalLineEntity { return &HorizontalLineEntity{&BaseEntity{ Tag: "hr", Block: true, DefaultHeight: 1, }} } func (he *HorizontalLineEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { he.BaseEntity = he.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) return he } func (he *HorizontalLineEntity) Clone() Entity { return NewHorizontalLineEntity() } func (he *HorizontalLineEntity) Draw(screen mauview.Screen, ctx DrawContext) { width, _ := screen.Size() for x := 0; x < width; x++ { screen.SetContent(x, 0, HorizontalLineChar, nil, he.Style) } } func (he *HorizontalLineEntity) PlainText() string { return strings.Repeat(string(HorizontalLineChar), 5) } func (he *HorizontalLineEntity) String() string { return "&html.HorizontalLineEntity{},\n" } gomuks-0.3.0/ui/messages/html/list.go000066400000000000000000000064061433617251100175220ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "fmt" "math" "strings" "go.mau.fi/mauview" "maunium.net/go/gomuks/ui/widget" ) type ListEntity struct { *ContainerEntity Ordered bool Start int } func digits(num int) int { if num <= 0 { return 0 } return int(math.Floor(math.Log10(float64(num))) + 1) } func NewListEntity(ordered bool, start int, children []Entity) *ListEntity { entity := &ListEntity{ ContainerEntity: &ContainerEntity{ BaseEntity: &BaseEntity{ Tag: "ul", Block: true, }, Indent: 2, Children: children, }, Ordered: ordered, Start: start, } if ordered { entity.Tag = "ol" entity.Indent += digits(start + len(children) - 1) } return entity } func (le *ListEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { le.BaseEntity = le.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) le.ContainerEntity.AdjustStyle(fn, reason) return le } func (le *ListEntity) Clone() Entity { return &ListEntity{ ContainerEntity: le.ContainerEntity.Clone().(*ContainerEntity), Ordered: le.Ordered, Start: le.Start, } } func (le *ListEntity) Draw(screen mauview.Screen, ctx DrawContext) { width, _ := screen.Size() proxyScreen := &mauview.ProxyScreen{Parent: screen, OffsetX: le.Indent, Width: width - le.Indent, Style: le.Style} for i, entity := range le.Children { proxyScreen.Height = entity.Height() if le.Ordered { number := le.Start + i line := fmt.Sprintf("%d. %s", number, strings.Repeat(" ", le.Indent-2-digits(number))) widget.WriteLine(screen, mauview.AlignLeft, line, 0, proxyScreen.OffsetY, le.Indent, le.Style) } else { screen.SetContent(0, proxyScreen.OffsetY, '●', nil, le.Style) } entity.Draw(proxyScreen, ctx) proxyScreen.SetStyle(le.Style) proxyScreen.OffsetY += entity.Height() } } func (le *ListEntity) PlainText() string { if len(le.Children) == 0 { return "" } var buf strings.Builder for i, child := range le.Children { indent := strings.Repeat(" ", le.Indent) if le.Ordered { number := le.Start + i _, _ = fmt.Fprintf(&buf, "%d. %s", number, strings.Repeat(" ", le.Indent-2-digits(number))) } else { buf.WriteString("● ") } for j, row := range strings.Split(child.PlainText(), "\n") { if j != 0 { buf.WriteRune('\n') buf.WriteString(indent) } buf.WriteString(row) } buf.WriteRune('\n') } return strings.TrimSpace(buf.String()) } func (le *ListEntity) String() string { return fmt.Sprintf("&html.ListEntity{Ordered=%t, Start=%d, Base=%s},\n", le.Ordered, le.Start, le.BaseEntity) } gomuks-0.3.0/ui/messages/html/parser.go000066400000000000000000000360501433617251100200410ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "fmt" "regexp" "strconv" "strings" "github.com/alecthomas/chroma" "github.com/alecthomas/chroma/lexers" "github.com/alecthomas/chroma/styles" "github.com/lucasb-eyer/go-colorful" "golang.org/x/net/html" "mvdan.cc/xurls/v2" "go.mau.fi/tcell" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/widget" ) type htmlParser struct { prefs *config.UserPreferences room *rooms.Room evt *muksevt.Event preserveWhitespace bool linkIDCounter int } func AdjustStyleBold(style tcell.Style) tcell.Style { return style.Bold(true) } func AdjustStyleItalic(style tcell.Style) tcell.Style { return style.Italic(true) } func AdjustStyleUnderline(style tcell.Style) tcell.Style { return style.Underline(true) } func AdjustStyleStrikethrough(style tcell.Style) tcell.Style { return style.StrikeThrough(true) } func AdjustStyleTextColor(color tcell.Color) AdjustStyleFunc { return func(style tcell.Style) tcell.Style { return style.Foreground(color) } } func AdjustStyleBackgroundColor(color tcell.Color) AdjustStyleFunc { return func(style tcell.Style) tcell.Style { return style.Background(color) } } func AdjustStyleLink(url, id string) AdjustStyleFunc { return func(style tcell.Style) tcell.Style { return style.Url(url).UrlId(id) } } func (parser *htmlParser) maybeGetAttribute(node *html.Node, attribute string) (string, bool) { for _, attr := range node.Attr { if attr.Key == attribute { return attr.Val, true } } return "", false } func (parser *htmlParser) getAttribute(node *html.Node, attribute string) string { val, _ := parser.maybeGetAttribute(node, attribute) return val } func (parser *htmlParser) hasAttribute(node *html.Node, attribute string) bool { _, ok := parser.maybeGetAttribute(node, attribute) return ok } func (parser *htmlParser) listToEntity(node *html.Node) Entity { children := parser.nodeToEntities(node.FirstChild) ordered := node.Data == "ol" start := 1 if ordered { if startRaw := parser.getAttribute(node, "start"); len(startRaw) > 0 { var err error start, err = strconv.Atoi(startRaw) if err != nil { start = 1 } } } listItems := children[:0] for _, child := range children { if child.GetTag() == "li" { listItems = append(listItems, child) } } return NewListEntity(ordered, start, listItems) } func (parser *htmlParser) basicFormatToEntity(node *html.Node) Entity { entity := &ContainerEntity{ BaseEntity: &BaseEntity{ Tag: node.Data, }, Children: parser.nodeToEntities(node.FirstChild), } switch node.Data { case "b", "strong": entity.AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal) case "i", "em": entity.AdjustStyle(AdjustStyleItalic, AdjustStyleReasonNormal) case "s", "del", "strike": entity.AdjustStyle(AdjustStyleStrikethrough, AdjustStyleReasonNormal) case "u", "ins": entity.AdjustStyle(AdjustStyleUnderline, AdjustStyleReasonNormal) case "code": bgColor := tcell.ColorDarkSlateGray fgColor := tcell.ColorWhite entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal) entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal) case "font", "span": fgColor, ok := parser.parseColor(node, "data-mx-color", "color") if ok { entity.AdjustStyle(AdjustStyleTextColor(fgColor), AdjustStyleReasonNormal) } bgColor, ok := parser.parseColor(node, "data-mx-bg-color", "background-color") if ok { entity.AdjustStyle(AdjustStyleBackgroundColor(bgColor), AdjustStyleReasonNormal) } spoilerReason, isSpoiler := parser.maybeGetAttribute(node, "data-mx-spoiler") if isSpoiler { return NewSpoilerEntity(entity, spoilerReason) } } return entity } func (parser *htmlParser) parseColor(node *html.Node, mainName, altName string) (color tcell.Color, ok bool) { hex := parser.getAttribute(node, mainName) if len(hex) == 0 { hex = parser.getAttribute(node, altName) if len(hex) == 0 { return } } cful, err := colorful.Hex(hex) if err != nil { color2, found := colorMap[strings.ToLower(hex)] if !found { return } cful, _ = colorful.MakeColor(color2) } r, g, b := cful.RGB255() return tcell.NewRGBColor(int32(r), int32(g), int32(b)), true } func (parser *htmlParser) headerToEntity(node *html.Node) Entity { return (&ContainerEntity{ BaseEntity: &BaseEntity{ Tag: node.Data, }, Children: append( []Entity{NewTextEntity(strings.Repeat("#", int(node.Data[1]-'0')) + " ")}, parser.nodeToEntities(node.FirstChild)..., ), }).AdjustStyle(AdjustStyleBold, AdjustStyleReasonNormal) } func (parser *htmlParser) blockquoteToEntity(node *html.Node) Entity { return NewBlockquoteEntity(parser.nodeToEntities(node.FirstChild)) } func (parser *htmlParser) linkToEntity(node *html.Node) Entity { sameURL := false href := parser.getAttribute(node, "href") entity := &ContainerEntity{ BaseEntity: &BaseEntity{ Tag: "a", }, Children: parser.nodeToEntities(node.FirstChild), } if len(href) == 0 { return entity } if len(entity.Children) == 1 { entity, ok := entity.Children[0].(*TextEntity) if ok && entity.Text == href { sameURL = true } } matrixURI, _ := id.ParseMatrixURIOrMatrixToURL(href) if matrixURI != nil && (matrixURI.Sigil1 == '@' || matrixURI.Sigil1 == '#') && matrixURI.Sigil2 == 0 { text := NewTextEntity(matrixURI.PrimaryIdentifier()) if matrixURI.Sigil1 == '@' { if member := parser.room.GetMember(matrixURI.UserID()); member != nil { text.Text = member.Displayname text.Style = text.Style.Foreground(widget.GetHashColor(matrixURI.UserID())) } entity.Children = []Entity{text} } else if matrixURI.Sigil1 == '#' { entity.Children = []Entity{text} } } else if parser.prefs.EnableInlineURLs() { linkID := fmt.Sprintf("%s-%d", parser.evt.ID, parser.linkIDCounter) parser.linkIDCounter++ entity.AdjustStyle(AdjustStyleLink(href, linkID), AdjustStyleReasonNormal) } else if !sameURL && !parser.prefs.DisableShowURLs && !parser.hasAttribute(node, "data-mautrix-exclude-plaintext") { entity.Children = append(entity.Children, NewTextEntity(fmt.Sprintf(" (%s)", href))) } return entity } func (parser *htmlParser) imageToEntity(node *html.Node) Entity { alt := parser.getAttribute(node, "alt") if len(alt) == 0 { alt = parser.getAttribute(node, "title") if len(alt) == 0 { alt = "[inline image]" } } entity := &TextEntity{ BaseEntity: &BaseEntity{ Tag: "img", }, Text: alt, } // TODO add click action and underline on hover for inline images return entity } func colourToColor(colour chroma.Colour) tcell.Color { if !colour.IsSet() { return tcell.ColorDefault } return tcell.NewRGBColor(int32(colour.Red()), int32(colour.Green()), int32(colour.Blue())) } func styleEntryToStyle(se chroma.StyleEntry) tcell.Style { return tcell.StyleDefault. Bold(se.Bold == chroma.Yes). Italic(se.Italic == chroma.Yes). Underline(se.Underline == chroma.Yes). Foreground(colourToColor(se.Colour)). Background(colourToColor(se.Background)) } func tokenToTextEntity(style *chroma.Style, token *chroma.Token) *TextEntity { return &TextEntity{ BaseEntity: &BaseEntity{ Tag: token.Type.String(), Style: styleEntryToStyle(style.Get(token.Type)), DefaultHeight: 1, }, Text: token.Value, } } func (parser *htmlParser) syntaxHighlight(text, language string) Entity { lexer := lexers.Get(strings.ToLower(language)) if lexer == nil { lexer = lexers.Get("plaintext") } iter, err := lexer.Tokenise(nil, text) if err != nil { return nil } // TODO allow changing theme style := styles.SolarizedDark tokens := iter.Tokens() var children []Entity for _, token := range tokens { lines := strings.SplitAfter(token.Value, "\n") for _, line := range lines { line_len := len(line) if line_len == 0 { continue } t := token.Clone() if line[line_len-1:] == "\n" { t.Value = line[:line_len-1] children = append(children, tokenToTextEntity(style, &t), NewBreakEntity()) } else { t.Value = line children = append(children, tokenToTextEntity(style, &t)) } } } return NewCodeBlockEntity(children, styleEntryToStyle(style.Get(chroma.Background))) } func (parser *htmlParser) codeblockToEntity(node *html.Node) Entity { lang := "plaintext" // TODO allow disabling syntax highlighting if node.FirstChild != nil && node.FirstChild.Type == html.ElementNode && node.FirstChild.Data == "code" { node = node.FirstChild attr := parser.getAttribute(node, "class") for _, class := range strings.Split(attr, " ") { if strings.HasPrefix(class, "language-") { lang = class[len("language-"):] break } } } parser.preserveWhitespace = true text := (&ContainerEntity{ Children: parser.nodeToEntities(node.FirstChild), }).PlainText() parser.preserveWhitespace = false return parser.syntaxHighlight(text, lang) } func (parser *htmlParser) tagNodeToEntity(node *html.Node) Entity { switch node.Data { case "blockquote": return parser.blockquoteToEntity(node) case "ol", "ul": return parser.listToEntity(node) case "h1", "h2", "h3", "h4", "h5", "h6": return parser.headerToEntity(node) case "br": return NewBreakEntity() case "b", "strong", "i", "em", "s", "strike", "del", "u", "ins", "font", "span", "code": return parser.basicFormatToEntity(node) case "a": return parser.linkToEntity(node) case "img": return parser.imageToEntity(node) case "pre": return parser.codeblockToEntity(node) case "hr": return NewHorizontalLineEntity() case "mx-reply": return nil default: return &ContainerEntity{ BaseEntity: &BaseEntity{ Tag: node.Data, Block: parser.isBlockTag(node.Data), }, Children: parser.nodeToEntities(node.FirstChild), } } } var spaces = regexp.MustCompile("\\s+") // textToHTMLEntity converts a plain text string into an HTML Entity while preserving newlines. func textToHTMLEntity(text string) Entity { if strings.Index(text, "\n") == -1 { return NewTextEntity(text) } return &ContainerEntity{ BaseEntity: &BaseEntity{Tag: "span"}, Children: textToHTMLEntities(text), } } func textToHTMLEntities(text string) []Entity { lines := strings.SplitAfter(text, "\n") entities := make([]Entity, 0, len(lines)) for _, line := range lines { line_len := len(line) if line_len == 0 { continue } if line == "\n" { entities = append(entities, NewBreakEntity()) } else if line[line_len-1:] == "\n" { entities = append(entities, NewTextEntity(line[:line_len-1]), NewBreakEntity()) } else { entities = append(entities, NewTextEntity(line)) } } return entities } func TextToEntity(text string, eventID id.EventID, linkify bool) Entity { if len(text) == 0 { return nil } if !linkify { return textToHTMLEntity(text) } indices := xurls.Strict().FindAllStringIndex(text, -1) if len(indices) == 0 { return textToHTMLEntity(text) } ent := &ContainerEntity{ BaseEntity: &BaseEntity{Tag: "span"}, } var lastEnd int for i, item := range indices { start, end := item[0], item[1] if start > lastEnd { ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:start])...) } link := text[start:end] linkID := fmt.Sprintf("%s-%d", eventID, i) ent.Children = append(ent.Children, NewTextEntity(link).AdjustStyle(AdjustStyleLink(link, linkID), AdjustStyleReasonNormal)) lastEnd = end } if lastEnd < len(text) { ent.Children = append(ent.Children, textToHTMLEntities(text[lastEnd:])...) } return ent } func (parser *htmlParser) singleNodeToEntity(node *html.Node) Entity { switch node.Type { case html.TextNode: if !parser.preserveWhitespace { node.Data = strings.ReplaceAll(node.Data, "\n", "") node.Data = spaces.ReplaceAllLiteralString(node.Data, " ") } return TextToEntity(node.Data, parser.evt.ID, parser.prefs.EnableInlineURLs()) case html.ElementNode: parsed := parser.tagNodeToEntity(node) if parsed != nil && !parsed.IsBlock() && parsed.IsEmpty() { return nil } return parsed case html.DocumentNode: if node.FirstChild.Data == "html" && node.FirstChild.NextSibling == nil { return parser.singleNodeToEntity(node.FirstChild) } return &ContainerEntity{ BaseEntity: &BaseEntity{ Tag: "html", Block: true, }, Children: parser.nodeToEntities(node.FirstChild), } default: return nil } } func (parser *htmlParser) nodeToEntities(node *html.Node) (entities []Entity) { for ; node != nil; node = node.NextSibling { if entity := parser.singleNodeToEntity(node); entity != nil { entities = append(entities, entity) } } return } var BlockTags = []string{"p", "h1", "h2", "h3", "h4", "h5", "h6", "ol", "ul", "li", "pre", "blockquote", "div", "hr", "table"} func (parser *htmlParser) isBlockTag(tag string) bool { for _, blockTag := range BlockTags { if tag == blockTag { return true } } return false } func (parser *htmlParser) Parse(htmlData string) Entity { node, _ := html.Parse(strings.NewReader(htmlData)) bodyNode := node.FirstChild.FirstChild for bodyNode != nil && (bodyNode.Type != html.ElementNode || bodyNode.Data != "body") { bodyNode = bodyNode.NextSibling } if bodyNode != nil { return parser.singleNodeToEntity(bodyNode) } return parser.singleNodeToEntity(node) } const TabLength = 4 // Parse parses a HTML-formatted Matrix event into a UIMessage. func Parse(prefs *config.UserPreferences, room *rooms.Room, content *event.MessageEventContent, evt *muksevt.Event, senderDisplayname string) Entity { htmlData := content.FormattedBody if content.Format != event.FormatHTML { htmlData = strings.Replace(html.EscapeString(content.Body), "\n", "
", -1) } htmlData = strings.Replace(htmlData, "\t", strings.Repeat(" ", TabLength), -1) parser := htmlParser{room: room, prefs: prefs, evt: evt} root := parser.Parse(htmlData) if root == nil { return nil } beRoot, ok := root.(*ContainerEntity) if ok { beRoot.Block = false if len(beRoot.Children) > 0 { beChild, ok := beRoot.Children[0].(*ContainerEntity) if ok && beChild.Tag == "p" { // Hacky fix for m.emote beChild.Block = false } } } if content.MsgType == event.MsgEmote { root = &ContainerEntity{ BaseEntity: &BaseEntity{ Tag: "emote", }, Children: []Entity{ NewTextEntity("* "), NewTextEntity(senderDisplayname).AdjustStyle(AdjustStyleTextColor(widget.GetHashColor(evt.Sender)), AdjustStyleReasonNormal), NewTextEntity(" "), root, }, } } return root } gomuks-0.3.0/ui/messages/html/spoiler.go000066400000000000000000000065441433617251100202270ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2022 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "fmt" "strings" "go.mau.fi/mauview" "go.mau.fi/tcell" ) type SpoilerEntity struct { reason string hidden *ContainerEntity visible *ContainerEntity } const SpoilerColor = tcell.ColorYellow func NewSpoilerEntity(visible *ContainerEntity, reason string) *SpoilerEntity { hidden := visible.Clone().(*ContainerEntity) hidden.AdjustStyle(func(style tcell.Style) tcell.Style { return style.Foreground(SpoilerColor).Background(SpoilerColor) }, AdjustStyleReasonHideSpoiler) if len(reason) > 0 { reasonEnt := NewTextEntity(fmt.Sprintf("(%s)", reason)) hidden.Children = append([]Entity{reasonEnt}, hidden.Children...) visible.Children = append([]Entity{reasonEnt}, visible.Children...) } return &SpoilerEntity{ reason: reason, hidden: hidden, visible: visible, } } func (se *SpoilerEntity) Clone() Entity { return &SpoilerEntity{ reason: se.reason, hidden: se.hidden.Clone().(*ContainerEntity), visible: se.visible.Clone().(*ContainerEntity), } } func (se *SpoilerEntity) IsBlock() bool { return false } func (se *SpoilerEntity) GetTag() string { return "span" } func (se *SpoilerEntity) Draw(screen mauview.Screen, ctx DrawContext) { if ctx.IsSelected { se.visible.Draw(screen, ctx) } else { se.hidden.Draw(screen, ctx) } } func (se *SpoilerEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { if reason != AdjustStyleReasonHideSpoiler { se.hidden.AdjustStyle(func(style tcell.Style) tcell.Style { return fn(style).Foreground(SpoilerColor).Background(SpoilerColor) }, reason) se.visible.AdjustStyle(fn, reason) } return se } func (se *SpoilerEntity) PlainText() string { if len(se.reason) > 0 { return fmt.Sprintf("spoiler: %s", se.reason) } else { return "spoiler" } } func (se *SpoilerEntity) String() string { var buf strings.Builder _, _ = fmt.Fprintf(&buf, `&html.SpoilerEntity{reason=%s`, se.reason) buf.WriteString("\n visible=") buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.visible.String(), "\n"), "\n"), "\n ")) buf.WriteString("\n hidden=") buf.WriteString(strings.Join(strings.Split(strings.TrimRight(se.hidden.String(), "\n"), "\n"), "\n ")) buf.WriteString("\n]},") return buf.String() } func (se *SpoilerEntity) Height() int { return se.visible.Height() } func (se *SpoilerEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { se.hidden.CalculateBuffer(width, startX, ctx) return se.visible.CalculateBuffer(width, startX, ctx) } func (se *SpoilerEntity) getStartX() int { return se.visible.getStartX() } func (se *SpoilerEntity) IsEmpty() bool { return se.visible.IsEmpty() } gomuks-0.3.0/ui/messages/html/text.go000066400000000000000000000076061433617251100175360ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package html import ( "fmt" "regexp" "github.com/mattn/go-runewidth" "go.mau.fi/mauview" "maunium.net/go/gomuks/ui/widget" ) type TextEntity struct { *BaseEntity // Text in this entity. Text string buffer []string } // NewTextEntity creates a new text-only Entity. func NewTextEntity(text string) *TextEntity { return &TextEntity{ BaseEntity: &BaseEntity{ Tag: "text", }, Text: text, } } func (te *TextEntity) IsEmpty() bool { return len(te.Text) == 0 } func (te *TextEntity) AdjustStyle(fn AdjustStyleFunc, reason AdjustStyleReason) Entity { te.BaseEntity = te.BaseEntity.AdjustStyle(fn, reason).(*BaseEntity) return te } func (te *TextEntity) Clone() Entity { return &TextEntity{ BaseEntity: te.BaseEntity.Clone().(*BaseEntity), Text: te.Text, } } func (te *TextEntity) PlainText() string { return te.Text } func (te *TextEntity) String() string { return fmt.Sprintf("&html.TextEntity{Text=%s, Base=%s},\n", te.Text, te.BaseEntity) } func (te *TextEntity) Draw(screen mauview.Screen, ctx DrawContext) { width, _ := screen.Size() x := te.startX for y, line := range te.buffer { widget.WriteLine(screen, mauview.AlignLeft, line, x, y, width, te.Style) x = 0 } } func (te *TextEntity) CalculateBuffer(width, startX int, ctx DrawContext) int { te.BaseEntity.CalculateBuffer(width, startX, ctx) if len(te.Text) == 0 { return te.startX } te.height = 0 te.prevWidth = width if te.buffer == nil { te.buffer = []string{} } bufPtr := 0 text := te.Text textStartX := te.startX for { // TODO add option no wrap and character wrap options extract := runewidth.Truncate(text, width-textStartX, "") extract, wordWrapped := trim(extract, text, ctx.BareMessages) if !wordWrapped && textStartX > 0 { if bufPtr < len(te.buffer) { te.buffer[bufPtr] = "" } else { te.buffer = append(te.buffer, "") } bufPtr++ textStartX = 0 continue } if bufPtr < len(te.buffer) { te.buffer[bufPtr] = extract } else { te.buffer = append(te.buffer, extract) } bufPtr++ text = text[len(extract):] if len(text) == 0 { te.buffer = te.buffer[:bufPtr] te.height += len(te.buffer) // This entity is over, return the startX for the next entity if te.Block { // ...except if it's a block entity return 0 } return textStartX + runewidth.StringWidth(extract) } textStartX = 0 } } var ( boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`) bareBoundaryPattern = regexp.MustCompile(`(\s+)`) spacePattern = regexp.MustCompile(`\s+`) ) func trim(extract, full string, bare bool) (string, bool) { if len(extract) == len(full) { return extract, true } if spaces := spacePattern.FindStringIndex(full[len(extract):]); spaces != nil && spaces[0] == 0 { extract = full[:len(extract)+spaces[1]] } regex := boundaryPattern if bare { regex = bareBoundaryPattern } matches := regex.FindAllStringIndex(extract, -1) if len(matches) > 0 { if match := matches[len(matches)-1]; len(match) >= 2 { if until := match[1]; until < len(extract) { extract = extract[:until] return extract, true } } } return extract, len(extract) > 0 && extract[len(extract)-1] == ' ' } gomuks-0.3.0/ui/messages/htmlmessage.go000066400000000000000000000051071433617251100201110ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package messages import ( "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/ui/messages/html" ) type HTMLMessage struct { Root html.Entity TextColor tcell.Color } func NewHTMLMessage(evt *muksevt.Event, displayname string, root html.Entity) *UIMessage { return newUIMessage(evt, displayname, &HTMLMessage{ Root: root, }) } func (hw *HTMLMessage) Clone() MessageRenderer { return &HTMLMessage{ Root: hw.Root.Clone(), } } func (hw *HTMLMessage) Draw(screen mauview.Screen, msg *UIMessage) { if hw.TextColor != tcell.ColorDefault { hw.Root.AdjustStyle(func(style tcell.Style) tcell.Style { fg, _, _ := style.Decompose() if fg == tcell.ColorDefault { return style.Foreground(hw.TextColor) } return style }, html.AdjustStyleReasonNormal) } screen.Clear() hw.Root.Draw(screen, html.DrawContext{IsSelected: msg.IsSelected}) } func (hw *HTMLMessage) OnKeyEvent(event mauview.KeyEvent) bool { return false } func (hw *HTMLMessage) OnMouseEvent(event mauview.MouseEvent) bool { return false } func (hw *HTMLMessage) OnPasteEvent(event mauview.PasteEvent) bool { return false } func (hw *HTMLMessage) CalculateBuffer(preferences config.UserPreferences, width int, msg *UIMessage) { if width < 2 { return } // TODO account for bare messages in initial startX startX := 0 hw.TextColor = msg.TextColor() hw.Root.CalculateBuffer(width, startX, html.DrawContext{ IsSelected: msg.IsSelected, BareMessages: preferences.BareMessageView, }) } func (hw *HTMLMessage) Height() int { return hw.Root.Height() } func (hw *HTMLMessage) PlainText() string { return hw.Root.PlainText() } func (hw *HTMLMessage) NotificationContent() string { return hw.Root.PlainText() } func (hw *HTMLMessage) String() string { return hw.Root.String() } gomuks-0.3.0/ui/messages/parser.go000066400000000000000000000307021433617251100170730ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package messages import ( "fmt" "strings" "go.mau.fi/tcell" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/messages/html" "maunium.net/go/gomuks/ui/messages/tstring" "maunium.net/go/gomuks/ui/widget" ) func getCachedEvent(mainView ifc.MainView, roomID id.RoomID, eventID id.EventID) *UIMessage { if roomView := mainView.GetRoom(roomID); roomView != nil { if replyToIfcMsg := roomView.GetEvent(eventID); replyToIfcMsg != nil { if replyToMsg, ok := replyToIfcMsg.(*UIMessage); ok && replyToMsg != nil { return replyToMsg } } } return nil } func ParseEvent(matrix ifc.MatrixContainer, mainView ifc.MainView, room *rooms.Room, evt *muksevt.Event) *UIMessage { msg := directParseEvent(matrix, room, evt) if msg == nil { return nil } if content, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && len(content.GetReplyTo()) > 0 { if replyToMsg := getCachedEvent(mainView, room.ID, content.GetReplyTo()); replyToMsg != nil { msg.ReplyTo = replyToMsg.Clone() } else if replyToEvt, _ := matrix.GetEvent(room, content.GetReplyTo()); replyToEvt != nil { if replyToMsg = directParseEvent(matrix, room, replyToEvt); replyToMsg != nil { msg.ReplyTo = replyToMsg msg.ReplyTo.Reactions = nil } else { // TODO add unrenderable reply header } } else { // TODO add unknown reply header } } return msg } func directParseEvent(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event) *UIMessage { displayname := string(evt.Sender) member := room.GetMember(evt.Sender) if member != nil { displayname = member.Displayname } if evt.Unsigned.RedactedBecause != nil || evt.Type == event.EventRedaction { return NewRedactedMessage(evt, displayname) } switch content := evt.Content.Parsed.(type) { case *event.MessageEventContent: if evt.Type == event.EventSticker { content.MsgType = event.MsgImage } return ParseMessage(matrix, room, evt, displayname) case *muksevt.BadEncryptedContent: return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString(content.Reason, tcell.StyleDefault.Italic(true))) case *muksevt.EncryptionUnsupportedContent: return NewExpandedTextMessage(evt, displayname, tstring.NewStyleTString("gomuks not built with encryption support", tcell.StyleDefault.Italic(true))) case *event.TopicEventContent, *event.RoomNameEventContent, *event.CanonicalAliasEventContent: return ParseStateEvent(evt, displayname) case *event.MemberEventContent: return ParseMembershipEvent(room, evt) default: debug.Printf("Unknown event content type %T in directParseEvent", content) return nil } } func findAltAliasDifference(newList, oldList []id.RoomAlias) (addedStr, removedStr tstring.TString) { var addedList, removedList []tstring.TString OldLoop: for _, oldAlias := range oldList { for _, newAlias := range newList { if oldAlias == newAlias { continue OldLoop } } removedList = append(removedList, tstring.NewStyleTString(string(oldAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(oldAlias)).Underline(true))) } NewLoop: for _, newAlias := range newList { for _, oldAlias := range oldList { if newAlias == oldAlias { continue NewLoop } } addedList = append(addedList, tstring.NewStyleTString(string(newAlias), tcell.StyleDefault.Foreground(widget.GetHashColor(newAlias)).Underline(true))) } if len(addedList) == 1 { addedStr = tstring.NewColorTString("added alternative address ", tcell.ColorGreen).AppendTString(addedList[0]) } else if len(addedList) != 0 { addedStr = tstring. Join(addedList[:len(addedList)-1], ", "). PrependColor("added alternative addresses ", tcell.ColorGreen). AppendColor(" and ", tcell.ColorGreen). AppendTString(addedList[len(addedList)-1]) } if len(removedList) == 1 { removedStr = tstring.NewColorTString("removed alternative address ", tcell.ColorGreen).AppendTString(removedList[0]) } else if len(removedList) != 0 { removedStr = tstring. Join(removedList[:len(removedList)-1], ", "). PrependColor("removed alternative addresses ", tcell.ColorGreen). AppendColor(" and ", tcell.ColorGreen). AppendTString(removedList[len(removedList)-1]) } return } func ParseStateEvent(evt *muksevt.Event, displayname string) *UIMessage { text := tstring.NewColorTString(displayname, widget.GetHashColor(evt.Sender)).Append(" ") switch content := evt.Content.Parsed.(type) { case *event.TopicEventContent: if len(content.Topic) == 0 { text = text.AppendColor("removed the topic.", tcell.ColorGreen) } else { text = text.AppendColor("changed the topic to ", tcell.ColorGreen). AppendStyle(content.Topic, tcell.StyleDefault.Underline(true)). AppendColor(".", tcell.ColorGreen) } case *event.RoomNameEventContent: if len(content.Name) == 0 { text = text.AppendColor("removed the room name.", tcell.ColorGreen) } else { text = text.AppendColor("changed the room name to ", tcell.ColorGreen). AppendStyle(content.Name, tcell.StyleDefault.Underline(true)). AppendColor(".", tcell.ColorGreen) } case *event.CanonicalAliasEventContent: prevContent := &event.CanonicalAliasEventContent{} if evt.Unsigned.PrevContent != nil { _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) prevContent = evt.Unsigned.PrevContent.AsCanonicalAlias() } debug.Printf("%+v -> %+v", prevContent, content) if len(content.Alias) == 0 && len(prevContent.Alias) != 0 { text = text.AppendColor("removed the main address of the room", tcell.ColorGreen) } else if content.Alias != prevContent.Alias { text = text. AppendColor("changed the main address of the room to ", tcell.ColorGreen). AppendStyle(string(content.Alias), tcell.StyleDefault.Underline(true)) } else { added, removed := findAltAliasDifference(content.AltAliases, prevContent.AltAliases) if len(added) > 0 { if len(removed) > 0 { text = text. AppendTString(added). AppendColor(" and ", tcell.ColorGreen). AppendTString(removed) } else { text = text.AppendTString(added) } } else if len(removed) > 0 { text = text.AppendTString(removed) } else { text = text.AppendColor("changed nothing", tcell.ColorGreen) } text = text.AppendColor(" for this room", tcell.ColorGreen) } } return NewExpandedTextMessage(evt, displayname, text) } func ParseMessage(matrix ifc.MatrixContainer, room *rooms.Room, evt *muksevt.Event, displayname string) *UIMessage { content := evt.Content.AsMessage() if len(content.GetReplyTo()) > 0 { content.RemoveReplyFallback() } if len(evt.Gomuks.Edits) > 0 { newContent := evt.Gomuks.Edits[len(evt.Gomuks.Edits)-1].Content.AsMessage().NewContent if newContent != nil { content = newContent } } switch content.MsgType { case event.MsgText, event.MsgNotice, event.MsgEmote: var htmlEntity html.Entity if content.Format == event.FormatHTML && len(content.FormattedBody) > 0 { htmlEntity = html.Parse(matrix.Preferences(), room, content, evt, displayname) if htmlEntity == nil { htmlEntity = html.NewTextEntity("Malformed message") htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal) } } else if len(content.Body) > 0 { content.Body = strings.Replace(content.Body, "\t", " ", -1) htmlEntity = html.TextToEntity(content.Body, evt.ID, matrix.Preferences().EnableInlineURLs()) } else { htmlEntity = html.NewTextEntity("Blank message") htmlEntity.AdjustStyle(html.AdjustStyleTextColor(tcell.ColorRed), html.AdjustStyleReasonNormal) } return NewHTMLMessage(evt, displayname, htmlEntity) case event.MsgImage, event.MsgVideo, event.MsgAudio, event.MsgFile: msg := NewFileMessage(matrix, evt, displayname) if !matrix.Preferences().DisableDownloads { renderer := msg.Renderer.(*FileMessage) renderer.DownloadPreview() } return msg } return nil } func getMembershipChangeMessage(evt *muksevt.Event, content *event.MemberEventContent, prevMembership event.Membership, senderDisplayname, displayname, prevDisplayname string) (sender string, text tstring.TString) { switch content.Membership { case "invite": sender = "---" text = tstring.NewColorTString(fmt.Sprintf("%s invited %s.", senderDisplayname, displayname), tcell.ColorGreen) text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) text.Colorize(len(senderDisplayname)+len(" invited "), len(displayname), widget.GetHashColor(evt.StateKey)) case "join": sender = "-->" if prevMembership == event.MembershipInvite { text = tstring.NewColorTString(fmt.Sprintf("%s accepted the invite.", displayname), tcell.ColorGreen) } else { text = tstring.NewColorTString(fmt.Sprintf("%s joined the room.", displayname), tcell.ColorGreen) } text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey)) case "leave": sender = "<--" if evt.Sender != id.UserID(*evt.StateKey) { if prevMembership == event.MembershipBan { text = tstring.NewColorTString(fmt.Sprintf("%s unbanned %s", senderDisplayname, displayname), tcell.ColorGreen) text.Colorize(len(senderDisplayname)+len(" unbanned "), len(displayname), widget.GetHashColor(evt.StateKey)) } else { text = tstring.NewColorTString(fmt.Sprintf("%s kicked %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed) text.Colorize(len(senderDisplayname)+len(" kicked "), len(displayname), widget.GetHashColor(evt.StateKey)) } text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) } else { if displayname == *evt.StateKey { displayname = prevDisplayname } if prevMembership == event.MembershipInvite { text = tstring.NewColorTString(fmt.Sprintf("%s rejected the invite.", displayname), tcell.ColorRed) } else { text = tstring.NewColorTString(fmt.Sprintf("%s left the room.", displayname), tcell.ColorRed) } text.Colorize(0, len(displayname), widget.GetHashColor(evt.StateKey)) } case "ban": text = tstring.NewColorTString(fmt.Sprintf("%s banned %s: %s", senderDisplayname, displayname, content.Reason), tcell.ColorRed) text.Colorize(len(senderDisplayname)+len(" banned "), len(displayname), widget.GetHashColor(evt.StateKey)) text.Colorize(0, len(senderDisplayname), widget.GetHashColor(evt.Sender)) } return } func getMembershipEventContent(room *rooms.Room, evt *muksevt.Event) (sender string, text tstring.TString) { member := room.GetMember(evt.Sender) senderDisplayname := string(evt.Sender) if member != nil { senderDisplayname = member.Displayname } content := evt.Content.AsMember() displayname := content.Displayname if len(displayname) == 0 { displayname = *evt.StateKey } prevMembership := event.MembershipLeave prevDisplayname := *evt.StateKey if evt.Unsigned.PrevContent != nil { _ = evt.Unsigned.PrevContent.ParseRaw(evt.Type) prevContent := evt.Unsigned.PrevContent.AsMember() prevMembership = prevContent.Membership prevDisplayname = prevContent.Displayname if len(prevDisplayname) == 0 { prevDisplayname = *evt.StateKey } } if content.Membership != prevMembership { sender, text = getMembershipChangeMessage(evt, content, prevMembership, senderDisplayname, displayname, prevDisplayname) } else if displayname != prevDisplayname { sender = "---" color := widget.GetHashColor(evt.StateKey) text = tstring.NewBlankTString(). AppendColor(prevDisplayname, color). AppendColor(" changed their display name to ", tcell.ColorGreen). AppendColor(displayname, color). AppendColor(".", tcell.ColorGreen) } return } func ParseMembershipEvent(room *rooms.Room, evt *muksevt.Event) *UIMessage { displayname, text := getMembershipEventContent(room, evt) if len(text) == 0 { return nil } return NewExpandedTextMessage(evt, displayname, text) } gomuks-0.3.0/ui/messages/redactedmessage.go000066400000000000000000000035621433617251100207230ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package messages import ( "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/config" ) type RedactedMessage struct{} func NewRedactedMessage(evt *muksevt.Event, displayname string) *UIMessage { return newUIMessage(evt, displayname, &RedactedMessage{}) } func (msg *RedactedMessage) Clone() MessageRenderer { return &RedactedMessage{} } func (msg *RedactedMessage) NotificationContent() string { return "" } func (msg *RedactedMessage) PlainText() string { return "[redacted]" } func (msg *RedactedMessage) String() string { return "&messages.RedactedMessage{}" } func (msg *RedactedMessage) CalculateBuffer(prefs config.UserPreferences, width int, uiMsg *UIMessage) { } func (msg *RedactedMessage) Height() int { return 1 } const RedactionChar = '█' const RedactionMaxWidth = 40 var RedactionStyle = tcell.StyleDefault.Foreground(tcell.NewRGBColor(50, 0, 0)) func (msg *RedactedMessage) Draw(screen mauview.Screen, _ *UIMessage) { w, _ := screen.Size() for x := 0; x < w && x < RedactionMaxWidth; x++ { screen.SetContent(x, 0, RedactionChar, nil, RedactionStyle) } } gomuks-0.3.0/ui/messages/textbase.go000066400000000000000000000055651433617251100174270ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package messages import ( "fmt" "regexp" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/ui/messages/tstring" ) // Regular expressions used to split lines when calculating the buffer. // // From tview/textview.go var ( boundaryPattern = regexp.MustCompile(`([[:punct:]]\s*|\s+)`) bareBoundaryPattern = regexp.MustCompile(`(\s+)`) spacePattern = regexp.MustCompile(`\s+`) ) func matchBoundaryPattern(bare bool, extract tstring.TString) tstring.TString { regex := boundaryPattern if bare { regex = bareBoundaryPattern } matches := regex.FindAllStringIndex(extract.String(), -1) if len(matches) > 0 { if match := matches[len(matches)-1]; len(match) >= 2 { if until := match[1]; until < len(extract) { extract = extract[:until] } } } return extract } // CalculateBuffer generates the internal buffer for this message that consists // of the text of this message split into lines at most as wide as the width // parameter. func calculateBufferWithText(prefs config.UserPreferences, text tstring.TString, width int, msg *UIMessage) []tstring.TString { if width < 2 { return nil } var buffer []tstring.TString if prefs.BareMessageView { newText := tstring.NewTString(msg.FormatTime()) if len(msg.Sender()) > 0 { newText = newText.AppendTString(tstring.NewColorTString(fmt.Sprintf(" <%s> ", msg.Sender()), msg.SenderColor())) } else { newText = newText.Append(" ") } newText = newText.AppendTString(text) text = newText } forcedLinebreaks := text.Split('\n') newlines := 0 for _, str := range forcedLinebreaks { if len(str) == 0 && newlines < 1 { buffer = append(buffer, tstring.TString{}) newlines++ } else { newlines = 0 } // Adapted from tview/textview.go#reindexBuffer() for len(str) > 0 { extract := str.Truncate(width) if len(extract) < len(str) { if spaces := spacePattern.FindStringIndex(str[len(extract):].String()); spaces != nil && spaces[0] == 0 { extract = str[:len(extract)+spaces[1]] } extract = matchBoundaryPattern(prefs.BareMessageView, extract) } buffer = append(buffer, extract) str = str[len(extract):] } } return buffer } gomuks-0.3.0/ui/messages/tstring/000077500000000000000000000000001433617251100167405ustar00rootroot00000000000000gomuks-0.3.0/ui/messages/tstring/cell.go000066400000000000000000000027601433617251100202130ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package tstring import ( "github.com/mattn/go-runewidth" "go.mau.fi/mauview" "go.mau.fi/tcell" ) type Cell struct { Char rune Style tcell.Style } func NewStyleCell(char rune, style tcell.Style) Cell { return Cell{char, style} } func NewColorCell(char rune, color tcell.Color) Cell { return Cell{char, tcell.StyleDefault.Foreground(color)} } func NewCell(char rune) Cell { return Cell{char, tcell.StyleDefault} } func (cell Cell) RuneWidth() int { return runewidth.RuneWidth(cell.Char) } func (cell Cell) Draw(screen mauview.Screen, x, y int) (chWidth int) { chWidth = cell.RuneWidth() for runeWidthOffset := 0; runeWidthOffset < chWidth; runeWidthOffset++ { screen.SetContent(x+runeWidthOffset, y, cell.Char, nil, cell.Style) } return } gomuks-0.3.0/ui/messages/tstring/doc.go000066400000000000000000000002601433617251100200320ustar00rootroot00000000000000// Package tstring contains a string type that stores style data for each // character, allowing it to be rendered to a tcell screen essentially // unmodified. package tstring gomuks-0.3.0/ui/messages/tstring/string.go000066400000000000000000000134461433617251100206050ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package tstring import ( "strings" "unicode" "github.com/mattn/go-runewidth" "go.mau.fi/mauview" "go.mau.fi/tcell" ) type TString []Cell func NewBlankTString() TString { return make(TString, 0) } func NewTString(str string) TString { newStr := make(TString, len(str)) for i, char := range str { newStr[i] = NewCell(char) } return newStr } func NewColorTString(str string, color tcell.Color) TString { newStr := make(TString, len(str)) for i, char := range str { newStr[i] = NewColorCell(char, color) } return newStr } func NewStyleTString(str string, style tcell.Style) TString { newStr := make(TString, len(str)) for i, char := range str { newStr[i] = NewStyleCell(char, style) } return newStr } func Join(strings []TString, separator string) TString { if len(strings) == 0 { return NewBlankTString() } out := strings[0] strings = strings[1:] if len(separator) == 0 { return out.AppendTString(strings...) } for _, str := range strings { out = append(out, str.Prepend(separator)...) } return out } func (str TString) Clone() TString { newStr := make(TString, len(str)) copy(newStr, str) return newStr } func (str TString) AppendTString(dataList ...TString) TString { newStr := str for _, data := range dataList { newStr = append(newStr, data...) } return newStr } func (str TString) PrependTString(data TString) TString { return append(data, str...) } func (str TString) Append(data string) TString { return str.AppendCustom(data, func(r rune) Cell { return NewCell(r) }) } func (str TString) TrimSpace() TString { return str.Trim(unicode.IsSpace) } func (str TString) Trim(fn func(rune) bool) TString { return str.TrimLeft(fn).TrimRight(fn) } func (str TString) TrimLeft(fn func(rune) bool) TString { for index, cell := range str { if !fn(cell.Char) { return append(NewBlankTString(), str[index:]...) } } return NewBlankTString() } func (str TString) TrimRight(fn func(rune) bool) TString { for i := len(str) - 1; i >= 0; i-- { if !fn(str[i].Char) { return append(NewBlankTString(), str[:i+1]...) } } return NewBlankTString() } func (str TString) AppendColor(data string, color tcell.Color) TString { return str.AppendCustom(data, func(r rune) Cell { return NewColorCell(r, color) }) } func (str TString) AppendStyle(data string, style tcell.Style) TString { return str.AppendCustom(data, func(r rune) Cell { return NewStyleCell(r, style) }) } func (str TString) AppendCustom(data string, cellCreator func(rune) Cell) TString { newStr := make(TString, len(str)+len(data)) copy(newStr, str) for i, char := range data { newStr[i+len(str)] = cellCreator(char) } return newStr } func (str TString) Prepend(data string) TString { return str.PrependCustom(data, func(r rune) Cell { return NewCell(r) }) } func (str TString) PrependColor(data string, color tcell.Color) TString { return str.PrependCustom(data, func(r rune) Cell { return NewColorCell(r, color) }) } func (str TString) PrependStyle(data string, style tcell.Style) TString { return str.PrependCustom(data, func(r rune) Cell { return NewStyleCell(r, style) }) } func (str TString) PrependCustom(data string, cellCreator func(rune) Cell) TString { newStr := make(TString, len(str)+len(data)) copy(newStr[len(data):], str) for i, char := range data { newStr[i] = cellCreator(char) } return newStr } func (str TString) Colorize(from, length int, color tcell.Color) { str.AdjustStyle(from, length, func(style tcell.Style) tcell.Style { return style.Foreground(color) }) } func (str TString) AdjustStyle(from, length int, fn func(tcell.Style) tcell.Style) { for i := from; i < from+length; i++ { str[i].Style = fn(str[i].Style) } } func (str TString) AdjustStyleFull(fn func(tcell.Style) tcell.Style) { str.AdjustStyle(0, len(str), fn) } func (str TString) Draw(screen mauview.Screen, x, y int) { for _, cell := range str { x += cell.Draw(screen, x, y) } } func (str TString) RuneWidth() (width int) { for _, cell := range str { width += runewidth.RuneWidth(cell.Char) } return width } func (str TString) String() string { var buf strings.Builder for _, cell := range str { buf.WriteRune(cell.Char) } return buf.String() } // Truncate return string truncated with w cells func (str TString) Truncate(w int) TString { if str.RuneWidth() <= w { return str[:] } width := 0 i := 0 for ; i < len(str); i++ { cw := runewidth.RuneWidth(str[i].Char) if width+cw > w { break } width += cw } return str[0:i] } func (str TString) IndexFrom(r rune, from int) int { for i := from; i < len(str); i++ { if str[i].Char == r { return i } } return -1 } func (str TString) Index(r rune) int { return str.IndexFrom(r, 0) } func (str TString) Count(r rune) (counter int) { index := 0 for { index = str.IndexFrom(r, index) if index < 0 { break } index++ counter++ } return } func (str TString) Split(sep rune) []TString { a := make([]TString, str.Count(sep)+1) i := 0 orig := str for { m := orig.Index(sep) if m < 0 { break } a[i] = orig[:m] orig = orig[m+1:] i++ } a[i] = orig return a[:i+1] } gomuks-0.3.0/ui/no-crypto-commands.go000066400000000000000000000027131433617251100175220ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . //go:build !cgo package ui func autocompleteDevice(cmd *CommandAutocomplete) ([]string, string) { return []string{}, "" } func autocompleteUser(cmd *CommandAutocomplete) ([]string, string) { return []string{}, "" } func cmdNoCrypto(cmd *Command) { cmd.Reply("This gomuks was built without encryption support") } var ( cmdDevices = cmdNoCrypto cmdDevice = cmdNoCrypto cmdVerifyDevice = cmdNoCrypto cmdVerify = cmdNoCrypto cmdUnverify = cmdNoCrypto cmdBlacklist = cmdNoCrypto cmdResetSession = cmdNoCrypto cmdImportKeys = cmdNoCrypto cmdExportKeys = cmdNoCrypto cmdExportRoomKeys = cmdNoCrypto cmdSSSS = cmdNoCrypto cmdCrossSigning = cmdNoCrypto ) gomuks-0.3.0/ui/password-modal.go000066400000000000000000000074171433617251100167330ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "fmt" "strings" "go.mau.fi/mauview" "go.mau.fi/tcell" ) type PasswordModal struct { mauview.Component outputChan chan string cancelChan chan struct{} form *mauview.Form text *mauview.TextField confirmText *mauview.TextField input *mauview.InputField confirmInput *mauview.InputField cancel *mauview.Button submit *mauview.Button parent *MainView } func (view *MainView) AskPassword(title, thing, placeholder string, isNew bool) (string, bool) { pwm := NewPasswordModal(view, title, thing, placeholder, isNew) view.ShowModal(pwm) view.parent.Render() return pwm.Wait() } func NewPasswordModal(parent *MainView, title, thing, placeholder string, isNew bool) *PasswordModal { if placeholder == "" { placeholder = "correct horse battery staple" } if thing == "" { thing = strings.ToLower(title) } pwm := &PasswordModal{ parent: parent, form: mauview.NewForm(), outputChan: make(chan string, 1), cancelChan: make(chan struct{}, 1), } pwm.form. SetColumns([]int{1, 20, 1, 20, 1}). SetRows([]int{1, 1, 1, 0, 0, 0, 1, 1, 1}) width := 45 height := 8 pwm.text = mauview.NewTextField() if isNew { pwm.text.SetText(fmt.Sprintf("Create a %s", thing)) } else { pwm.text.SetText(fmt.Sprintf("Enter the %s", thing)) } pwm.input = mauview.NewInputField(). SetMaskCharacter('*'). SetPlaceholder(placeholder) pwm.form.AddComponent(pwm.text, 1, 1, 3, 1) pwm.form.AddFormItem(pwm.input, 1, 2, 3, 1) if isNew { height += 3 pwm.confirmInput = mauview.NewInputField(). SetMaskCharacter('*'). SetPlaceholder(placeholder). SetChangedFunc(pwm.HandleChange) pwm.input.SetChangedFunc(pwm.HandleChange) pwm.confirmText = mauview.NewTextField().SetText(fmt.Sprintf("Confirm %s", thing)) pwm.form.SetRow(3, 1).SetRow(4, 1).SetRow(5, 1) pwm.form.AddComponent(pwm.confirmText, 1, 4, 3, 1) pwm.form.AddFormItem(pwm.confirmInput, 1, 5, 3, 1) } pwm.cancel = mauview.NewButton("Cancel").SetOnClick(pwm.ClickCancel) pwm.submit = mauview.NewButton("Submit").SetOnClick(pwm.ClickSubmit) pwm.form.AddFormItem(pwm.submit, 3, 7, 1, 1) pwm.form.AddFormItem(pwm.cancel, 1, 7, 1, 1) box := mauview.NewBox(pwm.form).SetTitle(title) center := mauview.Center(box, width, height).SetAlwaysFocusChild(true) center.Focus() pwm.form.FocusNextItem() pwm.Component = center return pwm } func (pwm *PasswordModal) HandleChange(_ string) { if pwm.input.GetText() == pwm.confirmInput.GetText() { pwm.submit.SetBackgroundColor(mauview.Styles.ContrastBackgroundColor) } else { pwm.submit.SetBackgroundColor(tcell.ColorDefault) } } func (pwm *PasswordModal) ClickCancel() { pwm.parent.HideModal() pwm.cancelChan <- struct{}{} } func (pwm *PasswordModal) ClickSubmit() { if pwm.confirmInput == nil || pwm.input.GetText() == pwm.confirmInput.GetText() { pwm.parent.HideModal() pwm.outputChan <- pwm.input.GetText() } } func (pwm *PasswordModal) Wait() (string, bool) { select { case result := <-pwm.outputChan: return result, true case <-pwm.cancelChan: return "", false } } gomuks-0.3.0/ui/rainbow.go000066400000000000000000000072011433617251100154270ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2022 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "fmt" "math/rand" "unicode" "github.com/rivo/uniseg" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/renderer" "github.com/yuin/goldmark/renderer/html" "github.com/yuin/goldmark/util" ) func Rand(n int) (str string) { b := make([]byte, n) rand.Read(b) str = fmt.Sprintf("%x", b) return } type extRainbow struct{} type rainbowRenderer struct { HardWraps bool ColorID string } var ExtensionRainbow = &extRainbow{} var defaultRB = &rainbowRenderer{HardWraps: true, ColorID: Rand(16)} func (er *extRainbow) Extend(m goldmark.Markdown) { m.Renderer().AddOptions(renderer.WithNodeRenderers(util.Prioritized(defaultRB, 0))) } func (rb *rainbowRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { reg.Register(ast.KindText, rb.renderText) reg.Register(ast.KindString, rb.renderString) } type rainbowBufWriter struct { util.BufWriter ColorID string } func (rbw rainbowBufWriter) WriteString(s string) (int, error) { i := 0 graphemes := uniseg.NewGraphemes(s) for graphemes.Next() { runes := graphemes.Runes() if len(runes) == 1 && unicode.IsSpace(runes[0]) { i2, err := rbw.BufWriter.WriteRune(runes[0]) i += i2 if err != nil { return i, err } continue } i2, err := fmt.Fprintf(rbw.BufWriter, "%s", rbw.ColorID, graphemes.Str()) i += i2 if err != nil { return i, err } } return i, nil } func (rbw rainbowBufWriter) Write(data []byte) (int, error) { return rbw.WriteString(string(data)) } func (rbw rainbowBufWriter) WriteByte(c byte) error { _, err := rbw.WriteRune(rune(c)) return err } func (rbw rainbowBufWriter) WriteRune(r rune) (int, error) { if unicode.IsSpace(r) { return rbw.BufWriter.WriteRune(r) } else { return fmt.Fprintf(rbw.BufWriter, "%c", rbw.ColorID, r) } } func (rb *rainbowRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } n := node.(*ast.Text) segment := n.Segment if n.IsRaw() { html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, segment.Value(source)) } else { html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, segment.Value(source)) if n.HardLineBreak() || (n.SoftLineBreak() && rb.HardWraps) { _, _ = w.WriteString("
\n") } else if n.SoftLineBreak() { _ = w.WriteByte('\n') } } return ast.WalkContinue, nil } func (rb *rainbowRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { if !entering { return ast.WalkContinue, nil } n := node.(*ast.String) if n.IsCode() { _, _ = w.Write(n.Value) } else { if n.IsRaw() { html.DefaultWriter.RawWrite(rainbowBufWriter{w, rb.ColorID}, n.Value) } else { html.DefaultWriter.Write(rainbowBufWriter{w, rb.ColorID}, n.Value) } } return ast.WalkContinue, nil } gomuks-0.3.0/ui/room-list.go000066400000000000000000000315441433617251100157220ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "math" "regexp" "sort" "strings" sync "github.com/sasha-s/go-deadlock" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/matrix/rooms" ) var tagOrder = map[string]int{ "net.maunium.gomuks.fake.invite": 4, "m.favourite": 3, "net.maunium.gomuks.fake.direct": 2, "": 1, "m.lowpriority": -1, "m.server_notice": -2, "net.maunium.gomuks.fake.leave": -3, } // TagNameList is a list of Matrix tag names where default names are sorted in a hardcoded way. type TagNameList []string func (tnl TagNameList) Len() int { return len(tnl) } func (tnl TagNameList) Less(i, j int) bool { orderI, _ := tagOrder[tnl[i]] orderJ, _ := tagOrder[tnl[j]] if orderI != orderJ { return orderI > orderJ } return strings.Compare(tnl[i], tnl[j]) > 0 } func (tnl TagNameList) Swap(i, j int) { tnl[i], tnl[j] = tnl[j], tnl[i] } type RoomList struct { sync.RWMutex parent *MainView // The list of tags in display order. tags TagNameList // The list of rooms, in reverse order. items map[string]*TagRoomList // The selected room. selected *rooms.Room selectedTag string scrollOffset int height int width int // The item main text color. mainTextColor tcell.Color // The text color for selected items. selectedTextColor tcell.Color // The background color for selected items. selectedBackgroundColor tcell.Color } func NewRoomList(parent *MainView) *RoomList { list := &RoomList{ parent: parent, items: make(map[string]*TagRoomList), tags: []string{}, scrollOffset: 0, mainTextColor: tcell.ColorDefault, selectedTextColor: tcell.ColorWhite, selectedBackgroundColor: tcell.ColorDarkGreen, } for _, tag := range list.tags { list.items[tag] = NewTagRoomList(list, tag) } return list } func (list *RoomList) Contains(roomID id.RoomID) bool { list.RLock() defer list.RUnlock() for _, trl := range list.items { for _, room := range trl.All() { if room.ID == roomID { return true } } } return false } func (list *RoomList) Add(room *rooms.Room) { if room.IsReplaced() { debug.Print(room.ID, "is replaced by", room.ReplacedBy(), "-> not adding to room list") return } debug.Print("Adding room to list", room.ID, room.GetTitle(), room.IsDirect, room.ReplacedBy(), room.Tags()) for _, tag := range room.Tags() { list.AddToTag(tag, room) } } func (list *RoomList) checkTag(tag string) { index := list.indexTag(tag) trl, ok := list.items[tag] if ok && trl.IsEmpty() { delete(list.items, tag) ok = false } if ok && index == -1 { list.tags = append(list.tags, tag) sort.Sort(list.tags) } else if !ok && index != -1 { list.tags = append(list.tags[0:index], list.tags[index+1:]...) } } func (list *RoomList) AddToTag(tag rooms.RoomTag, room *rooms.Room) { list.Lock() defer list.Unlock() trl, ok := list.items[tag.Tag] if !ok { list.items[tag.Tag] = NewTagRoomList(list, tag.Tag, NewOrderedRoom(tag.Order, room)) } else { trl.Insert(tag.Order, room) } list.checkTag(tag.Tag) } func (list *RoomList) Remove(room *rooms.Room) { for _, tag := range list.tags { list.RemoveFromTag(tag, room) } } func (list *RoomList) RemoveFromTag(tag string, room *rooms.Room) { list.Lock() defer list.Unlock() trl, ok := list.items[tag] if !ok { return } index := trl.Index(room) if index == -1 { return } trl.RemoveIndex(index) if trl.IsEmpty() { // delete(list.items, tag) } if room == list.selected { if index > 0 { list.selected = trl.All()[index-1].Room } else if trl.Length() > 0 { list.selected = trl.Visible()[0].Room } else if len(list.items) > 0 { for _, tag := range list.tags { moreItems := list.items[tag] if moreItems.Length() > 0 { list.selected = moreItems.Visible()[0].Room list.selectedTag = tag } } } else { list.selected = nil list.selectedTag = "" } } list.checkTag(tag) } func (list *RoomList) Bump(room *rooms.Room) { list.RLock() defer list.RUnlock() for _, tag := range room.Tags() { trl, ok := list.items[tag.Tag] if !ok { return } trl.Bump(room) } } func (list *RoomList) Clear() { list.Lock() defer list.Unlock() list.items = make(map[string]*TagRoomList) list.tags = []string{} for _, tag := range list.tags { list.items[tag] = NewTagRoomList(list, tag) } list.selected = nil list.selectedTag = "" } func (list *RoomList) SetSelected(tag string, room *rooms.Room) { list.selected = room list.selectedTag = tag pos := list.index(tag, room) if pos <= list.scrollOffset { list.scrollOffset = pos - 1 } else if pos >= list.scrollOffset+list.height { list.scrollOffset = pos - list.height + 1 } if list.scrollOffset < 0 { list.scrollOffset = 0 } debug.Print("Selecting", room.GetTitle(), "in", list.GetTagDisplayName(tag)) } func (list *RoomList) HasSelected() bool { return list.selected != nil } func (list *RoomList) Selected() (string, *rooms.Room) { return list.selectedTag, list.selected } func (list *RoomList) SelectedRoom() *rooms.Room { return list.selected } func (list *RoomList) AddScrollOffset(offset int) { list.scrollOffset += offset contentHeight := list.ContentHeight() if list.scrollOffset > contentHeight-list.height { list.scrollOffset = contentHeight - list.height } if list.scrollOffset < 0 { list.scrollOffset = 0 } } func (list *RoomList) First() (string, *rooms.Room) { list.RLock() defer list.RUnlock() return list.first() } func (list *RoomList) first() (string, *rooms.Room) { for _, tag := range list.tags { trl := list.items[tag] if trl.HasVisibleRooms() { return tag, trl.FirstVisible() } } return "", nil } func (list *RoomList) Last() (string, *rooms.Room) { list.RLock() defer list.RUnlock() return list.last() } func (list *RoomList) last() (string, *rooms.Room) { for tagIndex := len(list.tags) - 1; tagIndex >= 0; tagIndex-- { tag := list.tags[tagIndex] trl := list.items[tag] if trl.HasVisibleRooms() { return tag, trl.LastVisible() } } return "", nil } func (list *RoomList) indexTag(tag string) int { for index, entry := range list.tags { if tag == entry { return index } } return -1 } func (list *RoomList) Previous() (string, *rooms.Room) { list.RLock() defer list.RUnlock() if len(list.items) == 0 { return "", nil } else if list.selected == nil { return list.first() } trl := list.items[list.selectedTag] index := trl.IndexVisible(list.selected) indexInvisible := trl.Index(list.selected) if index == -1 && indexInvisible >= 0 { num := trl.TotalLength() - indexInvisible trl.maxShown = int(math.Ceil(float64(num)/10.0) * 10.0) index = trl.IndexVisible(list.selected) } if index == trl.Length()-1 { tagIndex := list.indexTag(list.selectedTag) tagIndex-- for ; tagIndex >= 0; tagIndex-- { prevTag := list.tags[tagIndex] prevTRL := list.items[prevTag] if prevTRL.HasVisibleRooms() { return prevTag, prevTRL.LastVisible() } } return list.last() } else if index >= 0 { return list.selectedTag, trl.Visible()[index+1].Room } return list.first() } func (list *RoomList) Next() (string, *rooms.Room) { list.RLock() defer list.RUnlock() if len(list.items) == 0 { return "", nil } else if list.selected == nil { return list.first() } trl := list.items[list.selectedTag] index := trl.IndexVisible(list.selected) indexInvisible := trl.Index(list.selected) if index == -1 && indexInvisible >= 0 { num := trl.TotalLength() - indexInvisible + 1 trl.maxShown = int(math.Ceil(float64(num)/10.0) * 10.0) index = trl.IndexVisible(list.selected) } if index == 0 { tagIndex := list.indexTag(list.selectedTag) tagIndex++ for ; tagIndex < len(list.tags); tagIndex++ { nextTag := list.tags[tagIndex] nextTRL := list.items[nextTag] if nextTRL.HasVisibleRooms() { return nextTag, nextTRL.FirstVisible() } } return list.first() } else if index > 0 { return list.selectedTag, trl.Visible()[index-1].Room } return list.last() } // NextWithActivity Returns next room with activity. // // Sorted by (in priority): // // - Highlights // - Messages // - Other traffic (joins, parts, etc) // // TODO: Sorting. Now just finds first room with new messages. func (list *RoomList) NextWithActivity() (string, *rooms.Room) { list.RLock() defer list.RUnlock() for tag, trl := range list.items { for _, room := range trl.All() { if room.HasNewMessages() { return tag, room.Room } } } // No room with activity found return "", nil } func (list *RoomList) index(tag string, room *rooms.Room) int { tagIndex := list.indexTag(tag) if tagIndex == -1 { return -1 } trl, ok := list.items[tag] localIndex := -1 if ok { localIndex = trl.IndexVisible(room) } if localIndex == -1 { return -1 } localIndex = trl.Length() - 1 - localIndex // Tag header localIndex++ if tagIndex > 0 { for i := 0; i < tagIndex; i++ { prevTag := list.tags[i] prevTRL := list.items[prevTag] localIndex += prevTRL.RenderHeight() } } return localIndex } func (list *RoomList) ContentHeight() (height int) { list.RLock() for _, tag := range list.tags { height += list.items[tag].RenderHeight() } list.RUnlock() return } func (list *RoomList) OnKeyEvent(_ mauview.KeyEvent) bool { return false } func (list *RoomList) OnPasteEvent(_ mauview.PasteEvent) bool { return false } func (list *RoomList) OnMouseEvent(event mauview.MouseEvent) bool { if event.HasMotion() { return false } switch event.Buttons() { case tcell.WheelUp: list.AddScrollOffset(-WheelScrollOffsetDiff) return true case tcell.WheelDown: list.AddScrollOffset(WheelScrollOffsetDiff) return true case tcell.Button1: x, y := event.Position() return list.clickRoom(y, x, event.Modifiers() == tcell.ModCtrl) } return false } func (list *RoomList) Focus() { } func (list *RoomList) Blur() { } func (list *RoomList) clickRoom(line, column int, mod bool) bool { line += list.scrollOffset if line < 0 { return false } list.RLock() for _, tag := range list.tags { trl := list.items[tag] if line--; line == -1 { trl.ToggleCollapse() list.RUnlock() return true } if trl.IsCollapsed() { continue } if line < 0 { break } else if line < trl.Length() { switchToRoom := trl.Visible()[trl.Length()-1-line].Room list.RUnlock() list.parent.SwitchRoom(tag, switchToRoom) return true } // Tag items line -= trl.Length() hasMore := trl.HasInvisibleRooms() hasLess := trl.maxShown > 10 if hasMore || hasLess { if line--; line == -1 { diff := 10 if mod { diff = 100 } if column <= 6 && hasLess { trl.maxShown -= diff } else if column >= list.width-6 && hasMore { trl.maxShown += diff } if trl.maxShown < 10 { trl.maxShown = 10 } list.RUnlock() return true } } // Tag footer line-- } list.RUnlock() return false } var nsRegex = regexp.MustCompile("^[a-z]+\\.[a-z]+(?:\\.[a-z]+)*$") func (list *RoomList) GetTagDisplayName(tag string) string { switch { case len(tag) == 0: return "Rooms" case tag == "m.favourite": return "Favorites" case tag == "m.lowpriority": return "Low Priority" case tag == "m.server_notice": return "System Alerts" case tag == "net.maunium.gomuks.fake.direct": return "People" case tag == "net.maunium.gomuks.fake.invite": return "Invites" case tag == "net.maunium.gomuks.fake.leave": return "Historical" case strings.HasPrefix(tag, "u."): return tag[len("u."):] case !nsRegex.MatchString(tag): return tag default: return "" } } // Draw draws this primitive onto the screen. func (list *RoomList) Draw(screen mauview.Screen) { list.width, list.height = screen.Size() y := 0 yLimit := y + list.height y -= list.scrollOffset // Draw the list items. list.RLock() for _, tag := range list.tags { trl := list.items[tag] tagDisplayName := list.GetTagDisplayName(tag) if trl == nil || len(tagDisplayName) == 0 { continue } renderHeight := trl.RenderHeight() if y+renderHeight >= yLimit { renderHeight = yLimit - y } trl.Draw(mauview.NewProxyScreen(screen, 0, y, list.width, renderHeight)) y += renderHeight if y >= yLimit { break } } list.RUnlock() } gomuks-0.3.0/ui/room-view.go000066400000000000000000000623741433617251100157260ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "fmt" "sort" "strings" "time" "unicode" "github.com/kyokomi/emoji/v2" "github.com/mattn/go-runewidth" "github.com/zyedidia/clipboard" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/mautrix" "maunium.net/go/mautrix/crypto/attachment" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/format" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/util/variationselector" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/lib/open" "maunium.net/go/gomuks/lib/util" "maunium.net/go/gomuks/matrix/muksevt" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/messages" "maunium.net/go/gomuks/ui/widget" ) type RoomView struct { topic *mauview.TextView content *MessageView status *mauview.TextField userList *MemberList ulBorder *widget.Border input *mauview.InputArea Room *rooms.Room topicScreen *mauview.ProxyScreen contentScreen *mauview.ProxyScreen statusScreen *mauview.ProxyScreen inputScreen *mauview.ProxyScreen ulBorderScreen *mauview.ProxyScreen ulScreen *mauview.ProxyScreen userListLoaded bool prevScreen mauview.Screen parent *MainView config *config.Config typing []string selecting bool selectReason SelectReason selectContent string replying *muksevt.Event editing *muksevt.Event editMoveText string completions struct { list []string textCache string time time.Time } } func NewRoomView(parent *MainView, room *rooms.Room) *RoomView { view := &RoomView{ topic: mauview.NewTextView(), status: mauview.NewTextField(), userList: NewMemberList(), ulBorder: widget.NewBorder(), input: mauview.NewInputArea(), Room: room, topicScreen: &mauview.ProxyScreen{OffsetX: 0, OffsetY: 0, Height: TopicBarHeight}, contentScreen: &mauview.ProxyScreen{OffsetX: 0, OffsetY: StatusBarHeight}, statusScreen: &mauview.ProxyScreen{OffsetX: 0, Height: StatusBarHeight}, inputScreen: &mauview.ProxyScreen{OffsetX: 0}, ulBorderScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListBorderWidth}, ulScreen: &mauview.ProxyScreen{OffsetY: StatusBarHeight, Width: UserListWidth}, parent: parent, config: parent.config, } view.content = NewMessageView(view) view.Room.SetPreUnload(func() bool { if view.parent.currentRoom == view { return false } view.content.Unload() return true }) view.Room.SetPostLoad(view.loadTyping) view.input. SetTextColor(tcell.ColorDefault). SetBackgroundColor(tcell.ColorDefault). SetPlaceholder("Send a message..."). SetPlaceholderTextColor(tcell.ColorGray). SetTabCompleteFunc(view.InputTabComplete). SetPressKeyUpAtStartFunc(view.EditPrevious). SetPressKeyDownAtEndFunc(view.EditNext) if room.Encrypted { view.input.SetPlaceholder("Send an encrypted message...") } view.topic. SetTextColor(tcell.ColorWhite). SetBackgroundColor(tcell.ColorDarkGreen) view.status.SetBackgroundColor(tcell.ColorDimGray) return view } func (view *RoomView) SetInputChangedFunc(fn func(room *RoomView, text string)) *RoomView { view.input.SetChangedFunc(func(text string) { fn(view, text) }) return view } func (view *RoomView) SetInputText(newText string) *RoomView { view.input.SetTextAndMoveCursor(newText) return view } func (view *RoomView) GetInputText() string { return view.input.GetText() } func (view *RoomView) Focus() { view.input.Focus() } func (view *RoomView) Blur() { view.StopSelecting() view.input.Blur() } func (view *RoomView) StartSelecting(reason SelectReason, content string) { view.selecting = true view.selectReason = reason view.selectContent = content msgView := view.MessageView() if msgView.selected != nil { view.OnSelect(msgView.selected) } else { view.input.Blur() view.SelectPrevious() } } func (view *RoomView) StopSelecting() { view.selecting = false view.selectContent = "" view.MessageView().SetSelected(nil) } func (view *RoomView) OnSelect(message *messages.UIMessage) { if !view.selecting || message == nil { return } switch view.selectReason { case SelectReply: view.replying = message.Event if len(view.selectContent) > 0 { go view.SendMessage(event.MsgText, view.selectContent) } case SelectEdit: view.SetEditing(message.Event) case SelectReact: go view.SendReaction(message.EventID, view.selectContent) case SelectRedact: go view.Redact(message.EventID, view.selectContent) case SelectDownload, SelectOpen: msg, ok := message.Renderer.(*messages.FileMessage) if ok { path := "" if len(view.selectContent) > 0 { path = view.selectContent } else if view.selectReason == SelectDownload { path = msg.Body } go view.Download(msg.URL, msg.File, path, view.selectReason == SelectOpen) } case SelectCopy: go view.CopyToClipboard(message.Renderer.PlainText(), view.selectContent) } view.selecting = false view.selectContent = "" view.MessageView().SetSelected(nil) view.input.Focus() } func (view *RoomView) GetStatus() string { var buf strings.Builder if view.editing != nil { buf.WriteString("Editing message - ") } else if view.replying != nil { buf.WriteString("Replying to ") buf.WriteString(string(view.replying.Sender)) buf.WriteString(" - ") } else if view.selecting { buf.WriteString("Selecting message to ") buf.WriteString(string(view.selectReason)) buf.WriteString(" - ") } if len(view.completions.list) > 0 { if view.completions.textCache != view.input.GetText() || view.completions.time.Add(10*time.Second).Before(time.Now()) { view.completions.list = []string{} } else { buf.WriteString(strings.Join(view.completions.list, ", ")) buf.WriteString(" - ") } } if len(view.typing) == 1 { buf.WriteString("Typing: " + string(view.typing[0])) buf.WriteString(" - ") } else if len(view.typing) > 1 { buf.WriteString("Typing: ") for i, userID := range view.typing { if i == len(view.typing)-1 { buf.WriteString(" and ") } else if i > 0 { buf.WriteString(", ") } buf.WriteString(string(userID)) } buf.WriteString(" - ") } return strings.TrimSuffix(buf.String(), " - ") } // Constants defining the size of the room view grid. const ( UserListBorderWidth = 1 UserListWidth = 20 StaticHorizontalSpace = UserListBorderWidth + UserListWidth TopicBarHeight = 1 StatusBarHeight = 1 MaxInputHeight = 5 ) func (view *RoomView) Draw(screen mauview.Screen) { width, height := screen.Size() if width <= 0 || height <= 0 { return } if view.prevScreen != screen { view.topicScreen.Parent = screen view.contentScreen.Parent = screen view.statusScreen.Parent = screen view.inputScreen.Parent = screen view.ulBorderScreen.Parent = screen view.ulScreen.Parent = screen view.prevScreen = screen } view.input.PrepareDraw(width) inputHeight := view.input.GetTextHeight() if inputHeight > MaxInputHeight { inputHeight = MaxInputHeight } else if inputHeight < 1 { inputHeight = 1 } contentHeight := height - inputHeight - TopicBarHeight - StatusBarHeight contentWidth := width - StaticHorizontalSpace if view.config.Preferences.HideUserList { contentWidth = width } view.topicScreen.Width = width view.contentScreen.Width = contentWidth view.contentScreen.Height = contentHeight view.statusScreen.OffsetY = view.contentScreen.YEnd() view.statusScreen.Width = width view.inputScreen.Width = width view.inputScreen.OffsetY = view.statusScreen.YEnd() view.inputScreen.Height = inputHeight view.ulBorderScreen.OffsetX = view.contentScreen.XEnd() view.ulBorderScreen.Height = contentHeight view.ulScreen.OffsetX = view.ulBorderScreen.XEnd() view.ulScreen.Height = contentHeight // Draw everything view.topic.Draw(view.topicScreen) view.content.Draw(view.contentScreen) view.status.SetText(view.GetStatus()) view.status.Draw(view.statusScreen) view.input.Draw(view.inputScreen) if !view.config.Preferences.HideUserList { view.ulBorder.Draw(view.ulBorderScreen) view.userList.Draw(view.ulScreen) } } func (view *RoomView) ClearAllContext() { view.SetEditing(nil) view.StopSelecting() view.replying = nil view.input.Focus() } func (view *RoomView) OnKeyEvent(event mauview.KeyEvent) bool { msgView := view.MessageView() kb := config.Keybind{ Key: event.Key(), Ch: event.Rune(), Mod: event.Modifiers(), } if view.selecting { switch view.config.Keybindings.Visual[kb] { case "clear": view.ClearAllContext() case "select_prev": view.SelectPrevious() case "select_next": view.SelectNext() case "confirm": view.OnSelect(msgView.selected) default: return false } return true } switch view.config.Keybindings.Room[kb] { case "clear": view.ClearAllContext() return true case "scroll_up": if msgView.IsAtTop() { go view.parent.LoadHistory(view.Room.ID) } msgView.AddScrollOffset(+msgView.Height() / 2) return true case "scroll_down": msgView.AddScrollOffset(-msgView.Height() / 2) return true case "send": view.InputSubmit(view.input.GetText()) return true } return view.input.OnKeyEvent(event) } func (view *RoomView) OnPasteEvent(event mauview.PasteEvent) bool { return view.input.OnPasteEvent(event) } func (view *RoomView) OnMouseEvent(event mauview.MouseEvent) bool { switch { case view.contentScreen.IsInArea(event.Position()): return view.content.OnMouseEvent(view.contentScreen.OffsetMouseEvent(event)) case view.topicScreen.IsInArea(event.Position()): return view.topic.OnMouseEvent(view.topicScreen.OffsetMouseEvent(event)) case view.inputScreen.IsInArea(event.Position()): return view.input.OnMouseEvent(view.inputScreen.OffsetMouseEvent(event)) } return false } func (view *RoomView) SetCompletions(completions []string) { view.completions.list = completions view.completions.textCache = view.input.GetText() view.completions.time = time.Now() } func (view *RoomView) loadTyping() { for index, user := range view.typing { member := view.Room.GetMember(id.UserID(user)) if member != nil { view.typing[index] = member.Displayname } } } func (view *RoomView) SetTyping(users []id.UserID) { view.typing = make([]string, len(users)) for i, user := range users { view.typing[i] = string(user) } if view.Room.Loaded() { view.loadTyping() } } var editHTMLParser = &format.HTMLParser{ PillConverter: func(displayname, mxid, eventID string, ctx format.Context) string { if len(eventID) > 0 { return fmt.Sprintf(`[%s](https://matrix.to/#/%s/%s)`, displayname, mxid, eventID) } else { return fmt.Sprintf(`[%s](https://matrix.to/#/%s)`, displayname, mxid) } }, Newline: "\n", HorizontalLine: "\n---\n", } func (view *RoomView) SetEditing(evt *muksevt.Event) { if evt == nil { view.editing = nil view.SetInputText(view.editMoveText) view.editMoveText = "" } else { if view.editing == nil { view.editMoveText = view.GetInputText() } view.editing = evt // replying should never be non-nil when SetEditing, but do this just to be safe view.replying = nil msgContent := view.editing.Content.AsMessage() if len(view.editing.Gomuks.Edits) > 0 { // This feels kind of dangerous, but I think it works msgContent = view.editing.Gomuks.Edits[len(view.editing.Gomuks.Edits)-1].Content.AsMessage().NewContent } text := msgContent.Body if len(msgContent.FormattedBody) > 0 && (!view.config.Preferences.DisableMarkdown || !view.config.Preferences.DisableHTML) { if view.config.Preferences.DisableMarkdown { text = msgContent.FormattedBody } else { text = editHTMLParser.Parse(msgContent.FormattedBody, make(format.Context)) } } if msgContent.MsgType == event.MsgEmote { text = "/me " + text } view.input.SetText(text) } view.status.SetText(view.GetStatus()) view.input.SetCursorOffset(-1) } type findFilter func(evt *muksevt.Event) bool func (view *RoomView) filterOwnOnly(evt *muksevt.Event) bool { return evt.Sender == view.parent.matrix.Client().UserID && evt.Type == event.EventMessage } func (view *RoomView) filterMediaOnly(evt *muksevt.Event) bool { content, ok := evt.Content.Parsed.(*event.MessageEventContent) return ok && (content.MsgType == event.MsgFile || content.MsgType == event.MsgImage || content.MsgType == event.MsgAudio || content.MsgType == event.MsgVideo) } func (view *RoomView) findMessage(current *muksevt.Event, forward bool, allow findFilter) *messages.UIMessage { currentFound := current == nil msgs := view.MessageView().messages for i := 0; i < len(msgs); i++ { index := i if !forward { index = len(msgs) - i - 1 } evt := msgs[index] if evt.EventID == "" || string(evt.EventID) == evt.TxnID || evt.IsService { continue } else if currentFound { if allow == nil || allow(evt.Event) { return evt } } else if evt.EventID == current.ID { currentFound = true } } return nil } func (view *RoomView) EditNext() { if view.editing == nil { return } foundMsg := view.findMessage(view.editing, true, view.filterOwnOnly) view.SetEditing(foundMsg.GetEvent()) } func (view *RoomView) EditPrevious() { if view.replying != nil { return } foundMsg := view.findMessage(view.editing, false, view.filterOwnOnly) if foundMsg != nil { view.SetEditing(foundMsg.GetEvent()) } } func (view *RoomView) SelectNext() { msgView := view.MessageView() if msgView.selected == nil { return } var filter findFilter if view.selectReason == SelectDownload || view.selectReason == SelectOpen { filter = view.filterMediaOnly } foundMsg := view.findMessage(msgView.selected.GetEvent(), true, filter) if foundMsg != nil { msgView.SetSelected(foundMsg) // TODO scroll selected message into view } } func (view *RoomView) SelectPrevious() { msgView := view.MessageView() var filter findFilter if view.selectReason == SelectDownload || view.selectReason == SelectOpen { filter = view.filterMediaOnly } foundMsg := view.findMessage(msgView.selected.GetEvent(), false, filter) if foundMsg != nil { msgView.SetSelected(foundMsg) // TODO scroll selected message into view } } type completion struct { displayName string id string } func (view *RoomView) AutocompleteUser(existingText string) (completions []completion) { textWithoutPrefix := strings.TrimPrefix(existingText, "@") for userID, user := range view.Room.GetMembers() { if user.Displayname == textWithoutPrefix || string(userID) == existingText { // Exact match, return that. return []completion{{user.Displayname, string(userID)}} } if strings.HasPrefix(user.Displayname, textWithoutPrefix) || strings.HasPrefix(string(userID), existingText) { completions = append(completions, completion{user.Displayname, string(userID)}) } } return } func (view *RoomView) AutocompleteRoom(existingText string) (completions []completion) { for _, room := range view.parent.rooms { alias := string(room.Room.GetCanonicalAlias()) if alias == existingText { // Exact match, return that. return []completion{{alias, string(room.Room.ID)}} } if strings.HasPrefix(alias, existingText) { completions = append(completions, completion{alias, string(room.Room.ID)}) continue } } return } func (view *RoomView) AutocompleteEmoji(word string) (completions []string) { if word[0] != ':' { return } var valueCompletion1 string var manyValues bool for name, value := range emoji.CodeMap() { if name == word { return []string{value} } else if strings.HasPrefix(name, word) { completions = append(completions, name) if valueCompletion1 == "" { valueCompletion1 = value } else if valueCompletion1 != value { manyValues = true } } } if !manyValues && len(completions) > 0 { return []string{emoji.CodeMap()[completions[0]]} } return } func findWordToTabComplete(text string) string { output := "" runes := []rune(text) for i := len(runes) - 1; i >= 0; i-- { if unicode.IsSpace(runes[i]) { break } output = string(runes[i]) + output } return output } var ( mentionMarkdown = "[%[1]s](https://matrix.to/#/%[2]s)" mentionHTML = `%[1]s` mentionPlaintext = "%[1]s" ) func (view *RoomView) defaultAutocomplete(word string, startIndex int) (strCompletions []string, strCompletion string) { if len(word) == 0 { return []string{}, "" } completions := view.AutocompleteUser(word) completions = append(completions, view.AutocompleteRoom(word)...) if len(completions) == 1 { completion := completions[0] template := mentionMarkdown if view.config.Preferences.DisableMarkdown { if view.config.Preferences.DisableHTML { template = mentionPlaintext } else { template = mentionHTML } } strCompletion = fmt.Sprintf(template, completion.displayName, completion.id) if startIndex == 0 && completion.id[0] == '@' { strCompletion = strCompletion + ":" } } else if len(completions) > 1 { for _, completion := range completions { strCompletions = append(strCompletions, completion.displayName) } } strCompletions = append(strCompletions, view.parent.cmdProcessor.AutocompleteCommand(word)...) strCompletions = append(strCompletions, view.AutocompleteEmoji(word)...) return } func (view *RoomView) InputTabComplete(text string, cursorOffset int) { if len(text) == 0 { return } str := runewidth.Truncate(text, cursorOffset, "") word := findWordToTabComplete(str) startIndex := len(str) - len(word) var strCompletion string strCompletions, newText, ok := view.parent.cmdProcessor.Autocomplete(view, text, cursorOffset) if !ok { strCompletions, strCompletion = view.defaultAutocomplete(word, startIndex) } if len(strCompletions) > 0 { strCompletion = util.LongestCommonPrefix(strCompletions) sort.Sort(sort.StringSlice(strCompletions)) } if len(strCompletion) > 0 && len(strCompletions) < 2 { strCompletion += " " strCompletions = []string{} } if len(strCompletion) > 0 && newText == text { newText = str[0:startIndex] + strCompletion + text[len(str):] } view.input.SetTextAndMoveCursor(newText) view.SetCompletions(strCompletions) } func (view *RoomView) InputSubmit(text string) { if len(text) == 0 { return } else if cmd := view.parent.cmdProcessor.ParseCommand(view, text); cmd != nil { go view.parent.cmdProcessor.HandleCommand(cmd) } else { go view.SendMessage(event.MsgText, text) } view.editMoveText = "" view.SetInputText("") } func (view *RoomView) CopyToClipboard(text string, register string) { if register == "clipboard" || register == "primary" { err := clipboard.WriteAll(text, register) if err != nil { view.AddServiceMessage(fmt.Sprintf("Clipboard unsupported: %v", err)) view.parent.parent.Render() } } else { view.AddServiceMessage(fmt.Sprintf("Clipboard register %v unsupported", register)) view.parent.parent.Render() } } func (view *RoomView) Download(url id.ContentURI, file *attachment.EncryptedFile, filename string, openFile bool) { path, err := view.parent.matrix.DownloadToDisk(url, file, filename) if err != nil { view.AddServiceMessage(fmt.Sprintf("Failed to download media: %v", err)) view.parent.parent.Render() return } view.AddServiceMessage(fmt.Sprintf("File downloaded to %s", path)) view.parent.parent.Render() if openFile { debug.Print("Opening file", path) open.Open(path) } } func (view *RoomView) Redact(eventID id.EventID, reason string) { defer debug.Recover() err := view.parent.matrix.Redact(view.Room.ID, eventID, reason) if err != nil { if httpErr, ok := err.(mautrix.HTTPError); ok { err = httpErr if respErr := httpErr.RespError; respErr != nil { err = respErr } } view.AddServiceMessage(fmt.Sprintf("Failed to redact message: %v", err)) view.parent.parent.Render() } } func (view *RoomView) SendReaction(eventID id.EventID, reaction string) { defer debug.Recover() if !view.config.Preferences.DisableEmojis { reaction = emoji.Sprint(reaction) } reaction = variationselector.Add(strings.TrimSpace(reaction)) debug.Print("Reacting to", eventID, "in", view.Room.ID, "with", reaction) eventID, err := view.parent.matrix.SendEvent(&muksevt.Event{ Event: &event.Event{ Type: event.EventReaction, RoomID: view.Room.ID, Content: event.Content{Parsed: &event.ReactionEventContent{RelatesTo: event.RelatesTo{ Type: event.RelAnnotation, EventID: eventID, Key: reaction, }}}, }, }) if err != nil { if httpErr, ok := err.(mautrix.HTTPError); ok { err = httpErr if respErr := httpErr.RespError; respErr != nil { err = respErr } } view.AddServiceMessage(fmt.Sprintf("Failed to send reaction: %v", err)) view.parent.parent.Render() } } func (view *RoomView) SendMessage(msgtype event.MessageType, text string) { view.SendMessageHTML(msgtype, text, "") } func (view *RoomView) getRelationForNewEvent() *ifc.Relation { if view.editing != nil { return &ifc.Relation{ Type: event.RelReplace, Event: view.editing, } } else if view.replying != nil { return &ifc.Relation{ Type: event.RelReply, Event: view.replying, } } return nil } func (view *RoomView) SendMessageHTML(msgtype event.MessageType, text, html string) { defer debug.Recover() debug.Print("Sending message", msgtype, text, "to", view.Room.ID) if !view.config.Preferences.DisableEmojis { text = emoji.Sprint(text) } rel := view.getRelationForNewEvent() evt := view.parent.matrix.PrepareMarkdownMessage(view.Room.ID, msgtype, text, html, rel) view.addLocalEcho(evt) } func (view *RoomView) SendMessageMedia(path string) { defer debug.Recover() debug.Print("Sending media at", path, "to", view.Room.ID) rel := view.getRelationForNewEvent() evt, err := view.parent.matrix.PrepareMediaMessage(view.Room, path, rel) if err != nil { view.AddServiceMessage(fmt.Sprintf("Failed to upload media: %v", err)) view.parent.parent.Render() return } view.addLocalEcho(evt) } func (view *RoomView) addLocalEcho(evt *muksevt.Event) { msg := view.parseEvent(evt.SomewhatDangerousCopy()) view.content.AddMessage(msg, AppendMessage) view.ClearAllContext() view.status.SetText(view.GetStatus()) eventID, err := view.parent.matrix.SendEvent(evt) if err != nil { msg.State = muksevt.StateSendFail // Show shorter version if available if httpErr, ok := err.(mautrix.HTTPError); ok { err = httpErr if respErr := httpErr.RespError; respErr != nil { err = respErr } } view.AddServiceMessage(fmt.Sprintf("Failed to send message: %v", err)) view.parent.parent.Render() } else { debug.Print("Event ID received:", eventID) msg.EventID = eventID msg.State = muksevt.StateDefault view.MessageView().setMessageID(msg) view.parent.parent.Render() } } func (view *RoomView) MessageView() *MessageView { return view.content } func (view *RoomView) MxRoom() *rooms.Room { return view.Room } func (view *RoomView) Update() { topicStr := strings.TrimSpace(strings.ReplaceAll(view.Room.GetTopic(), "\n", " ")) if view.config.Preferences.HideRoomList { if len(topicStr) > 0 { topicStr = fmt.Sprintf("%s - %s", view.Room.GetTitle(), topicStr) } else { topicStr = view.Room.GetTitle() } topicStr = strings.TrimSpace(topicStr) } view.topic.SetText(topicStr) if !view.userListLoaded { view.UpdateUserList() } } func (view *RoomView) UpdateUserList() { pls := &event.PowerLevelsEventContent{} if plEvent := view.Room.GetStateEvent(event.StatePowerLevels, ""); plEvent != nil { pls = plEvent.Content.AsPowerLevels() } view.userList.Update(view.Room.GetMembers(), pls) view.userListLoaded = true } func (view *RoomView) AddServiceMessage(text string) { view.content.AddMessage(messages.NewServiceMessage(text), AppendMessage) } func (view *RoomView) parseEvent(evt *muksevt.Event) *messages.UIMessage { return messages.ParseEvent(view.parent.matrix, view.parent, view.Room, evt) } func (view *RoomView) AddHistoryEvent(evt *muksevt.Event) { if msg := view.parseEvent(evt); msg != nil { view.content.AddMessage(msg, PrependMessage) } } func (view *RoomView) AddEvent(evt *muksevt.Event) ifc.Message { if msg := view.parseEvent(evt); msg != nil { view.content.AddMessage(msg, AppendMessage) return msg } return nil } func (view *RoomView) AddRedaction(redactedEvt *muksevt.Event) { view.AddEvent(redactedEvt) } func (view *RoomView) AddEdit(evt *muksevt.Event) { if msg := view.parseEvent(evt); msg != nil { view.content.AddMessage(msg, IgnoreMessage) } } func (view *RoomView) AddReaction(evt *muksevt.Event, key string) { msgView := view.MessageView() msg := msgView.getMessageByID(evt.ID) if msg == nil { // Message not in view, nothing to do return } heightChanged := len(msg.Reactions) == 0 msg.AddReaction(key) if heightChanged { // Replace buffer to update height of message msgView.replaceBuffer(msg, msg) } } func (view *RoomView) GetEvent(eventID id.EventID) ifc.Message { message, ok := view.content.messageIDs[eventID] if !ok { return nil } return message } gomuks-0.3.0/ui/syncing-modal.go000066400000000000000000000036411433617251100165360ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "time" "go.mau.fi/mauview" ) type SyncingModal struct { parent *MainView text *mauview.TextView progress *mauview.ProgressBar } func NewSyncingModal(parent *MainView) (mauview.Component, *SyncingModal) { sm := &SyncingModal{ parent: parent, progress: mauview.NewProgressBar(), text: mauview.NewTextView(), } return mauview.Center( mauview.NewBox( mauview.NewFlex(). SetDirection(mauview.FlexRow). AddFixedComponent(sm.progress, 1). AddFixedComponent(mauview.Center(sm.text, 40, 1), 1)). SetTitle("Synchronizing"), 42, 4). SetAlwaysFocusChild(true), sm } func (sm *SyncingModal) SetMessage(text string) { sm.text.SetText(text) } func (sm *SyncingModal) SetIndeterminate() { sm.progress.SetIndeterminate(true) sm.parent.parent.app.SetRedrawTicker(100 * time.Millisecond) sm.parent.parent.app.Redraw() } func (sm *SyncingModal) SetSteps(max int) { sm.progress.SetMax(max) sm.progress.SetIndeterminate(false) sm.parent.parent.app.SetRedrawTicker(1 * time.Minute) sm.parent.parent.Render() } func (sm *SyncingModal) Step() { sm.progress.Increment(1) } func (sm *SyncingModal) Close() { sm.parent.HideModal() } gomuks-0.3.0/ui/tag-room-list.go000066400000000000000000000210601433617251100164630ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "encoding/json" "fmt" "math" "strconv" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/gomuks/debug" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/widget" ) type OrderedRoom struct { *rooms.Room order float64 } func NewOrderedRoom(order json.Number, room *rooms.Room) *OrderedRoom { numOrder, err := order.Float64() if err != nil { numOrder = 0.5 } return &OrderedRoom{ Room: room, order: numOrder, } } func NewDefaultOrderedRoom(room *rooms.Room) *OrderedRoom { return NewOrderedRoom("0.5", room) } func (or *OrderedRoom) Draw(roomList *RoomList, screen mauview.Screen, x, y, lineWidth int, isSelected bool) { style := tcell.StyleDefault. Foreground(roomList.mainTextColor). Bold(or.HasNewMessages()) if isSelected { style = style. Foreground(roomList.selectedTextColor). Background(roomList.selectedBackgroundColor) } unreadCount := or.UnreadCount() widget.WriteLinePadded(screen, mauview.AlignLeft, or.GetTitle(), x, y, lineWidth, style) if unreadCount > 0 { unreadMessageCount := "99+" if unreadCount < 100 { unreadMessageCount = strconv.Itoa(unreadCount) } if or.Highlighted() { unreadMessageCount += "!" } unreadMessageCount = fmt.Sprintf("(%s)", unreadMessageCount) widget.WriteLine(screen, mauview.AlignRight, unreadMessageCount, x+lineWidth-7, y, 7, style) lineWidth -= len(unreadMessageCount) } } type TagRoomList struct { mauview.NoopEventHandler // The list of rooms in the list, in reverse order rooms []*OrderedRoom // Maximum number of rooms to show maxShown int // The internal name of this tag name string // The displayname of this tag displayname string // The parent RoomList instance parent *RoomList } func NewTagRoomList(parent *RoomList, name string, rooms ...*OrderedRoom) *TagRoomList { return &TagRoomList{ maxShown: 10, rooms: rooms, name: name, displayname: parent.GetTagDisplayName(name), parent: parent, } } func (trl *TagRoomList) Visible() []*OrderedRoom { return trl.rooms[len(trl.rooms)-trl.Length():] } func (trl *TagRoomList) FirstVisible() *rooms.Room { visible := trl.Visible() if len(visible) > 0 { return visible[len(visible)-1].Room } return nil } func (trl *TagRoomList) LastVisible() *rooms.Room { visible := trl.Visible() if len(visible) > 0 { return visible[0].Room } return nil } func (trl *TagRoomList) All() []*OrderedRoom { return trl.rooms } func (trl *TagRoomList) Length() int { if len(trl.rooms) < trl.maxShown { return len(trl.rooms) } return trl.maxShown } func (trl *TagRoomList) TotalLength() int { return len(trl.rooms) } func (trl *TagRoomList) IsEmpty() bool { return len(trl.rooms) == 0 } func (trl *TagRoomList) IsCollapsed() bool { return trl.maxShown == 0 } func (trl *TagRoomList) ToggleCollapse() { if trl.IsCollapsed() { trl.maxShown = 10 } else { trl.maxShown = 0 } } func (trl *TagRoomList) HasInvisibleRooms() bool { return trl.maxShown < trl.TotalLength() } func (trl *TagRoomList) HasVisibleRooms() bool { return !trl.IsEmpty() && trl.maxShown > 0 } const equalityThreshold = 1e-6 func almostEqual(a, b float64) bool { return math.Abs(a-b) <= equalityThreshold } // ShouldBeAfter returns if the first room should be after the second room in the room list. // The manual order and last received message timestamp are considered. func (trl *TagRoomList) ShouldBeAfter(room1 *OrderedRoom, room2 *OrderedRoom) bool { // Lower order value = higher in list return room1.order > room2.order || // Equal order value and more recent message = higher in the list (almostEqual(room1.order, room2.order) && room2.LastReceivedMessage.After(room1.LastReceivedMessage)) } func (trl *TagRoomList) Insert(order json.Number, mxRoom *rooms.Room) { room := NewOrderedRoom(order, mxRoom) // The default insert index is the newly added slot. // That index will be used if all other rooms in the list have the same LastReceivedMessage timestamp. insertAt := len(trl.rooms) // Find the spot where the new room should be put according to the last received message timestamps. for i := 0; i < len(trl.rooms); i++ { if trl.rooms[i].Room == mxRoom { debug.Printf("Warning: tried to re-insert room %s into tag %s", mxRoom.ID, trl.name) return } else if trl.ShouldBeAfter(room, trl.rooms[i]) { insertAt = i break } } trl.rooms = append(trl.rooms, nil) copy(trl.rooms[insertAt+1:], trl.rooms[insertAt:len(trl.rooms)-1]) trl.rooms[insertAt] = room } func (trl *TagRoomList) Bump(mxRoom *rooms.Room) { var roomBeingBumped *OrderedRoom for i := 0; i < len(trl.rooms); i++ { currentIndexRoom := trl.rooms[i] if roomBeingBumped != nil { if trl.ShouldBeAfter(roomBeingBumped, currentIndexRoom) { // This room should be after the room being bumped, so insert the // room being bumped here and return trl.rooms[i-1] = roomBeingBumped return } // Move older rooms back in the array trl.rooms[i-1] = currentIndexRoom } else if currentIndexRoom.Room == mxRoom { roomBeingBumped = currentIndexRoom } } if roomBeingBumped == nil { debug.Print("Warning: couldn't find room", mxRoom.ID, mxRoom.NameCache, "to bump in tag", trl.name) return } // If the room being bumped should be first in the list, it won't be inserted during the loop. trl.rooms[len(trl.rooms)-1] = roomBeingBumped } func (trl *TagRoomList) Remove(room *rooms.Room) { trl.RemoveIndex(trl.Index(room)) } func (trl *TagRoomList) RemoveIndex(index int) { if index < 0 || index > len(trl.rooms) { return } last := len(trl.rooms) - 1 if index < last { copy(trl.rooms[index:], trl.rooms[index+1:]) } trl.rooms[last] = nil trl.rooms = trl.rooms[:last] } func (trl *TagRoomList) Index(room *rooms.Room) int { return trl.indexInList(trl.All(), room) } func (trl *TagRoomList) IndexVisible(room *rooms.Room) int { return trl.indexInList(trl.Visible(), room) } func (trl *TagRoomList) indexInList(list []*OrderedRoom, room *rooms.Room) int { for index, entry := range list { if entry.Room == room { return index } } return -1 } var TagDisplayNameStyle = tcell.StyleDefault.Underline(true).Bold(true) var TagRoomCountStyle = tcell.StyleDefault.Italic(true) func (trl *TagRoomList) RenderHeight() int { if len(trl.displayname) == 0 { return 0 } if trl.IsCollapsed() { return 1 } height := 2 + trl.Length() if trl.HasInvisibleRooms() || trl.maxShown > 10 { height++ } return height } func (trl *TagRoomList) DrawHeader(screen mauview.Screen) { width, _ := screen.Size() roomCount := strconv.Itoa(trl.TotalLength()) // Draw tag name displayNameWidth := width - 1 - len(roomCount) widget.WriteLine(screen, mauview.AlignLeft, trl.displayname, 0, 0, displayNameWidth, TagDisplayNameStyle) // Draw tag room count roomCountX := len(trl.displayname) + 1 roomCountWidth := width - 2 - len(trl.displayname) widget.WriteLine(screen, mauview.AlignLeft, roomCount, roomCountX, 0, roomCountWidth, TagRoomCountStyle) } func (trl *TagRoomList) Draw(screen mauview.Screen) { if len(trl.displayname) == 0 { return } trl.DrawHeader(screen) width, height := screen.Size() items := trl.Visible() if trl.IsCollapsed() { screen.SetCell(width-1, 0, tcell.StyleDefault, '▶') return } screen.SetCell(width-1, 0, tcell.StyleDefault, '▼') y := 1 for i := len(items) - 1; i >= 0; i-- { if y >= height { return } item := items[i] lineWidth := width isSelected := trl.name == trl.parent.selectedTag && item.Room == trl.parent.selected item.Draw(trl.parent, screen, 0, y, lineWidth, isSelected) y++ } hasLess := trl.maxShown > 10 hasMore := trl.HasInvisibleRooms() if (hasLess || hasMore) && y < height { if hasMore { widget.WriteLine(screen, mauview.AlignRight, "More ↓", 0, y, width, tcell.StyleDefault) } if hasLess { widget.WriteLine(screen, mauview.AlignLeft, "↑ Less", 0, y, width, tcell.StyleDefault) } y++ } } gomuks-0.3.0/ui/ui.go000066400000000000000000000056631433617251100144150ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "os" "os/exec" "github.com/zyedidia/clipboard" "go.mau.fi/mauview" "go.mau.fi/tcell" ifc "maunium.net/go/gomuks/interface" ) type View string // Allowed views in GomuksUI const ( ViewLogin View = "login" ViewMain View = "main" ) type GomuksUI struct { gmx ifc.Gomuks app *mauview.Application mainView *MainView loginView *LoginView views map[View]mauview.Component } func init() { mauview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault mauview.Styles.PrimaryTextColor = tcell.ColorDefault mauview.Styles.BorderColor = tcell.ColorDefault mauview.Styles.ContrastBackgroundColor = tcell.ColorDarkGreen if tcellDB := os.Getenv("TCELLDB"); len(tcellDB) == 0 { if info, err := os.Stat("/usr/share/tcell/database"); err == nil && info.IsDir() { os.Setenv("TCELLDB", "/usr/share/tcell/database") } } } func NewGomuksUI(gmx ifc.Gomuks) ifc.GomuksUI { ui := &GomuksUI{ gmx: gmx, app: mauview.NewApplication(), } return ui } func (ui *GomuksUI) Init() { mauview.Backspace2RemovesWord = ui.gmx.Config().Backspace2RemovesWord mauview.Backspace1RemovesWord = ui.gmx.Config().Backspace1RemovesWord ui.app.SetAlwaysClear(ui.gmx.Config().AlwaysClearScreen) clipboard.Initialize() ui.views = map[View]mauview.Component{ ViewLogin: ui.NewLoginView(), ViewMain: ui.NewMainView(), } ui.SetView(ViewLogin) } func (ui *GomuksUI) Start() error { return ui.app.Start() } func (ui *GomuksUI) Stop() { ui.app.Stop() } func (ui *GomuksUI) Finish() { ui.app.ForceStop() } func (ui *GomuksUI) Render() { ui.app.Redraw() } func (ui *GomuksUI) OnLogin() { ui.SetView(ViewMain) } func (ui *GomuksUI) OnLogout() { ui.SetView(ViewLogin) } func (ui *GomuksUI) HandleNewPreferences() { ui.Render() } func (ui *GomuksUI) SetView(name View) { ui.app.SetRoot(ui.views[name]) } func (ui *GomuksUI) MainView() ifc.MainView { return ui.mainView } func (ui *GomuksUI) RunExternal(executablePath string, args ...string) error { callback := make(chan error) ui.app.Suspend(func() { cmd := exec.Command(executablePath, args...) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr cmd.Stdin = os.Stdin cmd.Env = os.Environ() callback <- cmd.Run() }) return <-callback } gomuks-0.3.0/ui/verification-modal.go000066400000000000000000000157251433617251100175540ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . //go:build cgo package ui import ( "fmt" "strconv" "strings" "time" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/mautrix/crypto" "maunium.net/go/mautrix/event" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ) type EmojiView struct { mauview.SimpleEventHandler Data crypto.SASData } func (e *EmojiView) Draw(screen mauview.Screen) { if e.Data == nil { return } switch e.Data.Type() { case event.SASEmoji: width := 10 for i, emoji := range e.Data.(crypto.EmojiSASData) { x := i*width + i y := 0 if i >= 4 { x = (i-4)*width + i y = 2 } mauview.Print(screen, string(emoji.Emoji), x, y, width, mauview.AlignCenter, tcell.ColorDefault) mauview.Print(screen, emoji.Description, x, y+1, width, mauview.AlignCenter, tcell.ColorDefault) } case event.SASDecimal: maxWidth := 43 for i, number := range e.Data.(crypto.DecimalSASData) { mauview.Print(screen, strconv.FormatUint(uint64(number), 10), 0, i, maxWidth, mauview.AlignCenter, tcell.ColorDefault) } } } type VerificationModal struct { mauview.Component device *crypto.DeviceIdentity container *mauview.Box waitingBar *mauview.ProgressBar infoText *mauview.TextView emojiText *EmojiView inputBar *mauview.InputField progress int progressMax int stopWaiting chan struct{} confirmChan chan bool done bool parent *MainView } func NewVerificationModal(mainView *MainView, device *crypto.DeviceIdentity, timeout time.Duration) *VerificationModal { vm := &VerificationModal{ parent: mainView, device: device, stopWaiting: make(chan struct{}), confirmChan: make(chan bool), done: false, } vm.progressMax = int(timeout.Seconds()) vm.progress = vm.progressMax vm.waitingBar = mauview.NewProgressBar(). SetMax(vm.progressMax). SetProgress(vm.progress). SetIndeterminate(false) vm.infoText = mauview.NewTextView() vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto accept", device.UserID)) vm.emojiText = &EmojiView{} vm.inputBar = mauview.NewInputField(). SetBackgroundColor(tcell.ColorDefault). SetPlaceholderTextColor(tcell.ColorDefault) flex := mauview.NewFlex(). SetDirection(mauview.FlexRow). AddFixedComponent(vm.waitingBar, 1). AddFixedComponent(vm.infoText, 4). AddFixedComponent(vm.emojiText, 4). AddFixedComponent(vm.inputBar, 1) vm.container = mauview.NewBox(flex). SetBorder(true). SetTitle("Interactive verification") vm.Component = mauview.Center(vm.container, 45, 12).SetAlwaysFocusChild(true) go vm.decrementWaitingBar() return vm } func (vm *VerificationModal) decrementWaitingBar() { for { select { case <-time.Tick(time.Second): if vm.progress <= 0 { vm.waitingBar.SetIndeterminate(true) vm.parent.parent.app.SetRedrawTicker(100 * time.Millisecond) return } vm.progress-- vm.waitingBar.SetProgress(vm.progress) vm.parent.parent.Render() case <-vm.stopWaiting: return } } } func (vm *VerificationModal) VerificationMethods() []crypto.VerificationMethod { return []crypto.VerificationMethod{crypto.VerificationMethodEmoji{}, crypto.VerificationMethodDecimal{}} } func (vm *VerificationModal) VerifySASMatch(device *crypto.DeviceIdentity, data crypto.SASData) bool { vm.device = device var typeName string if data.Type() == event.SASDecimal { typeName = "numbers" } else if data.Type() == event.SASEmoji { typeName = "emojis" } else { return false } vm.infoText.SetText(fmt.Sprintf( "Check if the other device is showing the\n"+ "same %s as below, then type \"yes\" to\n"+ "accept, or \"no\" to reject", typeName)) vm.inputBar. SetTextColor(tcell.ColorDefault). SetBackgroundColor(tcell.ColorDarkCyan). SetPlaceholder("Type \"yes\" or \"no\""). Focus() vm.emojiText.Data = data vm.parent.parent.Render() vm.progress = vm.progressMax confirm := <-vm.confirmChan vm.progress = vm.progressMax vm.emojiText.Data = nil vm.infoText.SetText(fmt.Sprintf("Waiting for %s\nto confirm", vm.device.UserID)) vm.parent.parent.Render() return confirm } func (vm *VerificationModal) OnCancel(cancelledByUs bool, reason string, _ event.VerificationCancelCode) { vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100) vm.parent.parent.app.SetRedrawTicker(1 * time.Minute) if cancelledByUs { vm.infoText.SetText(fmt.Sprintf("Verification failed: %s", reason)) } else { vm.infoText.SetText(fmt.Sprintf("Verification cancelled by %s: %s", vm.device.UserID, reason)) } vm.inputBar.SetPlaceholder("Press enter to close the dialog") vm.stopWaiting <- struct{}{} vm.done = true vm.parent.parent.Render() } func (vm *VerificationModal) OnSuccess() { vm.waitingBar.SetIndeterminate(false).SetMax(100).SetProgress(100) vm.parent.parent.app.SetRedrawTicker(1 * time.Minute) vm.infoText.SetText(fmt.Sprintf("Successfully verified %s (%s) of %s", vm.device.Name, vm.device.DeviceID, vm.device.UserID)) vm.inputBar.SetPlaceholder("Press enter to close the dialog") vm.stopWaiting <- struct{}{} vm.done = true vm.parent.parent.Render() if vm.parent.config.SendToVerifiedOnly { // Hacky way to make new group sessions after verified vm.parent.matrix.Crypto().(*crypto.OlmMachine).OnDevicesChanged(vm.device.UserID) } } func (vm *VerificationModal) OnKeyEvent(event mauview.KeyEvent) bool { kb := config.Keybind{ Key: event.Key(), Ch: event.Rune(), Mod: event.Modifiers(), } if vm.done { if vm.parent.config.Keybindings.Modal[kb] == "cancel" || vm.parent.config.Keybindings.Modal[kb] == "confirm" { vm.parent.HideModal() return true } return false } else if vm.emojiText.Data == nil { debug.Print("Ignoring pre-emoji key event") return false } if vm.parent.config.Keybindings.Modal[kb] == "confirm" { text := strings.ToLower(strings.TrimSpace(vm.inputBar.GetText())) if text == "yes" { debug.Print("Confirming verification") vm.confirmChan <- true } else if text == "no" { debug.Print("Rejecting verification") vm.confirmChan <- false } vm.inputBar. SetPlaceholder(""). SetTextAndMoveCursor(""). SetBackgroundColor(tcell.ColorDefault). SetTextColor(tcell.ColorDefault) return true } else { return vm.inputBar.OnKeyEvent(event) } } func (vm *VerificationModal) Focus() { vm.container.Focus() } func (vm *VerificationModal) Blur() { vm.container.Blur() } gomuks-0.3.0/ui/view-login.go000066400000000000000000000127731433617251100160600ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "math" "github.com/mattn/go-runewidth" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/mautrix" "maunium.net/go/mautrix/id" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" ) type LoginView struct { *mauview.Form container *mauview.Centerer homeserverLabel *mauview.TextField usernameLabel *mauview.TextField passwordLabel *mauview.TextField homeserver *mauview.InputField username *mauview.InputField password *mauview.InputField error *mauview.TextView loginButton *mauview.Button quitButton *mauview.Button loading bool matrix ifc.MatrixContainer config *config.Config parent *GomuksUI } func (ui *GomuksUI) NewLoginView() mauview.Component { view := &LoginView{ Form: mauview.NewForm(), usernameLabel: mauview.NewTextField().SetText("Username"), passwordLabel: mauview.NewTextField().SetText("Password"), homeserverLabel: mauview.NewTextField().SetText("Homeserver"), username: mauview.NewInputField(), password: mauview.NewInputField(), homeserver: mauview.NewInputField(), loginButton: mauview.NewButton("Login"), quitButton: mauview.NewButton("Quit"), matrix: ui.gmx.Matrix(), config: ui.gmx.Config(), parent: ui, } hs := ui.gmx.Config().HS view.homeserver.SetPlaceholder("https://example.com").SetText(hs).SetTextColor(tcell.ColorWhite) view.username.SetPlaceholder("@user:example.com").SetText(string(ui.gmx.Config().UserID)).SetTextColor(tcell.ColorWhite) view.password.SetPlaceholder("correct horse battery staple").SetMaskCharacter('*').SetTextColor(tcell.ColorWhite) view.quitButton. SetOnClick(func() { ui.gmx.Stop(true) }). SetBackgroundColor(tcell.ColorDarkCyan). SetForegroundColor(tcell.ColorWhite). SetFocusedForegroundColor(tcell.ColorWhite) view.loginButton. SetOnClick(view.Login). SetBackgroundColor(tcell.ColorDarkCyan). SetForegroundColor(tcell.ColorWhite). SetFocusedForegroundColor(tcell.ColorWhite) view. SetColumns([]int{1, 10, 1, 30, 1}). SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1}) view. AddFormItem(view.username, 3, 1, 1, 1). AddFormItem(view.password, 3, 3, 1, 1). AddFormItem(view.homeserver, 3, 5, 1, 1). AddFormItem(view.loginButton, 1, 7, 3, 1). AddFormItem(view.quitButton, 1, 9, 3, 1). AddComponent(view.usernameLabel, 1, 1, 1, 1). AddComponent(view.passwordLabel, 1, 3, 1, 1). AddComponent(view.homeserverLabel, 1, 5, 1, 1) view.SetOnFocusChanged(view.focusChanged) view.FocusNextItem() ui.loginView = view view.container = mauview.Center(mauview.NewBox(view).SetTitle("Log in to Matrix"), 45, 13) view.container.SetAlwaysFocusChild(true) return view.container } func (view *LoginView) resolveWellKnown() { _, homeserver, err := id.UserID(view.username.GetText()).Parse() if err != nil { return } view.homeserver.SetText("Resolving...") resp, err := mautrix.DiscoverClientAPI(homeserver) if err != nil { view.homeserver.SetText("") view.Error(err.Error()) } else if resp != nil { view.homeserver.SetText(resp.Homeserver.BaseURL) view.parent.Render() } } func (view *LoginView) focusChanged(from, to mauview.Component) { if from == view.username && view.homeserver.GetText() == "" { go view.resolveWellKnown() } } func (view *LoginView) Error(err string) { if len(err) == 0 && view.error != nil { debug.Print("Hiding error") view.RemoveComponent(view.error) view.container.SetHeight(13) view.SetRows([]int{1, 1, 1, 1, 1, 1, 1, 1, 1}) view.error = nil } else if len(err) > 0 { debug.Print("Showing error", err) if view.error == nil { view.error = mauview.NewTextView().SetTextColor(tcell.ColorRed) view.AddComponent(view.error, 1, 11, 3, 1) } view.error.SetText(err) errorHeight := int(math.Ceil(float64(runewidth.StringWidth(err)) / 45)) view.container.SetHeight(14 + errorHeight) view.SetRow(11, errorHeight) } view.parent.Render() } func (view *LoginView) actuallyLogin(hs, mxid, password string) { debug.Printf("Logging into %s as %s...", hs, mxid) view.config.HS = hs if err := view.matrix.InitClient(); err != nil { debug.Print("Init error:", err) view.Error(err.Error()) } else if err = view.matrix.Login(mxid, password); err != nil { if httpErr, ok := err.(mautrix.HTTPError); ok { if httpErr.RespError != nil { view.Error(httpErr.RespError.Err) } else { view.Error(httpErr.Message) } } else { view.Error(err.Error()) } debug.Print("Login error:", err) } view.loading = false view.loginButton.SetText("Login") } func (view *LoginView) Login() { if view.loading { return } hs := view.homeserver.GetText() mxid := view.username.GetText() password := view.password.GetText() view.loading = true view.loginButton.SetText("Logging in...") go view.actuallyLogin(hs, mxid, password) } gomuks-0.3.0/ui/view-main.go000066400000000000000000000304411433617251100156640ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "bufio" "fmt" "os" "sync/atomic" "time" sync "github.com/sasha-s/go-deadlock" "go.mau.fi/mauview" "go.mau.fi/tcell" "maunium.net/go/mautrix/id" "maunium.net/go/mautrix/pushrules" "maunium.net/go/gomuks/config" "maunium.net/go/gomuks/debug" ifc "maunium.net/go/gomuks/interface" "maunium.net/go/gomuks/lib/notification" "maunium.net/go/gomuks/matrix/rooms" "maunium.net/go/gomuks/ui/messages" "maunium.net/go/gomuks/ui/widget" ) type MainView struct { flex *mauview.Flex roomList *RoomList roomView *mauview.Box currentRoom *RoomView rooms map[id.RoomID]*RoomView roomsLock sync.RWMutex cmdProcessor *CommandProcessor focused mauview.Focusable modal mauview.Component lastFocusTime time.Time matrix ifc.MatrixContainer gmx ifc.Gomuks config *config.Config parent *GomuksUI } func (ui *GomuksUI) NewMainView() mauview.Component { mainView := &MainView{ flex: mauview.NewFlex().SetDirection(mauview.FlexColumn), roomView: mauview.NewBox(nil).SetBorder(false), rooms: make(map[id.RoomID]*RoomView), matrix: ui.gmx.Matrix(), gmx: ui.gmx, config: ui.gmx.Config(), parent: ui, } mainView.roomList = NewRoomList(mainView) mainView.cmdProcessor = NewCommandProcessor(mainView) mainView.flex. AddFixedComponent(mainView.roomList, 25). AddFixedComponent(widget.NewBorder(), 1). AddProportionalComponent(mainView.roomView, 1) mainView.BumpFocus(nil) ui.mainView = mainView return mainView } func (view *MainView) ShowModal(modal mauview.Component) { view.modal = modal var ok bool view.focused, ok = modal.(mauview.Focusable) if !ok { view.focused = nil } else { view.focused.Focus() } } func (view *MainView) HideModal() { view.modal = nil view.focused = view.roomView } func (view *MainView) Draw(screen mauview.Screen) { if view.config.Preferences.HideRoomList { view.roomView.Draw(screen) } else { view.flex.Draw(screen) } if view.modal != nil { view.modal.Draw(screen) } } func (view *MainView) BumpFocus(roomView *RoomView) { if roomView != nil { view.lastFocusTime = time.Now() view.MarkRead(roomView) } } func (view *MainView) MarkRead(roomView *RoomView) { if roomView != nil && roomView.Room.HasNewMessages() && roomView.MessageView().ScrollOffset == 0 { msgList := roomView.MessageView().messages if len(msgList) > 0 { msg := msgList[len(msgList)-1] if roomView.Room.MarkRead(msg.ID()) { view.matrix.MarkRead(roomView.Room.ID, msg.ID()) } } } } func (view *MainView) InputChanged(roomView *RoomView, text string) { if !roomView.config.Preferences.DisableTypingNotifs { view.matrix.SendTyping(roomView.Room.ID, len(text) > 0 && text[0] != '/') } } func (view *MainView) ShowBare(roomView *RoomView) { if roomView == nil { return } _, height := view.parent.app.Screen().Size() view.parent.app.Suspend(func() { print("\033[2J\033[0;0H") // We don't know how much space there exactly is. Too few messages looks weird, // and too many messages shouldn't cause any problems, so we just show too many. height *= 2 fmt.Println(roomView.MessageView().CapturePlaintext(height)) fmt.Println("Press enter to return to normal mode.") reader := bufio.NewReader(os.Stdin) _, _, _ = reader.ReadRune() print("\033[2J\033[0;0H") }) } func (view *MainView) OpenSyncingModal() ifc.SyncingModal { component, modal := NewSyncingModal(view) view.ShowModal(component) return modal } func (view *MainView) OnKeyEvent(event mauview.KeyEvent) bool { view.BumpFocus(view.currentRoom) if view.modal != nil { return view.modal.OnKeyEvent(event) } kb := config.Keybind{ Key: event.Key(), Ch: event.Rune(), Mod: event.Modifiers(), } switch view.config.Keybindings.Main[kb] { case "next_room": view.SwitchRoom(view.roomList.Next()) case "prev_room": view.SwitchRoom(view.roomList.Previous()) case "search_rooms": view.ShowModal(NewFuzzySearchModal(view, 42, 12)) case "scroll_up": msgView := view.currentRoom.MessageView() msgView.AddScrollOffset(msgView.TotalHeight()) case "scroll_down": msgView := view.currentRoom.MessageView() msgView.AddScrollOffset(-msgView.TotalHeight()) case "add_newline": return view.flex.OnKeyEvent(tcell.NewEventKey(tcell.KeyEnter, '\n', event.Modifiers()|tcell.ModShift)) case "next_active_room": view.SwitchRoom(view.roomList.NextWithActivity()) case "show_bare": view.ShowBare(view.currentRoom) default: goto defaultHandler } return true defaultHandler: if view.config.Preferences.HideRoomList { return view.roomView.OnKeyEvent(event) } return view.flex.OnKeyEvent(event) } const WheelScrollOffsetDiff = 3 func (view *MainView) OnMouseEvent(event mauview.MouseEvent) bool { if view.modal != nil { return view.modal.OnMouseEvent(event) } if view.config.Preferences.HideRoomList { return view.roomView.OnMouseEvent(event) } return view.flex.OnMouseEvent(event) } func (view *MainView) OnPasteEvent(event mauview.PasteEvent) bool { if view.modal != nil { return view.modal.OnPasteEvent(event) } else if view.config.Preferences.HideRoomList { return view.roomView.OnPasteEvent(event) } return view.flex.OnPasteEvent(event) } func (view *MainView) Focus() { if view.focused != nil { view.focused.Focus() } } func (view *MainView) Blur() { if view.focused != nil { view.focused.Blur() } } func (view *MainView) SwitchRoom(tag string, room *rooms.Room) { view.switchRoom(tag, room, true) } func (view *MainView) switchRoom(tag string, room *rooms.Room, lock bool) { if room == nil { return } room.Load() roomView, ok := view.getRoomView(room.ID, lock) if !ok { debug.Print("Tried to switch to room with nonexistent roomView!") debug.Print(tag, room) return } roomView.Update() view.roomView.SetInnerComponent(roomView) view.currentRoom = roomView view.MarkRead(roomView) view.roomList.SetSelected(tag, room) view.flex.SetFocused(view.roomView) view.focused = view.roomView view.roomView.Focus() view.parent.Render() if msgView := roomView.MessageView(); len(msgView.messages) < 20 && !msgView.initialHistoryLoaded { msgView.initialHistoryLoaded = true go view.LoadHistory(room.ID) } if !room.MembersFetched { go func() { err := view.matrix.FetchMembers(room) if err != nil { debug.Print("Error fetching members:", err) return } roomView.UpdateUserList() view.parent.Render() }() } } func (view *MainView) addRoomPage(room *rooms.Room) *RoomView { if _, ok := view.rooms[room.ID]; !ok { roomView := NewRoomView(view, room). SetInputChangedFunc(view.InputChanged) view.rooms[room.ID] = roomView return roomView } return nil } func (view *MainView) GetRoom(roomID id.RoomID) ifc.RoomView { room, ok := view.getRoomView(roomID, true) if !ok { return view.addRoom(view.matrix.GetOrCreateRoom(roomID)) } return room } func (view *MainView) getRoomView(roomID id.RoomID, lock bool) (room *RoomView, ok bool) { if lock { view.roomsLock.RLock() room, ok = view.rooms[roomID] view.roomsLock.RUnlock() } else { room, ok = view.rooms[roomID] } return room, ok } func (view *MainView) AddRoom(room *rooms.Room) { view.addRoom(room) } func (view *MainView) RemoveRoom(room *rooms.Room) { view.roomsLock.Lock() _, ok := view.getRoomView(room.ID, false) if !ok { view.roomsLock.Unlock() debug.Print("Remove aborted (not found)", room.ID, room.GetTitle()) return } debug.Print("Removing", room.ID, room.GetTitle()) view.roomList.Remove(room) t, r := view.roomList.Selected() view.switchRoom(t, r, false) delete(view.rooms, room.ID) view.roomsLock.Unlock() view.parent.Render() } func (view *MainView) addRoom(room *rooms.Room) *RoomView { if view.roomList.Contains(room.ID) { debug.Print("Add aborted (room exists)", room.ID, room.GetTitle()) return nil } debug.Print("Adding", room.ID, room.GetTitle()) view.roomList.Add(room) view.roomsLock.Lock() roomView := view.addRoomPage(room) if !view.roomList.HasSelected() { t, r := view.roomList.First() view.switchRoom(t, r, false) } view.roomsLock.Unlock() return roomView } func (view *MainView) SetRooms(rooms *rooms.RoomCache) { view.roomList.Clear() view.roomsLock.Lock() view.rooms = make(map[id.RoomID]*RoomView) for _, room := range rooms.Map { if room.HasLeft { continue } view.roomList.Add(room) view.addRoomPage(room) } t, r := view.roomList.First() view.switchRoom(t, r, false) view.roomsLock.Unlock() } func (view *MainView) UpdateTags(room *rooms.Room) { if !view.roomList.Contains(room.ID) { return } reselect := view.roomList.selected == room view.roomList.Remove(room) view.roomList.Add(room) if reselect { view.roomList.SetSelected(room.Tags()[0].Tag, room) } view.parent.Render() } func (view *MainView) SetTyping(roomID id.RoomID, users []id.UserID) { roomView, ok := view.getRoomView(roomID, true) if ok { roomView.SetTyping(users) view.parent.Render() } } func sendNotification(room *rooms.Room, sender, text string, critical, sound bool) { if room.GetTitle() != sender { sender = fmt.Sprintf("%s (%s)", sender, room.GetTitle()) } debug.Printf("Sending notification with body \"%s\" from %s in room ID %s (critical=%v, sound=%v)", text, sender, room.ID, critical, sound) notification.Send(sender, text, critical, sound) } func (view *MainView) Bump(room *rooms.Room) { view.roomList.Bump(room) } func (view *MainView) NotifyMessage(room *rooms.Room, message ifc.Message, should pushrules.PushActionArrayShould) { view.Bump(room) uiMsg, ok := message.(*messages.UIMessage) if ok && uiMsg.SenderID == view.config.UserID { return } // Whether or not the room where the message came is the currently shown room. isCurrent := room == view.roomList.SelectedRoom() // Whether or not the terminal window is focused. recentlyFocused := time.Now().Add(-30 * time.Second).Before(view.lastFocusTime) isFocused := time.Now().Add(-5 * time.Second).Before(view.lastFocusTime) // Whether or not the push rules say this message should be notified about. shouldNotify := should.Notify || !should.NotifySpecified if !isCurrent || !isFocused { // The message is not in the current room, show new message status in room list. room.AddUnread(message.ID(), shouldNotify, should.Highlight) } else { view.matrix.MarkRead(room.ID, message.ID()) } if shouldNotify && !recentlyFocused && !view.config.Preferences.DisableNotifications { // Push rules say notify and the terminal is not focused, send desktop notification. shouldPlaySound := should.PlaySound && should.SoundName == "default" && view.config.NotifySound sendNotification(room, message.NotificationSenderName(), message.NotificationContent(), should.Highlight, shouldPlaySound) } // TODO this should probably happen somewhere else // (actually it's probably completely broken now) message.SetIsHighlight(should.Highlight) } func (view *MainView) LoadHistory(roomID id.RoomID) { defer debug.Recover() roomView, ok := view.getRoomView(roomID, true) if !ok { return } msgView := roomView.MessageView() if !atomic.CompareAndSwapInt32(&msgView.loadingMessages, 0, 1) { // Locked return } defer atomic.StoreInt32(&msgView.loadingMessages, 0) // Update the "Loading more messages..." text view.parent.Render() history, newLoadPtr, err := view.matrix.GetHistory(roomView.Room, 50, msgView.historyLoadPtr) if err != nil { roomView.AddServiceMessage("Failed to fetch history") debug.Print("Failed to fetch history for", roomView.Room.ID, err) view.parent.Render() return } //debug.Printf("Load pointer %d -> %d", msgView.historyLoadPtr, newLoadPtr) msgView.historyLoadPtr = newLoadPtr for _, evt := range history { roomView.AddHistoryEvent(evt) } view.parent.Render() } gomuks-0.3.0/ui/widget/000077500000000000000000000000001433617251100147225ustar00rootroot00000000000000gomuks-0.3.0/ui/widget/border.go000066400000000000000000000036301433617251100165300ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package widget import ( "go.mau.fi/mauview" "go.mau.fi/tcell" ) // Border is a simple tview widget that renders a horizontal or vertical bar. // // If the width of the box is 1, the bar will be vertical. // If the height is 1, the bar will be horizontal. // If the width nor the height are 1, nothing will be rendered. type Border struct { Style tcell.Style } // NewBorder wraps a new tview Box into a new Border. func NewBorder() *Border { return &Border{ Style: tcell.StyleDefault.Foreground(mauview.Styles.BorderColor), } } func (border *Border) Draw(screen mauview.Screen) { width, height := screen.Size() if width == 1 { for borderY := 0; borderY < height; borderY++ { screen.SetContent(0, borderY, mauview.Borders.Vertical, nil, border.Style) } } else if height == 1 { for borderX := 0; borderX < width; borderX++ { screen.SetContent(borderX, 0, mauview.Borders.Horizontal, nil, border.Style) } } } func (border *Border) OnKeyEvent(event mauview.KeyEvent) bool { return false } func (border *Border) OnPasteEvent(event mauview.PasteEvent) bool { return false } func (border *Border) OnMouseEvent(event mauview.MouseEvent) bool { return false } gomuks-0.3.0/ui/widget/color.go000066400000000000000000000101731433617251100163710ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package widget import ( "fmt" "hash/fnv" "go.mau.fi/tcell" "maunium.net/go/mautrix/id" ) var colorNames = []string{ "maroon", "green", "olive", "navy", "purple", "teal", "silver", "gray", "red", "lime", "yellow", "blue", "fuchsia", "aqua", "white", "aliceblue", "antiquewhite", "aquamarine", "azure", "beige", "bisque", "blanchedalmond", "blueviolet", "brown", "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", "cornsilk", "crimson", "darkblue", "darkcyan", "darkgoldenrod", "darkgray", "darkgreen", "darkkhaki", "darkmagenta", "darkolivegreen", "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", "darkslateblue", "darkslategray", "darkturquoise", "darkviolet", "deeppink", "deepskyblue", "dimgray", "dodgerblue", "firebrick", "floralwhite", "forestgreen", "gainsboro", "ghostwhite", "gold", "goldenrod", "greenyellow", "honeydew", "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightpink", "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightsteelblue", "lightyellow", "limegreen", "linen", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", "navajowhite", "oldlace", "olivedrab", "orange", "orangered", "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", "rebeccapurple", "rosybrown", "royalblue", "saddlebrown", "salmon", "sandybrown", "seagreen", "seashell", "sienna", "skyblue", "slateblue", "slategray", "snow", "springgreen", "steelblue", "tan", "thistle", "tomato", "turquoise", "violet", "wheat", "whitesmoke", "yellowgreen", "grey", "dimgrey", "darkgrey", "darkslategrey", "lightgrey", "lightslategrey", "slategrey", } // GetHashColorName gets a color name for the given string based on its FNV-1 hash. // // The array of possible color names are the alphabetically ordered color // names specified in tcell.ColorNames. // // The algorithm to get the color is as follows: // // colorNames[ FNV1(string) % len(colorNames) ] // // With the exception of the three special cases: // // --> = green // <-- = red // --- = yellow func GetHashColorName(s string) string { switch s { case "-->": return "green" case "<--": return "red" case "---": return "yellow" default: h := fnv.New32a() _, _ = h.Write([]byte(s)) return colorNames[h.Sum32()%uint32(len(colorNames))] } } // GetHashColor gets the tcell Color value for the given string. // // GetHashColor calls GetHashColorName() and gets the Color value from the tcell.ColorNames map. func GetHashColor(val interface{}) tcell.Color { switch str := val.(type) { case string: return tcell.ColorNames[GetHashColorName(str)] case *string: return tcell.ColorNames[GetHashColorName(*str)] case id.UserID: return tcell.ColorNames[GetHashColorName(string(str))] default: return tcell.ColorNames["red"] } } // AddColor adds tview color tags to the given string. func AddColor(s, color string) string { return fmt.Sprintf("[%s]%s[white]", color, s) } gomuks-0.3.0/ui/widget/doc.go000066400000000000000000000001041433617251100160110ustar00rootroot00000000000000// Package widget contains additional tview widgets. package widget gomuks-0.3.0/ui/widget/util.go000066400000000000000000000044001433617251100162240ustar00rootroot00000000000000// gomuks - A terminal Matrix client written in Go. // Copyright (C) 2020 Tulir Asokan // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package widget import ( "fmt" "strconv" "github.com/mattn/go-runewidth" "go.mau.fi/mauview" "go.mau.fi/tcell" ) func WriteLineSimple(screen mauview.Screen, line string, x, y int) { WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault) } func WriteLineSimpleColor(screen mauview.Screen, line string, x, y int, color tcell.Color) { WriteLine(screen, mauview.AlignLeft, line, x, y, 1<<30, tcell.StyleDefault.Foreground(color)) } func WriteLineColor(screen mauview.Screen, align int, line string, x, y, maxWidth int, color tcell.Color) { WriteLine(screen, align, line, x, y, maxWidth, tcell.StyleDefault.Foreground(color)) } func WriteLine(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) { offsetX := 0 if align == mauview.AlignRight { offsetX = maxWidth - runewidth.StringWidth(line) } if offsetX < 0 { offsetX = 0 } for _, ch := range line { chWidth := runewidth.RuneWidth(ch) if chWidth == 0 { continue } for localOffset := 0; localOffset < chWidth; localOffset++ { screen.SetContent(x+offsetX+localOffset, y, ch, nil, style) } offsetX += chWidth if offsetX >= maxWidth { break } } } func WriteLinePadded(screen mauview.Screen, align int, line string, x, y, maxWidth int, style tcell.Style) { padding := strconv.Itoa(maxWidth) if align == mauview.AlignRight { line = fmt.Sprintf("%"+padding+"s", line) } else { line = fmt.Sprintf("%-"+padding+"s", line) } WriteLine(screen, mauview.AlignLeft, line, x, y, maxWidth, style) }